この記事は 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のタイミングに絞ると以下の図のようになります。
onResumeの挙動をまとめると以下の2点になります。
- 画面遷移時(Activity起動時と、他のActivityから戻った時)に発火する
- バックグラウンドからフォアグラウンドに復帰した際に発火する
Flutterだとどうなるか
前提としてFlutterでは画面はwidget(部品のようなもの)で構成されていて、widgetを切り替えることによって画面遷移しています。なのでそもそもAndroidのActivityは画面のような概念がないため、ルーティングを設定をすることで、切り替えています。
1.画面遷移時(Activity起動時と、他のActivityから戻った時)に発火する
Flutterで再現するためにNavigatorObserver
を使用しました。
MaterialApp()のroutesにルーティングを指定します。
後述しますが、navigatorObserversにMyNavigatorObserver(NavigatorObserverを継承したクラス)を指定することで、ルーティングが検知できるようになります。ちなみにルーティングの指定をしないと、didPush
やdidPop
などで受け取れる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.resumed
とNavigatorObserver
で発火するようにすることで画面遷移した時とバックグラウンドからフォアグラウンドに復帰した時にログが送れるようにしています。そのタイミングで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からのありがたいお話があると思うのでぜひご覧ください!