Flutterアプリでページビューのログを正確に送るのは、意外と面倒です。
画面遷移だけでなく、バックグラウンドからの復帰や、前の画面に戻ってきた時など、考慮すべきタイミングが複数あります。
今回は flutter_hooks
を使ったアプリの構成で、useRouteObserver と useAppLifecycleListener という2つのHookを作り、それらを組み合わせた usePageViewLogger を実装しました。
ページビューログの難しさ
ページビューのログを送りたいタイミングは、単純に「画面が表示された時」だけではありません。 正確にやろうとすると、以下のケースをすべてカバーする必要があります。
- 画面に遷移してきた時(push)
- 上に重なっていた画面が閉じて、この画面が再び見えるようになった時(popNext)
- アプリがバックグラウンドからフォアグラウンドに戻ってきた時(resume)
よくあるのは initState や useEffect の初回実行でログを送るパターンですが、これだと1のケースしかカバーできません。
2と3を正確に検知するには、Flutterの RouteObserver と WidgetsBindingObserver を使う必要があります。
それぞれ個別に実装するとボイラープレートが増えるので、再利用可能なHookとして切り出すことにしました。
useRouteObserver
まずはルート遷移を監視するHookです。
Flutterの RouteAware をラップして、画面遷移の4つのイベントをコールバックで受け取れるようにします。
void useRouteObserver({
VoidCallback? onDidPush,
VoidCallback? onDidPop,
VoidCallback? onDidPopNext,
VoidCallback? onDidPushNext,
}) {
final context = useContext();
final ref = useWidgetRef();
final route = ModalRoute.of(context);
useEffect(() {
if (route == null) {
return null;
}
final observer = _RouteAwareObserver(
onDidPush: onDidPush,
onDidPop: onDidPop,
onDidPopNext: onDidPopNext,
onDidPushNext: onDidPushNext,
);
final routeObserver = ref.read(routeObserverProvider)
..subscribe(observer, route);
return () {
routeObserver.unsubscribe(observer);
};
}, [route, onDidPush, onDidPop, onDidPopNext, onDidPushNext]);
}
内部で使っている _RouteAwareObserver は RouteAware を継承したシンプルなクラスです。
class _RouteAwareObserver extends RouteAware {
_RouteAwareObserver({
this.onDidPush,
this.onDidPop,
this.onDidPopNext,
this.onDidPushNext,
});
final VoidCallback? onDidPush;
final VoidCallback? onDidPop;
final VoidCallback? onDidPopNext;
final VoidCallback? onDidPushNext;
@override
void didPush() => onDidPush?.call();
@override
void didPop() => onDidPop?.call();
@override
void didPopNext() => onDidPopNext?.call();
@override
void didPushNext() => onDidPushNext?.call();
}
RouteObserverの登録
RouteObserver はアプリ全体で1つのインスタンスを共有する必要があるので、Riverpodで keepAlive にして提供します。
@Riverpod(keepAlive: true)
RouteObserver<ModalRoute<void>> routeObserver(Ref ref) =>
RouteObserver<ModalRoute<void>>();
このProviderから取得したインスタンスを GoRouter の observers に渡します。
これにより、ルート遷移が発生するたびに RouteAware のコールバックが呼ばれるようになります。
各イベントの意味
onDidPush: この画面がpushされて表示された時onDidPop: この画面がpopされて閉じられた時onDidPopNext: この画面の上にあった画面がpopされて、この画面が再び見えるようになった時onDidPushNext: この画面の上に新しい画面がpushされた時
ページビューログの観点では、onDidPush と onDidPopNext のタイミングで送信すればよいことになります。
useAppLifecycleListener
次に、アプリのライフサイクルを監視するHookです。
WidgetsBindingObserver をラップして、フォアグラウンド/バックグラウンドの切り替えを検知します。
void useAppLifecycleListener({
VoidCallback? onResumed,
VoidCallback? onPaused,
VoidCallback? onInactive,
VoidCallback? onDetached,
}) {
useEffect(() {
final observer = _AppLifecycleObserver(
onResumed: onResumed,
onPaused: onPaused,
onInactive: onInactive,
onDetached: onDetached,
);
WidgetsBinding.instance.addObserver(observer);
return () {
WidgetsBinding.instance.removeObserver(observer);
};
}, [onResumed, onPaused, onInactive, onDetached]);
}
class _AppLifecycleObserver extends WidgetsBindingObserver {
_AppLifecycleObserver({
this.onResumed,
this.onPaused,
this.onInactive,
this.onDetached,
});
final VoidCallback? onResumed;
final VoidCallback? onPaused;
final VoidCallback? onInactive;
final VoidCallback? onDetached;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
onResumed?.call();
case AppLifecycleState.paused:
onPaused?.call();
case AppLifecycleState.inactive:
onInactive?.call();
case AppLifecycleState.detached:
onDetached?.call();
case AppLifecycleState.hidden:
break;
}
}
}
useEffect のクリーンアップで removeObserver を呼んでいるので、Widgetが破棄された時に自動的に監視が解除されます。
StatefulWidgetで WidgetsBindingObserver を使う場合は initState と dispose でそれぞれ addObserver / removeObserver を書く必要がありますが、Hookにすることでその手間がなくなります。
usePageViewLogger
ここまでで作った2つのHookを組み合わせて、ページビューログを正確なタイミングで送信するHookを作ります。
コアな機能は載せますが、GoRouterなどのRouterを使っている場合と、Navigatorを使っている場合でもうひと手間加える必要があるので注意してください。
特にRouterを使っている場合はuseRouteObserver の RouteAware はNavigatorのpush/popには反応しますが、GoRouterの go() による遷移では発火しないケースがあります。
例えばBottom Navigation Barでタブを切り替えた場合などです。
そのため、routerDelegate の addListener でGoRouterのlocation変更も別途監視する必要があります。
void usePageViewLogger(Future<void> Function() sendLog) {
final isVisible = useState(false);
final sendLogRef = useRef(sendLog)..value = sendLog;
final lastLogTime = useRef<DateTime?>(null);
// 重複送信を防ぐ
final sendLogIfNotDuplicateRef = useRef<void Function()>(() {})
..value = () async {
final now = DateTime.now();
final last = lastLogTime.value;
if (last != null && now.difference(last).inMilliseconds < 50) {
return;
}
lastLogTime.value = now;
try {
await sendLogRef.value();
} on Exception catch (_) {}
};
// バックグラウンドから復帰した時(画面が見えている場合のみ)
final handleResumed = useCallback(() {
if (isVisible.value) {
sendLogIfNotDuplicateRef.value();
}
}, []);
useAppLifecycleListener(onResumed: handleResumed);
// ルート遷移の監視
final handleDidPush = useCallback(() {
isVisible.value = true;
sendLogIfNotDuplicateRef.value();
}, []);
final handleDidPopNext = useCallback(() {
isVisible.value = true;
sendLogIfNotDuplicateRef.value();
}, []);
final handleDidPushNext = useCallback(() {
isVisible.value = false;
}, []);
final handleDidPop = useCallback(() {
isVisible.value = false;
}, []);
useRouteObserver(
onDidPush: handleDidPush,
onDidPopNext: handleDidPopNext,
onDidPushNext: handleDidPushNext,
onDidPop: handleDidPop,
);
}
なぜ2つのHookを組み合わせるのか
useRouteObserver だけでは、アプリがバックグラウンドから復帰した時のログが送れません。
useAppLifecycleListener だけでは、画面遷移のタイミングが取れません。
この2つを組み合わせることで、すべてのケースをカバーできます。
さらに、useAppLifecycleListener の onResumed はアプリ全体のイベントなので、現在見えていない画面でも発火します。
そのため isVisible フラグで「この画面が実際に見えているかどうか」を管理し、見えている時だけログを送るようにしています。
このフラグは useRouteObserver の onDidPush / onDidPopNext で true に、onDidPushNext / onDidPop で false に切り替えています。
重複送信の防止
useRouteObserver と routerDelegate のリスナーが同時に発火するケースがあるため、50ms以内の連続送信をスキップする仕組みを入れています。
ハック感はありますが、こうしないと同じページビューイベントが2回送信されてしまうことがあります。
使用例
各ページで usePageViewLogger を呼ぶだけで、正確なタイミングでログが送信されるようになります。
class HogePage extends HookConsumerWidget {
const HogePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
usePageViewLogger(() async {
// send log
});
return Scaffold(
appBar: AppBar(title: const Text('Hoge')),
body: const Center(child: Text('Hello world')),
);
}
}
以前は各ページで RouteAware と WidgetsBindingObserver を個別に実装していたので、1ページあたり数十行のボイラープレートがありました。
それが usePageViewLogger の1行で済むようになり、ロジックの重複もなくなりました。
まとめ
ページビューログを正確に送るには、ルート遷移とアプリライフサイクルの両方を監視する必要があります。
useRouteObserver と useAppLifecycleListener をそれぞれ汎用Hookとして切り出し、usePageViewLogger で組み合わせることで、再利用性が高く正確なロギングが実現できました。
flutter_hooksを使うことで、StatefulWidgetの initState / dispose を書かずに済み、ロジックの見通しも良くなります。
同じような課題を抱えている方の参考になれば幸いです!