↪️

go_router_builderとパス定義

2024/03/15に公開

記事について

Flutter Webのサポートに、日夜勤しんでいます。

Flutter Webのサポートをするためには、画面遷移をRouter APIを前提にする必要があります。ただRouter APIを直接利用するのは厳しいものがあるので、go_routergo_router_builderを利用することが大半です。

https://pub.dev/packages/go_router

https://pub.dev/packages/go_router_builder

Router APIを利用した設計は、従来のAndroidやiOSアプリケーションの開発と異なる発想を求められます。
この記事では、特にgo_router_builderによる強く静的に実装されたルーティングにおいて、こういった考え方ができるのでは? と最近考えていることを紹介します。最近考えていることと、最近の自由研究の発表なので、参考になる箇所だけ拾い読みしていただければうれしいです。

go_routergo_router_builder

go_router_builderは、go_routerのオプション的な存在です。

go_router_builderを使わずとも、Router APIを利用したアプリケーション開発に取り組むことができます。踏み込んだことを言ってしまうと、go_routerに追加されたAPIに対して、go_router_builderが対応するまでのラグがあるため、使わない方が快適な開発になることもあります。


go_router_builderのメリットについて。まずは、READMEに記載されている内容を確認します。

https://pub.dev/packages/go_router_builder#overview

「画面遷移を行うために記述するDartのコードと、画面遷移を実現するために記述するDartのコードの、間の処理を自動生成する」ことができるため、「画面遷移時の処理が型安全になる」のがgo_router_builderです。
簡単な例で、ざっくりと説明します。

記事一覧ページ(/articles)から記事詳細ページ(/articles/:id)に遷移するケースがあるとします。ブログや、旅行情報の紹介がなされるケースなど、割と一般的なケースではないでしょうか。
このとき、Flutterのコードとしては、context.go(/articles/1)で画面遷移をおこないます。将来的に/articles/1/links/articles/1/commentsなどのページも追加されるかもしれないので、ここではqueryではなくpathでidを表現しておくべきでしょう。

この遷移においては、実装者は、:idの部分をStringからintに変換する必要が生じます。画面遷移時に引き渡されるのはURIであり、値はStringで表現されるためです。URIは、pathが(もともと)intだったのかStringだったのかという情報を持ちません。

しかし、この変換は手間ですし、本来不要なはずです。
context.go(/articles/1)の引数がintであることは、アプリケーションの実装者であれば知っています。また記事詳細ページクラスのコンストラクタには、おそらくrequired int idと記述されているでしょう。

この手間を解消するのが、go_router_builderで記述するGoRouteDataを継承したクラスであり、それらを元に自動生成されるコードです。


多人数でアプリケーションを開発するのであれば、go_router_builderの恩恵を感じる場面が多いかな? と思っています。少人数で小規模なアプリケーションを開発する場合であれば、go_router_builderは過剰な印象を受けるかもしれません。
とはいえ、go_router_builderを経験がない状態で、大規模なアプリケーションに導入するのは大変だと思われます。Router API(go_router)と独自の記法(go_router_builder)の両方を同時に学習する必要が生じ、かつ数多くのパスを考慮する必要があるはずです。時間をとって、じっくりと取り組む必要があるでしょう。

go_router_builderによる宣言的なパスの定義

先述のような印象を持ちつつも、筆者はgo_router_builderを(大抵の場合)採用します。
これはgo_router_builderを採用することで、アプリケーション内のパス定義が一元管理されることに魅力を感じているためです。宣言的ナビゲーションを実現するにあたって、アプリケーション内のパスが静的に網羅されているクラスが必要だと考えています。これが、go_router_builderで簡単に実現される、というのがその理由です。

次のリンクは、FlutterKaigi 2023 conference-appのコードです。パスの文字列を諸事情でenumにしてある[1]のですが、アプリケーションで遷移可能なpathがすべて列挙されている、という意味が伝わるのではないでしょうか。筆者の考えるアプリケーション内のパスが静的に網羅されているクラスとは、このようなクラスを想定しています。

https://github.com/FlutterKaigi/conference-app-2023/blob/main/lib/ui/router/router_app.dart

以前の記事[2]で書いたのですが、筆者は宣言的ナビゲーションを「URIが表示されている画面の状態を記述する」仕組みだと捉えています。このため、アプリケーションが取りうるパスが全て記述することができていれば、アプリケーションが表示しうる画面表示のパターンを全て記述できているはずとみなしています。

go_router_builderによる型安全な遷移

型安全な遷移に関しては、ユーザーが「任意のURLをブラウザのアドレスバーに入力して遷移する」ことを考慮する必要があります。モバイル向けのアプリケーションでは、適当なDeep Linksを許可しなければ起きなかったような問題が、Webアプリケーションでは起きることを想定しなければなりません。

go_router_builderを利用していても、この問題は回避できません。
go_router_builderが生成するのは、あくまでも正常系の遷移を実現するためのコードです。異常なパターン、例えば先ほどの例で言えば/articles/1の代わりに/articles/1aを入力した場合、画面を表示するためのパース処理が失敗することになります。

go_router_builderが提供する型安全は、あくまでも、実装時のコードにおける型安全であることを理解しておく必要があります。
もちろん、typoやリファクタリング時の意図しないミスを防ぐ意味で、go_router_builderは有用です。しかし、この型があるからと言って、不具合を完全に防ぐことは(原理的に)できないことは把握しておく必要があります。

Router APIで考えること

Router APIを使ってアプリケーションを構築するにあたって、考えるべきことは、大きく分けて次の2つに集約されます。RoutingRedirectです。

Routing

Routingは(任意の画面から)ある画面へ遷移するパターンの列挙になります。

go_roterをそのまま使う場合、enumや何らかのクラスを用意することで、列挙の抜け漏れを防ぐことになります。go_router_builderを使う場合には、先述の通り、go_router_builderで生成対象にするクラスがその役割を担います。

この記事はgo_router_builderの話を主題においているので、go_router_builderの記法について説明します。
列挙に利用するのがTypedGoRouteアノテーションと、そのオブジェクトです。

https://pub.dev/documentation/go_router/latest/go_router/TypedGoRoute-class.html

TypedGoRouteは、自身が対応するpathを必ず持ちます。そして、自身の子要素としてList<TypedGoRoute>routesに持つことができます。
例えば/settingsパスの下に、/settings/account/settings/notificationsなどのパスを持ちたいケースであれば、

  • path
    • /settings
  • routes
    • account
    • notifications

を持つことを意味します。

ShellRouteStatefulShellRouteになるとpathが持てなくなるのですが、これは実現される機能に合致しており、正しい実装です。深入りするとShellRouteの紹介になってしまうので、ここでは割愛します。


遷移可能な画面が決まれば、あとは遷移するだけです。

go_router_builderを利用する場合、GoRouteDataを継承したクラスを用意することで、パスに対応するDartのクラスを用意できます。このクラスを設計図に、build_runnerを走らせることで、自動生成されたクラスが利用できるようになります。
自動生成されたクラスには.go.pushなどのメソッドが、生成元のクラスに書いたclassの拡張関数として実装されます。この拡張関数を、画面遷移の処理として呼び出すことで、画面遷移を実現できます。

Redirect

Redirectは、ユーザーの状態によって遷移のパターンを切り替える機能です。
404ページ、つまり存在しないパスを開いたケースでも利用できます。「存在しないパスをユーザーが任意の方法で開く」ケースと「ユーザーに応じてパスが存在しなくなる」ケースがあるため、重要な機能です。

Redirectを利用しない場合、パスによって開かれる画面側で、全てのケースを考慮する必要があります。逆にいうと、Redirectを利用することで、複数のWidgetに責務を切り分けることができます。
コードの可読性やメンテナンス性を保つためには、積極的に利用したい機能です。

https://pub.dev/documentation/go_router/latest/topics/Redirection-topic.html

Redirectにはtop-levelroute-levelの2つの仕組みがあります。

  • top-level
    • GoRouterredirectプロパティ
    • 全ての画面遷移時にチェックされる
    • アプリケーション全体で共通のRedirectを利用するケース
      • ログインしていないユーザーを、ログイン画面に遷移させるなど
  • route-level
    • GoRoute(go_router)とGoRouteData(go_router_builder)のredirectプロパティ
    • あるパス以下の画面遷移時にチェックされる
    • 状態や機能に応じたRedirectを利用するケース
      • 課金機能を利用できないユーザーを、課金画面に遷移させるなど

すべての画面遷移時に走らせるか、特定のパス以下で走らせるか、という区分で筆者は理解しています。おおよその場合、この区別で十分です。
うまく実装すれば、URLを直入力されたケースにも対応できるので、あり得るパターンをすべて網羅するのがポイントだと思っています。

go_routerriverpod

ユーザーの状態を確認する、とサラッと書いたのですが、どう実現するか悩むポイントではないでしょうか。
筆者はriverpodを利用するので、GoRouteGoRouteDataの中からRefなどにアクセスする必要が生じることになります。

riverpodの公式ドキュメントからは、go_routerを利用するケースのサンプルが紹介されています。

https://github.com/lucavenir/go_router_riverpod

一方で、go_router_builderを利用するサンプルの紹介はありません。なので、筆者が「これでいけるのでは?」と考えている方法を紹介します。
筆者がriverpodを理解している範囲でいけるのではと思っているので、間違っているかもしれません。ご注意ください。


GoRouterオブジェクトは、MaterialApp.routerの引数として利用されます。このため、GoRouterオブジェクトはMaterialApp.routerの中で利用されることになります。
ということは、ProviderScopeGoRouteDataの内部から呼び出せるはずです。GoRouteDataに渡されるBuildContextを利用し、rootに定義されたProviderScopeにアクセスを試みます。

ProviderScopeにアクセスするためのメソッドはProviderScope.containerOfです。

https://pub.dev/documentation/flutter_riverpod/latest/flutter_riverpod/ProviderScope/containerOf.html

パラメーターがいくつかあるので、ConsumerWidgetにおけるreadメソッドを参考にします。

https://github.com/rrousselGit/riverpod/blob/riverpod-v2.5.1/packages/flutter_riverpod/lib/src/consumer.dart#L617-L621

出来上がるコードがこちらです。redirect処理のみで利用することを想定しているので、app_routes.dartにprivateで閉じるように実装しています。

extension on BuildContext {
  T read<T>(ProviderListenable<T> provider) {
    return ProviderScope.containerOf(this, listen: false).read<T>(provider);
  }
}

筆者の手元では、Redirectの判定を行う最中であれば、想定通りに動作しています。
なおbuildbuildPageの中だと.readなのか.watchなのか問題が起きるはずです。利用は避けた方が良いと思いますが、利用をされる場合には、十分に検証の上で採用してみてください。[3]

自由研究

以下、自由研究です。

go_router_builderとファイル分割

go_router_builderを利用する場合、小規模なアプリケーションでは気にならないのですが、コードが増えるにつれルートを記載するファイル(仮にapp_routes.dartとします)が大きくなっていきます。このため、ファイル分割をどのように行うか、という議論が生じます。

Dartの言語機能を利用すると、ファイル分割は「importexport」か「partpart of」のどちらかを利用することになります。
importexport」はファイルが実際に分割されますが、「partpart of」はファイルが分割されているように見えるだけの仕組みです。このため「importexport」では分割したファイルごとに(ファイル内で使うクラスの)importが制御できますが、「partpart of」では統合されたファイルで1つのimport文群を管理することになります。

筆者の意見としては、app_routes.dartにおいては「partpart of」を利用するほうがよい、と考えています。理由は、次の2点です。

  1. go_router_builder@TypedGoRouterアノテーションごとにコード生成し、それらをまとめた$appRoutesを提供する
  2. Redirectの仕組みを考えると、GoRouteDataの継承クラスは他のGoRouteDataの継承クラスを(すべて)知っている必要がある

前者はライブラリの思想、制限に近いものがあるため、後者について述べます。

Redirectは、あるパスから別のパスに遷移させることを意味します。つまり、Redirectを実現するためには、GoRouteDataの継承クラスは他のGoRouteData継承クラスを参照できる必要がある、ということです。
この実現がどうなされるかを考えると、アプリケーション内のすべてのルートは1つのファイルにまとまっているべきだと考えます。

例えば、記事が課金コンテンツの場合、/articles/:idから/account/chargeに遷移させる必要があるとします。これを、次の2つのクラスで表現します。

class ArticleDetailRoute extends GoRouteData {
  const ArticleDetailRoute({
    required this.id,
  });

  final int id:

  
  Widget build(BuildContext context, GoRouterState state) {
    return ArticleDetailPage(
      id: id,
    );
  }
}

class AccountChargeRoute extends GoRouteData {
  const AccountChargeRoute();

  
  Widget build(BuildContext context, GoRouterState state) {
    return AccountChargePage();
  }
}

この時、Redirectを追加すると次のような実装になるでしょう。

class ArticleDetailRoute extends GoRouteData {
  const ArticleDetailRoute({
    required this.id,
  });

  final int id:

  
  Widget build(BuildContext context, GoRouterState state) {
    return ArticleDetailPage(
      id: id,
    );
  }

  
  FutureOr<String>? redirect(BuildContext context, GoRouterState state) {
    final isPurchased = context.read(isPurchasedProvider);
    if (!isPurchased) {
      return const AccountChargeRoute().location;
    }

    return null;
  }
}

この実装を「importexport」で表現しようとすると、articlesをまとめているファイルが、accountをまとめているファイルをimportする必要が生じます。しかし、このarticlesとaccountのファイルの関係は、必ずしも一方向の関係、accountがarticlesimportするだけとは限りません。場合によっては、逆にaccountがarticlesimportすることもあります。

Redirectが実現されるためには、あらゆるパスが他のパスに遷移しうることを想定する必要がある、ということです。
よって、app_routes.dartはアプリケーションで利用される、すべてのパスをまとめたファイルであるべきだと考えます。

このような関係を表現するためには、「partpart of」によるファイル分割が適切です。

DialogやSheetをURLで表現する

go_router_builderを利用すると、Scaffoldレベルでの画面遷移を宣言的ナビゲーションで実現できます。

一方、DialogBottomSheetについては、showDialogshowModalBottomSheetを利用することが一般的です。大抵の場合、showDialogshowModalBottomSheetは「画面の中でサッと行うアクション」を実現します。このため宣言的ナビゲーションで表現したいケース、つまりURIでDialogBottomSheetを表現したいケースには、出会わないと思われます。

とはいえ、選択肢はあっても良いはず、と思って試しているのが次のリポジトリです。materialとcupertinoの両方をサポートしています。

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

現状の問題点としては、DialogからDialogに遷移する際、DialogのModalBarrierがチラついてしまう箇所があります。
barrierの色を透明にすると解消できるのですが、それだとDialogをサポートしている意味が薄くなってしまうなと。解消のためには、TransitionRouteあたりから書き直せば良さそうなので、時間がある時にやってみたいなと。

色々と端折ると、比較的簡単なコードでDialogBottomSheetの表示をURIで表現できます。
Flutter Webのサポートを進める上で、画面をリロードしてもDialogを表示したいケースなどで、お役立てください。

まとめ

go_router_builderを利用することで、アプリケーションの内部パスを静的に網羅できます。かつbuild_runnerによるコード生成が利用できるため、得られる成果に対してのコストが低い、メタプログラミングを導入できます。

他のルーティングを管理するライブラリでも同様だと思いますが、アプリケーションの根幹部分を整理することで、より見通しのよいアプリケーション開発につながると考えています。宣言的ナビゲーションを使いこなしていきましょう!

脚注
  1. enum側でpathを受け取るべきだった気もしますが… ↩︎

  2. https://zenn.dev/koji_1009/articles/7b99e332c537cd ↩︎

  3. おそらく、呼び出すWidget側で判定する方が確実です。 ↩︎

GitHubで編集を提案

Discussion