go_router_builderとパス定義
記事について
Flutter Webのサポートに、日夜勤しんでいます。
Flutter Webのサポートをするためには、画面遷移をRouter APIを前提にする必要があります。ただRouter APIを直接利用するのは厳しいものがあるので、go_router
とgo_router_builder
を利用することが大半です。
Router APIを利用した設計は、従来のAndroidやiOSアプリケーションの開発と異なる発想を求められます。
この記事では、特にgo_router_builder
による強く静的に実装されたルーティングにおいて、こういった考え方ができるのでは? と最近考えていることを紹介します。最近考えていることと、最近の自由研究の発表なので、参考になる箇所だけ拾い読みしていただければうれしいです。
go_router
とgo_router_builder
go_router_builder
は、go_router
のオプション的な存在です。
go_router_builder
を使わずとも、Router APIを利用したアプリケーション開発に取り組むことができます。踏み込んだことを言ってしまうと、go_router
に追加されたAPIに対して、go_router_builder
が対応するまでのラグがあるため、使わない方が快適な開発になることもあります。
go_router_builder
のメリットについて。まずは、READMEに記載されている内容を確認します。
「画面遷移を行うために記述する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がすべて列挙されている、という意味が伝わるのではないでしょうか。筆者の考えるアプリケーション内のパスが静的に網羅されているクラスとは、このようなクラスを想定しています。
以前の記事[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つに集約されます。RoutingとRedirectです。
Routing
Routingは(任意の画面から)ある画面へ遷移するパターンの列挙になります。
go_roter
をそのまま使う場合、enumや何らかのクラスを用意することで、列挙の抜け漏れを防ぐことになります。go_router_builder
を使う場合には、先述の通り、go_router_builder
で生成対象にするクラスがその役割を担います。
この記事はgo_router_builder
の話を主題においているので、go_router_builder
の記法について説明します。
列挙に利用するのがTypedGoRoute
アノテーションと、そのオブジェクトです。
TypedGoRoute
は、自身が対応するpath
を必ず持ちます。そして、自身の子要素としてList<TypedGoRoute>
をroutes
に持つことができます。
例えば/settings
パスの下に、/settings/account
や/settings/notifications
などのパスを持ちたいケースであれば、
-
path
/settings
-
routes
account
notifications
を持つことを意味します。
ShellRoute
やStatefulShellRoute
になるとpath
が持てなくなるのですが、これは実現される機能に合致しており、正しい実装です。深入りするとShellRouteの紹介になってしまうので、ここでは割愛します。
遷移可能な画面が決まれば、あとは遷移するだけです。
go_router_builder
を利用する場合、GoRouteData
を継承したクラスを用意することで、パスに対応するDartのクラスを用意できます。このクラスを設計図に、build_runner
を走らせることで、自動生成されたクラスが利用できるようになります。
自動生成されたクラスには.go
や.push
などのメソッドが、生成元のクラスに書いたclassの拡張関数として実装されます。この拡張関数を、画面遷移の処理として呼び出すことで、画面遷移を実現できます。
Redirect
Redirectは、ユーザーの状態によって遷移のパターンを切り替える機能です。
404ページ、つまり存在しないパスを開いたケースでも利用できます。「存在しないパスをユーザーが任意の方法で開く」ケースと「ユーザーに応じてパスが存在しなくなる」ケースがあるため、重要な機能です。
Redirectを利用しない場合、パスによって開かれる画面側で、全てのケースを考慮する必要があります。逆にいうと、Redirectを利用することで、複数のWidgetに責務を切り分けることができます。
コードの可読性やメンテナンス性を保つためには、積極的に利用したい機能です。
Redirectにはtop-levelとroute-levelの2つの仕組みがあります。
- top-level
-
GoRouter
のredirect
プロパティ - 全ての画面遷移時にチェックされる
- アプリケーション全体で共通のRedirectを利用するケース
- ログインしていないユーザーを、ログイン画面に遷移させるなど
-
- route-level
-
GoRoute
(go_router
)とGoRouteData
(go_router_builder
)のredirect
プロパティ - あるパス以下の画面遷移時にチェックされる
- 状態や機能に応じたRedirectを利用するケース
- 課金機能を利用できないユーザーを、課金画面に遷移させるなど
-
すべての画面遷移時に走らせるか、特定のパス以下で走らせるか、という区分で筆者は理解しています。おおよその場合、この区別で十分です。
うまく実装すれば、URLを直入力されたケースにも対応できるので、あり得るパターンをすべて網羅するのがポイントだと思っています。
go_router
とriverpod
ユーザーの状態を確認する、とサラッと書いたのですが、どう実現するか悩むポイントではないでしょうか。
筆者はriverpodを利用するので、GoRoute
やGoRouteData
の中からRef
などにアクセスする必要が生じることになります。
riverpodの公式ドキュメントからは、go_router
を利用するケースのサンプルが紹介されています。
一方で、go_router_builder
を利用するサンプルの紹介はありません。なので、筆者が「これでいけるのでは?」と考えている方法を紹介します。
筆者がriverpodを理解している範囲でいけるのではと思っているので、間違っているかもしれません。ご注意ください。
GoRouter
オブジェクトは、MaterialApp.router
の引数として利用されます。このため、GoRouter
オブジェクトはMaterialApp.router
の中で利用されることになります。
ということは、ProviderScope
をGoRouteData
の内部から呼び出せるはずです。GoRouteData
に渡されるBuildContext
を利用し、rootに定義されたProviderScope
にアクセスを試みます。
ProviderScope
にアクセスするためのメソッドはProviderScope.containerOf
です。
パラメーターがいくつかあるので、ConsumerWidget
におけるread
メソッドを参考にします。
出来上がるコードがこちらです。redirect処理のみで利用することを想定しているので、app_routes.dart
にprivateで閉じるように実装しています。
extension on BuildContext {
T read<T>(ProviderListenable<T> provider) {
return ProviderScope.containerOf(this, listen: false).read<T>(provider);
}
}
筆者の手元では、Redirectの判定を行う最中であれば、想定通りに動作しています。
なおbuild
やbuildPage
の中だと.read
なのか.watch
なのか問題が起きるはずです。利用は避けた方が良いと思いますが、利用をされる場合には、十分に検証の上で採用してみてください。[3]
自由研究
以下、自由研究です。
go_router_builder
とファイル分割
go_router_builder
を利用する場合、小規模なアプリケーションでは気にならないのですが、コードが増えるにつれルートを記載するファイル(仮にapp_routes.dart
とします)が大きくなっていきます。このため、ファイル分割をどのように行うか、という議論が生じます。
Dartの言語機能を利用すると、ファイル分割は「import
とexport
」か「part
とpart of
」のどちらかを利用することになります。
「import
とexport
」はファイルが実際に分割されますが、「part
とpart of
」はファイルが分割されているように見えるだけの仕組みです。このため「import
とexport
」では分割したファイルごとに(ファイル内で使うクラスの)import
が制御できますが、「part
とpart of
」では統合されたファイルで1つのimport
文群を管理することになります。
筆者の意見としては、app_routes.dart
においては「part
とpart of
」を利用するほうがよい、と考えています。理由は、次の2点です。
-
go_router_builder
は@TypedGoRouter
アノテーションごとにコード生成し、それらをまとめた$appRoutes
を提供する - 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;
}
}
この実装を「import
とexport
」で表現しようとすると、articlesをまとめているファイルが、accountをまとめているファイルをimport
する必要が生じます。しかし、このarticlesとaccountのファイルの関係は、必ずしも一方向の関係、accountがarticlesをimport
するだけとは限りません。場合によっては、逆にaccountがarticlesをimport
することもあります。
Redirectが実現されるためには、あらゆるパスが他のパスに遷移しうることを想定する必要がある、ということです。
よって、app_routes.dart
はアプリケーションで利用される、すべてのパスをまとめたファイルであるべきだと考えます。
このような関係を表現するためには、「part
とpart of
」によるファイル分割が適切です。
DialogやSheetをURLで表現する
go_router_builder
を利用すると、Scaffold
レベルでの画面遷移を宣言的ナビゲーションで実現できます。
一方、Dialog
やBottomSheet
については、showDialog
やshowModalBottomSheet
を利用することが一般的です。大抵の場合、showDialog
やshowModalBottomSheet
は「画面の中でサッと行うアクション」を実現します。このため宣言的ナビゲーションで表現したいケース、つまりURIでDialog
やBottomSheet
を表現したいケースには、出会わないと思われます。
とはいえ、選択肢はあっても良いはず、と思って試しているのが次のリポジトリです。materialとcupertinoの両方をサポートしています。
現状の問題点としては、Dialog
からDialog
に遷移する際、Dialog
のModalBarrierがチラついてしまう箇所があります。
barrierの色を透明にすると解消できるのですが、それだとDialog
をサポートしている意味が薄くなってしまうなと。解消のためには、TransitionRoute
あたりから書き直せば良さそうなので、時間がある時にやってみたいなと。
色々と端折ると、比較的簡単なコードでDialog
やBottomSheet
の表示をURIで表現できます。
Flutter Webのサポートを進める上で、画面をリロードしてもDialog
を表示したいケースなどで、お役立てください。
まとめ
go_router_builder
を利用することで、アプリケーションの内部パスを静的に網羅できます。かつbuild_runner
によるコード生成が利用できるため、得られる成果に対してのコストが低い、メタプログラミングを導入できます。
他のルーティングを管理するライブラリでも同様だと思いますが、アプリケーションの根幹部分を整理することで、より見通しのよいアプリケーション開発につながると考えています。宣言的ナビゲーションを使いこなしていきましょう!
Discussion