December 30, 2025

【Flutter】flutter_hooksでページビューロギングを正確に実装する

Flutterアプリでページビューのログを正確に送るのは、意外と面倒です。 画面遷移だけでなく、バックグラウンドからの復帰や、前の画面に戻ってきた時など、考慮すべきタイミングが複数あります。 今回は flutter_hooks を使ったアプリの構成で、useRouteObserveruseAppLifecycleListener という2つのHookを作り、それらを組み合わせた usePageViewLogger を実装しました。

ページビューログの難しさ

ページビューのログを送りたいタイミングは、単純に「画面が表示された時」だけではありません。 正確にやろうとすると、以下のケースをすべてカバーする必要があります。

  1. 画面に遷移してきた時(push)
  2. 上に重なっていた画面が閉じて、この画面が再び見えるようになった時(popNext)
  3. アプリがバックグラウンドからフォアグラウンドに戻ってきた時(resume)

よくあるのは initStateuseEffect の初回実行でログを送るパターンですが、これだと1のケースしかカバーできません。 2と3を正確に検知するには、Flutterの RouteObserverWidgetsBindingObserver を使う必要があります。

それぞれ個別に実装するとボイラープレートが増えるので、再利用可能な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]);
}

内部で使っている _RouteAwareObserverRouteAware を継承したシンプルなクラスです。

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から取得したインスタンスを GoRouterobservers に渡します。 これにより、ルート遷移が発生するたびに RouteAware のコールバックが呼ばれるようになります。

各イベントの意味

  • onDidPush: この画面がpushされて表示された時
  • onDidPop: この画面がpopされて閉じられた時
  • onDidPopNext: この画面の上にあった画面がpopされて、この画面が再び見えるようになった時
  • onDidPushNext: この画面の上に新しい画面がpushされた時

ページビューログの観点では、onDidPushonDidPopNext のタイミングで送信すればよいことになります。

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 を使う場合は initStatedispose でそれぞれ addObserver / removeObserver を書く必要がありますが、Hookにすることでその手間がなくなります。

usePageViewLogger

ここまでで作った2つのHookを組み合わせて、ページビューログを正確なタイミングで送信するHookを作ります。
コアな機能は載せますが、GoRouterなどのRouterを使っている場合と、Navigatorを使っている場合でもうひと手間加える必要があるので注意してください。

特にRouterを使っている場合はuseRouteObserverRouteAware はNavigatorのpush/popには反応しますが、GoRouterの go() による遷移では発火しないケースがあります。 例えばBottom Navigation Barでタブを切り替えた場合などです。 そのため、routerDelegateaddListener で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つを組み合わせることで、すべてのケースをカバーできます。

さらに、useAppLifecycleListeneronResumed はアプリ全体のイベントなので、現在見えていない画面でも発火します。 そのため isVisible フラグで「この画面が実際に見えているかどうか」を管理し、見えている時だけログを送るようにしています。 このフラグは useRouteObserveronDidPush / onDidPopNexttrue に、onDidPushNext / onDidPopfalse に切り替えています。

重複送信の防止

useRouteObserverrouterDelegate のリスナーが同時に発火するケースがあるため、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')),
    );
  }
}

以前は各ページで RouteAwareWidgetsBindingObserver を個別に実装していたので、1ページあたり数十行のボイラープレートがありました。 それが usePageViewLogger の1行で済むようになり、ロジックの重複もなくなりました。

まとめ

ページビューログを正確に送るには、ルート遷移とアプリライフサイクルの両方を監視する必要があります。 useRouteObserveruseAppLifecycleListener をそれぞれ汎用Hookとして切り出し、usePageViewLogger で組み合わせることで、再利用性が高く正確なロギングが実現できました。 flutter_hooksを使うことで、StatefulWidgetの initState / dispose を書かずに済み、ロジックの見通しも良くなります。 同じような課題を抱えている方の参考になれば幸いです!

© AAkira 2023