我这周处理的一条反馈很没道理:用户在联系人详情页点「同组的人」进另一个详情,再按 Android 的返回键,回到的不是刚才那个列表,而是首页。两个页面本身都正常,控制台一行报错都没有。
查下去的结论是那次跳转我写的是 context.go('/contacts/$id')。go_router 的文档对它的说法是「Navigating to a destination in GoRouter will replace the current stack of screens with the screens configured to be displayed for the destination route」——它换掉的是整条栈,而我一直以为它和 Navigator.push 一样只是往上面加一层。
同一批里还有另外两条。
- 同事在微信里发了一个详情页链接,对方点开停在浏览器,标题栏是站点名。Android 那边
AndroidManifest.xml里根本没有intent-filter;iOS 那边我改完apple-app-site-association之后半小时没生效,后来发现这个等待时间是写在文档里的。 - 「登录态过期就踢回登录页」这件事,我散在 12 个
build()里各写一遍if (!auth.signedIn) ...,每加一个页面就漏一个。漏掉的那两个我在线上表现为:未登录用户能进详情页,接口 401,页面空白。
这三条我后来复盘是同一件事的三个面:跳转的目的地、目的地要的参数、以及谁能进这个目的地,全写在调用点上。这一篇记录我把这三样挪到路由层之后剩下的问题、新增的问题,和各自付出去的代价。
Navigator 的模型是一个栈
先讲最底层的 Navigator,因为 go_router 没有替换它,它只是替我算出要塞进这个栈的东西。
Navigator 是一个持有 Route 列表的 widget,列表顺序就是栈顺序,栈顶是当前可见的那一层。几个方法对栈做了什么,我自己整理成一张表,右边一列是我以前最容易记混的返回值:
| 我调的方法 | 对栈做了什么 | 返回什么 |
|---|---|---|
Navigator.push(route) | 栈顶压入一条 | Future<T?>,这条被 pop 且带 result 时 resolve |
Navigator.pop(context, result) | 弹栈顶,result 交给上面那个 Future | bool,表示有没有真的弹掉 |
pushReplacement(route) | 弹栈顶再压新的,旧那条的 State 一起 dispose | 同 push |
pushAndRemoveUntil(route, pred) | 压新的,同时往下清到 pred 返回 true 为止 | 同 push |
popUntil(pred) | 一直弹,pred 为 true 的那条留下 | void |
maybePop(context, result) | 先问栈顶那条 Route 的 willPop / 上层的 PopScope,允许才弹 | Future<bool> |
push 和 maybePop 的差别我踩过一次:前者不问任何人,直接把栈顶弹掉,所以在有 PopScope 的页面上用 Navigator.pop 会绕开我自己写的拦截逻辑;后者才是「按返回键的语义」。
往上找 Navigator 这件事是靠 context:Navigator.of(context) 从当前节点往树上找那个 NavigatorState。Navigator 是 MaterialApp 在自己的 build 里造出来的,位置在我的页面之下、在 MaterialApp 之内。这个层级关系是我撞的第一条和导航有关的报错的根因,写在后面「学习时要特别注意的几处」第一条里,因为它和路由层无关,纯粹是 widget 树的机制。
传参的三个位置
// 列表页的点击:目的地、参数、返回值三样都在这里
final confirmed = await Navigator.push<bool>(
context,
MaterialPageRoute(
// 方式一:构造函数直接传,编译期就检查住了
builder: (context) => ContactDetailPage(contactId: contact.id),
// 方式二:随路由挂一个 arguments,详情页里靠 ModalRoute 取
settings: const RouteSettings(
arguments: {'from': 'list', 'keepScrollOffset': true},
),
),
);
// 方式三:等返回值。不用 await 的话就是 push(...).then((v) {...}),
// 两者是同一件事,我后来统一用 await,因为要接着做「取消就不刷新」的判断
if (confirmed == true) {
ref.invalidate(contactListProvider);
}// 详情页
class ContactDetailPage extends StatelessWidget {
const ContactDetailPage({super.key, required this.contactId});
final int contactId;
@override
Widget build(BuildContext context) {
// arguments 是 `Object?`,取出来要用 as,这一层赌注和上一篇讲的那个
// 「Map<String, dynamic> 是类型黑洞」是同一个性质:错了不在编译期报
final args = ModalRoute.of(context)?.settings.arguments
as Map<String, dynamic>?;
return Scaffold(
appBar: AppBar(
actions: [
IconButton(
icon: const Icon(Icons.check),
// result 交给调用点那个 Future;传了 result 就不能只调 Navigator.pop(context)
onPressed: () => Navigator.pop(context, true),
),
],
),
body: ContactDetailBody(id: contactId, fromList: args?['from'] == 'list'),
);
}
}三种位置我用在不同的场景,各自代价不一样。
构造函数是我最常写的,因为重构跟着改名、类型检查在编译期。它的短板不在类型上,而在于这个页面的入参从此只能由调用点在内存里给:任何「不经过调用点就要重建这个页面」的路径——深链冷启动、进程被系统回收后恢复、从通知栏点进来——都拿不到那批参数。我一开始觉得这些是边缘场景,直到第一条真的发生了一次。
settings.arguments 我用来传「从哪来、要不要保留滚动位置」这类跳转上下文,而不是业务对象。中间那版我把整个 Contact 塞进 arguments,省掉一次详情接口,代价是这个页面的数据源和内存绑定了:深链进来时 arguments 是 null,我得补一套「没有对象就按 id 再拉一次」的 fallback,而这套 fallback 我过了两个月才补上,中间那段时间详情页从通知栏打开就是空白。
返回值我留给确认弹窗这类一次性布尔量。页面状态同步我不走它了——Object? 上没有任何字段信息,返回值一多我就分不清那个 null 是「用户取消」还是「我没传」。
命名路由表为什么失控
上面那套字符串路由的中间形态,我在工程里维护过大半年:
MaterialApp(
initialRoute: '/contacts',
routes: {
'/contacts': (context) => const ContactListPage(),
'/login': (context) => const LoginPage(),
// 详情页要参数,routes 这种「一屏一个固定 builder」的表放不住它,
// 于是所有带参数的路径都从这里漏出去
},
onGenerateRoute: (settings) {
final segments = settings.name?.split('/').where((s) => s.isNotEmpty).toList() ?? [];
if (segments.length == 2 && segments.first == 'contacts') {
// 参数解析写在这里:字符串下标、类型自己转、越界自己判
final id = int.tryParse(segments[1]);
if (id == null) return null;
return MaterialPageRoute(
builder: (context) => ContactDetailPage(contactId: id),
settings: settings,
);
}
return null;
},
)它坏在三处,而且三处都不会在编译期响。
参数没有类型。 routes 那张表只能放「无参的 builder」,一旦页面要参数就得上 onGenerateRoute,从字符串里手动切、手动 tryParse。表里的 /contacts 和我代码里拼的 '/contacts/$id' 靠人记住它们是同一套路径。
重构即断。 我把 '/contacts' 改成 '/home' 的那天,routes 的 key 和 onGenerateRoute 里的 segments.first == 'contacts' 是两个不同的位置,IDE 的重命名对字符串 key 有效、对我散在各处的字面量无效。漏改的失败发生在点击之后:onGenerateRoute 走到最后那条 return null,页面没动,控制台是一条指向 navigator.dart 的 assert 失败,报错信息里出现的是框架文件和我那个路径名,离我真正改错的那一行隔了两帧以上。
没有「谁能进」这一层。 路由表只是 name → widget 的映射,登录守卫只能做在页面内部。我那 12 个 if (!auth.signedIn) 就是这么来的,而且它们还有一个更隐蔽的副作用:判断发生在 build() 里,页面已经入栈、已经请求过一轮数据,才在渲染时把内容换成 loading。
onGenerateRoute 里那个 return null 我也留了个尾巴:它代表「我不认识这个路径」,但调用方拿不到这个信息,Navigator.pushNamed 只是不跳。要不要在这里做兜底页,取决于有没有外部输入的路径能进来(一旦上了深链,就有,而且这不是我方能控制的)。
go_router 换的是「谁决定栈长什么样」
升版本前我核了一遍依赖约束:我写这篇时 pub.dev 上是 18.1.2,它的 pubspec.yaml 要求 Flutter ≥ 3.44、Dart SDK ≥ 3.12。这个约束值得先看一眼再抄示例,因为这个包的公开 API 这几年改过名字——GoRouterError、NoStack 这些在旧文档里常见的类型,18 的源码里已经没有,state.error 现在的类型是 GoException。我是靠 IDE 补全和进 lib/src/ 看字段来确定当前形状的,照旧文章抄的片段有一半编译不过。
它和 Navigator 的接法是这样:GoRouter 实现了 RouterConfig,接到 MaterialApp.router(routerConfig: ...) 上,底下跑的是 Flutter 框架自带的 Router。链路是 location(一个 URI 字符串)→ 解析成 RouteMatchList → delegate 算出 List<Page> → Navigator 拿到这个 list 去渲染。
这条链路里我真正在意的性质是:栈的样子由 location 算出来,不由「我上一跳用了什么方法」决定。同一个 /contacts/9527?tab=posts 在什么时候进来都是同一套页面,这是命名路由和 push 都给不了的性质,也是深链能成立的前提——外部进来的只有一个字符串,没有调用点。
一份实际在用的配置:登录守卫
登录状态我沿用的是第三篇那个 ChangeNotifier 的 store。这里有个方便的地方:refreshListenable 要的是 Listenable,而 ChangeNotifier 本身就实现了它,不需要额外包一层。
// lib/store/auth.dart
class AuthStore extends ChangeNotifier {
bool signedIn = false;
void signIn() {
signedIn = true;
notifyListeners(); // 这一句会让根 redirect 重跑一次,见下面的时序
}
void signOut() {
signedIn = false;
notifyListeners();
}
}// lib/router.dart
final auth = AuthStore();
final router = GoRouter(
initialLocation: '/contacts',
refreshListenable: auth,
// 根 redirect:签名是 FutureOr<String?> Function(BuildContext, GoRouterState)
// 返回 null 表示「就按现在这个 location 显示」,返回字符串表示换目的地
redirect: (context, state) {
final goingToLogin = state.matchedLocation == '/login';
if (!auth.signedIn && !goingToLogin) {
// from 要 encode:/contacts/9527?tab=posts 里全是 / 和 ?
final from = Uri.encodeComponent(state.uri.toString());
return '/login?from=$from';
}
// 已登录又在登录页,且没有 from:回首页。
// 带 from 的那种情况留给登录页自己跳(要的就是这个区分,见下)
if (auth.signedIn && goingToLogin &&
state.uri.queryParameters['from'] == null) {
return '/contacts';
}
return null;
},
errorBuilder: (context, state) => RouteErrorScreen(state.error),
routes: [
GoRoute(
path: '/login',
builder: (context, state) =>
LoginPage(returnTo: _safeFrom(state.uri.queryParameters['from'])),
),
GoRoute(
path: '/contacts',
builder: (context, state) => const ContactListPage(),
routes: [
GoRoute(
path: ':id', // 完整路径是 /contacts/:id,父路径不用重复写
builder: (context, state) {
// 数字由 URL 给,不由调用点给,所以这里只有 tryParse 一个选择
final id = int.tryParse(state.pathParameters['id'] ?? '') ?? 0;
final tab = state.uri.queryParameters['tab'] ?? 'info';
return ContactDetailPage(contactId: id, tab: tab);
},
),
],
),
],
);
/// 只接受本站内路径,避免 `?from=//evil.com` 这类带 authority 的串被当成路径跳出去
String? _safeFrom(String? raw) {
if (raw == null || raw.isEmpty) return null;
final uri = Uri.parse(raw);
return uri.hasEmptyAuthority ? uri.toString() : null;
}// 登录页成功回调
void _onSignedIn(BuildContext context, String? returnTo) {
// 顺序在这里是有意的:先让状态变更(它会重跑 redirect,命中 from 非空那条 null,
// 人还停在登录页),再由我明确跳去 returnTo
auth.signIn();
context.go(returnTo ?? '/contacts');
}这两步反过来写我试过一次:先 context.go(returnTo) 再 auth.signIn()。中间那一刻 auth.signedIn 还是 false,根 redirect 看到「未登录且不在登录页」,把我弹回 /login?from=...,然后状态才变、再重跑一次 redirect——这次带 from,返回 null,于是登录成功之后停在登录页,界面上是键盘已经收起、按钮变灰的那个状态。症状很好认:跳转之后 location 里还挂着 from=。
_safeFrom 那一步是我加上之后才想起的。from 的值完全来自外部链接(深链进来的 location 里带着它),不做判断的话 ?from=//evil.com/contacts 会被 context.go 当成目的地——go_router 里没有「evil.com」这条 route,落的是 errorBuilder,但路径已经不在我控制里了。
errorBuilder 指向的那个 RouteErrorScreen 是我自己写的一屏。包里有默认的错误页(MaterialErrorScreen / CupertinoErrorScreen),形态是写死的:标题 Text('Page Not Found'),中间一行 state.error.toString(),底下一个回首页的按钮。默认那张在 debug 期挺好用的,我不满意的地方只有一个——那行 error 文本对用户没有意义,而我需要它同时把原始 location 打出来方便我核对是哪条外部链接进来的。onException、errorBuilder、errorPageBuilder 这三个只能给一个,同时给会在构造断言处报 Only one of onException, errorPageBuilder, or errorBuilder can be provided.,我照旧文章同时写了 onException 和 errorBuilder 一次,起不来的是整个 app 而不是某个页面。
路径参数与查询参数
/contacts/9527?tab=posts 这一条 URL 上,9527 和 tab=posts 取的地方不一样,这是我一开始最容易记混的一组:
// 取
state.pathParameters['id'] // Map<String, String>,值是 '9527',永远得自己转
state.uri.queryParameters['tab'] // Map<String, String>;同名多值用 state.uri.queryParametersAll
state.matchedLocation // '/contacts/9527',不带 query —— 比较「现在在哪页」我用它
state.uri.toString() // '/contacts/9527?tab=posts',完整的那串
state.pageKey.value // 这一条在栈上的 key,排查重复入栈时很好用// 跳。我这里用 Uri 而不是手拼字符串,是因为 id 里一旦混进 '?' 或 '#',
// 拼出来的 location 就直接变形
context.go(Uri(
path: '/contacts/$id',
queryParameters: {'tab': 'posts'},
).toString());pathParameters 全是 String 这件事我一开始嫌烦,但对照上一篇的结论看反而更踏实:类型转换发生在我看得见的一行里,tryParse 失败我还能给默认值;如果它能直接给出 int,那失败的位置就又回到运行时深处了。
state.queryParameters 这个成员不存在。我按 go_router 自带那份例子写过一次——path_and_query_parameters.dart 里有句注释说 query 参数「自动存进 GoRouterState.queryParameters」,而 lib/src/state.dart 的字段列表里只有 uri、matchedLocation、pathParameters、fullPath、extra、error、pageKey、topRoute 这些。那句注释是过期的,IDE 补全里没有这个方法。我的习惯是凡是不确定的字段名,Ctrl+click 进 GoRouterState 的定义看一遍,而不是接着搜教程。
go 与 push:那两次搞混的返回行为
这一块我踩的就是开头那个 bug。两者的机制在源码里能看到分界(parser.dart 的 _updateRouteMatchList):
context.go(location):解析出新的RouteMatchList,整个替换当前的匹配列表。栈变成「这个 location 按我的配置应该长什么样」——比如/contacts/9527的父级是/contacts,那这条栈就是列表 → 详情两层,之前 push 上去的其它东西一概不留。context.push(location):把新解析出来的那份包成一个ImperativeRouteMatch,附加在当前列表之上。原来的栈保持不动,多出一层。context.pushReplacement(location):换掉栈顶那一条,并且用一个新的 pageKey。context.replace(location):也换栈顶,但复用 pageKey——文档写的是保留状态、不放转场动画。context.pop():弹当前最上面那条;文档另外提示了一句,如果最上面是个弹窗或 dialog,它弹的是那个弹窗而不是底下的 GoRoute。
我按这个区分留下的判断是两条:目的地是「我现在在哪」就用 go(底部 Tab、搜索结果进某人主页、深链进来、登录完成后的跳转),目的地是「临时看一眼再回来」就用 push(详情里再开详情、编辑表单、图片预览)。这个判断在移动端只影响返回键,在 Web 上还多影响一层:push 上去的那条不在地址栏的 location 里,文档明确写了这样显示的页面不可深链,而且一旦父级 GoRoute 的页面被移除(比如任何一次 go),它会跟着没了。我曾在 push 出来的编辑页上期望「刷新之后还停在这一页」,那是拿不到的。
Web 那边文档里还有一句更直接的警告:命令式导航 known to cause issues with the browser history(那里引的是 flutter/flutter#99112)。包里另有一个 GoRouter.optionURLReflectsImperativeAPIs 的开关,文档态度是明确不建议轻易打开。我这边 Web 只做了内部页面,深链走的全是 go 的路径,所以没碰这个开关,要不要用取决于 Web 是不是真面向用户。
同一个路径 push 两次是合法的,我确认过这一点是因为开头那个 bug 修完之后出现过一个「按两次返回才退出一层」的现象。push 每次都会生成一个新的随机 key,所以两条记录是完全独立的两层,pop 两次才对得上;判断方法很轻:把 state.pageKey.value 打到详情页标题上,连点两次同一条联系人,看那两个值是不是不一样。
底部导航那条线我按第三篇的结论换成了 StatefulShellRoute.indexedStack——就是「切走再切回某个 Tab,列表滚回顶部、数据重拉」那个问题的路由层版本:
final _tabKeys = <GlobalKey<NavigatorState>>[
GlobalKey(), // 每个 branch 一个,不能共用
GlobalKey(),
];
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) => Scaffold(
body: navigationShell, // 它自己就是个带 IndexedStack 的 widget
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: (i) =>
// 切 branch,branch 内部自己的返回栈保留
navigationShell.goBranch(i),
),
),
branches: [
StatefulShellBranch(
navigatorKey: _tabKeys[0],
routes: [
GoRoute(
path: '/contacts',
builder: (context, state) => const ContactListPage(),
routes: [
GoRoute(
path: ':id',
builder: (context, state) => ContactDetailPage(
contactId: int.tryParse(state.pathParameters['id'] ?? '') ?? 0,
),
),
],
),
],
),
StatefulShellBranch(
navigatorKey: _tabKeys[1],
routes: [
GoRoute(
path: '/groups',
builder: (context, state) => const GroupListPage(),
),
],
),
],
)这里我撞过一次 assert:两个 branch 图省事复用了同一个 GlobalKey,包里的检查信息就一句 Navigator keys must be unique。它的意思很直白——每个 branch 是一个独立的嵌套 Navigator,key 就是它的身份。
Android 返回键与 PopScope
返回拦截这件事和路由层是分开的,走的是 widget 树的机制。我先把这条链上的 API 变化写清楚,因为它在几年内换过两次名字:
WillPopScope+onWillPop已经被换掉了。框架文档里那句原因是:为了支持 Android 14 的 Predictive Back,「在收到返回手势的那一刻才决定取不取消」这种能力被替换成了提前声明(ahead-of-time)的 API,这个改动在 stable 3.16 落地。- 现在是
PopScope:canPop是个提前算好的 bool,onPopInvokedWithResult在 pop 处理完之后才调用。文档里那句对比我抄在这里:onWillPopwas called before the pop was handled and had the ability to cancel it,而onPopInvokedWithResultis called after the pop is finished being handled。
也就是说,取消返回这个动作只能靠 canPop 的值,回调里取消不了。回调签名是 (bool didPop, Object? result),didPop 为 false 才代表这次被 canPop 挡住了。
class ExitConfirmScope extends StatefulWidget {
const ExitConfirmScope({super.key, required this.child});
final Widget child;
@override
State<ExitConfirmScope> createState() => _ExitConfirmScopeState();
}
class _ExitConfirmScopeState extends State<ExitConfirmScope> {
DateTime? _lastBack;
@override
Widget build(BuildContext context) {
return PopScope(
// 根页面提前声明「我不允许被弹掉」,返回事件就停在下面的回调里
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (didPop) return; // 真弹走了,这里没有后续
final now = DateTime.now();
final withinTwoSecs =
_lastBack != null && now.difference(_lastBack!) < const Duration(seconds: 2);
_lastBack = now;
if (withinTwoSecs) {
// 第二次:交给系统结束应用
SystemNavigator.pop();
return;
}
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(const SnackBar(content: Text('再按一次返回退出')));
},
child: widget.child,
);
}
}栈里只剩根页面的时候,返回键是不是还经过 Flutter 这件事,我是靠这个 PopScope 生效反推的:canPop: false 时它停在回调里、应用不退出;没有这一层(或 canPop 为 true)时,事件交给系统结束 Activity。context.pop() / Navigator.pop 这类程序内的弹栈和系统返回是不是同一条路径,我没有实测过两者在 canPop: false 下的组合行为,这块要用的时候我会在真机上验一次再写进业务里。
深链:三处配置,两处等不了热重载
深链是这套声明式配置唯一「外部输入」的入口,也是我那两条没道理的反馈的源头。三块配置各管一段,我把它们的作用边界先画清楚:manifest / entitlement 决定「这个链接允不允许开我的 app」,go_router 的 routes 决定「开进来之后落到哪一页」。前者放行、后者没命中,就是 errorBuilder 那张 Page Not Found,而不是「打不开 app」——这两种失败的表现不一样,排查方向也不一样。
Android 这边:
<!-- android/app/src/main/AndroidManifest.xml,加在 activity 里 -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="app.baikeyang.com" />
</intent-filter>autoVerify="true" 是给 http/https 这类 App Link 用的,它要求域名下有一份 assetlinks.json,验证过了系统才会「点开链接直接进 app」;没验证过时我遇到的表现是弹一次「用哪个应用打开」的选择,或者直接留在浏览器。另一条路是自定义 scheme(<data android:scheme="contactsapp" />),不需要域名、不需要验证文件,代价是任何应用都能声明同一个 scheme,链接归属不唯一。我自己的选择是内部分享用自定义 scheme、面向用户的分享用 App Link,两条 filter 同时挂着。
版本相关的开关在这里有个坑:Flutter 3.27 之前的版本要在 manifest 里显式加一行 <meta-data android:name="flutter_deeplinking_enabled" android:value="true" /> 才启用 Flutter 自己的处理;之后的版本默认就是开的。反过来,如果我改用 app_links 这类第三方插件自己接链接,cookbook 里写的是把它的值设成 false 来关掉 Flutter 的默认 handler。这两种值我按项目版本各配过一次,表现都是「链接能开 app,但 go_router 那边 location 没变」,方向相反,很容易看错。
iOS 这边两块:
<!-- ios/Runner/Runner.entitlements -->
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:app.baikeyang.com</string>
</array>{
"applinks": {
"details": [
{
"appIDs": ["<team id>.<bundle id>"],
"components": [{ "/": "/contacts/*" }]
}
]
}
}上面这份内容要挂在 https://app.baikeyang.com/.well-known/apple-app-site-association,末尾没有 .json。挂对了也不代表它马上生效——文档里写的是 Apple 的 CDN 主动去拉,最长可能要 24 小时,拉之前 universal link 不通。我那半小时的「本地看 JSON 明明是对的」就是这个等待,判断方法不是盯着 app,而是先确认那个 URL 在浏览器里返回的是那份内容,再等。
测的时候有两把绕开浏览器的命令,这两条只验证 app 侧的配置,不验证域名那两个文件:
# Android:直接投一个 VIEW intent,package 换成自己的 applicationId
adb shell 'am start -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d "https://app.baikeyang.com/contacts/9527?tab=posts"' \
com.baikeyang.contacts
# iOS 模拟器
xcrun simctl openurl booted https://app.baikeyang.com/contacts/9527真机上文档给的思路是走一个能渲染可点链接的载体(它举的例子是备忘录里打字再点),因为「在 app 内部点自己的 universal link」这条路径本身有额外限制。
host 和 scheme 这些我没有硬编码在跳转的地方,而是编译期注入,这样 debug 包装的是 staging 域名:
flutter run --dart-define=DEEP_LINK_HOST=app-staging.baikeyang.com// 只在 Dart 侧可见:拼分享链接、生成二维码时用同一份配置
const deepLinkHost =
String.fromEnvironment('DEEP_LINK_HOST', defaultValue: 'app.baikeyang.com');这块我卡过一次半小时:改完 manifest 里的 host 之后热重载,链接还是打不开。--dart-define 影响的是上面那个 Dart 常量,而 AndroidManifest.xml 是编译期打进 APK 的,热重载不带它——manifest 的任何改动都要完整重新构建、重新安装一次。这个和 AASA 的 24 小时是我在深链上唯一两处「配置对了但当下看不到效果」的地方。
学习时要特别注意的几处
Navigator operation requested with a context that does not include a Navigator.
症状:我在 MaterialApp.router 的 builder: 里想给整个 app 挂个 overlay,顺手调了 Navigator.of(context)(换成 GoRouter.of(context) 一样炸),第二句是 The context used to push or pop routes from the Navigator must be that of a widget that is a descendant of a Navigator widget.。原因:Navigator 是 MaterialApp 在自己的 build 里造的,builder 传进来的 context 在那个 Navigator 之上,不是它的后代。这个层级差在命令式写法下不明显,因为 routes 里那些 builder 已经在 Navigator 下面了。flutter/flutter#126845 就是这个,标题里那句 "previously available in navigatorBuilder" 对得上我犯的错:这个位置在更早的版本里能拿到 Navigator,我照旧写法升上来就开始抛。怎么判断:看抛错的那个 widget 沿着树往上有没有 Navigator 节点;同一个调用挪进 builder 返回的子树(也就是任何一层页面 widget 的 build())就不再抛,说明问题在层级而不是调用本身。
too many redirects
症状:进某个页面直接白屏,控制台是 GoException('too many redirects ...'),消息里带一串 a => b => a 形式的路径链。原因:go_router 解析时会反复跑 redirect 直到结果稳定,次数有上限(redirectLimit 默认 5),超过就抛。我那次是在登录页的 route 级 redirect 里返回了「原样、带 query 的当前 location」而不是 null——文档里那句「return null or the original route path」说的是路径本身,我把整个 state.uri.toString() 原样返回,每一轮都算一次新的重定向,几跳之内计数打满。怎么判断:看消息里那条链,同一对路径来回出现就是这条;顺带确认父 route 的 redirect 优先级高于子 route(文档里明确写了这句),两条同时存在时我以为的判断顺序不成立。
按两次返回才退出一层。 症状:从列表进详情,按一次返回屏幕看着没动,第二次返回才回到列表——而回到的那张详情和刚离开的是同一个人。原因:这条路径被 context.push 了两次,每次都是独立的一层,pageKey 是新随机生成的,go_router 不会把相同 location 合并。怎么判断:把 state.pageKey.value 打在详情页的一个临时 Text 上,连点两次同一条联系人,看两个值不一样;再确认调用点用的是 push 还是 go。要不要合并取决于这两层是不是两件事——同一页面的第二次进入我后来统一改成 go。
登录成功后停在登录页。 症状:接口返回成功、按钮变灰、键盘收起,页面不动;地址里还挂着 from=。原因:auth.signIn() 之前就先 context.go(returnTo),中间那一刻 signedIn 还是 false,根 redirect 把它弹回登录页。这是「redirect 是持续生效的,不是你跳一次就绕过它」。怎么判断:跳转之后读一下当前 location 有没有那个 from 参数;有就是被 redirect 拦回来了,而不是 go 没执行。
深链点开停在浏览器,和点开之后 Page Not Found。 症状看起来都是链接进不去具体页面,实际是两段。前者是 OS 层面没把链接交给 app:Android 查 intent-filter 有没有命中、App Link 的 assetlinks.json 验证有没有过;后者是 app 开了但 location 没匹配到任何 route,走的是 errorBuilder,state.error 里那条消息形如 no routes for location: ...。怎么判断:用上面那条 adb shell am start 直接投 intent——它能进 app 就说明域名验证那一段没问题,剩下的是路由表;它都不进 app,就是 manifest 或没重新安装。
state.queryParameters 补全不到。 症状:IDE 里没有这个成员,照着包里那份例子的注释写完编译不过。原因:GoRouterState 上只有 uri,query 要从 state.uri.queryParameters 取,那句注释在当前版本是过期的。怎么判断:Ctrl+click 进 GoRouterState 看字段列表,比搜教程快。
换过去之后我付出去的三样
redirect 的可见性变差了。 命令式写法里「谁把谁弹走」是一次函数调用,断点能停在调用点;现在它挂在根上,命中之后页面直接换一个 location。我的兜底是在根 redirect 里对 kDebugMode 打一行(进来了什么、要去了哪里),出问题时那三行日志比翻匹配规则快。
跳转和 URL 的对应要靠人维护。 routes 里的路径嵌套和我 Uri(path: ...) 里手拼的那串仍然是两份,拼错的表现是 no routes for location。我试过给每个 route 起 name 再用 goNamed,也试过把路径常量收在一个文件里,两种都只是把重复挪了个位置;彻底的那条路是代码生成,我没有为三个 Tab 的工程引入那条链路。
底部导航那条分支的成本比看上去高。 StatefulShellRoute.indexedStack 换掉了我第三篇手写的 IndexedStack,代价是每个 branch 要有独立的 GlobalKey<NavigatorState>、branch 之间跳转要显式写 goBranch(index, initialLocation: ...),以及深链进某个 Tab 时要保证匹配落在对应 branch 上(这一步我做错过一次,现象是链接进了首页那个 Tab,页面内容却是我预期第二个 Tab 的详情)。
一条深链进来之后的完整时序
最后把整条链路按我现在的理解排一遍,从手指落在链接上开始:
- OS 按
intent-filter(Android)或 Associated Domains + AASA(iOS)决定这个 URL 交给哪个应用,这一步在 app 之外,--dart-define和热重载都影响不到它。 - Flutter 收到一条
RouteInformation,交给GoRouteInformationProvider;refreshListenable通知的时候也走这里,所以auth.signIn()那一句会引发一次重新解析。 - parser 把 location 和
routes树做匹配,得到一串RouteMatch;命中不了就生成GoException('no routes for location: ...'),下一步交给errorBuilder。 - 依次跑 redirect:根的那条,然后是父 route、子 route(父优先于子)。任何一条返回非 null 就换目的地重来,这一轮有次数上限。
- 结果稳定后 delegate 算出
List<Page>,Navigator按这个 list 渲染——到这一步才回到前面讲Navigator那一节里的栈模型上。 - 页面 builder 拿到
state.pathParameters/state.uri.queryParameters,转成有类型的入参(int.tryParse那一步),发请求走第六篇那套 dio,响应解析交给第七篇那套生成器。
这条链走完,我从 /contacts/9527?tab=posts 这个字符串里得到的页面,和我在列表里点进去得到的那个是同一个,而且返回键的行为由配置决定、不由「上一跳用了什么方法」决定。这一层写完,我手上剩的活儿是把 Uri(path: ...) 那些手拼的地方逐个对齐路径常量,以及在两个 Tab 的 branch 上把深链的落点再验一次。
评论
暂未收到