1. 为什么导航与路由是Flutter绕不开的第一道坎
接触Flutter的第一周,绝大多数人都会有同感:写静态页面还算顺畅,一旦涉及页面跳转、参数传递、返回刷新这些“日常操作”,就开始被各种概念卡住——Navigator、Route、MaterialPageRoute、命名路由、onGenerateRoute……这些名词堆在一起,看起来只是“跳转页面”四个字,实际用起来却各有门道。
我自己带过不少从Android、iOS、Vue转过来的新人,发现一个规律:凡是能把导航与路由梳理清楚的人,后面学状态管理、组件通信、模块化拆分都会快很多。反过来说,这部分一直停留在“能跑就行”状态的人,写出来的Flutter应用往往页面耦合严重、改一处崩三处。导航与路由不只是“点按钮换页面”这么简单,它本质上是整个App页面组织、状态传递、模块解耦的地基。
这篇内容适合两类人:一是刚入门Flutter、正在被Navigator.push和命名路由搞得一头雾水的初学者;二是已经写了一段时间Flutter、想从“会跳转”进阶到“设计好页面导航架构”的中级开发者。我会从最基础的Navigator 1.0讲起,再聊到声明式路由的Navigator 2.0,最后结合实际项目经验,把参数传递、路由守卫、生命周期那些坑一个个摊开说清楚。
先说一个最容易踩的认知误区:很多人刚学Flutter时,看到Navigator.push觉得这玩意儿跟startActivity没啥区别,看到命名路由又觉得这跟Vue Router差不多。其实Flutter的路由体系要比传统移动端或者Web端Router复杂得多,它既是“页面栈管理器”,又是“状态恢复机制”,还是“深链接入口”。如果不理解这层设计逻辑,后面遇到“为什么页面重建了”“为什么返回时数据丢了”“为什么热重启后路由栈错乱”这类问题,基本无从下手。
2. Navigator 1.0:命令式路由的基石
2.1 从MaterialPageRoute说起
Flutter里最基础、最常用的跳转方式就是Navigator.push配合MaterialPageRoute。一段最朴素的代码长这样:
Navigator.push( context, MaterialPageRoute( builder: (context) => const DetailPage(), ), );这段代码背后的运行逻辑,我会拆成三步来说清楚。
第一步,MaterialPageRoute创建了一个路由对象。这个对象描述了“新页面长什么样”(通过builder构建页面Widget)、“进入动画是什么”(Android上默认是自下而上的缩放+淡入,iOS上默认是右侧滑入)、“页面被系统回收时如何处理”等一系列信息。换句话说,Route是页面的一个描述元数据,而不是页面本身。
第二步,Navigator把这个Route压入自己的管理栈。Navigator内部维护了一个_History栈,栈里的每个元素对应一个页面。push操作就是往栈顶添加新路由;pop操作就是从栈顶移除路由。这也是为什么页面跳转天然支持“返回”——栈这种数据结构天生就适合表达“从哪来、回哪去”。
第三步,Navigator根据栈的状态更新界面。栈顶的Route会生成对应的页面Widget显示出来,新路由进入时,旧路由并没有被销毁,只是被暂停了(或者说移出了焦点)。我习惯用一个类比帮助理解:Navigator就像一个放映员,手里捏着一叠幻灯片,每次只让你看最上面那张,但下面的片子都还在。这就是为什么从详情页返回时,列表页还保持着原来的滚动位置和状态。
理解了这三步,你就能明白为什么MaterialPageRoute的builder回调里拿到的context不是页面真正的BuildContext,而是一个包含路由信息的上下文。很多初学者在这里踩坑:在builder里用context去拿MediaQuery或者Theme没问题,但如果用context去Navigator.push,就可能报警告甚至报错——因为这个context还没有准备好执行导航操作。
2.2 命名路由与路由表:是方便也是枷锁
用MaterialPageRoute直接push有一个明显的问题:如果页面跳转关系很多,代码里到处都充斥Navigator.push(context, MaterialPageRoute(...))这种样板代码,而且页面跳转的“路径”被硬编码在业务逻辑里。为了解决这个问题,Flutter提供了命名路由机制。
命名路由的思路很直观:给每个页面起一个字符串名字,然后在MaterialApp里注册一个路由表,跳转时只用名字:
MaterialApp( routes: { '/': (context) => const HomePage(), '/detail': (context) => const DetailPage(), '/settings': (context) => const SettingsPage(), }, );Navigator.pushNamed(context, '/detail');看起来简洁了不少,但这里有一个很多教程没提透的坑:命名路由不支持直接传参(至少官方静态注册方式是这样)。你可能会想,跳转详情页总得带个ID吧?于是很多人会这样写:
Navigator.pushNamed(context, '/detail', arguments: {'id': 123});然后在DetailPage的build方法里通过ModalRoute.of(context).settings.arguments去取参数。这样写当然能跑,但问题也随之而来:参数字段没有类型约束,全都变成了Map的key-value,一旦页面多了、参数多了,极易出现拼写错误,而且IDE完全无法帮你检查。我见过不止一个项目,就因为参数名从userName改成username,漏改了一个页面,导致线上偶发崩溃。
所以我对命名路由的态度一直是有保留的。它在小型Demo、页面结构简单、且不需要带复杂参数的场景下确实省事;但在中大型项目里,我反而更推荐显式传参的MaterialPageRoute方式,或者在命名路由上套一层类型安全的包装。后面讲go_router的时候会再展开。
2.3 返回机制与数据回传
说完去路说归途。Navigator.push返回的是一个Future,这个设计很多人刚开始不理解,直到需要“从下一个页面带回结果”时才发现它的妙处:
final result = await Navigator.push<String>( context, MaterialPageRoute( builder: (context) => const SelectPage(), ), );在SelectPage里,通过Navigator.pop(context, '选择了苹果')就能把字符串返回给上一个页面。整个过程和异步函数调用非常像——push像发起调用,pop像是return,而await则帮你接住返回值。
这里要注意几点,都是实战中容易出问题的:
第一,pop时如果带了结果,而这个结果又被另一个pop覆盖了,会直接报错。比如你在页面上同时注册了系统返回键的监听和自定义返回按钮,两次pop就会造成“返回一次却关掉两层页面”的混乱。解决思路是:要么只留一个返回入口,要么在系统返回时先判断Navigator.canPop()再决定是否执行pop。
第二,await Navigator.push的回调时机。如果你在SelectPage里用Navigator.pop(context, result)返回后,紧接着在await之后的代码里访问上一个页面的状态,可能偶发遇到context已被销毁的报错。这不是路由的问题,而是异步时序+页面生命周期的叠加效应。稳妥的做法是在await之后先判断mounted,再继续操作。
第三,pop不传参数的情况。如果被弹出的页面没有显式pop值(比如用户按了手机物理返回键),await拿到的就是null。所以接收方必须对null做处理,不能默认“只要返回了就有结果”。
3. 参数传递的艺术与实操代码
3.1 简单的值传递:直接又直观
先看最直接最简单的一种方式,适合参数不多、结构不复杂的场景,直接通过构造方法传参:
class DetailPage extends StatelessWidget { final int articleId; const DetailPage({super.key, required this.articleId}); }然后跳转:
Navigator.push( context, MaterialPageRoute( builder: (context) => DetailPage(articleId: 42), ), );这种写法的最大优势就是类型安全。articleId是int就是int,编译期就能发现传错类型的低级错误,不会运行时黑屏。而且在IDE里跳转、查询有哪些参数都是直接可见的,新人接手也很容易看懂。
缺点也是显而易见的:跟命名路由完全相反,它要求页面跳转前必须先import目标页面文件,然后在业务代码里显式构建页面对象。对于页面多、业务膨胀的项目来说,各处都出现import 'xxx_page.dart'会让模块之间的依赖关系变得纠缠不清,架构评审时经常被吐槽“上层业务直接依赖了底层页面详情”。
3.2 用arguments传递:灵活但要用好
如果你坚持用命名路由,那arguments参数就必须用得规范。官方支持两种写法,第一种是Navigator.pushNamed(context, '/detail', arguments: {'id': 42})。这种写成Map的我是真不推荐。你想想看:这个页面可能被三个地方跳转,每个地方塞的key都可能不同,你根本不知道这个页面真正需要哪些key,只能去翻实现。
第二种写法稍微好一点——传一个自定义对象:
class DetailArguments { final int id; final String title; const DetailArguments({required this.id, required this.title}); }跳转时:
Navigator.pushNamed( context, '/detail', arguments: DetailArguments(id: 42, title: 'Flutter路由深入'), );在DetailPage中取参:
final args = ModalRoute.of(context)?.settings.arguments as DetailArguments?;这种写法保障了参数结构,而且后面要追加参数时,只需修改DetailArguments类,所有调用方的编译错误就能帮你自动排查出来。用自定义参数对象替代裸Map,这是命名路由场景下性价比最高的优化。唯一的隐患是ModalRoute.of(context)可能返回null(理论上不会,但做空安全兜底总没坏处),以及如果这个页面同时支持两种不同入口传参,你得留意参数的null兼容。
3.3 返回时带数据刷新列表:经典翻车现场
“从详情页返回后刷新列表”是我见过翻车最多的高频需求。常见错误做法是:在详情页里pop回列表页后,列表页在initState里重新请求数据。结果发现列表页根本没有重新走initState,数据纹丝不动。
原因前面已经讲过了:Navigator.pop只是把栈顶页面移除,下面的页面对象并没有重新创建,initState自然不会再次执行。
正确方案是在await之后触发刷新。比如:
Future<void> _openDetail(int id) async { final changed = await Navigator.push<bool>( context, MaterialPageRoute( builder: (context) => DetailPage(articleId: id), ), ); if (changed == true) { // 重新拉取数据或更新本地状态 _fetchData(); } }然后详情页在需要“让上一个页面知道数据有变”的地方,pop时带上true。
还有一种更“响应式”的做法是用状态管理库(比如Provider、Riverpod)去驱动页面刷新。比如在详情页编辑完昵称,直接修改一个共享的状态对象,列表页因为监听了该状态而自动重建。这种方案的好处是彻底摆脱了“返回后手动通知”的链路,坏处是要求你从一开始就把状态设计好,否则容易变成把所有东西都塞进全局Store的脏架构。
4. 路由守卫:像Vue Router一样拦截导航
4.1 为什么需要路由守卫
接触Vue的开发者都知道,Vue Router有全局前置守卫(beforeEach)、路由独享守卫、组件内守卫,核心场景就是登录鉴权:用户未登录时访问需要登录的页面,就强制跳转到登录页。
Flutter官方并没有提供一个跟Vue Router的beforeEach完全等价的“路由守卫API”——如果你去查文档,会发现根本不存在Navigator.beforeEach这样的方法。但这个需求在真实项目中又非常常见,所以大家用的都是变通方案。理解了底层机制,实现起来其实不复杂,甚至比Vue的路由守卫还灵活。
4.2 用onGenerateRoute做统一拦截
最常用的方案是自定义onGenerateRoute。当你没有在routes表中命中路由时,Flutter会回调onGenerateRoute,由你决定生成什么样的Route。利用这个时机做路由拦截是非常自然的:
MaterialApp( onGenerateRoute: (settings) { final needAuth = settings.name == '/profile' || settings.name == '/orders'; if (needAuth && !AuthManager.isLoggedIn) { return MaterialPageRoute( builder: (context) => const LoginPage(), settings: settings, ); } return MaterialPageRoute( builder: (context) => _buildPageByName(settings.name), ); }, );这样写的好处是所有导航统一收口。无论业务代码中是pushNamed、push,还是深链接触发的路由,走到onGenerateRoute时都能做一次统一的逻辑判断。登录状态、权限验证、页面参数预处理,都可以在这个环节完成。
有一点需要提醒:onGenerateRoute里通常不要再调用Navigator去push跳转,因为这时Navigator正在处理当前路由请求,再push会导致路由栈操作嵌套,容易引发断言错误。正确做法是像上面的示例一样,直接返回一个Route对象让Navigator自己处理跳转,而不是在回调里去“手动跳转”。
4.3 用全局导航观察者实现后置守卫
还有一种更细粒度的方案:实现NavigatorObserver,在didPush、didPop、didRemove等回调中监听每一次导航动作。这种方案适合做一些“路由日志埋点”“页面访问统计”,或者统一处理页面返回时的刷新逻辑。
class RouteObserver extends NavigatorObserver { @override void didPush(Route route, Route? previousRoute) { super.didPush(route, previousRoute); debugPrint('新页面进入: ${route.settings.name}'); // 可以在这里做页面曝光埋点 } @override void didPop(Route route, Route? previousRoute) { super.didPop(route, previousRoute); debugPrint('页面退出: ${route.settings.name}'); } }在MaterialApp里注册:
MaterialApp( navigatorObservers: [RouteObserver()], );需要注意,NavigatorObserver只是“观察者”,它决定不了“能否跳转”——如果你想拦截跳转而不只是记录,就需要配合PopScope或者onGenerateRoute来实现。很多团队的实际方案是:鉴权用onGenerateRoute,埋点和统计用NavigatorObserver,两者各司其职。
4.4 单个页面的离开拦截:PopScope
很多时候我们不需要全局守卫,只想在某个页面拦截返回操作,比如编辑页有未保存内容时,用户点返回要弹出确认框。老版本写法是WillPopScope,现在已经废弃了,要用PopScope。
PopScope( canPop: _isSaved, onPopInvokedWithResult: (didPop, result) { if (!didPop) { // 弹窗提示用户是否确认离开 _showExitDialog(); } }, child: Scaffold(...), );这里的逻辑细节值得说清楚:canPop为false时,系统返回(手势返回、物理返回键、AppBar默认返回按钮)都会先触发onPopInvokedWithResult回调,但页面不会真正退出;你在回调里弹窗,用户确认后再手动Navigator.pop。如果canPop为true,则页面正常退出,回调里的didPop会是true。
这个API的名字很直观,但也容易误读:onPopInvokedWithResult并不是“要不要允许pop”的决策点,而是“pop行为发生之后的通知”。如果不理解这一点,你可能会试图在回调里调用Navigator.pop,结果又触发了新一轮拦截逻辑,造成递归。正确的流程我一般这样写:
Future<void> _confirmExit() async { final shouldExit = await showDialog<bool>( context: context, builder: (context) => AlertDialog( title: const Text('内容尚未保存'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('继续编辑'), ), TextButton( onPressed: () => Navigator.pop(context, true), child: const Text('放弃修改'), ), ], ), ); if (shouldExit == true) { Navigator.pop(context); } }5. 页面生命周期与Navigator的联动
5.1 路由切换时页面方法调用顺序
Flutter页面的生命周期并不像Android那样有onPause/onStop的细粒度回调,但这不代表没有生命周期概念。当从A页push到B页时,A页会经历如下变化:
A页的build不会重新执行,但A页状态会进入inactive(不活跃)状态,不过它依然挂在元素树里。B页执行initState、build,变成当前焦点页面。
从B返回A时:
B页被销毁,dispose被调用。A页重新变为活跃状态——但不会重新执行initState,因为A的State对象还在。
这个机制对于理解“哪些状态可以保留,哪些必须重建”很关键。A页的ScrollController、TabController、表单内容等,在跳转B页后其实都还保留着,所以返回时列表位置不丢、输入框内容不丢。但是,如果你依赖build来“刷新数据”,那就麻烦了——因为返回A时可能只触发一次didChangeDependencies或者build,而如果你在这里发起网络请求,会导致每次从任何页面返回都会重新请求,这在列表页是非常严重的性能问题。
5.2 路由栈与热重载、内存回收
Flutter的热重载(Hot Reload)是一个开发利器,但跟路由栈配合时有个经典坑:当你已经push到第3层页面,此时执行热重载,Flutter会尽量保持当前路由栈结构。但如果你在热重载前修改了路由表(删除了某个页面构造函数参数),栈里的旧页面可能无法重建,轻则报错、重则卡死在白屏。
遇到这种情况,我的习惯是:改路由相关代码时,先Navigator.popToFirst回到根页面再热重载,或者干脆热重启(Hot Restart)。这看起来是很笨的办法,实际上却是最节省时间的方式。
还有一个容易被忽略的点:Navigator的栈是会一直持有页面的。如果你在push了数量极多的页面后从不pop(比如某些循环跳转场景),内存占用会持续上涨。虽然现代手机内存够大,但页面里的图片、控制器、流订阅都会累积。审计自己的项目时,留意一下是否有“层层push却没有返回入口”的页面链路。我见过一个外包项目,用户疯狂滑动Feed流进入详情再进入详情,最后App直接被杀,原因就是这个。
5.3 嵌套Navigator:底部Tab与全局页面的冲突
底部导航栏(BottomNavigationBar)和业务页面栈的关系,是路由设计里的另一个大坑。很多初学者直接把TabBarView和Navigator.push混用,结果发现:从Tab1的子页面跳转到详情页后,点击底部Tab切换,详情页没消失,界面状态完全错乱了。
这背后的原因是:MaterialApp默认有一个全局Navigator,但每个Tab里的页面如果也维护了自己的子Navigator,就会形成嵌套导航栈。返回时,子Navigator先出栈,还是全局Navigator出栈,取决于当前焦点在哪个栈里。为了避免混乱,常见的架构方案有两种:
方案一:一个Navigator,用页面栈模拟Tab。这种方案下,底部Tab切换本质上也是push/pop页面。因为所有页面都在同一个栈里,返回行为好预测。缺点是:Tab切换时没有“保留每个Tab独立状态”的原生便利,你得自己保存状态。
方案二:嵌套Navigator,每个Tab维护独立栈。这种方案每个Tab都有自己的完整页面栈,切Tab时状态天然保留。缺点是:全局跳转(比如从Tab1的详情页跳到一个全屏Modal页面)得仔细选择到底用哪个Navigator。用错了,页面的返回按钮就消失了或者行为诡异。
我自己在项目里比较倾向“状态管理负责Tab状态、全局Navigator只负责跨Tab跳转”的混合模式。具体讲起来又是一篇长文,这里先提个醒:在设计底部导航时就要考虑清楚,你的返回键在Tab切换后的行为究竟是“退出App”还是“回到上一个Tab”,这决定了你的嵌套结构怎么搭。
6. Navigator 2.0与声明式路由:大项目的地基
6.1 命令式路由的边界在哪
Navigator 1.0这套命令式API,在小中型项目里完全够用,但一旦业务变大,痛点就很明显了:
- 路由栈状态是“隐式”的,散落在各个业务代码的
push/pop调用里,出了问题不好追踪。 - 页面跳转逻辑和业务逻辑耦合,测试时不太容易模拟特定路由状态。
- 对深链接(Deep Link)、Web端URL同步这些能力的支持比较弱。
Navigator 2.0就是冲着这些问题来的。它的核心思路是把路由变成“状态”而非“命令”——你声明“当前应用处于什么状态”,框架负责把状态翻译成界面。
这里不需要绕太多术语,你只需要理解两个核心类就够了:
RouteInformationParser:负责把RouteInformation(比如URL字符串)解析成内部路由状态数据。RouterDelegate:负责根据路由状态数据构建Navigator的页面栈。
比如在Web端,如果你在浏览器地址栏输入https://example.com/items/42,RouteInformationParser需要告诉你“当前想打开ID为42的商品详情页”,RouterDelegate再根据这个意图去更新界面。
6.2 什么时候该上Navigator 2.0
需要老实说,Navigator 2.0的API设计并不讨喜。刚接触的时候,你会被它绕晕:写一个最简单的“跳转到详情页”,你需要同时改造RouterDelegate、RouteInformationParser,还要处理BackButtonDispatcher,代码量比命令式方式多出不少。
所以我个人的建议很明确:如果你的项目只是普通的App、没有深链接需求、不需要地址栏同步,那就继续用Navigator 1.0,别盲目跟风升级。“够用就好”在这个场景下不是一句敷衍,而是基于学习和维护成本的判断。
什么时候需要认真考虑Navigator 2.0或者基于它封装的库呢?我总结了几类场景:
- 你的App需要在Web端跑,并且希望浏览器地址栏内容和页面状态同步——比如用户复制URL发给别人,对方打开直接落地到对应页面。
- 你需要支持复杂的深链接规则,比如收到推送消息跳转到特定页面、然后还能正常返回。
- 你的页面栈状态被外部系统驱动(比如物联网大屏、多端联动),需要把“路由意图”和“路由界面”解耦。
前两类场景在电商、内容社区类App里很常见。第三类相对小众,但如果你在做桌面端或大屏项目,Navigator 2.0的声明式思维会让你省很多事。
6.3 go_router:更友好的声明式路由选择
Navigator 2.0的原生API学习曲线比较陡,所以社区涌现了一批封装库,其中最出名的就是go_router。它对声明式路由做了非常友好的封装,让开发者可以用接近Vue Router的方式配置路由表:
GoRouter( initialLocation: '/', routes: [ GoRoute( path: '/', builder: (context, state) => const HomePage(), ), GoRoute( path: '/detail/:id', builder: (context, state) => DetailPage( articleId: int.parse(state.pathParameters['id']!), ), ), ], );跳转时:
context.go('/detail/42');go_router不仅处理了参数解析,还内置了redirect机制,可以当路由守卫用:
GoRouter( redirect: (context, state) { final loggedIn = AuthManager.isLoggedIn; final goingToLogin = state.matchedLocation == '/login'; if (!loggedIn && !goingToLogin) { return '/login'; } return null; }, )这段代码的含义是:未登录用户访问任何需要登录的页面,自动重定向到登录页。逻辑上跟Vue Router的beforeEach非常接近,只是写法不同而已。
go_router在官方文档中被持续维护,也是Flutter团队推荐的第三方路由方案之一。如果你的团队人手充足、项目处在快速增长期,我建议直接用go_router来搭导航层,它比裸写Navigator 2.0省下至少三分之一的心力。当然,如果你喜欢完全掌控底层行为、不想要库依赖,那用官方方案也没问题,只是要准备好写更多样板代码。
7. 常见问题排查与避坑实录
7.1 页面被build了多次
用Navigator.push跳转新页面后,发现新页面build被调用了两次甚至多次。先别急改代码,这是正常的。原因在于:路由页面进入时,框架会先以“待定尺寸”build一次,随后动画开始、尺寸确定后再build一次。只要build是纯函数(不产生副作用),多次调用并无大碍。
真正需要警惕的是——build里不要放网络请求。如果每次build都触发请求,页面跳转动画时你就在狂拉接口,既浪费流量又掉帧。正确做法是:网络请求放initState或者FutureBuilder的future里,build只根据已有状态渲染。
7.2 Navigator报“duplicate page route”
有时快速双击跳转按钮,会报“duplicate page route”错误。原因是同一时刻同一个页面被push了两次。解决方法有三层:
- 按钮加防重复点击逻辑(比如设置一个冷却时间,或根据页面是否已经在栈中决定是否跳转)。
- 在跳转前用
Navigator.of(context).canPop()做判断,或者记录当前路由名、避免相同路由连续入栈。 - 更彻底的方案是统一封装跳转方法,在里面加一个“是否已存在同名路由”的判断。
实战经验是:防重复点击逻辑一定要有,而且不能只靠业务侧自觉。因为线上会有极快的手速(或者网络卡顿时用户反复点击),一旦页面重复入栈,用户返回时就会看到“多返回了一层”的怪象。
7.3 从Index页面路由到Tab页内部
之前讲嵌套Navigator时提过这个问题。最常见的错误表现是:从某个Tab的子页面里Navigator.push跳到一个新页面,结果发现底部Tab切换后,新页面被“卡住”了,怎么返回都回不去。
排查思路是先搞清楚当前使用的到底哪个Navigator。如果页面是从context里Navigator.of(context)拿到的导航器,那它可能属于最近的上层Navigator,也可能是全局的。打印一下栈里路由数量就知道了:
final navigator = Navigator.of(context); debugPrint('栈中路由数: ${navigator.widget.pages.length}');如果栈中路由数不符合预期,就要检查页面是否被嵌套在其他Navigator中。封装统一的跳转工具类,并在工具类里明确“由哪个Navigator执行跳转”,是避免这类混乱最有效的办法。
7.4 返回时拿不到参数
Navigator.pop(context, result)弹回后,在上一页await处拿到的却是null,很多新手当场蒙圈。大概率是下面几个原因之一:
- 页面通过系统返回键退出,没有执行
pop(result),所以返回值为null。 pop时被另一个页面“截胡”——比如在PopScope的onPopInvokedWithResult里又手动pop了一次,把结果覆盖了。- 跳转用的不是同一个
Navigator,你在子Navigator里push,却在全局Navigator上等待结果。
排查方法:在await处打印调试日志,看是整条链路没返回,还是返回了null。如果是前者,重点检查页面退出方式;如果是后者,重点检查pop调用位置和参数。
7.5 命令式路由在异常状态下的白屏
开发过程中,如果你在未登录状态下通过某种方式pushNamed了一个需要登录态的页面,而这个页面在build里直接操作了登录态数据(比如读取用户Token),就会出现空指针或白屏。尽管业务上已经有了“跳转登录页”的守卫,但页面自身的空安全校验绝对不能省。
我一般会在每个页面的build开头做一次核心依赖检查,缺少数据时渲染一个统一的重试/空态组件。这属于“路由之外的防御”。把“路由能不能进”和“进来后能不能渲染”两件事分开处理,代码的稳健性会提升很多。
8. 导航与路由,真正理解它的角色
回到最开始那个话题:导航与路由在Flutter里到底是个什么角色?
它不是“点按钮跳页面”的工具函数,也不是“给页面取个名字”的注册表。它是整个App页面组织方式的抽象,是你写业务代码之前最先应该想清楚的事情。你的App是简单两三层的页面结构,还是多Tab+多模块+深链接的复杂结构,决定了你选Navigator 1.0、go_router还是裸写Navigator 2.0。选型没有绝对的对错,只有适配场景与否。
我自己带项目的习惯是:小项目直接Navigator 1.0,清爽不折腾;大项目优先go_router,路由表统一管理、深浅链接都方便;只有遇到非常特殊的定制需求,才会深入Navigator 2.0原生的RouterDelegate层写定制逻辑。
最后分享一个我在实际开发中觉得特别值得养成的习惯:给导航层单独写一个工具类,比如AppNavigator,所有页面跳转、返回、替换、清理栈的操作都走这个类。这样即使未来把路由方案从Navigator 1.0换成go_router,也只需要改这一个文件,业务代码几乎不用动。这个习惯帮我度过了好几次“导航方案重构”的艰难时期,也让我带的团队成员没有因为导航API变动而大面积返工。导航与路由这门课,学完不算本事,能在项目里设计出一套适合自己的组织方案,才算真正吃透了。