💄

go_router_builder で1つのファイルに押し込まれがちな定義を分割して見通しよくする

2023/08/15に公開2

はじめに

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 対応されましたよ

分割前

routes.dart
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 { /* ... */ }

TypedGoRouteroutes はタブ毎の遷移定義ですが、複数の定義が存在して結構な分量になることが考えられるため、見通しが悪くなっていくことでしょう。

また、 GoRouteData を継承したクラスも、画面遷移毎に大量に定義していくことになると、管理が大変かつ面倒になってくることでしょう。

分割途中(動きません)

最初、このように実装しました。ただしこのままだと動きません。

routes.dart
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();

  // ...
}
home_branch.dart
import ...

const homeTypedStatefulShellBranch = TypedStatefulShellBranch(
  routes: <TypedRoute<RouteData>>[
    TypedGoRoute<HomePageRouteData>(
      path: "/home",
      routes: [/* ... */],
    ),
  ],
);
home_page.dart
import ...

class HomePageRoute extends GoRouteData {
  const HomePageRoute();

  
  Page<void> buildPage(BuildContext context, GoRouterState state) {
    return const NoTransitionPage(child: HomePage());
  }
}

class HomePage extends StatelessWidget { /* ... */ }

(他のファイルは省略)

分割のポイント

1. アノテーションに定義した TypedStatefulShellBranch をタブ毎にファイルに分けることが出来る

実は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 'HomePageRouteExtension', extension 'MapsPageRouteExtension', and extension '$AboutPageRouteExtension', and none are more specific.
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.dartimport 'home_page.dart' を記述すると解決します。

ただ、 HomePageRoute を参照しているのは本質的には home_branch.dart であり、 routes.dart ではないので、単にimportを書くだけだと関係性が曖昧になり、なぜそのimport文があるのか分かりづらくなり、リファクタ時に誤ってimport文を消してしまうことがあるかも知れません。
(とはいえ routes.g.dart から参照していることは確定しているので、unused import的なエラーは出ないはずですが。。。)。

というわけで完全解決編です。

分割後

routes.dart
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();

  // ...
}
home_branch.dart
part of 'routes.dart'; // ★

const homeTypedStatefulShellBranch = TypedStatefulShellBranch(
  routes: <TypedRoute<RouteData>>[
    TypedGoRoute<HomePageRouteData>(
      path: "/home",
      routes: [/* ... */],
    ),
  ],
);

(他のファイルは省略)

partpart of について

freezedgo_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 'home_branch.dart'; // ★
part 'maps_branch.dart';
part 'about_branch.dart';
part 'routes.g.dart';

final goRouter = GoRouter(
initialLocation: "/home",
routes: $appRoutes,
);

こちらの対応なのですが私の環境ではpart対応しなくても動きました!
バージョンはこちらです。

flutter: ^3.13.1
go_router: ^10.1.1
go_router_builder: ^2.3.0
Kouta ImanakaKouta Imanaka

(返信遅くなりました)
コメントありがとうございます!

part なくてもいけるんですね。。。
自分の調べ方が悪いのか、 partpart of についての情報があまりないので
go_router_builder などのコード生成系ライブラリの実装をそのまま持ってきています。
もう少しこのあたりの理解を深めたいところだと思いました。