
从Vue项目转到Flutter那段时间我最大的挫败感不是来自Dart语法也不是来自布局而是来自路由。习惯了router.push(/detail?id3)这种思维之后我第一次面对Flutter的Navigator.push和MaterialPageRoute时整个人是懵的路径呢路由表呢导航守卫呢带着这套错误认知写了两个页面之后各种别扭接踵而来。这篇文章就是把我后来重新理解Flutter导航与路由的过程整理成文应该能帮到刚从前端转过来或者已经写了几个页面但没系统梳理过导航机制的开发者。1. 先重置认知Flutter的导航是页面栈不是URL映射1.1 Navigator、Route、Overlay三者到底各干什么如果只记一句话我建议你记Flutter导航就是对一棵页面栈做push和pop。点击一个按钮跳页本质上是把一个新的页面元素压到栈顶按返回键就是从栈顶弹出一个页面元素。这套模型和iOS的UINavigationController几乎一模一样和Android的Fragment事务管理也很像但和浏览器地址栏路由完全是两码事。展开来讲这里有三个角色要分清。Navigator是管理这个栈的调度员它持有当前栈中的所有RouteRoute描述一个页面在栈中的身份和样式比如它是普通的Material页面、是全屏对话框、还是透明的弹层Overlay则负责把这些Route实际渲染到屏幕上每个Route都对应Overlay里的一个entry栈里同时存在多个entry时上面的盖住下面的。这个结构带来的第一个实际影响是什么就是当你在栈顶push一个新页面时下面的旧页面并不是被删掉了它只是被一个不透明的页面盖住对应的Route仍然留在栈里State也没有被销毁只是暂时不参与渲染。理解这一点后面所有关于状态丢了没有的讨论才有基础。1.2 一次跳转背后发生了什么我建议你亲自在代码里加一行debugPrint放在目标页面的initState、didChangeDependencies、build里然后执行一次push和pop观察打印顺序这个实验做一次比读十遍文档都管用。你大概率会看到这样的过程点击跳转后目标页面的initState先执行然后didChangeDependencies接着build渲染完成后才真正出现在屏幕上等触发pop栈顶Route被移除目标页面的dispose会执行。而旧页面呢push发生时它不会被dispose只是被从正在渲染降级为不可见。pop回来时它也不会重新执行initState只会重新build一次因为State对象从头到尾都活着。这个State活着的特征是后面判断状态会不会丢的核心依据。我见过不少新手在这里犯一个认知错误以为切换页面就像前端路由切换组件一样旧组件卸载了新组件挂载了。这在Flutter里不成立至少在标准的Navigator.push场景下不成立。所以请把这个模型先刻在脑子里路由栈里的Route不销毁页面State就不销毁。如果这一节的内容你接收了后面四章基本就是顺水推舟。2. 写路由的三种姿势从直接push到声明式路由2.1 直接push最简单也最直接最朴素的方式是Navigator.push配合MaterialPageRoute大概长这样Navigator.of(context).push( MaterialPageRoute( builder: (context) DetailPage(itemId: 42), ), );就这么几行没有任何全局的路由表也不需要起名字。好处是类型安全、传参直观构造器里要什么给什么坏处是跳转逻辑散落在各个页面里一旦页面多了你想统一控制跳转比如所有跳转都要埋点、都要检查登录态就只能在每个调用点重复代码。所以我的经验是原型阶段、临时演示、页面数量很少的小工具直接用push完全没问题别为了规范而上纲上线。不过有一点我每次都要提醒builder里的页面不要在它的build方法中放每次build都要执行的重活因为页面切回来时它可能再次build。后面讲状态保持还会再遇到这个坑。2.2 命名路由把路由表集中到一处当页面集合开始固定很多人会把跳转改造成命名路由。在MaterialApp里声明一份表MaterialApp( routes: { /: (context) HomePage(), /detail: (context) DetailPage(), /settings: (context) SettingsPage(), }, )然后跳转变成Navigator.pushNamed(context, /detail)。这样跳转目标集中管理写起来也清爽。但这张表有个先天不足它只认识无参数页面。你想给DetailPage传个itemId就得走Navigator.pushNamed(context, /detail, arguments: ...)然后在目标页面里手动取ModalRoute.of(context)!.settings.arguments再自己cast。这没问题但类型安全就完全靠你自己的纪律了。我个人的态度是小型固定页面集合可以用命名路由一旦涉及参数传递、通配符路径、动态页面它反而成了负担。在我实际项目里带参数的页面几乎都不进routes表而是交给下面的onGenerateRoute统一处理。2.3 onGenerateRoute把路由表升级成路由工厂如果命名路由是一张查表那onGenerateRoute就是一个工厂函数。当Navigator.pushNamed找不到匹配的routes条目时框架会把这次跳转交给onGenerateRoute你在里面拿到route name和arguments自己决定创建什么页面甚至返回一个统一的404页。MaterialApp( onGenerateRoute: (settings) { if (settings.name /detail) { final args settings.arguments; if (args is DetailArgs) { return MaterialPageRoute( builder: (context) DetailPage(args: args), settings: settings, ); } } return MaterialPageRoute( builder: (context) NotFoundPage(), ); }, )这一步把跳转到哪从页面代码里彻底抽离出来也是实现路由统一拦截的好位置。后面讲路由守卫时我会详细展开这个用法。很多前端同学看这个函数会想到Vue的动态路由、约定式路由确实思路是通的——你不再写死路径与组件的映射而是把解析规则放在一个函数里让跳转具备动态性。但注意它的本质仍然是入栈出栈别因为长得很像前端路由就又开始用URL思维规划页面栈。2.4 选型表什么时候用哪种我整理了一个自己的参考表不一定权威但实际用下来挺顺手方式适用场景优点要注意的坑直接push原型、数量少的页面类型安全、改起来快跳转逻辑分散难统一控制routes表固定、无参或少参页面声明集中读代码直观传参麻烦不支持动态路径onGenerateRoute多数中大型项目统一解析、支持参数、好加守卫需要自己维护解析逻辑go_router等声明式路由深链接、Web端、复杂嵌套导航路径可序列化支持redirect引入第三方依赖学习成本这不是说哪种最高级而是看项目阶段和复杂度。我的建议是超过三四个模块、或者有登录态控制、埋点需求的项目趁早把路由收敛到onGenerateRoute或者go_router上越往后迁移成本越高。我自己吃过亏第一版图省事全用直接push功能做到一半想加全局登录拦截改了几十个调用点那酸爽至今难忘。3. 页面间传参与回传这本质上是组件通信问题3.1 构造器传参最直白的方式但要注意不可变先回答热搜词里那个组件通信父传子子传父的问题路由传参其实也是一次组件通信只不过通信的两端是处在不同路由栈层级的页面组件。最简单的传参方式就是把参数写进构造器class DetailPage extends StatelessWidget { const DetailPage({super.key, required this.itemId}); final int itemId; ... }配合Navigator.push跳转时直接传入干净、类型安全。我建议把这类参数设计成final不可变字段不要在目标页面里动不动去改写跳转时带来的原始值。你可能会想用StatefulWidget配合setState在详情页里改itemId我见过这种代码后面排查起来特别费劲因为谁也不知道这个值是被谁改的。把它当成跳转快照页面内部只消费、不修改通信逻辑会清晰很多。3.2 命名路由与onGenerateRoute的arguments当你用了命名路由或者onGenerateRoute参数传递就走arguments通道。Flutter对这个通道的设计比较宽容——它的类型是Object?也就是说随便什么都能传但也正因如此类型安全完全靠接收方校验。// 跳转方 Navigator.of(context).pushNamed(/detail, arguments: DetailArgs(itemId: 42)); // 接收方 final args ModalRoute.of(context)?.settings.arguments; if (args is DetailArgs) { // 使用args }我强烈建议不要用Map塞一堆key-value然后到页面里再逐字段取。理由很简单Dart的Map取出来是dynamic等于把编译期检查全部放弃字段名写错要到运行时才崩调试成本翻倍。定义专门的参数类比如DetailArgs哪怕只是两个字段也比Map强得多。这是我从一个线上崩溃里学到的教训——当时少传了一个字段路由没报错页面白屏查了半天。3.3 返回值与await pushFuture在什么时机resolve页面之间不止要传进去还要传回来。Flutter的标准做法是让Navigator.push返回一个FutureT目标页面在某个时机调用Navigator.of(context).pop(result)这个Future就会resolve发起跳转的一方通过await拿到返回值。final result await Navigator.of(context).pushint( MaterialPageRoute(builder: (context) SelectPage()), ); if (result ! null) { setState(() selectedId result); }有前端同学问过我说这个then回调会不会被放进微任务队列——这确实是个很准确的观察。Navigator.push返回的Future在pop被调用的一刻resolvethen注册的回调会被安排进微任务队列在当前同步代码跑完之后才执行。所以如果你在pop下一行立刻读某个全局临时变量是不保证能读到的因为then里的赋值还没执行。解决方式很简单需要用回调结果就老老实实await别依赖执行顺序的运气。另外提醒一句如果用户不是通过你的按钮返回而是通过系统返回键、手势返回pop也同样会发生Future同样会resolve只是结果可能是null。所以下游判空是必要的别假设既然进来了就一定能拿到结果。3.4 什么时候不该用路由传参下面这句话值得记下来路由传参适合传递跳转快照不适合承载持续同步的共享状态。比如登录用户信息、购物车商品数量、主题配置这种东西如果你每次跳转都顺着构造器传一遍页面一多就会变成参数地狱漏传一个就崩。我的做法是跳转快照id、标题、来源标记之类走路由参数跨页面持续共享的数据走全局状态方案——轻量场景用ValueNotifier加ListenableBuilder复杂场景上Provider或者Riverpod。这也回应了热搜词里的组件通信话题。路由传参只是组件通信的一个场景不要把路由系统当成万能状态容器。我见过一个项目连当前语言都用路由参数传结果每次跳转都得检查有没有传漏一次就UI错乱。正确做法是把这类数据放到业务层统一管理路由参数保持和这次跳转强相关即可。4. 切页面会不会丢状态拆解State生命周期与保活方案4.1 先回答最核心的问题什么时候丢什么时候不丢flutter navigator切换页面后会丢失状态吗这个问题我猜是很多人的真实困惑。答案取决于你怎么理解切换页面。如果是标准的Navigator.push压入一个新页面旧页面被覆盖它的State不会丢。因为旧Route还在栈里State还挂在组件树上只是暂时看不见而已等新页面pop回来旧页面重新可见State原封不动TextField里的输入、滚动位置都还在。这一点和前端路由切换完全相反请一定注意。真正会丢状态的是两种情况。第一种页面被pop出栈Route被销毁State走dispose里面所有状态一起消失。这符合直觉。第二种页面没有被pop但所在容器主动销毁了它的子树——最常见的就是TabBarView/PageView里不靠当前Tab的页面。PageView默认会回收离屏页面当你从Tab1切到Tab2再切回来Tab1的State可能已经被销毁重建了表单数据、动画进度全没。很多人说Flutter切Tab丢状态其实指的就是这个严格来说这不叫Navigator切换而是PageView的懒加载回收机制。4.2 让Tab页保活AutomaticKeepAliveClientMixin和IndexedStack如果你希望PageView切换回来时保留Tab页状态最标准的做法是让页面混入AutomaticKeepAliveClientMixin并把wantKeepAlive返回trueclass HomeTab extends StatefulWidget { ... } class HomeTabState extends StateHomeTab with AutomaticKeepAliveClientMixin { override bool get wantKeepAlive true; override Widget build(BuildContext context) { super.build(context); return ...; } }注意一定要在build里调用super.build(context)否则keepAlive的标记不会被通知给底层结构这个细节我忘了不止一次。另外如果你的Tab切换用的是IndexedStack它天然会一次性把子页面全部构建并保持状态不需要keepAlive。代价是性能子页面如果是重型列表建议还是用PageView加keepAlive别全塞进IndexedStack。4.3 为什么State明明在还会看起来丢了还有一种很迷惑的情况State明明没有dispose也没被回收但页面切回来后某些视觉状态不对——滚动位置回顶了、展开的菜单合拢了、下拉刷新动画停在半路。这种情况我追查过多次根源往往不是State丢失而是依赖的Widget在重建时被换掉了。比如你在build里重新创建了一个新的TextEditingController每次build都TextEditingController()一下上次输入的内容当然跟着旧controller消失再比如ListView没有给item配置PageStorageKey滚回后位置就丢了。这些状态不是框架帮你丢的是你自己每次build时重建了它们。检查思路也很简单如果State没dispose但状态表现不正常优先排查有没有每次build都new对象的坏习惯以及有没有给可滚动组件设置合适的PageStorageKey。4.4 PageStorageKey滚动位置的隐形存储滚动位置是状态丢失问题里最高频的一项。Flutter提供了一个基于PageStorage的机制给滚动组件设置PageStorageKey之后它的滚动偏移量会被存进PageStorage桶里页面被重建后可以从桶里恢复。ListView( key: PageStorageKey(home_list), ... )这个key的值在同一路由里要稳定别用随机数也别用每次变化的对象。如果同时多个ListView共用同一个key滚动位置会互相污染这种bug的表现是A页面的列表突然跳到B页面的滚动位置排查起来很酸爽。我的习惯是key命名尽量语义化一眼看出是哪个页面的哪个列表。5. 路由守卫与拦截Flutter没有beforeEach但我有几种平替5.1 需求先想清楚你要拦截什么聊到路由守卫很多从Vue过来的同学会惯性地说我们要加beforeEach。先别急着上方案把需求拆清楚你要做的是登录态校验未登录跳转去登录页、权限控制某些角色不能进某些页面、埋点统计、还是灰度开关Flutter没有内置一个跳转前统一执行的生命周期但它给了你足够灵活的组合方式下面三种是我实际用过的平替。5.2 方案一包装Navigator跳转方法最简单粗暴的方式是自己封装一个跳转工具类所有页面一律走这个封装不再直接调Navigator.of(context).pushclass AppNavigator { static FutureT? pushT(BuildContext context, Widget page) async { if (!Auth.instance.isLoggedIn) { return Navigator.of(context).push(MaterialPageRoute(builder: (_) LoginPage())); } return Navigator.of(context).pushT(MaterialPageRoute(builder: (_) page)); } }优点是好理解、可控缺点是所有页面都走封装完全依赖团队纪律一旦有人图省事直接调了原生Navigator拦截就漏了。它不是技术方案是管理方案。小团队、小项目可以用但我后来觉得它治标不治本。5.3 方案二在onGenerateRoute里做统一分流当你已经把所有命名跳转收敛到onGenerateRoute之后在它里面做拦截就非常自然了。框架保证了所有命名跳转都会过这里你只要在创建Route之前判断条件即可MaterialApp( onGenerateRoute: (settings) { if (settings.name ! /login !Auth.instance.isLoggedIn) { return MaterialPageRoute( builder: (_) LoginPage(), settings: settings, ); } if (settings.name /admin !Auth.instance.isAdmin) { return MaterialPageRoute(builder: (_) ForbiddenPage()); } // 正常生成目标路由 }, )这里有个细节像/detail这种带参数的动态路由你仍然可以在进入首屏前的拦截逻辑中拿到settings.arguments所以基于参数的权限校验也能做。同时它天然解决了未登录用户访问深层链接时先回登录页的需求——你在拦截后可以保留原始settings等登录成功再用原始路由继续跳转。这种记住来路、登完续走的效果是我以前在Vue里用beforeEach加next()才能实现的在Flutter这里用onGenerateRoute同样能做到。5.4 方案三go_router的redirect如果你的项目已经用了go_router它的redirect回调就是官方提供的、专门干这件事的钩子GoRouter( redirect: (context, state) { final isLoggedIn Auth.instance.isLoggedIn; final isLoginPage state.matchedLocation /login; if (!isLoggedIn !isLoginPage) { return /login; } if (state.matchedLocation /admin !Auth.instance.isAdmin) { return /forbidden; } return null; }, )返回null表示不重定向返回一个字符串路径就去往那个路径。它的触发时机不仅包括你主动跳转还包括用户从外部深链接进入App时这一点对移动端场景很有用。需要注意redirect回调可能非常频繁地触发包括在构建路由的过程中所以不要在里面做耗时操作或setState尽量保持纯函数风格状态判断从单例或Provider读取。5.5 我的选型建议三套方案之间怎么选我给一句很实在的建议如果项目里还没有go_router不建议为了守卫这一个需求专门引入它onGenerateRoute统一拦截能把大多数需求解决掉。如果项目已经在用go_router那就别重复造轮子优先用它的redirect。而包装Navigator那套我把它定位为过渡期方案它能帮你把散落的跳转点收拢但最终要给onGenerateRoute或go_router让位。另外埋点这类只记录不影响流程的需求其实更适合用navigatorObservers我放到下一章讲它跟守卫的职责是有明确区分的。6. 踩坑合集这些坑不写下来过两个月我自己也会再犯6.1 返回键拦截从WillPopScope到PopScope需求很常见表单页在没保存时点返回要弹确认框安卓端物理返回键也一样处理。老版本用WillPopScope新版本已经废弃现在用PopScope。以Flutter 3.22之后的API为例PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) { if (!didPop) { _showUnsavedDialog(); } }, child: Scaffold(...), )把canPop设为false后系统返回和AppBar返回都会被拦截由你在onPopInvokedWithResult里决定后续。注意如果条件满足你想允许返回正确做法是动态把canPop改成true而不是在回调里强行调用Navigator.pop——那样容易产生一次多余的压栈或双重pop。我踩过这个坑表现是弹两次确认框或者返回了两层页面。6.2 底部Tab再嵌套路由最典型的设计错误做带底部导航的App时很多人第一反应是把每个Tab内容塞进同一个Navigator栈里然后切Tab。这个设计很快会翻车因为整个App只有一个路由栈你在Tab1里push了详情页切到Tab2再按返回可能会把整个Tab1的详情页弹出来甚至把整个App退到后台行为完全错乱。更别提Tab页面间还互相销毁状态的问题。我的建议是分清壳和内容。壳层的Tab切换用IndexedStack或PageView加keepAlive管理让各个Tab的根页面保持状态如果每个Tab内部有独立的下钻页面流比如Tab2需要push自己的二级三级页面就给每个Tab配一个独立的Navigator让它们在各自的空间里压栈互不干扰。在go_router里这个场景对应StatefulShellRoute能比较规范地管理多个导航分支比手动嵌套Navigator省心。6.3 命名路由的查无此路404和通配符如果你用了命名路由一定要处理用户跳转到了一个routes表里不存在的路径的情况。在MaterialApp里onUnknownRoute就是兜底入口配合onGenerateRoute可以返回一个通用404页MaterialApp( onGenerateRoute: ..., onUnknownRoute: (settings) { return MaterialPageRoute( builder: (context) NotFoundPage(), settings: settings, ); }, )做深链接测试时尤其会碰到这种问题——外部链接拼错一个斜杠整个App白屏甚至崩溃有个兜底页至少能友好地提示用户。这一条我不止一次在联调时被坑到跟后端同学联调分享链接对方随手改了路径大小写我这边直接崩溃加了这个兜底之后世界清静了。6.4 navigatorObservers调试和埋点的最佳帮手最后分享一个我几乎每个项目都会加的小工具。MaterialApp有一个navigatorObservers参数你可以注册自定义的NavigatorObserver监听每一次页面push、pop、replaceclass RouteLogObserver extends NavigatorObserver { override void didPush(Route route, Route? previousRoute) { debugPrint([route] push - ${route.settings.name}); } override void didPop(Route route, Route? previousRoute) { debugPrint([route] pop - ${route.settings.name}); } }把它注册到MaterialApp后每次路由变动都会打印日志。调试阶段能直观看到页面栈的变化上生产可以做无侵入的路由埋点比在业务代码里到处埋点干净得多。它和路由守卫的分工是守卫决定能不能去observer记录去了哪里两件事不要混在同一个函数里。最后再分享一个我在实操里总结的习惯每当我开始一个新模块我都会先把导航方式定下来是走onGenerateRoute还是go_router再开始写页面。因为路由是页面结构的骨架骨架定歪了后面加页面、加拦截、加状态保持都会特别费劲。如果你正被页面切来切去状态怪怪的深链接进来白屏想统一做登录拦截却到处改代码这些问题困扰从本文的导航模型和踩坑清单入手大概率能定位到原因。我也建议你找一个简单的项目把push、命名路由、onGenerateRoute、PopScope都分别跑一遍用debugPrint观察路由栈的变化这一套走下来比看十篇文档都有用。