☰
Flutter 全局状态管理:成熟项目只保留这四类状态
2026/10/14 3:59:17 网站建设 项目流程

我一直忘不了接手某个项目头两周感受到的那种混乱。当时全局状态散得到处都是,随便打开一个页面,背后就挂着三四个 Provider 或者 Controller,有的是给服务端数据用的,有的是给表单临时状态用的,还有的只是为了让两个组件之间传个参数。全局状态这个概念本身没错,但什么都能往里塞的时候,它就开始变成负担了。后来我陆续参与过几个从零搭到上线、再从上线熬到迭代多版本的项目,观察到一个共同的现象:真正成熟的 Flutter 项目里,全局状态反而会越收敛越少,到最后基本只剩四类。这篇文章就把这四类拆开讲清楚,包括每一类的典型内容、适合的代码形态,以及哪些状态看起来应该放全局、实际上完全不应该放。如果你正在做状态管理选型,或者接手了一个“全局状态失控”的老项目,这篇文章应该能帮你省不少功夫。

1. 我见过的早期项目:全局状态是怎么一步步失控的

1.1 一个页面对应五六个 Controller 的典型现场

很多 Flutter 项目刚起步的时候,团队人数少、页面少,大家最关心的是“功能能不能跑起来”,很少有人花精力去定义状态到底该放在哪一层。最常见的情况是:一个页面里需要获取用户信息,就 new 一个全局 Provider 存下来;另一个页面需要展示购物车角标,又 new 一个全局 Provider;再有一个页面需要记住用户上次选的 Tab,干脆也提一个全局变量出来。

我当时接手那个项目的时候,光全局 Provider 就数出了十六个,里面还包括几个ChangeNotifier的实例。其中一个让我印象特别深:某个详情页的滚动偏移量被存成了全局状态,结果用户在 A 页面滚到一半,跑去 B 页面做操作,再切回来时详情页直接滚到了 B 页面操作前记录的位置——完全不是用户预期的行为。这种“被全局”的临时 UI 状态,就是失控的起点。

1.2 失控的代价:调试、内存、和回滚成本

全局状态一旦泛滥,最先出问题的不是功能,而是排查问题的时间。当你发现某个页面的数据被改了,你想知道是谁改的,可是全局状态在任何一个地方都能被写入,它在代码里没有明确的“写入口”和“写权限”,你只能全局搜ref.read(xxxProvider.notifier).update这类写法,逐个排查。

热重载也会变得很卡。状态对象一多,每次点击热重载,框架要重新触发的监听器和初始化逻辑就多,十几二十个 Provider 叠在一起,重载一次等两三秒很正常。更可怕的是回滚成本:今天你觉得某个全局状态没用了,把它删掉,结果两天后另一个页面告诉你它一直在读写这个状态,你只能再找回来,顺便修一圈编译报错。到了这一步,团队基本会达成一个共识:得给全局状态做一个“收棺”动作,能不放全局的,坚决不放。

从我自己带项目的经验看,成熟项目并不是天生就能写出“正确”的全局状态划分,而是吃过亏之后被迫收敛的。收敛出来的结果才是能长期维护的形态。

2. 成熟项目里真正剩下的四类全局状态

2.1 会话态:App 的地基,所有页面的公共上下文

第一类全局状态是会话态。只要 App 需要登录,这类状态就几乎一定存在。你不可能在每一个页面都单独去请求一次“当前用户是谁”,也不应该在页面之间用路由参数去传递 Token 和用户 ID,因为它们的生命周期远比单个页面长,整个 App 从启动到退出,或者从进程被杀到重新启动,它们都在。

会话态里具体包含什么,不同 App 会有细微差别,但大致就这几样:

  • 登录凭证:Token、Refresh Token、登录方式、过期时间
  • 当前用户档案:用户 ID、昵称、头像、会员等级、手机号
  • 客户端偏好:亮暗模式、语言选择、字体大小这类用户设置
  • 登录之后的权限信息:可访问的功能列表、菜单开关

这几样东西有几个共同特征:App 启动之后基本上立刻要用,写入频率极低(登录、登出、改设置时才会变),一旦丢失会影响整个 App 的使用体验,所以通常会配合本地持久化做恢复。

会话态的关键点是只读扩散、集中写入。页面可以随便读用户 ID 和 Token,但所有写操作都必须走同一套登录/登出逻辑,防止出现“一个地方改了用户状态,另一个地方不知道”的竞态问题。

2.2 配置态:由远端驱动的低频状态

会话态解决的是“我是谁”,配置态解决的是“当前客户端该怎么表现”。这类状态在早期项目里容易被忽略,但一个 App 进入成熟期之后,它几乎是绕不开的。

配置态典型的长这样:

  • 远程配置开关:某个新功能是否灰度开放、某个按钮文案是否显示
  • Feature Flag:新旧版本切换、A/B 测试分组标记
  • 接口环境配置:当前请求的 Base URL、当前环境(测试/预发/生产)
  • 常用基础配置:是否需要展示协议弹窗、默认城市的用户协议版本号

配置态和会话态的区别在于来源。会话态主要来自本地用户行为和登录接口,配置态更多是被远端推送或拉取下来的。它被读的频率很高,但被修改的频率很低,一天可能只更新一次甚至几天一次。而且它通常是全 App 一致的——你不能让首页读的是配置 A,我的页面读的却是配置 B。

我建议把配置态单独分离出来,核心原因是它的变化来源很特殊。如果你把 Feature Flag 混在会话态里,每次登切换账号,远端配置可能都要重新拉一遍,逻辑会非常拧巴。单独做一个配置 Controller,启动时拉取一次,收到推送时刷新一次,干净很多。

2.3 跨模块业务态:唯一需要克制定义的类别

第三类稍微有点微妙,它是最容易泛滥的类别,也是“全局状态”这个名词最容易招黑的原因。

跨模块业务态,指的是同时被两个以上业务模块关心、并且在模块之间承担一定“语义交换”任务的状态。我见过最典型的几个场景:

  • 未读消息角标:首页显示 15,消息页显示 30,两个页面都依赖同一个未读数状态
  • 购物车/收藏:列表页可以操作收藏,个人页要展示收藏列表总数,需要共享同一个集合对象
  • 多 Tab 之间的筛选条件:比如订单列表有“全部 / 待付款 / 已完成”三个 Tab,首页角标和订单页都要根据同一个状态变化

这类状态的特点是有明确的业务实体,生命周期介于“页面级”和“App 级”之间。它们需要被全局共享,但绝不能像会话态那样只读,它们随时会有多个模块写入——于是这里就非常容易出问题。

我的实践经验是,跨模块业务态要满足两个条件才值得放全局:第一,确实存在两个或以上的同屏/跨页面观察者;第二,状态更新有明确的业务归口,不能是每个页面各写各的。如果你硬要把“只在列表页内部维护的选中项”也拉出来全局共享,那你只是把一个局部问题变成了全局复杂度。

2.4 基础设施能力态:藏在底层的全局信号

最后一类全局状态容易被忽视,因为它通常不参与业务 UI,但它确实影响所有页面。我把它叫基础设施能力态,它描述的是“当前运行环境给 App 提供了哪些能力”。

典型的例子包括:

  • 网络连通性:在线、离线、Wi-Fi、蜂窝网络
  • 推送能力:推送 Token 是否注册成功、通知权限是否开启
  • 位置权限:用户是否授权定位、最近一次定位结果
  • 生物识别可用性:设备是否支持指纹/面容、当前是否开启
  • 全局弹窗状态:底部 Toast、全局加载蒙层的展示/隐藏

这些状态的特征是:底层变化驱动它们,业务层只负责响应。页面不会主动去“设置”网络状态,它只能观察到网络从断开变成了连上。所以这类全局状态不应该被页面写入,它们的写入口应该在基础设施层——比如网络监听器、权限服务、推送服务那边。

如果你不用全局状态去管理这些东西,会出现很滑稽的 bug:比如网络断开时页面 A 弹了一个错误提示,网络恢复时页面 B 根本不知道,角标和加载逻辑全都乱了。把它们归到基础设施能力态,统一用监听流刷新,各页面只消费结果,是成本最低的做法。

3. 四类状态分别怎么落地:代码形态与读写纪律

3.1 技术选型:为什么我更倾向用 Riverpod 收口

聊完分类,接下来就是落地问题。Flutter 里可选的状态管理方案很多,Provider、Riverpod、Bloc、GetX 都有人用。我自己的项目从 Provider 迁移到 Riverpod 之后,就基本没有换过。

原因不复杂。第一,Riverpod 天然具备“读写分离”的基础设施:StateProvider或者NotifierProvider的ref.read可以拿到当前值,ref.read(provider.notifier)才能拿到写入口,页面代码里到底在读还是再写,一眼就能看出来。第二,它支持在编译期就能定位 Provider 的错误,不像手写ChangeNotifier那样依赖运行时报错。第三,它的AsyncNotifier、StreamProvider对付远端数据和监听流非常好用,和配置态、基础设施能力态的匹配度很高。

如果说你的项目里还全是InheritedWidget或者手写单例,我建议还是花点时间迁移过来。迁移成本通常比想象的低,因为四类全局状态的边界一旦划清楚了,Provider 的声明结构非常稳定。

3.2 会话态与配置态的代码骨架

先看会话态的代码形态。我习惯定义一个SessionState模型,然后暴露一个SessionController:

class SessionState { final String? token; final String? refreshToken; final UserProfile? profile; final bool isLoggedIn; const SessionState({ this.token, this.refreshToken, this.profile, this.isLoggedIn = false, }); SessionState copyWith({...}) {...} } class SessionController extends Notifier<SessionState> { @override SessionState build() { // 启动时从本地缓存恢复 return _loadFromStorage(); } Future<void> login(String username, String password) async { // 调用登录接口,拿到 token 和 profile // 写入本地存储 state = SessionState( token: ..., profile: ..., isLoggedIn: true, ); } Future<void> logout() async { // 清理本地存储 state = SessionState.empty(); } } final sessionProvider = NotifierProvider<SessionController, SessionState>( SessionController.new, );

页面上要读取用户信息时,只要ref.watch(sessionProvider).profile就行,而且整个 App 只有一个写入口:SessionController。改登录逻辑时不需要担心某个页面另外偷偷改了个 Token。

配置态的骨架类似,但它更适合用AsyncNotifier来承载异步拉取的过程:

class RemoteConfigState { final Map<String, dynamic> flags; final DateTime fetchedAt; } class RemoteConfigController extends AsyncNotifier<RemoteConfigState> { @override Future<RemoteConfigState> build() async { final flags = await _api.fetchRemoteConfig(); return RemoteConfigState(flags: flags, fetchedAt: DateTime.now()); } Future<void> reload() async { state = const AsyncLoading(); state = await AsyncValue.guard(() async { final flags = await _api.fetchRemoteConfig(); return RemoteConfigState(flags: flags, fetchedAt: DateTime.now()); }); } }

用AsyncNotifier的好处是,页面在等待远端配置时天然有AsyncLoading状态,不需要自己再定义一个 isLoading 标志。这比手写状态枚举要省事得多,而且不容易漏掉错误分支。

3.3 基础设施能力态的流式实现

基础设施能力态里,网络连通性是最典型的例子。它天然是事件流,而不是由用户动作触发的普通状态,用 Riverpod 的StreamProvider最顺手:

final networkStatusProvider = StreamProvider<NetworkStatus>((ref) { return ConnectivityService().statusStream(); });

对应的服务层负责监听系统事件,把底层状态翻译成业务语义:

class ConnectivityService { Stream<NetworkStatus> statusStream() { return Connectivity() .onConnectivityChanged .map((results) => NetworkStatus.fromResults(results)); } }

页面里只需要ref.watch(networkStatusProvider),网络状态一变化,所有关心它的组件都能同时收到通知。同样的模式可以推给推送权限、定位权限、生物识别开关——写入口永远在 service 层,业务层只观察,不修改。

3.4 读写权限分离:页面只读,写入入口收口到服务层

这部分是我认为整个收敛过程中最重要的一条纪律:全局状态的写入权限必须收口。四类状态里,会话态和配置态的写入归业务服务,跨模块业务态的写入归对应的领域逻辑,基础设施能力态的写入归基础设施层。页面拿到的永远是“只读视图”。

具体落地时,我会在项目里建一个providers.dart之类的聚合文件,把所有全局 Provider 都集中声明。这样任何人打开这个文件,就能看到当前 App 的全部全局状态。如果这个文件里出现了“页面名 + 局部功能名 + Provider”的组合,比如orderDetailScrollProvider,那就是一个信号:这个状态八成不该出现在这里。

读写分离还有一个非常实际的好处:代码审查的时候,reviewer 只需要看写入口改了没有、影响面有多大,而不是到处找ref.read(provider.notifier).xxx()的散落调用。对团队协作来说,这一个纪律能省掉大量沟通成本。

4. 不该进全局状态的清单:缓存与局部状态的家

4.1 三条判断标准:生命周期、影响面、可恢复性

很多朋友问我:“那你看我这个状态到底要不要放全局?”我一般直接让他用三个问题判断:

  • 生命周期:这个状态的生命周期是不是和整个 App 一样长?如果它只是页面生命周期,那就不该放全局
  • 影响面:这个状态发生变化时,是不是至少有两个模块需要感知?如果只有当前页面 / 当前组件关心,那它就是局部状态
  • 可恢复性:如果用户清空缓存或杀掉 App 再重新打开,这个状态丢失了,用户会明显觉得“东西没了”吗?如果不会,它就不需要持久化全局状态

举几个典型的“看起来需要全局、实际上不该全局”的例子:

  • 表单输入框内容:注册流程里的邮箱、密码、验证码,只属于表单页,页面销毁就应该丢
  • 列表的分页数据和滚动位置:列表页重新进入时重新加载是最常见的行为,不需要全局记住
  • 页面被弹出的弹窗开关:谁触发的弹窗,谁自己管理开关,不需要全局广播
  • 临时选中项:一个多选弹窗里选中的条目,只在弹窗内部有意义

我见过一个项目把表单的每一步都放进了全局状态,结果用户填到第三步不小心触发了一个全局刷新,前面两步的数据全被重置,气得用户直接卸载了 App。这种事故的根源就是把生命周期短的状态放进了长生命周期的容器里。

4.2 服务端数据的正确归宿是缓存层,不是全局 Store

还有一种更容易混的:服务端数据。很多人觉得“这个用户列表每个页面都要用,所以我要建一个usersProvider全局存着”,这是典型的概念混淆。

服务端数据应该交给 Repository / Cache 层管理,而不是塞进全局状态。因为服务端数据的核心竞争力是“减少重复请求”和“离线缓存”,这属于缓存策略的范畴,它应该在 Repository 层做:内存缓存一份、可选的本地持久化一份,然后按需刷新或失效。页面仍然可以拿全局状态去“观察”某个数据是否在加载,但数据本体不应该放在 Provider 里彼此复制。

举个具体场景:你的首页需要展示文章列表,详情页也需要展示同一份文章数据。看起来是两个页面共享数据,但实际上它们的职责不同。首页关心列表分页,详情页通常只需要根据文章 ID 从本地缓存里取那一篇。你把整个列表放进全局,列表一刷新,详情页展示的数据也跟着变了,用户会一脸懵:为什么我看了半天的文章突然内容被替换了?

正确的做法是把文章数据放进 Repository 的缓存 MapMap<String, Article> cachedArticles,按需要用 ID 取。页面之间不需要为“同一份数据”建立全局绑定。

4.3 页面内局部状态的边界实操

局部状态应该老老实实住在StatefulWidget或者ConsumerStatefulWidget里。搜索框的TextEditingController、Tab 切换的selectedIndex、下拉加载的isLoadingMore、底部弹窗的打开/关闭,都属于这一类。

你可能会问:如果这些局部状态需要用 Provider 来触发页面重建,那怎么办?这是一个很常见的设计问题。我的习惯是:可以用StateProvider,但 Provider 的作用域必须限在页面内,也就是说 Provider 是autoDispose且由页面自己创建的,而不是声明在全局providers.dart里。用局部的 Provider 容器包裹页面,状态跟页面一起销毁,这样既享受了响应式,又不会污染全局。

判断一个状态到底属于谁,我有一个更加直白的比喻:把它想象成“写在便签纸上的备忘”和“印在身份证上的信息”。便签纸随手写随手撕,对应局部状态;身份证上的信息走到哪跟到哪,对应全局状态。你要是把便签纸内容全印到身份证上,那整个人的信息就彻底失控了。

5. 从失控到收敛的过渡经验:盘点、迁移与踩坑

5.1 先盘名单再动手:给现有全局状态打标签

如果你接手一个全局状态已经明显过量的项目,别急着重构。第一步永远是盘点,把当前所有的全局 Provider、Controller、单例都列出来,然后在表格里给每一项目打标签。我的做法是这样:

状态名称当前类型生命周期被谁读被谁写归类判断
userTokenProvider全局App 级多个页面登录/登出会话态,保留
shoppingCartProvider全局App 级购物车页/首页角标多个页面跨模块业务态,保留
homePageTabProvider全局页面级首页首页应该改为局部状态
detailPageScrollProvider全局页面级详情页详情页应该删除
remoteFlagProvider全局App 级全部页面远端配置态,保留

这张表格做完之后,你会发现真正够格待在全局列表里的状态通常只有个位数。剩下的全都应该迁出去。这个盘点过程本身就是一次团队评审,最好拉上所有参与模块开发的同学一起过一遍,避免你单方面判了某个状态“死刑”,结果另一个模块的同事跳出来说“我天天用它”。

5.2 迁移顺序与一次性切干净

迁移顺序我推荐“先易后难,先底层后业务”。基础设施能力态和会话态是最优先的,因为它们的定义最清晰,迁移风险最低。先把这两个类别的边界钉死,再处理配置态,最后才动跨模块业务态。跨模块业务态是最容易反复的,需要和产品确认清楚:这个角标计数到底在哪些页面保持同步?不同步的页面怎么办?

和很多人的直觉相反,迁移状态管理不应该“磨叽”,最好是每一条状态都一次性切换干净。什么叫切干净?就是定义好新的 Provider 之后,把旧的引用全部替换掉,然后立刻删除旧 Provider,不给代码库留“双份状态”的窗口期。双份状态的危害远大于状态本身的问题:它会让团队不知道该读哪一个,也会让数据出现两套、互相漂移、难以对账。

我见过最痛苦的重构就是那种“先加一个新的 Provider,旧的先留着,大家慢慢迁”的半吊子方案。结果迁了三个月,旧状态的引用还是遍地都是,最后干脆又回滚了。真正有效的做法是排期集中做,一个状态一个状态地清理干净,每次改动配一轮全量回归测试。

5.3 收敛过程中我踩过的三个具体坑

最后分享几个我在收敛过程里真实踩过的坑,这些“雷区”你在文档里通常看不到。

第一个坑:会话态恢复时导致的首页闪屏。我把SessionController改成启动时从本地恢复数据后,第一次冷启动时build()还在读存储,首页的某个依赖直接把用户当成“未登录”处理,渲染了一版错误的界面,然后才突然变成登录态。解决方式很简单:SessionController.build()里先返回一个isRestoring: true的初始状态,页面在恢复期间显示统一的启动 Page,恢复完成后再放行进入主页面。这个细节如果你不做,测试同学会反复给你提“启动白屏/闪烁”的 bug。

第二个坑:配置态刷新时的副作用。配置态的reload()如果写得不够细致,远端配置一更新,所有依赖的页面都会立刻重建。页面重建本身没问题,问题在于有些页面把“拿到配置后的动作”做成了副作用,比如每次配置一变就弹一个公告,用户一天切了三次网络,公告就弹了三次。正确姿势是把配置态当作纯数据源,页面里用ref.listen去处理必要的响应动作,并且保证动作幂等。

第三个坑:跨模块业务态没有做预处理。拿未读消息角标来说,如果聊天模块和首页角标各自维护一份计数,哪怕你用了同一个全局变量,只要有一方更新时机不对,角标就会闪烁或者对不上。后来我强制要求:跨模块业务态的所有写操作都通过一个统一的 Service 方法完成,比如unreadMessageService.markAllRead(),谁都不准直接改底层状态。这个规定执行一个月后,角标相关的 bug 直接清零。

给一个可执行的硬规矩:新状态上线的四问检查

经过这几个项目,我给自己定了一个硬规矩:任何新状态想进入全局列表,必须回答四个问题再开会评审。第一,它的生命周期是否覆盖多个页面?第二,修改它的是不是一个明确的写入口?第三,它被并发监听时会产生什么边缘情况?第四,如果它不放进全局,有没有替代方案?

回答不上来前两个问题,就不许进全局;回答不上后两个问题,说明你对这个状态的理解还不够,先做原型或者画边界图再说。这套检查机制听上去很死板,但真的能拦住 90% 的“顺手就全局了”的临时方案。

我自己在实际工作中的体会是,全局状态收敛不是一个纯技术问题,它更是一个团队对“状态归属”达成共识的过程。分类方案和技术选型其实都很标准,难的是一致性。如果你能让团队所有人都认同一句话——“全局状态是最后的选择,不是默认的选择”,那这个项目的状态管理基本就不会走到失控那一步。上面的四类划分也一样,它本质上是给团队划定了一个边界,边界内可以放心写,边界外则要三思而后行。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询