🎢

Navigator 2.0 の解説 後編 1

2021/01/05に公開

前編では、Navigator 2.0 の基本として、新しい Page API を用いた複数の Route の宣言的な状態管理について解説しました。

Navigator の Page API だけでは、Android の戻るボタンや Web ブラウザーの戻る/進むボタンの対応、Web ブラウザーの history の更新(それによるアドレスバーの URL の更新)といった、OS と協調した処理に対応しません。後編では、それらの処理に対応するために新しく提供された Router API について解説します。その後、Navigator 2.0 のまとめと、今後の課題について言及します。

Router による Routing の全体像

Router は、Android の戻るボタンや Web ブラウザーの戻る/進むボタンからの通知の受信、Web ブラウザーの history の更新(それによるアドレスバーの URL の更新)といった、Routing に関する OS との通信手段を提供する Widget です。

Router は、

  • RouteInformationProvider
  • RouteInformationParser
  • BackButtonDispatcher
  • RouterDelegate

といった構成部品と協調して動作します。

以下は、Learning Flutter’s new navigation and routing system からの、Routing 処理の全体図の抜粋です。

Router によって実現するそれぞれの処理の流れを見ていきましょう。

OS からアプリへの RouteInformation の通知による、その情報を反映しての route stack の再構成

アプリの起動時、Intent 、そして Web アプリでは Web ブラウザーの戻る/進むボタンの押下時に RouteInformation が通知されます。RouteInformation を元にアプリの状態が更新され、Navigator はそのアプリの状態を反映して route stack を更新します。

RouteInformationは、location と state から構成されます。Web アプリでは、この location が URL の path segments 以降を指します。例えば、https://example.com/a/b/c?d=e では、location は /a/b/c?d=e です。Web アプリでは、それに加えて state が利用できます。

OS からアプリへの RouteInformation の通知からの、 route stack の更新処理の流れは以下です。

  1. RouteInformationProvider を通じて OS から RouteInformation が通知されます。
  2. RouteInformationParser の parseRouteInformation() によって、RouteInformation が、アプリケーションコード内で定義された Routing の状態を表すオブジェクトに変換されます。
  3. RouterDelegate の setNewRoutePath() によって、2.のオブジェクトを用いてアプリの状態が更新されます。(または、setInitialRoutePath()で、アプリ起動時のみにされる処理も記述可能です)
  4. setNewRoutePath() の処理の完了後に、Router は自身をリビルドします。それによってその Router の子孫 Widget の Navigator がリビルドされます。
  5. Navigator は pages をアプリの状態を反映して再構成し、その結果、各 page に紐ついた route の stack が更新されます。

アプリから OS への RouteInformation の通知 による、OS への反映

アプリの状態から RouteInformation を生成して、OS に通知することもできます。これは、Web ブラウザーの history の更新による戻る/進むボタンの対応、アドレスバーの URL の更新のために必要です。

  1. アプリの状態が更新されたことを通知された RouterDelegate が自身が属する Router に更新通知し、Router が自身をリビルドする際に RouterDelegate.currentConfiguration を呼び出し、アプリの状態をアプリケーションコード内で定義された Routing の状態を表すオブジェクトに変換させて、それを取得します。
  2. 1.で取得したオブジェクトをRouteInformationParser.restoreRouteInformation()によって RouteInformation に変換し、その RouteInformation.location が前回のものと異なれば、OS にその情報を通知します。(Router.navigate() で、RouteInformation.location が前回のものと同じでも強制的に通知させることもできます。)

RouterDelegate.currentConfiguration と RouteInformationParser.restoreRouteInformation() の実装は、Web アプリに対応しないならば、または Routing をしないならば、省略できます。そうでないならば、Web アプリのためにこれらを実装することが強く推奨されています。

OS からアプリへの戻るボタン (System Back Button) 押下情報の通知による、その情報を反映しての route stack の再構成

これは、Flutter が現在対応している OS のなかでは、Android の戻るボタンの押下時の通知を指します。(他の対応 OS がもしあれば教えて下さい。) BackButtonDispatcher が担当します。

BackButtonDispatcher は、Web ブラウザーの戻るボタンに対応するものではないことに注意してください。 Web ブラウザーの戻るボタンの押下時には、すでに説明したとおり、RouteInformation が通知されます。BackButtonDispatcher は関係ありません。

  1. BackButtonDispatcher は、Router に戻るボタンが押されたことを通知します。
  2. Router は、RouterDelegate.popRoute() を呼び出します。
  3. RouterDelegate.popRoute() で、ユーザーの戻るボタンの押下時の期待に沿うようにアプリ固有の処理を実装します。

3.の処理内容について、単純に Navigator.pop() したいだけならば、PopNavigatorRouterDelegateMixin によって、RouterDelegate の子孫 Widget の Navigator の pop() を試みる popRoute() の実装を RouterDelegate に mixin できます。多くのユースケースではこの mixin で済むでしょう。RouterDelegate.popRoute() で戻るボタン押下時のみのなんらかの前処理を実行した後、super.popRoute() で呼び出すように使用しても便利でしょう。

前編で説明したように、Navigator 2.0 では、Navigator.pop() の実行の コールバック として、Navigator.onPopPage にコールバック関数が定義されることを思い出してください。pop() 対象が pageless route でなければ、onPopPage でアプリの状態更新処理を行います。そして、今回は Router とも連携しますので、RouterDelegate に Router へ更新通知をさせて、それを通じて Router と Navigator をリビルドさせて、route stack を再構成しましょう。

Page API と Router API を組み合わせる

ここまでで、Router API の全体像と処理の流れを見ました。では、実際に Page API と Router API を組み合わせたサンプルコードを見ていきましょう。Learning Flutter’s new navigation and routing system で説明されているものです。

https://gist.github.com/johnpryan/430c1d3ad771c43bf249c07fa3aeef14#file-main-dart

Router の設定

Router をアプリで設定するには、実際は通常、新たに追加された MaterialApp.router 名前付きコンストラクターを通じて行うことになります。(同様に、CupertinoApp.router、WidgetsApp.router も追加されています。)

class BooksApp extends StatefulWidget {
  
  State<StatefulWidget> createState() => _BooksAppState();
}

class _BooksAppState extends State<BooksApp> {
  BookRouterDelegate _routerDelegate = BookRouterDelegate();
  BookRouteInformationParser _routeInformationParser =
      BookRouteInformationParser();

  
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Books App',
      routerDelegate: _routerDelegate,
      routeInformationParser: _routeInformationParser,
    );
  }
}

後に説明する nested router の構成にする場合は、nest された router には Router Widget をそのまま使用します。

MaterialApp.router のコンストラクター引数には、routerDelegate および routeInformationParser の指定は必須ですが、routeInformationProvider および backButtonDispatcher の指定は任意です。これは、前者2つはアプリ固有の処理をするべきオブジェクトなのに対し、後者2つはそうではないので、デフォルトの処理が用意されているからです。それらを継承して override したアプリ独自の処理を提供することもできますが、それが必要になることは稀のようです。

一方、onGenerateRoute:home: といった、Navigator 1.0 時代に Route 設定するための引数は Navigator2.0 の Router API のための設定ではないので、そのコンストラクター引数には存在しません。

では、RouteInformationParser と RouterDelegate の実装を見ていきましょう。

RouteInformation のアプリ内表現の定義

RouteInformation を RouteInformationParser によって アプリ独自のオブジェクトに変換してアプリで扱いやすくします。その実装はアプリ開発者に委ねられています。サンプルコードでは、BookRoutePath という名前で定義しています。

class BookRoutePath {
  final int id;
  final bool isUnknown;

  BookRoutePath.home()
      : id = null,
        isUnknown = false;

  BookRoutePath.details(this.id) : isUnknown = false;

  BookRoutePath.unknown()
      : id = null,
        isUnknown = true;

  bool get isHomePage => id == null;

  bool get isDetailsPage => id != null;
}

RouteInformationParser による RouteInformation の parse と restore

RouteInformation は、RouteInformationProvider を通じて提供されます。それを、RouteInformationParser.parseRouteInformation() によって、先に定義した BookRoutePath に変換する処理を書きます。この RouteInformationParser の実装もアプリ開発者に委ねられています。

class BookRouteInformationParser extends RouteInformationParser<BookRoutePath> {
  
  Future<BookRoutePath> parseRouteInformation(
      RouteInformation routeInformation) async {
    final uri = Uri.parse(routeInformation.location);
    // Handle '/'
    if (uri.pathSegments.length == 0) {
      return BookRoutePath.home();
    }

    // Handle '/book/:id'
    if (uri.pathSegments.length == 2) {
      if (uri.pathSegments[0] != 'book') return BookRoutePath.unknown();
      var remaining = uri.pathSegments[1];
      var id = int.tryParse(remaining);
      if (id == null) return BookRoutePath.unknown();
      return BookRoutePath.details(id);
    }

    // Handle unknown routes
    return BookRoutePath.unknown();
  }

  
  RouteInformation restoreRouteInformation(BookRoutePath path) {
    if (path.isUnknown) {
      return RouteInformation(location: '/404');
    }
    if (path.isHomePage) {
      return RouteInformation(location: '/');
    }
    if (path.isDetailsPage) {
      return RouteInformation(location: '/book/${path.id}');
    }
    return null;
  }
}

parseRouteInformation の返り値が Future なのは、ここで非同期処理を行う可能性があるからです。たとえば、認証情報をサーバーに確認したり(いわゆる auth guard) するためです。

BookRouteInformationParser.restoreRouteInformation()は、parseRouteInformation とは逆に、BookRoutePathRouteInformation に変換する処理です。RouteInformationParserには、restoreRouteInformation() インターフェースも定義されています。これは、OS に アプリの現在のRouteInformationを通知するためのメソッドです。この実装が必要になるのは、Flutter の現在のサポート OS の中では、Web ブラウザーのみです。

RouterDelegate によるアプリの制御

RouterDelegate は、RouteInformation からの情報をもとにアプリの状態を更新して Navigator による route stack の再構成につなげます、また、アプリの状態から RouteInformation を復元するための情報を提供します。Router とアプリとの相互作用のための中心的な役割を担うクラスです。setNewRoutePath, popRoute, build の実装が必要です。

currentConfiguration は、Web アプリに対応する場合には実装が必要です。

RouterDelegate は Widget ではありませんが、build インターフェースがあり、ここで Widget tree を組み立てます。典型的には、この Widget tree に Navigator が含まれます。

また、RouterDelegate には Listenable インターフェースの実装が必要です。このインターフェースで RouterDelegate は Router に更新通知をするためです。ここでは、ChangeNotifier を mixin することで Listenable インターフェースを実装します。

class BookRouterDelegate extends RouterDelegate<BookRoutePath>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin<BookRoutePath> {
  final GlobalKey<NavigatorState> navigatorKey;

  Book _selectedBook;
  bool show404 = false;

  List<Book> books = [
    Book('Stranger in a Strange Land', 'Robert A. Heinlein'),
    Book('Foundation', 'Isaac Asimov'),
    Book('Fahrenheit 451', 'Ray Bradbury'),
  ];

  BookRouterDelegate() : navigatorKey = GlobalKey<NavigatorState>();

  BookRoutePath get currentConfiguration {
    if (show404) {
      return BookRoutePath.unknown();
    }
    return _selectedBook == null
        ? BookRoutePath.home()
        : BookRoutePath.details(books.indexOf(_selectedBook));
  }

  
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      pages: [
        MaterialPage(
          key: ValueKey('BooksListPage'),
          child: BooksListScreen(
            books: books,
            onTapped: _handleBookTapped,
          ),
        ),
        if (show404)
          MaterialPage(key: ValueKey('UnknownPage'), child: UnknownScreen())
        else if (_selectedBook != null)
          BookDetailsPage(book: _selectedBook)
      ],
      onPopPage: (route, result) {
        if (!route.didPop(result)) {
          return false;
        }

        // Update the list of pages by setting _selectedBook to null
        _selectedBook = null;
        show404 = false;
        notifyListeners();

        return true;
      },
    );
  }

  
  Future<void> setNewRoutePath(BookRoutePath path) async {
    if (path.isUnknown) {
      _selectedBook = null;
      show404 = true;
      return;
    }

    if (path.isDetailsPage) {
      if (path.id < 0 || path.id > books.length - 1) {
        show404 = true;
        return;
      }

      _selectedBook = books[path.id];
    } else {
      _selectedBook = null;
    }

    show404 = false;
  }

  void _handleBookTapped(Book book) {
    _selectedBook = book;
    notifyListeners();
  }
}

(このサンプルコードでは、アプリの状態も BookRouterDelegate の中で表現していますが、言うまでもなく実際のプロダクションコードでは適切に分割すべきです。)

setNewRoutePath

BookRouteInformationParser.parseRouteInformation() で RouteInformation が BookRoutePath に変換されると、次に Router は BookRouterDelegate.setNewRoutePath を呼び出します。この setNewRoutePath で、RouteInformation に対応してのアプリの状態に更新します。setNewRoutePath の戻り値は Future なので、非同期でアプリの状態を更新できます。非同期処理をしないならば、SynchronousFuture を返すと同一 microtask で処理できるのでより良い、と案内されています。

setNewRoutePath() の処理の完了後に、Router は自身をリビルドします。それによってその Router の子孫 Widget の Navigator がリビルドされます。Navigator は pages をアプリの状態を反映して再構成し、その結果、各 page に紐ついた route の stack が更新されます。

setNewRoutePath という名付けがされていますが、引数はT configuration です。この、configuration とは、RouteInformation を変換したアプリ内で定義されたユーザー定義型の表現です。サンプルコードでは、BookRoutePath のことです。後述の currentConfiguration まで併せて考えると、これらの method の名付けがやや一貫していないように感じられます。

currentConfiguration

currentConfigurationは、Router がリビルドされた際、Router から呼ばれる getter method です。RouteInformation の復元につなげるためのユーザー定義型を返します。サンプルコードでは、BookRoutePath です。

Router は、この currentConfiguration の戻り値で、RouteInformationParser.restoreRouteInformation を呼び出して RouteInformation を復元させて、OS に通知します。

popRoute

Router は、BackButtonDispacher からの通知を受けると、この RouterDelegate の popRoute を呼び出します。popRouteは、戻るボタンを押した際にユーザーが期待する動作になるように実装するべきです。今回は、単に Navigator.pop() をさせたいので、PopNavigatorRouterDelegateMixin を RouterDelegate に mixin することpopRoute を実装しています。navigatorKeyは、PopNavigatorRouterDelegateMixinが Navigator を特定するために必要です。

PopNavigatorRouterDelegateMixin.onPopPage によって、Navigator.pop() が試みられ、その際のコールバックとしてNavigator.onPopPage 呼び出され、アプリの状態が更新されます。そして、notifyListeners() により RouterDelegate から Router に更新通知されます。それにより、Navigator.pages が更新されたアプリの状態を反映して再構成され、その結果、route stack が再構成されます。


これで、サンプルコードを一通り読むことができました。Page API を使用した Navigator Widget の動作については、前編を合わせて読むと良いと思います。Page API と Router API を組み合わせた Navigator 2.0 の動作を把握できたでしょうか?

全体像を理解すれば、それほど複雑ではないと感じるかもしれませんが、しかしまだプロダクションへの導入の感触もつかめないかもしれません。

後編 2 へ

長くなってしまったので、後編を分割します。後編 2 では、

  • Router の入れ子 (Nested Router)
  • Web ブラウザー対応についての注意点
  • Navigator 2.0 の課題

について言及します。

cf.

Learning Flutter’s new navigation and routing system | by John Ryan | Flutter | Medium

Navigator 2.0 and Router (PUBLICLY SHARED) - Google Docs

DevFest Saudi Arabia 2020: Navigator 2.0 - YouTube

Discussion