March 31, 2021

FlutterのLifecycle(onResume, onPause)を検知する

FlutterはUIを構成するものはすべてWidgetでできています。そのため、AndroidにあるActivityやiOSにあるViewControllerのような画面の概念は厳密にはありません。
FlutterではNavigatorがページのような概念を持ったRoute(参考) のスタックを管理することで画面遷移を実現しています。 今回は NatigatorObserverWidgetsBindingObserver を使ってアプリのライフサイクルイベントを検知します。

ちなみに、今までは主にモバイルアプリのマルチプラットフォームとして使われていたのでこれだけで大きな問題にはなっていなかったのですが、従来のNavigatorだとFlutter WEBのRoute管理が不十分なためNavigator 2.0 が開発されました。
既に使うことはできますが、今回はNavigator1.0でライフサイクルを管理します。

Motivation

自分がAndroidアプリのエンジニアという点や元々Android, iOSアプリを作っていたモバイルエンジニアがFlutterもスムーズに開発できるように、MVVMで従来のアプリっぽい作り方をしているのですが、先程も書いたとおりFlutterはEverything is a Widgetになっているので、無理やり既存のモバイルアプリのライフサイクルの概念にあてはめるには少し設計を考える必要があります。
ライフサイクルの検出をしようと思うと各ページで RouteObserver を持つ方法がありますが、単純に実装するとStatefulWidgetになってしまうので微妙です。

Flutterでライフサイクルを検出するにはもう一つ方法があって MaterialAppnavigatorObserversNavigatorObserverを指定してグローバルで管理する方法です。
グローバルで管理すると現在のNavigator Routeのスタックを自分で管理してViewModel等に通知する方法を考える必要があります。
この記事ではContextに依存せずに任意のNavigator Route(画面)でライフサイクルを検知する方法を解説します。

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 namepath
route page/
page 1/page1
page 2/page2
page 3/page3
page 4/page4

スタックの状態

  1. [launch] -> root

[push] target: /, previous: null

/
  1. root -> page1

[push] target: /page1, previous: /

/
/page1
  1. page1 -> page2

[push] target: /page2, previous: /page1

/
/page1
/page2
  1. 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の挙動を把握するためpageを1から3まで積んだ後に行うNavigatorでスタックがどう変わるのかを把握します。 (全てPage3からの操作)

※ 名前が被っているページに関しては、便宜上新しく追加されたpage1をpage1’とします

pop()

  1. page3 -> page2

[pop] target: /page3, previous: /page2

/
/page1
/page2

popUntil((route) => route.settings.name == PAGE1)

  1. page3 -> page2

[pop] target: /page3, previous: /page2

/
/page1
/page2
  1. page2 -> page1

[pop] target: /page2, previous: /page1

/
/page1

popAndPushNamed(PAGE1)

  1. page3 -> page2

[pop] target: /page3, previous: /page2

/
/page1
/page2
  1. page2 -> page1'

[push] target: /page1, previous: /page2

/
/page1
/page2
/page1'

removeRoute(page1)

(名前からpage1のrouteを取ってきています)

  1. page3 stay

[remove] target: /page1, previous: /

/
/page2
/page3

removeRoute(page3)

  1. page3 -> page2

[remove] target: /page3, previous: /page2

/
/page1
/page2

removeRouteBelow(page1)

  1. page3 stay

[remove] target: /, previous: null

/page1
/page2
/page3

pushNamedAndRemoveUntil(PAGE1, (_) => false)

  1. page3 -> page1'

[push] target: /page1, previous: /page3

/
/page1
/page2
/page3
/page1'
  1. page1’ stay

[remove] target: /page3, previous: null

/
/page1
/page2
/page1'
  1. page1’ stay

[remove] target: /page2, previous: null

/
/page1
/page1'
  1. page1’ stay

[remove] target: /page1, previous: null

/
/page1'
  1. page1’ stay

[remove] target: /, previous: null

/page1'

pushReplacementNamed(PAGE1)

  1. page3 -> page1'

[replace] new: /page1, old: /page3

/
/page1
/page2
/page1'

replace(oldRoute: page1, newRoute: page4)

(名前からpage1のrouteを取ってきています)

  1. 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 を使う必要があります。

そこで先程作成した AppLifecycleObserverWidgetsBindingObserver の機能を足します。

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のみを使っています。
大半の人がProviderRiverPod を使っていると思うので、それらのライブラリで管理されているdisposeのタイミングを使ったほうが良いと思います。

これで画面遷移と画面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の検知をしました。
ログだったりライブラリのタイミング制御など開発の役に立つと嬉しいです。
このパターン抜け漏れているなどあれば教えて下さい。

© AAkira 2023