🔄

FlutterのNavigator(Navigator 1)とRouter(Navigator 2)のちがい

2024/01/18に公開

Flutterに画面遷移は必要不可欠です。ただ、画面遷移の実装方法はFlutterの歴史的な経緯により、複数のパターンが存在します。とりわけ2020年末にRouter(Navigator 2)が登場したことで、アプリケーションを開発にするにあたり、幾つかの意思決定をする必要が生じています。
この意思決定においては、Flutterのちょっとした経緯を把握していれば進めやすいのですが、イマイチピンとこない状態だと進めにくいものになっています。

当記事では、そんな状況を踏まえて、FlutterにおけるNavigator 1(Navigator)とNavigator 2(Router)の考え方の違いを確認します。
なお、公式ドキュメントでは初期からあるシステムをNavigator、2020年末に登場したシステムをRouterとしています。ただ、多くの記事ではNavigator 1やNavigator 2という表現が利用されていることもあるため、この記事の中ではNavigator 1(Navigator)とNavigator 2(Router)という表現を利用します。

まずは、公式ドキュメントにおけるNavigationの紹介を確認します。[1]

https://docs.flutter.dev/ui/navigation

Flutter provides a complete system for navigating between screens and handling deep links. Small applications without complex deep linking can use Navigator, while apps with specific deep linking and navigation requirements should also use the Router to correctly handle deep links on Android and iOS, and to stay in sync with the address bar when the app is running on the web.

Navigator 1(Navigator)とNavigator 2(Router)の違いが明瞭に説明されています。公式ドキュメントを読んで、動きが理解できた場合には、この章を読み飛ばしてもらっても大丈夫です。

では、これから公式ドキュメントにおけるUsing the NavigatorUsing named routesUsing the Routerの説明を試みます。よろしくお願いします。

Using the Navigator

https://docs.flutter.dev/ui/navigation#using-the-navigator

サンプルコードを引用します。トップの文章における"Small applications without complex deep linking can use Navigator"が想定している実装です。

onPressed: () {
  Navigator.of(context).push(
    MaterialPageRoute(
      builder: (context) => const SongScreen(song: song),
    ),
  );
},
child: Text(song.name),

このパターンでは、Navigator.of(context).pushの引数にMaterialPageRouteを渡しています。pushする際に、次に開く画面(Widget)を作成し、Navigatorが管理するstackに積み上げる実装です。最も直感的に理解できる実装ではないでしょうか。
Androidで言えば、Activity.startActivityFragmentTransaction.addに相当するのかなと。画面遷移で積み上げて、戻る際に一番上のものを削除する。モバイルアプリケーションにおける画面の管理としては、コレですよね。

メリットとしては、画面遷移の実装を1つのメソッドの中で完結させることができます。またRouterで必要になるような、面倒なセットアップも不要です。
デメリットとしては、自由度が非常に高いがゆえに、どこからどの画面に遷移するかをコントロールしにくい点があります。画面遷移の検知にも、ひと工夫が必要です。なによりDeep Linkへの対応を行う場合、Deep Link用にpathの定義とハンドリングが必要となります。[2]

サンプルコードや小規模なアプリケーションでは、十分な処理かもしれません。多くの場合は、開発初期でUsing the Navigatorを採用していても、次のUsing named routesかその亜種に移行することになる(なった)のではないかなと思います。

Using named routes

https://docs.flutter.dev/ui/navigation#using-the-router

MaterialApp#routesを利用するパターンです。公式ドキュメントでは、次のようなルートの定義が紹介されています。


Widget build(BuildContext context) {
  return MaterialApp(
    routes: {
      '/': (context) => HomeScreen(),
      '/details': (context) => DetailScreen(),
    },
  );
}

このテーブルを利用するには、Navigator.pushNamedを利用します。次のような実装です。[3]

onPressed: () {
  Navigator.of(context).pushNamed(
    '/details',
  );
},
child: Text('See details'),

MaterialApp#routesのドキュメントにて、動きの紹介がされています。

The application's top-level routing table.

When a named route is pushed with Navigator.pushNamed, the route name is looked up in this map. If the name is present, the associated widgets.WidgetBuilder is used to construct a MaterialPageRoute that performs an appropriate transition, including Hero animations, to the new route.

記載の通りnamed routesのパターンでは、アプリケーションが対応可能なpathの一覧を定義します。素朴なNavigatorのパターンに比べると、より構造化された印象です。pushNamedで指定するpathをenumとして管理したり、画面を構築するクラスで定義するなどの工夫を行えば、ある程度安全に利用することもできます。

一方で、ドキュメントにあるように、named routesのパターンは推奨されていません。Limitationsを読む限り、次の2つの理由が述べられています。

  1. Deep Linkがサポートされているが、必ずDeep Linkで指定されたpathに遷移してしまう
  2. Webアプリケーションにおいて、ブラウザ上で一度戻るボタンを押した後に、進むボタンを押すことができない

2の方は、Webアプリケーションを利用しない場合には、問題になりません。Flutterを利用してWebアプリケーションを作るケースは少ないため、1の方が問題としての重要度は高いでしょう。
ただ、1がどれほどの問題になるかは、開発しているアプリケーションによります。[4]次に紹介するUsing the Routerのパターンと見比べることで、どちらのパターンを採用するか考えるべきだと、筆者は考えています。

onGenerateRouteonUnknownRoute

なお、MaterialApp#onGenerateRouteを利用するパターンも存在します。このパターンでは、動的にnamed routesのようなルーティングを実現できます。もしも不明なpathが指定された場合には、MaterialApp#onUnknownRouteが利用されるので、アプリケーションで必要となるpathの網羅も可能です。

公式ドキュメントから省かれているのも同じ理由だと思うのですが、このパターンの紹介をここで挟むと、Using the Routerのパターンが説明しづらくなる(何が違うのか一見してわからなくなる)問題があります。
筆者の知る限り、画面間でデータの引き渡しをしつつルーティングを整理するには、onGenerateRouteのパターンが構造化しやすくメンテナンスしやすい実装となります。ただ、構造化されているという点においては、Using the Routerのパターンがより優れていると思われるので、新規に採用する場合には一度比較する時間を取ることをお勧めします。

Using the Router

https://docs.flutter.dev/ui/navigation#using-the-router

Navigator 2(Router)です。公式ドキュメントでは、Flutterチームが開発しているgo_routerが紹介されています。

MaterialApp.router(
  routerConfig: GoRouter(
    // …
  )
);

…コレだけだとGoRouterの使い方の説明になるので、簡単に解説を試みます。

Navigator 2(Router)においては、named routesのようなpathのテーブルを定義します。この時、pathの親子関係も考慮するのが、大きな特徴の1つ目です。

採用例を紹介します。FlutterKaigi 2023の公式アプリケーションでは、go_router_builderも利用しているのですが、次のように画面の親子関係を定義しています。

https://github.com/FlutterKaigi/conference-app-2023/blob/1.5.0/lib/ui/router/router_app.dart#L87-L101

定義に対応して、生成されたコードが次の箇所です。/licensesの下にabout-us、つまり/licenses/about-usが定義されています。
気になる方は、アプリケーションを実際に動かしてみてください。

https://github.com/FlutterKaigi/conference-app-2023/blob/1.5.0/lib/ui/router/router_app.g.dart#L87-L106

このように、GoRouterではある画面が表示された時に、その画面の親がどの画面なのかをpathから定義します。named routesのパターンでは、このような親子関係は簡単には定義できません。というのもNavigatorは命令的な処理であり、ユーザーが行なった画面遷移がstackに積み上げられるため、開かれた画面から、画面の親子関係を確定させstackに反映させることが難しいからです。

そして、こういった画面の親子関係は、Webアプリケーションでは必須となります。
一例として、Twitter(X)をみてみましょう。ツイート(ポスト)の詳細画面には、前の画面に戻るボタンが存在します。このボタンの押した時の挙動を、次の2つのケースで確認してみてください。

  1. ホームからユーザー詳細に飛び、ユーザー詳細から任意のツイートの詳細を開く。その後、Twitterアプリケーション内の戻るボタンを押す。
  2. 任意のツイート詳細ページを、お気に入りやURLの直打ちから開く。その後、Twitterアプリケーション内の戻るボタンを押す。

筆者の手元の環境では、1はユーザー詳細画面に、2はホーム画面に戻ります。
ここで強調したいのは、Webアプリケーションにおいては任意の画面をお気に入りやURL直打ちから開くケースが存在する、という点です。[5]モバイルアプリケーションでは、このようなケースはほぼ存在しないため、考慮するべきパターンが増えることになります。

公式ドキュメントの紹介はここで終わりとします。ざっとではあるのですが、3つのパターンの違いが把握できたのではないかなと。


公式ドキュメントの話は終わったのですが、述べたいことがあるので、記事を続けます。

先ほど大きな特徴の1つ目と書いたのですが、2つ目がpathの定義によって、画面が構築される点です。筆者はNavigator 2(Router)の最大の特徴は、コレだと思っています。
特にNavigator 2(Router)の考え方に直結する点だと思うので、気合を入れて紹介したいと思います…が、文章だけだと意味が分かりにくいので、次の章でサンプル実装を交えながら紹介します。

URLによって構築される画面

Flutterにおける宣言的UIの紹介には、UI=f(state)という式が登場します。宣言的UIといえば、この説明って感じですよね。

https://docs.flutter.dev/data-and-backend/state-mgmt/declarative

筆者は、宣言的Navigationにおいてはpathがstateに含まれる、と考えています。つまりpathによって画面が構築される、ということです。state=/homeであればHomePageが、state=/settingsであればSettingsPageが、それぞれ導かれるイメージです。
このURLによって画面が構築される、ということを、改めて紹介したいと思います。

実装例の紹介

非常に簡単な、説明のためのサンプルを用意しました。Navigator 1(Navigator)とNavigator 2(Router)の2つの実装で、ほぼ同じ機能を実現しています。[6]

https://koji-1009.github.io/example_navigator_1_and_2/

コードはこちらです。必要最低限の実装をしています。

https://github.com/koji-1009/example_navigator_1_and_2

wordのケースは任意の文字列を画面で保持するケース、pushのケースは画面遷移を行うケースです。どちらのケースにおいても、同じ機能が実現されています。異なっているのは、Navigator 2のケースではURLに取得した文字列や、新たなpathが反映されている点です。

なお、Navigator 2のケースでは、GoRouterの説明用にGoRouter#pushGoRouter#goの2つのメソッドを利用するパターンを実装しました。GoRouter#pushの結果がURLに反映されるためには、GoRouter#optionURLReflectsImperativeAPIsの設定が必要なのですが、ここではtrueとしています。[7]


アプリケーションを触ってもらうと、動きに違いはないと思われます。実装は割と違うと思うのですが、見た目上違いがあることには気づけないのではないかなと。なので、画面のリロードを試してみてください。ブラウザのボタンでも、ショートカットコマンドでも構いません。

Navigator 1(Navigator)のケース

Navigator 2(Router)のケース

URLに状態が反映されている影響は、画面のリロード時に明らかです。Navigator 1(Navigator)では入力した文字列や、行なった画面遷移がリセットされます。Navigator 2(Router)では、リロードしてもリロード前の状態が表示されているはずです。
AndroidやiOSでは、特定の画面のリロードを行うことはできません。一方で、Webアプリケーションの場合には、当たり前の動作として画面のリロードを考慮する必要があります。この点が、Navigator 2(Router)がWebアプリケーションの開発において、必須になる理由だと筆者は考えています。

画面遷移が宣言的であるということ

ちょっと話が前後するのですが、宣言的Navigationを使う場合には、今この画面をリロードした時に、どのように画面が構築されるかを考えるのがポイントです。

AndroidやiOS向けのアプリケーション開発においてはGoRouterのメリットはDeep Linkのハンドリングがしやすい程度のものです。しかし、先ほどのサンプルで示したように、Navigator 1(Navigator)とNavigation 2(Router)では、画面構築や画面遷移に対する考え方が全く異なります。

例えば、先ほどのwordでは、Navigator 1(Navigator)とNavigator 2(Router)でプロパティの持ち方が明確に異なります。他の書き方もできますが、Navigationの各パラダイムを考慮すると、このような違いが生まれるはずです。[8]

https://github.com/koji-1009/example_navigator_1_and_2/blob/main/lib/pages/navigator_1_word.dart

https://github.com/koji-1009/example_navigator_1_and_2/blob/main/lib/pages/navigator_2_word.dart

実際のアプリケーションにおいては、必ずしもURLにすべての状態を反映させる必要はありません。なので、Navigator 1(Navigator)でNavigator 2(Router)のような動作ができないというと、それは嘘になります。
例えばshared_preferencesを利用して、アプリケーションの状態をLocalStorageに保存できます。[9]このようにすると、画面のリロードや立ち上げ直しにおいても、状態を保持されます。

以上の話題を踏まえると、Navigator 1(Navigator)とNavigator 2(Router)では、問題に対するアプローチが違うといえそうです。2つの手法は考えるべき問題と考え方が異なる、ということです。


Navigator 2(Router)は、コードがより宣言的に記述できるようになる手法です。Navigator 1(Navigator)に比べると、画面を構築する際にURLという情報を加味することが前提となり、構築される画面にURLの状態を反映できます。
また、先述の通り画面と画面の親子関係を、URLをベースに解決できます。例えば「ユーザー登録でstackを積み上げた後、ホーム画面に戻る」ようなケースにおいて、Navigator 1(Navigator)ではpopUntilによるクリーンアップを挟んだのちに、新たな画面をpushできます。一方でNavigator 2(Router)では、/homeに遷移することで、stackを気にせず遷移が可能です。この2つの実装をイメージすると、後者の方がより宣言的な実装になっている、と言える…と思います。

以上が、筆者の考えるNavigator 2(Router)の大きな特徴です。

おわりに

最後に、記事内であまり述べなかった点も含めて、筆者の意見を記載します。改めて強調しますが私見となり、他の意見を否定するものではありません。

  • Webアプリケーションを作る場合、Navigator 2は必須です
    • Navigator 1でもWebアプリケーションを作ることはできますが、推奨されません
    • FlutterでWebアプリケーションを作る場合、Webアプリケーションらしい動きを実現する必要があります
  • Webアプリケーションを作らない場合には、Navigator 2を採用する必要はありません
    • 動くアプリを作る場合には、push処理時にRouteを作成しても動作します
    • アプリケーションの構造を整理したり、特定の画面遷移をしたいケースでは、Navigator 2が候補に入ると思います
    • 筆者はNavigator 2が便利だなと思うので、Navigator 2をモバイルアプリケーションでも採用しています
  • Navigator 2のAPIを直接利用するのは現実的ではないです
    • go_routerauto_routebeamerなどを利用しましょう
    • Navigator 2のAPIを理解するよりも、ライブラリの利用を通してNavigator 2に親しむことを推奨します

宣言的Navigationは、FlutterアプリケーションにURLという表示するべき画面を一意に特定できる情報を導入します。この情報を利用することで、画面の構築や画面遷移をより宣言的に記述できます。
ただ、採用にあたってはNavigator 2(Router)用の考え方を導入する必要があると思います。AndroidやiOSで採用した場合には、画面のリロードがないため、イメージしにくいかもしません。この時には、頭の中で仮想的にリロードを行なってみると、Navigator 2(Router)らしい設計ができるのではないかなと思います。

ぜひぜひ、Navigator 2(Router)を利用してみてください。

脚注
  1. なおNavigator 2を紹介する記事もあるのですが、現在では公式ドキュメントを参照するようにと注記があります ↩︎

  2. 受け取ったlinkに対応するstackを、Navigator.popUntilNavigator.pushを駆使して実現する必要があります。たぶん。 ↩︎

  3. Navigator.pushNamed(context, '/details');の方が馴染みがあるかもしれません。どちらも同じ動作です。 ↩︎

  4. Deep Linkを利用していなければ、問題になりませんし…。 ↩︎

  5. Webアプリケーション開発をしている人からすると、当たり前の話ですが…。 ↩︎

  6. GitHub Pagesの処理が適当なのは、ご容赦ください。 ↩︎

  7. 公式ドキュメントにはfalseにすることが推奨されていますが、モバイル向けのアプリケーションではcontext.pushが必要になることも多いため、現実的にはtrueにすることが大半だと筆者は考えています ↩︎

  8. 筆者の意見です。 ↩︎

  9. ここではLocalStorage利用の良し悪しは議論しません。 ↩︎

GitHubで編集を提案

Discussion