用脚步丈量人生宽度

Flutter 从零开始(八):导航与路由,从 Navigator.push 到 go_router

我这周处理的一条反馈很没道理:用户在联系人详情页点「同组的人」进另一个详情,再按 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 交给上面那个 Futurebool,表示有没有真的弹掉
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 处理完之后才调用。文档里那句对比我抄在这里:onWillPop was called before the pop was handled and had the ability to cancel it,而 onPopInvokedWithResult is 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 的详情)。

一条深链进来之后的完整时序

最后把整条链路按我现在的理解排一遍,从手指落在链接上开始:

  1. OS 按 intent-filter(Android)或 Associated Domains + AASA(iOS)决定这个 URL 交给哪个应用,这一步在 app 之外,--dart-define 和热重载都影响不到它。
  2. Flutter 收到一条 RouteInformation,交给 GoRouteInformationProvider;refreshListenable 通知的时候也走这里,所以 auth.signIn() 那一句会引发一次重新解析。
  3. parser 把 location 和 routes 树做匹配,得到一串 RouteMatch;命中不了就生成 GoException('no routes for location: ...'),下一步交给 errorBuilder。
  4. 依次跑 redirect:根的那条,然后是父 route、子 route(父优先于子)。任何一条返回非 null 就换目的地重来,这一轮有次数上限。
  5. 结果稳定后 delegate 算出 List<Page>,Navigator 按这个 list 渲染——到这一步才回到前面讲 Navigator 那一节里的栈模型上。
  6. 页面 builder 拿到 state.pathParameters / state.uri.queryParameters,转成有类型的入参(int.tryParse 那一步),发请求走第六篇那套 dio,响应解析交给第七篇那套生成器。

这条链走完,我从 /contacts/9527?tab=posts 这个字符串里得到的页面,和我在列表里点进去得到的那个是同一个,而且返回键的行为由配置决定、不由「上一跳用了什么方法」决定。这一层写完,我手上剩的活儿是把 Uri(path: ...) 那些手拼的地方逐个对齐路径常量,以及在两个 Tab 的 branch 上把深链的落点再验一次。

评论

暂未收到

写下评论

支持 Markdown 语法