FlutterはUIを構成するものはすべてWidgetでできています。そのため、AndroidにあるActivityやiOSにあるViewControllerのような画面の概念は厳密にはありません。
FlutterではNavigatorがページのような概念を持ったRoute(参考)
のスタックを管理することで画面遷移を実現しています。
今回は NatigatorObserver
と WidgetsBindingObserver
を使ってアプリのライフサイクルイベントを検知します。
ちなみに、今までは主にモバイルアプリのマルチプラットフォームとして使われていたのでこれだけで大きな問題にはなっていなかったのですが、従来のNavigatorだとFlutter WEBのRoute管理が不十分なためNavigator 2.0
が開発されました。
既に使うことはできますが、今回はNavigator1.0でライフサイクルを管理します。
Motivation
自分がAndroidアプリのエンジニアという点や元々Android, iOSアプリを作っていたモバイルエンジニアがFlutterもスムーズに開発できるように、MVVMで従来のアプリっぽい作り方をしているのですが、先程も書いたとおりFlutterはEverything is a Widgetになっているので、無理やり既存のモバイルアプリのライフサイクルの概念にあてはめるには少し設計を考える必要があります。
ライフサイクルの検出をしようと思うと各ページで RouteObserver
を持つ方法がありますが、単純に実装するとStatefulWidgetになってしまうので微妙です。
Flutterでライフサイクルを検出するにはもう一つ方法があって MaterialApp
のnavigatorObservers
にNavigatorObserver
を指定してグローバルで管理する方法です。
グローバルで管理すると現在のNavigator Routeのスタックを自分で管理してViewModel等に通知する方法を考える必要があります。
この記事ではContextに依存せずに任意のNavigator Route(画面)でライフサイクルを検知する方法を解説します。
NavigatorObserver
NavigatorObserverにはNavigatorの遷移手法に合わせて主に4つのコールバックがあります。(厳密にはあと2つありますが、今回は省略)
このコールバックはNavigatorのメソッドに合わせて呼ばれるのではなく、現在のRouteスタックからpushされたり、popされた際に呼ばれます。
つまり、このコールバックを適切に管理すれば現在のRouteスタックを把握することが可能です。
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) { }
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) { }
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) { }
void didReplace({ Route<dynamic>? newRoute, Route<dynamic>? oldRoute }) { }
準備
今回はスタックの挙動を把握するために3ページ用意してroute pageから順番にpage1, page2, page3を開いてpage3でpushやpopのNavigationを行います。(replace用にpage4もあります)
ページ名、パス対応表
page name | path |
---|---|
route page | / |
page 1 | /page1 |
page 2 | /page2 |
page 3 | /page3 |
page 4 | /page4 |
スタックの状態
- [launch] -> root
[push] target: /, previous: null
/
- root -> page1
[push] target: /page1, previous: /
/
/page1
- page1 -> page2
[push] target: /page2, previous: /page1
/
/page1
/page2
- page2 -> page3
[push] target: /page3, previous: /page2
/
/page1
/page2
/page3
ページ側のコード
Routeを直接渡す方法とNameで指定するやり方では挙動が若干異なるので今回はNameを指定する方法でやります。
void main() => runApp(MyApp());
const PAGE1 = '/page1';
const PAGE2 = '/page2';
const PAGE3 = '/page3';
const PAGE4 = '/page4';
final observer = AppLifecycleObserver();
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorObservers: [
observer,
],
initialRoute: '/',
routes: {
'/': (_) => Home(),
PAGE1: (_) => Page1(),
PAGE2: (_) => Page2(),
PAGE3: (_) => Page3(),
},
);
}
}
class Home extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('home')),
body: Center(
child: ElevatedButton(
child: Text('push'),
onPressed: () {
Navigator.of(context).pushNamed(PAGE1);
}),
),
);
}
}
class Page1 extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('page1')),
body: Center(
child: ElevatedButton(
child: Text('push'),
onPressed: () {
Navigator.of(context).pushNamed(PAGE2);
}),
),
);
}
}
class Page2 extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('page2')),
body: Center(
child: ElevatedButton(
child: Text('push'),
onPressed: () {
Navigator.of(context).pushNamed(PAGE3);
}),
),
);
}
}
class Page3 extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('page3')),
body: Center(
child: ElevatedButton(
child: Text('action'),
onPressed: () {
// action
}),
),
);
}
}
class Page4 extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('page4')),
body: Center(
child: Text('Hello'),
),
);
}
}
Navigator stack
Navigatorの挙動を把握するためpageを1から3まで積んだ後に行うNavigatorでスタックがどう変わるのかを把握します。 (全てPage3からの操作)
※ 名前が被っているページに関しては、便宜上新しく追加されたpage1をpage1’とします
pop()
- page3 -> page2
[pop] target: /page3, previous: /page2
/
/page1
/page2
popUntil((route) => route.settings.name == PAGE1)
- page3 -> page2
[pop] target: /page3, previous: /page2
/
/page1
/page2
- page2 -> page1
[pop] target: /page2, previous: /page1
/
/page1
popAndPushNamed(PAGE1)
- page3 -> page2
[pop] target: /page3, previous: /page2
/
/page1
/page2
- page2 -> page1'
[push] target: /page1, previous: /page2
/
/page1
/page2
/page1'
removeRoute(page1)
(名前からpage1のrouteを取ってきています)
- page3 stay
[remove] target: /page1, previous: /
/
/page2
/page3
removeRoute(page3)
- page3 -> page2
[remove] target: /page3, previous: /page2
/
/page1
/page2
removeRouteBelow(page1)
- page3 stay
[remove] target: /, previous: null
/page1
/page2
/page3
pushNamedAndRemoveUntil(PAGE1, (_) => false)
- page3 -> page1'
[push] target: /page1, previous: /page3
/
/page1
/page2
/page3
/page1'
- page1’ stay
[remove] target: /page3, previous: null
/
/page1
/page2
/page1'
- page1’ stay
[remove] target: /page2, previous: null
/
/page1
/page1'
- page1’ stay
[remove] target: /page1, previous: null
/
/page1'
- page1’ stay
[remove] target: /, previous: null
/page1'
pushReplacementNamed(PAGE1)
- page3 -> page1'
[replace] new: /page1, old: /page3
/
/page1
/page2
/page1'
replace(oldRoute: page1, newRoute: page4)
(名前からpage1のrouteを取ってきています)
- page3 stay
[replace] new: /page4, old: /page1
/
/page4
/page2
/page3
特徴的なのは pushNamedAndRemoveUntil
等の組み合わせ系のメソッドです。
先にremoveされるのではなく名前の通りpushされた後にremoveされます。
Notify lifecycle
上記のスタックの状態を元にライフサイクルのイベントを通知する方法を考えます。
push, popに関しては最前面にいるRouteの操作なので単純に考えるだけで良いのですが、 remove, replaceに関しては最前面にあるRouteの操作とは限らないので少し工夫が必要になります。
NavigatorObserverを継承したAppLifecycleObserverというクラスを作ってAndroidでいうonResume, onPauseのタイミングを enum (NavigatorEvent) で通知するクラスを作りました。
enum NavigatorEvent {
RESUME,
PAUSE,
}
typedef LifecycleObserverCallback = void Function(NavigatorEvent event);
class AppLifecycleObserver extends NavigatorObserver {
final Map<String, List<LifecycleObserverCallback>>
_lifecycleObserverCallbacks = {};
final List<Route<dynamic>> _routeStack = [];
/// [notifyImmediately] observeした段階で自身がcurrent routeだったら通知するかどうか
void addObserver(
String key,
LifecycleObserverCallback callback, {
bool notifyImmediately = true,
}) {
final List<LifecycleObserverCallback> callbacks =
_lifecycleObserverCallbacks.putIfAbsent(key, () => []);
callbacks.add(callback);
if (notifyImmediately &&
_routeStack.isNotEmpty &&
_routeStack.last.settings.name == key) {
_sendLifecycleEvent(_routeStack.last, NavigatorEvent.RESUME);
}
}
void removeObserver(String key) {
_lifecycleObserverCallbacks.remove(key);
}
void _sendLifecycleEvent(Route? route, NavigatorEvent event) {
final key = route?.settings.name;
if (key == null) {
return;
}
final listeners = _lifecycleObserverCallbacks[key];
if (listeners == null) {
return;
}
for (final listener in listeners) {
listener.call(event);
}
}
}
remove
removeで特殊なのは消される予定のRouteが最前面にいない場合です。
つまり、スタックの状態を見て削除予定のRouteと最前面のRouteが一致した場合のみライフサイクルイベントの通知を行います。
@override
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
super.didRemove(route, previousRoute);
// 削除されるRouteが最前面にある場合は1つ前のRouteが最前面にくる
if (_routeStack.last == route) {
_sendLifecycleEvent(route, NavigatorEvent.PAUSE);
_sendLifecycleEvent(previousRoute, NavigatorEvent.RESUME);
}
_routeStack.remove(route);
}
replace
replaceもremoveと同様に入れ替わるRouteが最前面にあるかないかによってライフサイクルの通知を行うかを決めます。
@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
// 入れ替える予定のindexを保持しておく
final index = _routeStack.indexWhere((element) => element == oldRoute);
// 削除予定のRouteが最前面にあればPAUSEの通知
if (_routeStack.last == oldRoute) {
_sendLifecycleEvent(oldRoute, NavigatorEvent.PAUSE);
}
_routeStack.remove(oldRoute);
if (index >= 0 && newRoute != null) {
_routeStack.insert(index, newRoute);
}
// newRouteが最前面にあればRESUMEの通知
if (_routeStack.last == newRoute) {
_sendLifecycleEvent(newRoute, NavigatorEvent.RESUME);
}
}
push
pushはそのままです
@override
void didPush(Route route, Route? previousRoute) {
super.didPush(route, previousRoute);
_routeStack.add(route);
_sendLifecycleEvent(previousRoute, NavigatorEvent.PAUSE);
_sendLifecycleEvent(route, NavigatorEvent.RESUME);
}
pop
popもそのままです
@override
void didPop(Route route, Route? previousRoute) {
super.didPop(route, previousRoute);
_routeStack.remove(route);
_sendLifecycleEvent(route, NavigatorEvent.PAUSE);
_sendLifecycleEvent(previousRoute, NavigatorEvent.RESUME);
}
WidgetsBindingObserver
既存のモバイルアプリのonResume, onPauseには画面遷移以外にも画面ON/OFFやホーム遷移のようなライフサイクルイベントも含まれています。
NavigatorObserverは画面遷移を検知できるのですが、後者のライフサイクルイベントを検出するには WidgetsBindingObserver
を使う必要があります。
そこで先程作成した AppLifecycleObserver
に WidgetsBindingObserver
の機能を足します。
class AppLifecycleObserver extends NavigatorObserver with WidgetsBindingObserver {
AppLifecycleObserver() {
WidgetsBinding.instance?.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
_sendLifecycleEvent(_routeStack.last, NavigatorEvent.RESUME);
break;
case AppLifecycleState.paused:
_sendLifecycleEvent(_routeStack.last, NavigatorEvent.PAUSE);
break;
default:
// noop
break;
}
}
void dispose() {
WidgetsBinding.instance?.removeObserver(this);
}
}
addObserver
すると didChangeAppLifecycleState
に状態が送られてきます。PAUSE
, RESUME
の他にINACTIVE
(PIP等), DETACHED
のイベントもあるのですが、DESTROY系の管理はRouteの状態を完全に把握する必要があるため今回はRESUME, PAUSEのみを使っています。
大半の人がProvider
やRiverPod
を使っていると思うので、それらのライブラリで管理されているdisposeのタイミングを使ったほうが良いと思います。
NavigatorObserver + WidgetsBindingObserver
これで画面遷移と画面ON, OFFの検知ができるようになったので、1つにまとめるとこうなります。
class AppLifecycleObserver extends NavigatorObserver with WidgetsBindingObserver {
AppLifecycleObserver() {
WidgetsBinding.instance?.addObserver(this);
}
final Map<String, List<LifecycleObserverCallback>>
_lifecycleObserverCallbacks = {};
final List<Route<dynamic>> _routeStack = [];
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
_sendLifecycleEvent(_routeStack.last, NavigatorEvent.RESUME);
break;
case AppLifecycleState.paused:
_sendLifecycleEvent(_routeStack.last, NavigatorEvent.PAUSE);
break;
default:
// noop
break;
}
}
@override
void didPush(Route route, Route? previousRoute) {
super.didPush(route, previousRoute);
_routeStack.add(route);
_sendLifecycleEvent(previousRoute, NavigatorEvent.PAUSE);
_sendLifecycleEvent(route, NavigatorEvent.RESUME);
}
@override
void didPop(Route route, Route? previousRoute) {
super.didPop(route, previousRoute);
_routeStack.remove(route);
_sendLifecycleEvent(route, NavigatorEvent.PAUSE);
_sendLifecycleEvent(previousRoute, NavigatorEvent.RESUME);
}
@override
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
super.didRemove(route, previousRoute);
if (_routeStack.last == route) {
_sendLifecycleEvent(route, NavigatorEvent.PAUSE);
_sendLifecycleEvent(previousRoute, NavigatorEvent.RESUME);
}
_routeStack.remove(route);
}
@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
final index = _routeStack.indexWhere((element) => element == oldRoute);
if (_routeStack.last == oldRoute) {
_sendLifecycleEvent(oldRoute, NavigatorEvent.PAUSE);
}
_routeStack.remove(oldRoute);
if (index >= 0 && newRoute != null) {
_routeStack.insert(index, newRoute);
}
if (_routeStack.last == newRoute) {
_sendLifecycleEvent(newRoute, NavigatorEvent.RESUME);
}
}
void dispose() {
WidgetsBinding.instance?.removeObserver(this);
}
/// [notifyImmediately] observeした段階で自身がcurrent routeだったら通知するかどうか
void addObserver(
String key,
LifecycleObserverCallback callback, {
bool notifyImmediately = true,
}) {
final List<LifecycleObserverCallback> callbacks =
_lifecycleObserverCallbacks.putIfAbsent(key, () => []);
callbacks.add(callback);
if (notifyImmediately &&
_routeStack.isNotEmpty &&
_routeStack.last.settings.name == key) {
_sendLifecycleEvent(_routeStack.last, NavigatorEvent.RESUME);
}
}
void removeObserver(String key) {
_lifecycleObserverCallbacks.remove(key);
}
void _sendLifecycleEvent(Route? route, NavigatorEvent event) {
final key = route?.settings.name;
if (key == null) {
return;
}
final listeners = _lifecycleObserverCallbacks[key];
if (listeners == null) {
return;
}
for (final listener in listeners) {
listener(event);
}
}
}
Observe
各ページや共通のViewModel等で監視するのが良いと思います。
Keyにはページ名を使っています。
observer.addObserver('page_key', (event) {
print('[observe] $event');
});
まとめ
Flutteでは管理しづらいけど、モバイルアプリ開発ではほぼ必須となるResumeとPauseの検知をしました。
ログだったりライブラリのタイミング制御など開発の役に立つと嬉しいです。
このパターン抜け漏れているなどあれば教えて下さい。