every Tech Blog

株式会社エブリーのTech Blogです。

AndroidのonResumeの挙動を再現したい

タイトル

この記事は every Tech Blog Advent Calendar 2023 の24日目です。

DELISH KITCHEN 開発部で小売向き合いでFlutterのアプリ開発をしている野口です。

本記事では、弊社の開発しているFlutterアプリでユーザーがどの画面を表示したかのログを取るために、AndroidにおけるonResumeのタイミングでログを送る必要が出たのでその際に得られた知見について紹介します。

やりたいこと

要件としては、以下のタイミングでアプリが表示している画面情報を送る必要があります。

  • 画面遷移時(Navigatorのpush、pop、replaceでの画面遷移)
  • アプリの復帰時

これを行うにはAndroidで言うonResumeのような挙動が必要でこの挙動をFlutterで再現するのがやりたいことです。

AndroidのonResumeはどのタイミングで発火するものなのか

公式だとライフサイクルは以下の画像のようになっています。

Activityとは表示されている画面で、画面が表示されると画像のようなライフサイクルで動きます。

アクティビティのライフサイクルに関する簡略な図

https://developer.android.com/guide/components/activities/activity-lifecycle?hl=ja

onResumeのタイミングに絞ると以下の図のようになります。

AndroidのonResumeの挙動

onResumeの挙動をまとめると以下の2点になります。

  1. 画面遷移時(Activity起動時と、他のActivityから戻った時)に発火する
  2. バックグラウンドからフォアグラウンドに復帰した際に発火する

Flutterだとどうなるか

前提としてFlutterでは画面はwidget(部品のようなもの)で構成されていて、widgetを切り替えることによって画面遷移しています。なのでそもそもAndroidのActivityは画面のような概念がないため、ルーティングを設定をすることで、切り替えています。

1.画面遷移時(Activity起動時と、他のActivityから戻った時)に発火する

Flutterで再現するためにNavigatorObserverを使用しました。

MaterialApp()のroutesにルーティングを指定します。 後述しますが、navigatorObserversにMyNavigatorObserver(NavigatorObserverを継承したクラス)を指定することで、ルーティングが検知できるようになります。ちなみにルーティングの指定をしないと、didPushdidPopなどで受け取れるrouteでルーティングが取得できなくなリます。

Widget build(BuildContext context) {
  ...
  return MaterialApp(
    title: 'sample',
    home: Page1(),
    routes: {
      '/page1' : (_) => Page1(),
      '/page2' : (_) => Page2(),
      '/page3' : (_) => Page3(),
    },
    navigatorObservers: [
      MyNavigatorObserver(),
    ],
  );
}

NavigatorObserverを継承したクラスでは、以下のタイミングで画面遷移を検知できるようになります。

didPushはNavigator.of(context).push()され、画面遷移した時に発火します。

didPopはNavigator.of(context).pop()され、画面遷移先から戻る時に発火します。

didReplaceはNavigator.of(context).pushReplacement()され、画面遷移した時に発火します。

class MyNavigatorObserver extends NavigatorObserver {
  @override
  void didPush(Route route, Route previousRoute) {
    super.didPush(route, previousRoute);
  }

  @override
  void didPop(Route route, Route previousRoute) {
    super.didPop(route, previousRoute);
  }
  @override
  void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
    super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
  }
}

NavigatorObserverのやり方はこちらが参考になりました! 参考:https://qiita.com/kurun_pan/items/b725e02051ab90759df4

ちなみに、routesを設定しなくても以下のようにpush時にルーティングを設定することもできます。

Navigator.of(context).push(
    MaterialPageRoute(
        settings: RouteSettings(name: '/page1'),
        builder: (context) {
            return const Page1();
        },
    ),
);

2. バックグラウンドからフォアグラウンドに復帰した際に発火する

Flutterでバックグラウンドからフォアグラウンドの検知はdidChangeAppLifecycleStateでできます。

didChangeAppLifecycleStateを使用するためにはStatefulWidgetでWidgetsBindingObserverを継承する必要があります。(厳密にはMixinsです、わかりやすいのでこちらの記事をご参照ください。) https://qiita.com/trm11tkr/items/b0c1c50b81d5c40d8bbf

didChangeAppLifecycleStateのAppLifecycleState.resumedを使用することによってバックグラウンドからフォアグラウンドに復帰したことを検知することができます。

class App extends StatefulWidget {
  const App({super.key});

  @override
  State<App> createState() => _AppState();
}

class _AppState extends State<App> with WidgetsBindingObserver {
    @override
    void didChangeAppLifecycleState(AppLifecycleState state) {
        if (state == AppLifecycleState.paused) {
            // アプリがバックグラウンドに移行した時
        } else if (state == AppLifecycleState.resumed) {
            // アプリがフォアグラウンドに戻った時
        } else if (state == AppLifecycleState.inactive) {
         // アプリが一時的に非アクティブになる時
        } else if (state == AppLifecycleState.detached) {
            // アプリが終了する時
        }
    }
}

didChangeAppLifecycleStateについてはこちらが参考になりました! 参考:https://zenn.dev/riscait/books/flutter-riverpod-practical-introduction/viewer/v2-app-lifecycle

また、onResumeのタイミングで画面情報のログを送りたいですが、didChangeAppLifecycleStateからは今どの画面にいるかわからないため、ルーティングのパスの情報を状態で保持する必要があります。

これは、グローバルで状態を保持できれば方法はなんでもいいと思いますが、以下のようにRiverpodで保持することにしました。

final routeNameProvider = StateProvider<String?>((_) => null);

実装まとめ

上記の内容を実装としてまとめると以下のようになりました。

sendViewLogで表示している画面名をログに送るようにしているのですが、それをAppLifecycleState.resumedNavigatorObserverで発火するようにすることで画面遷移した時とバックグラウンドからフォアグラウンドに復帰した時にログが送れるようにしています。そのタイミングでref.read(routeNameProvider.notifier).state = routeName;で遷移した画面名を状態に保持しています。

また、AppLifecycleState.resumedでは表示している画面名が受け取れないのでfinal routeName = ref.read(routeNameProvider.notifier);で保持していた画面名の状態を取得してログで送るようにしています。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final routeNameProvider = StateProvider<String?>((_) => null);

class App extends ConsumerStatefulWidget {
  const App({super.key});

  @override
  ConsumerState<App> createState() => _AppState();
}

class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
  Future<void> sendViewLog(String routeName) {
    // ログを送る処理を行う
    
    ref.read(routeNameProvider.notifier).state = routeName; // 受け取ったルート名を保持する
  }

 @override
 void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.resumed) {
      // アプリがフォアグラウンドに戻った時
        final routeName = ref.read(routeNameProvider.notifier);
        sendViewLog(routeName);
    }
 }

 return MaterialApp(
    title: 'sample',
    home: Page1(),
    routes: {
      '/page1' : (_) => Page1(),
      '/page2' : (_) => Page2(),
      '/page3' : (_) => Page3(),
    },
    navigatorObservers: [
      MyNavigatorObserver((routeName) => sendViewLog(routeName))
    ],
  );
}

class MyNavigatorObserver extends NavigatorObserver {
  final Future<void> Function(String?) sendViewLog;
  MyNavigatorObserver(this.sendViewLog);

  @override
  void didPush(Route route, Route previousRoute) {
    super.didPush(route, previousRoute);
    final routeName = route.settings.name;
    sendViewLog(routeName);
  }

  @override
  void didPop(Route route, Route previousRoute) {
    super.didPop(route, previousRoute);
    final routeName = route.settings.name;
    sendViewLog(routeName);
  }
  @override
  void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
    super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
    final routeName = newRoute?.settings.name;
    sendViewLog(routeName);
  }
}

感想

AndroidではonResumeだけで行える処理がFlutterだとかなり手間がかかる処理になってしまいました。

ただ、今回の実装で画面遷移を検知した時、その画面の情報(パスや画面名など)を取得するためにルーティングの設定が必要ということがわかったのは大きな収穫でした。 Flutterはルーティングを気にしなくても作ろうと思えば作れてしまうので、ルーティングの重要性があまり分かっていませんでした。遷移先が一元管理できるからいいよね!くらいに思っていましたが、ルーティングがないとそもそも表示している画面がわからないのでルーティングは必須だということが理解できました。

go_routerとかだと

今回、ルーティングをNavigatorで行なっているため、このような実装になってますが、go_routerとか使えばもう少し楽に実装できるのかなと思いました。

例えば、go_routerはredirectというものがあり、画面遷移すると発火し、遷移先のパスが取得できるのでこれを使用した方が簡単になるかなと。

GoRouter(
    redirect: (BuildContext context, GoRouterState state) {
        sendViewLog(state.location);
    }
);

https://zenn.dev/joo_hashi/books/fa5c73ffcbf71a/viewer/aae5cf

とはいえフォアグラウンドの復帰時の処理は必要なのであまり変わらないですかね。

ちなみに本アプリでもgo_router使いたかったですが、開発当初に以下の問題でボトムナビゲーションバーを表示しながら画面遷移できなかったため使用を断念しました(涙)

https://zenn.dev/flutteruniv_dev/articles/20230427-095829-flutter-auto-route

ただ、現在は解決しているらしいのでgo_routerに書き換えるのもありかなーと思っています。 https://zenn.dev/flutteruniv_dev/articles/stateful_shell_route

終わりに

FlutterでのonResumeの再現方法について紹介しました! 明日はAdvent Calendarの最終日です。CTOからのありがたいお話があると思うのでぜひご覧ください!