指南:用注解驅(qū)動生成類型安全的 go_router 路由代碼)
go_router_builder 實戰(zhàn)指南用注解驅(qū)動生成類型安全的 go_router 路由代碼【免費下載鏈接】packagesA collection of useful packages maintained by the Flutter team項目地址: https://gitcode.com/GitHub_Trending/pac/packagesgo_router_builder 是 Flutter 官方 packages 倉庫中為 go_router 提供代碼生成的構(gòu)建器builder它通過TypedGoRoute、TypedShellRoute等注解在編譯期把字符串形式的路徑參數(shù)、查詢參數(shù)轉(zhuǎn)換為強類型 Dart 代碼并自動生成go、push、location等導航樣板。本文以倉庫內(nèi) go_router_builder/README.md 為主線結(jié)合其源碼lib/src/、示例工程example/與測試用例test_inputs/、tool/run_tests.dart從依賴配置、路由定義到生成代碼原理逐一展開讀完你可以直接在自己的 Flutter 項目中接入這套類型安全路由方案。為什么需要 go_router_builder把運行時錯誤提前到編譯期go_router的核心能力是把一個 URI 格式的字符串 location 匹配到若干個頁面構(gòu)建器每個構(gòu)建器可能需要零個或多個來自路徑path與查詢query的參數(shù)。雖然GoRouterState通過pathParameters和queryParameters暴露了這些參數(shù)但它們默認都是String頁面構(gòu)建器往往需要手工解析成非字符串類型。例如GoRoute( path: :familyId, builder: (BuildContext context, GoRouterState state) { // Require the familyId to be present and be an integer. final int familyId int.parse(state.pathParameters[familyId]!); return FamilyScreen(familyId); }, );這里familyId既是必填參數(shù)、又必須是int但這兩個約束都要等到運行時才會暴露void tap() context.go(/familyId/a42); // This is an error: a42 is not an int.Dart 類型系統(tǒng)的價值在于把這類錯誤從運行時提前到編譯期。go_router_builder 的目標正是由開發(fā)者聲明某條路由消費哪些必填/可選參數(shù)用代碼生成把手工編寫go、push、location樣板的工作自動化從而獲得編譯期類型檢查。從源碼看這個構(gòu)建器是標準package:build生態(tài)的一員lib/go_router_builder.dart 通過SharedPartBuilder注冊了GoRouterGenerator產(chǎn)物部件名為go_router并讀取build.yaml中的duplicate_route_paths選項解析邏輯見 lib/src/duplicate_path_severity.dart。當前倉庫內(nèi)該包版本為 4.5.0SDK 約束^3.10.0、Flutter 約束3.38.0dev 依賴使用 go_router^17.3.0見 go_router_builder/pubspec.yaml。依賴配置pubspec.yaml 三件套要使用go_router_builder需要在pubspec.yaml中同時加入三個依賴dependencies: # ...along with your other dependencies go_router: ^16.2.0 dev_dependencies: # ...along with your other dev-dependencies build_runner: ^2.6.0 go_router_builder: ^4.0.1要點說明go_router放在dependencies因為生成的代碼與運行時都要引用它go_router_builder與build_runner放在dev_dependencies它們只參與開發(fā)期的代碼生成不進入產(chǎn)物上例是 README 給出的版本下限go_router^16.2.0、go_router_builder^4.0.1、build_runner^2.6.0。倉庫內(nèi)部自用時版本更激進示例工程 example/pubspec.yaml 使用 go_router^17.3.0、build_runner^2.3.0并以path: ..直接依賴本地源碼方便驗證最新改動。源碼接入import 與 part 指令缺一不可生成代碼以part文件形式并入你的源碼。除了導入package:go_router/go_router.dart必須包含一條指向生成文件的part指令生成文件固定命名為[source_file].g.dartimport package:go_router/go_router.dart; part readme_excerpts.g.dart;示例工程里的 readme_excerpts.dart 正是這種結(jié)構(gòu)頂部part readme_excerpts.g.dart;同目錄存在對應的readme_excerpts.g.dart產(chǎn)物。完整的可運行示例可對照 simple_example.dart其生成結(jié)果見 simple_example.g.dart。運行 build_runner 生成代碼在項目根目錄執(zhí)行一次性構(gòu)建dart run build_runner build構(gòu)建成功后每個被注解的源文件旁會出現(xiàn).g.dart文件。從生成結(jié)果可以看到構(gòu)建器實際產(chǎn)出的內(nèi)容simple_example.g.dartListRouteBase get $appRoutes [$homeRoute]; RouteBase get $homeRoute GoRouteData.$route( path: /, name: Home, hasOverriddenOnExit: false, factory: $HomeRoute._fromState, routes: [ GoRouteData.$route( path: family/:familyId, hasOverriddenOnExit: false, factory: $FamilyRoute._fromState, ), ], );也就是說TypedGoRoute注解最終被翻譯為GoRouteData.$route(...)調(diào)用每個路由類會生成一個以$開頭的 mixin如$HomeRoute內(nèi)部實現(xiàn)locationgetter 以及go、push、pushReplacement、replace等方法。生成代碼頭部帶有 “GENERATED CODE - DO NOT MODIFY BY HAND” 標識切勿手工編輯。定義一條路由GoRouteData build用 go_router_builder 定義路由的方式是讓路由類繼承GoRouteData并覆寫build方法同時with生成器生成的同名 mixinclass HomeRoute extends GoRouteData with $HomeRoute { const HomeRoute(); override Widget build(BuildContext context, GoRouterState state) const HomeScreen(); }GoRouteData提供location、go、push等基礎(chǔ)設(shè)施mixin$HomeRoute由代碼生成負責把構(gòu)造函數(shù)參數(shù)映射到 location 字符串并在go/push內(nèi)部調(diào)用context.go(location)、context.pushT(location)生成器要求路由類必須有命名構(gòu)造函數(shù)未命名構(gòu)造函數(shù)否則會在構(gòu)建時報錯例如 route_config.dart 中對ShellRouteData未命名構(gòu)造函數(shù)的校驗。組織路由樹TypedGoRoute 注解路由樹通過頂層路由上的TypedGoRoute注解聲明子路由寫在routes列表中TypedGoRouteHomeRoute( path: /, routes: TypedGoRouteGoRouteData[ TypedGoRouteFamilyRoute(path: family/:fid), ], ) class HomeRoute extends GoRouteData with $HomeRoute { const HomeRoute(); override Widget build(BuildContext context, GoRouterState state) const HomeScreen(); } class RedirectRoute extends GoRouteData { // There is no need to implement [build] when this [redirect] is unconditional. override String? redirect(BuildContext context, GoRouterState state) { return const HomeRoute().location; } } TypedGoRouteLoginRoute(path: /login) class LoginRoute extends GoRouteData with $LoginRoute { LoginRoute({this.from}); final String? from; override Widget build(BuildContext context, GoRouterState state) { return LoginScreen(from: from); } }值得注意的細節(jié)path支持兩種寫法以/開頭的絕對路徑如/login與不帶/的相對路徑如family/:fid會拼到父路由之下RedirectRoute只實現(xiàn)redirect而不實現(xiàn)build用于無條件重定向場景見 README 的 Route-level redirection 一節(jié)子路由泛型參數(shù)寫TypedGoRouteGoRouteData還是具體類均可生成器按注解讀取。GoRouter 初始化$appRoutes代碼生成器會把所有頂層路由聚合進一個名為$appRoutes的列表直接用于初始化GoRouterfinal router GoRouter(routes: $appRoutes);這也解釋了為什么需要part指令——$appRoutes定義在.g.dart中通過part成為當前庫的一部分才能被直接引用。示例工程中 simple_example.dart 的實際用法是final GoRouter _router GoRouter(routes: $appRoutes);隨后交給MaterialApp.router(routerConfig: _router)。錯誤頁面errorBuilder類型化路由同樣可以承載錯誤頁面。先定義一個接收error的ErrorRouteclass ErrorRoute extends GoRouteData { ErrorRoute({required this.error}); final Exception error; override Widget build(BuildContext context, GoRouterState state) { return ErrorScreen(error: error); } }然后在初始化GoRouter時把errorBuilder指向它final routerWithErrorBuilder GoRouter( routes: $appRoutes, errorBuilder: (BuildContext context, GoRouterState state) { return ErrorRoute(error: state.error!).build(context, state); }, );state.error!是 go_router 在匹配失敗時設(shè)置的非空異常類型化路由讓錯誤頁也能拿到強類型字段而不再需要散落的手工字符串拼接。導航go / push / location 與編譯期校驗導航使用生成器提供的go或push方法void onTap() const FamilyRoute(fid: f2).go(context);如果你漏掉了必填參數(shù)編譯器會直接報錯// This is an error: missing required parameter fid. void errorTap() const FamilyRoute().go(context);這正是類型化路由的核心價值錯誤在靜態(tài)分析階段就被發(fā)現(xiàn)。從生成代碼可以印證這一點——$FamilyRoutemixin 的_fromState會讀取state.pathParameters[familyId]!而locationgetter 則用Uri.encodeComponent編碼參數(shù)后拼出/family/...字符串參數(shù)與路徑的對應關(guān)系完全由生成器維護不再依賴手寫。返回值push從 go_router 6.5.0 起push 一條路由再 pop 時可以攜帶返回值生成的路由同樣支持final bool? result await const FamilyRoute( fid: John, ).pushbool(context);pushT的泛型參數(shù)就是 pop 時的返回類型。示例中的 readme_excerpts.dart 展示了完整閉環(huán)HomeScreen 里await ...pushbool(context)FamilyScreen 里通過context.pop(true)把true傳回上一頁。查詢參數(shù)與默認值構(gòu)造函數(shù)的參數(shù)命名或位置參數(shù)只要沒有出現(xiàn)在TypedGoRoute的path中就會被當作查詢參數(shù)處理TypedGoRouteLoginRoute(path: /login) class LoginRoute extends GoRouteData with $LoginRoute { LoginRoute({this.from}); final String? from; override Widget build(BuildContext context, GoRouterState state) { return LoginScreen(from: from); } }這里的from未出現(xiàn)在 path 中因此會編碼為查詢參數(shù)形如/login?fromxxx。默認值對于非空類型的查詢參數(shù)可以聲明默認值TypedGoRouteMyRoute(path: /my-route) class MyRoute extends GoRouteData with $MyRoute { MyRoute({this.queryParameter defaultValue}); final String queryParameter; override Widget build(BuildContext context, GoRouterState state) { return MyScreen(queryParameter: queryParameter); } }一個關(guān)鍵行為當查詢參數(shù)的值等于默認值時它不會出現(xiàn)在 location 中。這保證了 URL 的簡潔性也意味著生成的 location 是確定性輸出。查詢參數(shù)相關(guān)的更復雜類型如Iterable、枚舉在測試用例 typed_query_parameter.dart 及其.expect中有完整覆蓋。額外參數(shù) $extra 與混合參數(shù)路由可以通過名為$extra的特殊構(gòu)造參數(shù)消費 go_router 的 extra 機制class PersonRouteWithExtra extends GoRouteData with $PersonRouteWithExtra { PersonRouteWithExtra(this.$extra); final Person? $extra; override Widget build(BuildContext context, GoRouterState state) { return PersonScreen($extra); } }導航時直接傳入類型化對象void tapWithExtra() { PersonRouteWithExtra(Person(id: 1, name: Marvin, age: 42)).go(context); }需要明確$extra的邊界它仍然在 location 之外傳遞依然無法支持動態(tài)鏈接和深鏈接包括瀏覽器返回按鈕面向 Flutter Web 時不推薦依賴它。$extra這個字段名在源碼中定義為常量extraFieldName見 lib/src/type_helpers.dart。當然路徑、查詢、extra 三種參數(shù)可以自由組合TypedGoRouteHotdogRouteWithEverything(path: /:ketchup) class HotdogRouteWithEverything extends GoRouteData with $HotdogRouteWithEverything { HotdogRouteWithEverything(this.ketchup, this.mustard, this.$extra); final bool ketchup; // A required path parameter. final String? mustard; // An optional query parameter. final Sauce $extra; // A special $extra parameter. override Widget build(BuildContext context, GoRouterState state) { return HotdogScreen(ketchup, mustard, $extra); } }這個示例略顯夸張但它驗證了生成器對參數(shù)來源的判定規(guī)則出現(xiàn)在 path 中的是路徑參數(shù)未出現(xiàn)的是查詢參數(shù)名為$extra的走 extra 通道。重定向全局與路由級全局 redirect結(jié)合生成的location屬性可以在GoRouter的redirect回調(diào)中實現(xiàn)登錄守衛(wèi)等邏輯redirect: (BuildContext context, GoRouterState state) { final bool loggedIn loginInfo.loggedIn; final loggingIn state.matchedLocation LoginRoute().location; if (!loggedIn !loggingIn) { return LoginRoute(from: state.matchedLocation).location; } if (loggedIn loggingIn) { return const HomeRoute().location; } return null; },這里LoginRoute().location與HomeRoute().location都由生成器保證與路由定義一致避免了手寫路徑字符串帶來的漂移問題返回null表示不重定向。路由級 redirect如果重定向只作用于某條路由可以直接在路由類上實現(xiàn)redirect方法class RedirectRoute extends GoRouteData { // There is no need to implement [build] when this [redirect] is unconditional. override String? redirect(BuildContext context, GoRouterState state) { return const HomeRoute().location; } }當redirect是無條件返回時甚至無需實現(xiàn)build。類型轉(zhuǎn)換int、enum、extension type 與更多內(nèi)置支持生成器可以把int、enum、extension type等簡單類型與底層pathParameters的String相互轉(zhuǎn)換。以枚舉為例enum BookKind { all, popular, recent } TypedGoRouteBooksRoute(path: /books) class BooksRoute extends GoRouteData with $BooksRoute { BooksRoute({this.kind BookKind.popular}); final BookKind kind; override Widget build(BuildContext context, GoRouterState state) { return BooksScreen(kind: kind); } }從源碼看類型轉(zhuǎn)換能力由一組內(nèi)置_TypeHelper提供lib/src/type_helpers.dart當前支持的類型包括BigInt、bool、DateTime、double、int、num、String、Uri等基礎(chǔ)類型enum類型生成_$fromName輔助函數(shù)做名稱映射extension typeIterable可迭代集合支持默認值JSON 對象MapString, dynamic風格見測試用例 json.dart用戶自定義的 string?string 編解碼函數(shù)源碼通過_isStringToStringFunction識別。生成器還會生成_$boolConverter、_$durationConverter、_$convertMapValue、_$iterablesEqual等私有輔助函數(shù)常量定義見 lib/src/type_helpers.dart用于可空性處理、集合比較與類型轉(zhuǎn)換。測試用例 enum_parameter.dart、extension_type_parameter.dart、unsupported_type.dart 分別驗證了枚舉、extension type 的轉(zhuǎn)換以及不支持類型的報錯路徑。轉(zhuǎn)場動畫覆寫 buildPage 與自定義過渡默認情況下GoRouter會使用 widget 樹中的 AppMaterialApp、CupertinoApp、WidgetApp等以及對應的頁面類型MaterialPage、CupertinoPage、NoTransitionPage等來包裝路由build返回的Widget并用state.pageKey同時設(shè)置頁面的key與restorationId。覆蓋頁面創(chuàng)建如果想改變頁面創(chuàng)建方式——例如換用不同的頁面類型、傳入自定義 key或訪問GoRouterState——可以覆寫buildPage而不是buildclass MyMaterialRouteWithKey extends GoRouteData with $MyMaterialRouteWithKey { const MyMaterialRouteWithKey(); static const LocalKey _key ValueKeyString(my-route-with-key); override MaterialPagevoid buildPage(BuildContext context, GoRouterState state) { return const MaterialPagevoid(key: _key, child: MyPage()); } }自定義過渡動畫覆寫buildPage同樣是實現(xiàn)自定義轉(zhuǎn)場的入口。下面的例子返回一個旋轉(zhuǎn)進入的CustomTransitionPageclass FancyRoute extends GoRouteData with $FancyRoute { const FancyRoute(); override CustomTransitionPagevoid buildPage( BuildContext context, GoRouterState state, ) { return CustomTransitionPagevoid( key: state.pageKey, child: const MyPage(), transitionsBuilder: ( BuildContext context, Animationdouble animation, Animationdouble secondaryAnimation, Widget child, ) { return RotationTransition(turns: animation, child: child); }, ); } }這里使用state.pageKey作為頁面 key保證與 go_router 的頁面生命周期管理包括恢復保持一致。倉庫還提供了 animations 包packages/animations/可配合實現(xiàn)更豐富的過渡效果。TypedShellRoute 與 Navigator key當 Shell 的子路由需要顯示在不同的 Navigator 上時可以聲明靜態(tài)navigator key命名規(guī)則為Shell 路由使用$navigatorKey普通 GoRoute 使用$parentNavigatorKey。示例final GlobalKeyNavigatorState shellNavigatorKey GlobalKeyNavigatorState(); final GlobalKeyNavigatorState rootNavigatorKey GlobalKeyNavigatorState(); TypedShellRouteMyShellRouteData( routes: TypedRouteRouteData[ TypedGoRouteMyGoRouteData(path: my-go-route), ], ) class MyShellRouteData extends ShellRouteData { const MyShellRouteData(); static final GlobalKeyNavigatorState $navigatorKey shellNavigatorKey; override Widget builder(BuildContext context, GoRouterState state, Widget navigator) { return MyShellRoutePage(navigator); } } // For GoRoutes: class MyGoRouteData extends GoRouteData with $MyGoRouteData { const MyGoRouteData(); static final GlobalKeyNavigatorState $parentNavigatorKey rootNavigatorKey; override Widget build(BuildContext context, GoRouterState state) const MyPage(); }從源碼看ShellRouteConfig會把這些 key 拼進生成的路由構(gòu)造參數(shù)navigatorKey、parentNavigatorKey、observers、restorationScopeId等見 lib/src/route_config.dart并生成_fromState工廠與$route數(shù)據(jù)轉(zhuǎn)換函數(shù)。倉庫中還有更完整的參考實現(xiàn)shell_route_with_keys_example.dart多級 Navigator、shell_route_with_observers_example.dartNavigatorObserver、stateful_shell_route_example.dartStatefulShellRoute。相對路由RelativeGoRouteData相對路由允許在路由樹的不同位置復用同一個RouteData。定義方式是把基類換成RelativeGoRouteDataTypedRelativeGoRouteDetailsRoute(path: details) class DetailsRoute extends RelativeGoRouteData with $DetailsRoute { const DetailsRoute(); override Widget build(BuildContext context, GoRouterState state) const DetailsScreen(); }導航時使用生成器提供的goRelative或pushRelativevoid onTapRelative() const DetailsRoute().goRelative(context);注意相對路由方法不是冪等的當相對 location 無法匹配任何路由時會報錯。相關(guān)約束在測試用例 go_relative.dart 與 relative_route_with_absolute_path.dart 中均有驗證。構(gòu)建器選項duplicate_route_paths當兩條路由解析到同一個 URL 時go_router 會匹配第一條第二條變得不可達——導航到第二條的location會展示第一條的頁面。構(gòu)建器會在構(gòu)建期對此發(fā)出警告。比較規(guī)則路由按解析后的完整 URL比較而不是按聲明的 path 比較因此路由樹深度無關(guān)緊要section/detail會與section下掛detail子路由沖突參數(shù)名被忽略product/:id與product/:productId視為同一 URL參數(shù)的正則約束計入比較product/:id(\d)與product/:id(\w)不算沖突大小寫當前面的路由設(shè)置了caseSensitive: false時忽略大小寫因為它會匹配任意大小寫比較范圍是整個庫包括part文件Shell 路由與StatefulShellRoute分支對其下的 URL 不產(chǎn)生額外貢獻??蛇x值通過build.yaml調(diào)整重復路徑的處理策略targets: $default: builders: go_router_builder: options: duplicate_route_paths: error取值分別為warning默認、error、ignore。為什么默認是 warning部分重復是合法的go_router 匹配時會回溯因此同一個路由類在相同路徑上聲明兩次、各自攜帶不同子路由是可行的——兩者解析到同一個類所有子路由仍可達這也成為一種按功能分組子路由的方式。但構(gòu)建器無法區(qū)分“有意的分組”與“意外的重復”所以仍會警告如果是有意為之請使用ignore。不過它們的子路由是另一回事跨兩次聲明重復出現(xiàn)的子路徑是真正的沖突會被單獨報告。error級別作用于整個包、沒有按路由豁免的機制因此連有意的分組也會構(gòu)建失敗。從源碼看該選項在 lib/src/duplicate_path_severity.dart 中定義為DuplicatePathSeverity枚舉ignore/warning/error默認warning傳入非法值時拋出ArgumentError并提示合法取值。構(gòu)建器入口 lib/go_router_builder.dart 通過duplicatePathSeverityFromOptions讀取該選項后構(gòu)造GoRouterGenerator。倉庫的test_inputs/目錄提供了大量針對該行為的用例duplicate_path_sibling_routes、duplicate_path_different_param_names參數(shù)名不同不算沖突、duplicate_path_case_insensitive/duplicate_path_case_sensitive_distinct大小寫敏感性、duplicate_path_ignored配合.options設(shè)置ignore且.warnings為空文件斷言無警告、duplicate_path_same_class同一類兩次聲明等對應的測試入口為 test/duplicate_path_severity_test.dart。測試與驗證test_inputs 雙文件機制包級單元測試在packages/go_router_builder/目錄下執(zhí)行dart tool/run_tests.dart測試機制非常精巧見 tool/run_tests.darttest_inputs/下每個.dart文件是一個測試用例必須配對一個.expect文件內(nèi)容是生成器應產(chǎn)出的代碼或構(gòu)建失敗時應報出的錯誤消息可選的兩個伴生文件用于微調(diào)用例name.dart.optionsJSON 格式的構(gòu)建器選項等價于build.yaml傳給構(gòu)建器的配置name.dart.warnings構(gòu)建器必須輸出的警告每行一條空文件表示斷言構(gòu)建器不輸出任何警告每個用例會解析真實 SDK 與包源碼設(shè)置 5 分鐘超時以容忍繁忙機器上的慢解析比較前統(tǒng)一格式化并對 CRLF 做歸一化。以默認警告行為為例輸入文件 duplicate_path_warns_by_default.dart 在/home下聲明了兩個同為details的子路由其.warnings文件聲明了預期警告而duplicate_path_ignored.dart.warnings為空文件用于斷言ignore模式下零警告。示例工程測試在packages/go_router_builder/example目錄下執(zhí)行flutter test示例工程除展示各功能外還通過build_verify見 example/pubspec.yaml校驗生成的.g.dart與源碼保持一致ensure_build_test.dart 會在測試中強制執(zhí)行構(gòu)建比對防止生成代碼與注解定義漂移。版本遷移README 中針對 4.0.0 提供了遷移指南“Migrating to 4.0.0”章節(jié)指向 Flutter 官方 breaking changes 說明升級到 4.x 前建議先閱讀該章節(jié)了解破壞性變更點。本文所有示例均基于倉庫當前源碼4.5.0驗證與 README 中的 go_router^16.2.0起點版本兼容。小結(jié)go_router_builder 把 go_router 的聲明式路由推進到了類型安全的一步注解聲明參數(shù)契約、代碼生成消除樣板、編譯期攔截錯誤。從依賴配置、TypedGoRoute路由定義、$appRoutes初始化到路徑/查詢/extra 參數(shù)、重定向、類型轉(zhuǎn)換、轉(zhuǎn)場動畫、Shell 路由、相對路由以及duplicate_route_paths構(gòu)建器選項與雙文件測試機制本文覆蓋了 README 的全部要點并以倉庫源碼與測試用例做了印證。想深入源碼可以繼續(xù)閱讀 lib/src/go_router_generator.dart、lib/src/route_config.dart 與 lib/src/type_helpers.dart?!久赓M下載鏈接】packagesA collection of useful packages maintained by the Flutter team項目地址: https://gitcode.com/GitHub_Trending/pac/packages創(chuàng)作聲明:本文部分內(nèi)容由AI輔助生成(AIGC),僅供參考