go_router_builder で1つのファイルに押し込まれがちな定義を分割して見通しよくする
はじめに
Flutterアプリ開発で go_router
をお使いの方は多いと思います。
go_router
は、pathあるいはnameを文字列で指定して画面遷移を指示する為、場合によっては遷移先が存在していなかったり、打ち間違いをした結果、ビルド時に問題に気付くことが出来ず、実行時に遷移失敗することがあります。
それを解決するための仕組みとして、 go_router_builder
を用いてルーティングを構築すると、クラスを用いて画面遷移を行う為、存在しないという問題やtypoを起こりえなくすることができます。
ところで、ルーティング構築をおこなうファイルは単一のファイルに実装されることが多いようで、GitHubなどでコード検索をしてみても、 routes.dart
に全てが実装されていることが多いです。
ただ、 StatefulShellRoute
などでタブ要素毎に遷移を管理したいとき、それぞれにファイル分割、したくなるやん・・・?
というわけで、それを実現するやり方を解説します。とはいえそんなに複雑なことではないです。
バージョン
- flutter 3.10.6
- go_router: ^9.0.3
- go_router_builder: ^2.3.0
- 2.3.0 から StatefulShellRoute 対応されましたよ
分割前
import ...
part 'routes.g.dart'
final goRouter = GoRouter(
initialLocation: "/home",
routes: $appRoutes,
);
<MainShellRouteData>(
branches: <TypedStatefulShellBranch<StatefulShellBranchData>>[
TypedStatefulShellBranch(
routes: <TypedRoute<RouteData>>[
TypedGoRoute<HomePageRoute>(
path: "/home",
routes: [/* ... */],
),
],
)
TypedStatefulShellBranch(
routes: <TypedRoute<RouteData>>[
TypedGoRoute<MapsPageRoute>(
path: "/maps",
routes: [/* ... */],
),
],
),
TypedStatefulShellBranch(
routes: <TypedRoute<RouteData>>[
TypedGoRoute<AboutPageRoute>(
path: "/about",
routes: [/* ... */],
),
],
),
],
)
class MainShellRouteData extends StatefulShellRouteData {
const MainShellRouteData();
// ...
}
class HomePageRoute extends GoRouteData {
const HomePageRoute();
Page<void> buildPage(BuildContext context, GoRouterState state) {
return const NoTransitionPage(child: HomePage());
}
}
class MapsPageRoute extends GoRouteData { /* ... */ }
class AboutPageRoute extends GoRouteData { /* ... */ }
TypedGoRoute
の routes
はタブ毎の遷移定義ですが、複数の定義が存在して結構な分量になることが考えられるため、見通しが悪くなっていくことでしょう。
また、 GoRouteData
を継承したクラスも、画面遷移毎に大量に定義していくことになると、管理が大変かつ面倒になってくることでしょう。
分割途中(動きません)
最初、このように実装しました。ただしこのままだと動きません。
import ...
import 'home_branch.dart' // ★
import 'maps_branch.dart' // ★
import 'about_branch.dart' // ★
part 'routes.g.dart'
final goRouter = GoRouter(
initialLocation: "/home",
routes: $appRoutes,
);
<MainShellRouteData>(
branches: <TypedStatefulShellBranch<StatefulShellBranchData>>[
homeTypedStatefulShellBranch, // ★
mapTypedStatefulShellBranch, // ★
aboutTypedStatefulShellBranch, // ★
],
)
class MainShellRouteData extends StatefulShellRouteData {
const MainShellRouteData();
// ...
}
import ...
const homeTypedStatefulShellBranch = TypedStatefulShellBranch(
routes: <TypedRoute<RouteData>>[
TypedGoRoute<HomePageRouteData>(
path: "/home",
routes: [/* ... */],
),
],
);
import ...
class HomePageRoute extends GoRouteData {
const HomePageRoute();
Page<void> buildPage(BuildContext context, GoRouterState state) {
return const NoTransitionPage(child: HomePage());
}
}
class HomePage extends StatelessWidget { /* ... */ }
(他のファイルは省略)
分割のポイント
TypedStatefulShellBranch
をタブ毎にファイルに分けることが出来る
1. アノテーションに定義した 実はDartのアノテーションに渡す要素は変数に分けることが可能です。
よって、ファイルで分割することができます。
(Javaのアノテーションに馴染みがあると気付きづらいかもしれない。僕もJava出身なので。。。)
2. 画面遷移や遷移パラメーターを定義するクラスは画面毎に管理することも出来る
これは実際にされている方もいるかも知れませんが、画面となるWidgetと同じファイルに、GoRouteData
を継承したクラスを置くことが出来ます。
別に同じファイルでなくてもいいですが、遷移パラメータを定義するロジックと使用するロジックは近い方が間違いが生じづらいと僕は考えます。
ただし、ルーティングを管理するファイル(今回の場合は home_branch.dart
)に定義を置いておくこともアリでしょう。
例えば同じ画面を使い回すのだけど、タブAとタブBそれぞれ独立して遷移があってほしい場合(それぞれでスタックがあってほしい場合)は、ルーティングを管理する場所に置いておくべきかな、と思います。
なぜ動かない?
実はこのままだと動作しません。
flutter pub run build_runner build
を実行するとファイル生成自体は問題なく完了するのですが、遷移クラスを用いた箇所( const HomePageRoute().go(context)
)では以下のエラーが表示されます。
A member named 'go' is defined in extension '
MapsPageRouteExtension', and extension '$AboutPageRouteExtension', and none are more specific. HomePageRouteExtension', extension '
Try using an extension override to specify the extension you want to be chosen.dartambiguous_extension_member_access
いろいろと調べたところ、生成された routes.g.dart
を確認してみると、以下のようなエラーが表示されていました
Undefined class 'HomePageRoute'.
Try changing the name to the name of an existing class, or creating a class with the name 'HomePageRoute'. dartundefined_class
これは、エラーそのままですが、 routes.g.dart
から HomePageRoute
が参照できていないといことです。
解決方法
解決策は routes.g.dart
から HomePageRoute
が存在する home_page.dart
を参照できるようにします。
このときのアプローチとして、 routes.dart
に import 'home_page.dart'
を記述すると解決します。
ただ、 HomePageRoute
を参照しているのは本質的には home_branch.dart
であり、 routes.dart
ではないので、単にimportを書くだけだと関係性が曖昧になり、なぜそのimport文があるのか分かりづらくなり、リファクタ時に誤ってimport文を消してしまうことがあるかも知れません。
(とはいえ routes.g.dart
から参照していることは確定しているので、unused import的なエラーは出ないはずですが。。。)。
というわけで完全解決編です。
分割後
import ...
import 'home_page.dart'; // ★
import ...
part 'home_branch.dart'; // ★
part 'maps_branch.dart';
part 'about_branch.dart';
part 'routes.g.dart';
final goRouter = GoRouter(
initialLocation: "/home",
routes: $appRoutes,
);
<MainShellRouteData>(
branches: <TypedStatefulShellBranch<StatefulShellBranchData>>[
homeTypedStatefulShellBranch,
mapTypedStatefulShellBranch,
aboutTypedStatefulShellBranch,
],
)
class MainShellRouteData extends StatefulShellRouteData {
const MainShellRouteData();
// ...
}
part of 'routes.dart'; // ★
const homeTypedStatefulShellBranch = TypedStatefulShellBranch(
routes: <TypedRoute<RouteData>>[
TypedGoRoute<HomePageRouteData>(
path: "/home",
routes: [/* ... */],
),
],
);
(他のファイルは省略)
part
と part of
について
freezed
や go_router_builder
でも実は内部的に普通に使われているのですが、 part
と対になるのが part of
です。
part of 'A.dart'
と書かれたファイルBは、 A.dart
の一部であるという扱いになり、 B.dart
は import を持つことが出来なくなるかわりに A.dart
のimportを参照するようになります。
考え方としては、雑にファイルをぶった切ったものをつなぎ合わせる為の呪文、と捉えるのが良いかも知れません(正確な表現ではないかも知れません)。
僕がわざわざpart/part of
を利用する方法を提案する理由として、親ファイルのimportを参照するという性質は、(少なくともVSCodeなどで記述している際のimportの自動補完を利用する際に)非常に便利であり、今回のように親ファイルに必要なimportを記述する必要がある場合において記述漏れを防ぐことが出来るからです。
このように書いておくと、将来の画面数の増加でも、ミスなく対応することが出来るはずです。
おわりに
皆さんのプロジェクトの routes.dart
が見通しの良いものになれば幸いです。
Discussion
こちらの対応なのですが私の環境ではpart対応しなくても動きました!
バージョンはこちらです。
(返信遅くなりました)
コメントありがとうございます!
part
なくてもいけるんですね。。。自分の調べ方が悪いのか、
part
とpart of
についての情報があまりないのでgo_router_builder などのコード生成系ライブラリの実装をそのまま持ってきています。
もう少しこのあたりの理解を深めたいところだと思いました。