做 Flutter 开发有些年头后,回头看自己早期写的项目,多少会被当初往全局状态里塞东西的豪迈吓一跳:用户信息、列表页筛选条件、某个弹窗的开关状态、甚至一次临时输入的草稿,全都塞进一个巨大的状态类里,再配上各种全局单例,整个项目一启动就背着一个越来越重的“背包”。但随着项目不断迭代、团队磨合、架构逐渐定型,你会发现真正需要留在全局的状态其实越来越少,最后稳定下来的,往往就那么几类。
这个现象不是偶然。全局状态本质上是“所有组件都能无障碍读写”的公共空间,多放一个状态进去,就多一分耦合和不可预知性。一个成熟 Flutter 项目的标志,恰恰是你对“哪些状态必须全局”这件事想得足够清楚。这篇文章就结合我实际做过和维护的几个项目,聊聊最终沉淀下来的全局状态到底是哪几类、为什么是这几类、以及怎么收敛和控制它们。
1. 为什么成熟项目里的全局状态会越变越少
很多刚入门的同学会陷入一种惯性:Provider或者Riverpod暴露出来的容器,什么都能往里面放,全局状态类一天天膨胀。这么做早期确实爽,任何页面想用某个数据,直接context.read或者Provider.of一下,不用考虑数据怎么传、组件怎么解耦。但项目一旦进入维护期,这种爽就会变成持续的成本。
1.1 全局状态的“信任成本”是隐藏的
我常给团队里的小朋友打个比方:全局状态就像公司里的公共茶水间,你可以随时进去拿东西,但也正因为谁都能进,你拿到的任何东西都不确定是不是最新的,也不确定有没有被谁动过。当全局状态数量超过一个阈值,你每次读写都要在脑子里梳理“谁可能改了这个值”,这个认知负担就是一种信任成本。成熟项目之所以把全局状态压到最小,本质上是在压缩这种信任成本,让每个状态的变更路径可追踪、可预期。
一个具体的例子:早期我参与的一个社区类应用,把“当前用户浏览过的最近一条帖子ID”放进了全局状态。初衷是想在首页和详情页之间共享阅读进度,看起来人畜无害。结果后面增加了一个运营弹窗,弹窗逻辑需要知道用户是否刚浏览过某类帖子,于是又引用了这个全局 ID。再到后来,用户从别的入口进入详情页时,这个 ID 的更新时机变得非常微妙,线上出现几次偶发的“弹窗不出现”问题,排查起来牵一发动全身。
1.2 局部状态优先,全局状态兜底
成熟项目的状态管理原则通常是:能用局部状态解决的,绝不放到全局;能通过构造函数参数传递的,就老老实实传参。只有那些“跨越多层组件、多个页面,且各页面都依赖同一个单一事实来源”的数据,才值得成为全局状态。
这意味着,你在设计一个新功能时,应该先问自己三个问题:
- 这个状态是否存在页面销毁后还需要保留?
- 这个状态是否会被两个以上互不相关模块同时读写?
- 如果失去这个状态,用户需要重新从哪里获取?
如果这三个问题答案都是否,那它大概率不需要全局。比如筛选条件,通常在进入列表页时通过参数传入,页内自己维护即可;某个详情页的展开状态,也完全应该属于页面自身的生命周期。只有当你发现同一个数据在 A 页面改了、B 页面要立刻感知、C 页面还要根据它做联动时,才进入全局状态的候选池。
1.3 收敛全局状态的过程是架构进化的缩影
随手翻开一个成熟项目的状态目录,你看到的不再是扁平的一大堆散装字段,而是有层次、有域划分的状态集。这背后通常是团队经历过几个阶段:一开始什么都在全局,后来开始按业务模块拆分,再后来发现一些“假全局”其实可以下沉到路由或页面级,最后才沉淀出稳定、精简的全局集合。这篇文章里说的“只剩下哪几类”,就是这种进化结束后的样子。
2. 稳定沉淀下来的四类全局状态
以我经历过的中型 Flutter 项目为例,无论是电商、工具类还是内容类 App,最后全局状态基本都能归纳进下面四类。它们不是某个具体业务的状态,而是一系列业务状态的共同抽象。
2.1 用户身份与登录态
这一类几乎无可争议。当前用户是谁、是否已登录、token 何时过期、用户资料变更后全站如何感知,这是天然需要“全局单一事实来源”的状态。因为用户从首页到我的页面,到下单流程,再到客服聊天,每个模块都可能读取用户身份。
具体来说,我习惯在全局状态里维护一个UserSession对象,内部包含:
- 用户基础信息(昵称、头像、UID、手机号脱敏信息)
- 登录态枚举(未登录、登录中、已登录、已过期)
- 访问令牌与刷新令牌(Token 和 RefreshToken,但通常会做一层封装,不让业务代码直接接触裸串)
- 最近一次用户信息拉取时间(用于判断是否需要刷新)
不少团队会把 Token 单独放在一个持久化仓库里,这没问题,但只放在本地存储是不够的。因为很多业务 UI 需要根据“是否登录”来渲染不同界面(比如未登录显示登录按钮,已登录显示头像),所以内存中必须有一份可观察的登录态。换句话说,“持久化 Token”和“全局登录态”是两个不同层面的东西:前者解决下次启动时的恢复,后者解决运行时各模块的状态同步。
这里有一个我在实际项目中反复遇到的坑:用户信息散落在不同的全局状态里。比如 A 模块在全局存了一份nickname,B 模块又存了一份avatarUrl,C 模块再存一份userId,看起来各自独立,但用户一旦在“我的页面”修改了昵称,你得手动去同步这三个地方,漏一个就会出诡异 Bug。成熟项目的做法是:把用户相关的所有字段收敛进同一个状态类,并保证这个类只有一个实例入口,页面通过select或compute只取自己需要的字段,修改时也只能通过统一的控制器方法去改。
2.2 用户偏好、主题与全局配置
第二类稳定的是那些“用户不管走到哪个页面都应该生效”的配置项。最常见的就是深色模式、跟随系统、非系统字体大小、语言地区、单位制(公里/英里、摄氏度/华氏度)等等。
这类状态需要全局的原因很容易理解:主题切换要刷新整个组件树,语言切换要重新构建所有文案,单位制会影响全站所有数值展示。它们不属于任何单页面的局部状态,而是应用级的环境变量。
我见过不少团队会把这一块做得比较重:把十几套主题色、所有字体配置、动画时长、圆角风格全都填充进一个全局状态类。这其实没必要。成熟项目会把“全局配置”再细分:动态变化且需要响应式修改的偏好项(如深色模式、语言)放进全局状态,静态的样式常量则应该放进主题配置文件里,只在应用启动时读取,不让它参与运行时广播。因为每多一个全局响应式状态,组件树重建的机会就多一分,性能损耗虽然微乎其微,但维护起来确实要更小心。
实操中,我的选择是只把真正会在运行时被用户修改的偏好扔进全局状态,其他用ThemeData和常量配置类搞定。比如深色模式和亮度模式,用全局ThemeMode;单位制,用全局UnitPreference。其余像品牌色、默认间距、圆角这些,根本不需要响应式,放进静态配置里反而更清晰。
2.3 跨模块共享的业务状态
第三类是有明确业务含义、且被多个业务模块共同依赖的状态。常见的有电商应用的购物车数据、消息中心未读条数、内容类应用的历史记录同步位点、IM 应用的会话列表等。这类状态之所以进入全局,是因为它们天然存在“一处更新、多处感知”的需求。
拿一个电商 App 举例:商品详情页点击“加入购物车”,底部 Tab 栏的角标要立刻更新,购物车页面要展示最新列表,订单结算页要根据购物车选中项计算金额,这些页面之间没有直接的父子关系,甚至分布在不同的路由层级中。如果不把购物车作为全局状态,你就只能在各个页面之间频繁传递数据、手动发通知,最后维护成本一定爆表。
但这里我一定要提醒一句:跨模块业务状态进入全局后,要控制它的“边界”。比如购物车,全局状态里存的应该只是一份“购物车条目列表 + 选中状态”,而不是把每个商品的详情、库存、促销信息全部拷贝进来。商品详情应该通过 ID 去数据仓库查询,或者由商品模块自身管理。我曾经见过一个项目,购物车状态里塞了每个商品完整的SkuInfo、PriceInfo、ActivityInfo,结果商品促销价格一变,购物车里的数据还维持着旧值,用户下单时看到的价格和实际结算价不一致,被运营投诉了不知道多少次。
正确的做法是:全局状态只保留核心引用(商品 ID、数量、加购时的快照信息),涉及实时价格、库存、营销优惠这些动态数据时,要么在进入购物车页面时重新查询,要么让数据层提供流式更新。把全局状态当作“索引”,而不是“数据仓库”,这个观念很重要。
2.4 基础设施本身:访问令牌、网络连接与推送能力
严格来说,这一项可能不算业务状态,但它确实是几乎所有成熟项目都会保留的“全局能力状态”。比如:
- 访问令牌和刷新令牌的缓存状态,以及令牌刷新中的并发锁状态
- 当前网络连接类型(WiFi/蜂窝/离线),用于全局判断某些资源加载策略
- 推送 Token 注册状态、通知权限授权状态
- 全局消息通道(如 WebSocket 连接状态),用于向全站广播服务端消息
这类状态往往由基础设施层以单例或独立容器的形式存在,业务代码不会直接修改它,但它又确实是全局的、可观察的。比如网络连接状态,很多业务页面在加载失败时要判断“是不是因为没网”,而不是直接弹错误;通知权限授权状态会影响“是否引导用户打开推送”的入口展示。把这些基础设施状态放成全局可观察对象,能让业务层写起来很顺手。
不过,基础设施状态要有意识的与业务状态隔离。我的习惯是:业务全局状态放在一个业务状态容器里,基础设施状态放在另一个容器里,两者不要混在一起。否则,业务状态更新时会把网络状态也一起通知出去,组件又得排查自己到底依赖了哪个字段,逻辑容易乱。
3. 实操落地:如何收敛和设计这几类全局状态
光说不练没用。这一节我用一个贴近真实项目的示例,讲一讲我是如何把上面四类状态落到 Flutter 代码里的。这里以目前社区里用得比较多的Riverpod为例,因为它天然能表达“全局单例”和“局部覆盖”,但思路同样适用于其他方案。
3.1 用户会话状态的接口设计
先看用户身份这一块。我建议定义一个不可变的UserSession,然后通过Notifier/StateNotifier对外暴露:
enum AuthStatus { unknown, unauthenticated, authenticating, authenticated, expired, } class UserSession { final AuthStatus status; final String? userId; final String? nickname; final String? avatar; final DateTime? lastRefreshAt; const UserSession({ required this.status, this.userId, this.nickname, this.avatar, this.lastRefreshAt, }); factory UserSession.empty() => const UserSession(status: AuthStatus.unauthenticated); UserSession copyWith({...}) { // 实现不可变对象的部分更新 } }然后定义一个全局的NotifierProvider:
class UserSessionNotifier extends Notifier<UserSession> { @override UserSession build() { // 启动时从持久化仓库恢复上次会话 final cached = _loadFromLocal(); return cached ?? UserSession.empty(); } Future<void> login(String mobile, String code) async { state = state.copyWith(status: AuthStatus.authenticating); try { final result = await authApi.login(mobile, code); _saveToLocal(result.toLocalCache()); state = UserSession( status: AuthStatus.authenticated, userId: result.userId, nickname: result.nickname, avatar: result.avatar, lastRefreshAt: DateTime.now(), ); } catch (_) { state = state.copyWith(status: AuthStatus.unauthenticated); } } Future<void> refreshToken() async { // 防止并发刷新,可以用一个私有 Future 保存当前刷新任务 // 这里主要说明通过 Notifier 统一管理令牌刷新 } }为什么要用不可变对象?因为状态不可变才能保证“旧页面持有的引用不会莫名被改”,Riverpod的依赖追踪也才可靠。如果直接在一个可变对象上改字段,很容易导致 UI 组件没感知到变化,或者产生多余的重建。
这里还有一个细节:不要把Token放进UserSession的公开字段。Token 是敏感信息,业务层大多数时候不需要直接读它,需要的时候应该从Dio拦截器里拿。我更愿意让UserSessionNotifier内部维护一个SecureTokenStore,并在刷新 Token 的Future里统一更新,这样全局状态对外暴露的字段就干干净净。
3.2 主题与用户偏好的实现
主题这类全局状态,在设计上要尽量让业务页面无感知。换句话说,页面只管用Theme.of(context)去拿样式,至于主题是怎么全局切换的,页面不需要知道。我建议在全局放一个AppPreferences,它只包含少量用户可调整项:
class AppPreferences { final ThemeMode themeMode; final Locale locale; final String lengthUnit; // 'metric' or 'imperial' const AppPreferences({ required this.themeMode, required this.locale, required this.lengthUnit, }); } class AppPreferencesNotifier extends Notifier<AppPreferences> { @override AppPreferences build() { return AppPreferences( themeMode: ThemeMode.system, locale: const Locale('zh'), lengthUnit: 'metric', ); } Future<void> setThemeMode(ThemeMode mode) async { state = state.copyWith(themeMode: mode); await _persist(mode); } }监听主题切换时,在MaterialApp上直接使用这个全局状态即可:
final preferences = ref.watch(appPreferencesProvider); return MaterialApp( theme: AppTheme.light(), darkTheme: AppTheme.dark(), themeMode: preferences.themeMode, ... );这样全局状态的消费方只有MaterialApp这棵树,其他组件不需要知道状态存在。这也是全局状态的一个判断标准:如果一个全局状态只有极少数上层组件消费,其他页面通过继承间接使用,那它的“全局扩散面”其实是可控的。
3.3 购物车这类跨模块业务状态如何避免膨胀
跨模块业务状态最容易失控,所以我单独说下做法。以购物车为例,我建议全局 State 里只维护:
class CartState { final List<CartItem> items; // 核心条目 final Set<String> selectedIds; // 选中的条目 ID final DateTime updatedAt; // 便于做缓存失效 const CartState({ required this.items, required this.selectedIds, required this.updatedAt, }); } class CartItem { final String productId; final String skuId; final int quantity; final int unitPriceAtAdd; // 加入时的单价快照,仅用于展示 }页面需要实时价格时,不应该去读CartItem.unitPriceAtAdd,而是通过商品模块提供的AsyncNotifier按productId去拿最新价格。用开发者喜欢说的一句话:全局状态里放“关系”和“数量”,别放“所有能拿到的详情”。
有一点容易被忽视:购物车这种高频更新状态,在 UI 层要用select精确监听。比如购物车角标只需要知道“总件数”,那就不要 Watch 整个CartState,而是:
final totalCount = ref.watch( cartProvider.select((cart) => cart.items.fold(0, (sum, e) => sum + e.quantity)), );这样只有items变化且总件数变化时才触发角标重建,避免整个购物车页面的所有组件都跟着重建一遍。成熟项目在状态多了以后,这类细节对流畅度的影响会被放大。
3.4 基础设施状态的隔离设计
基础设施状态我习惯直接单拎出来,不和业务状态混在同一个 Provider 里。示例:
enum NetworkStatus { online, offline, unknown } class AppInfrastructureState { final NetworkStatus networkStatus; final bool pushPermissionGranted; final bool hasInitialized; } class InfrastructureNotifier extends Notifier<AppInfrastructureState> { @override AppInfrastructureState build() { // 监听网络、查询推送权限等 } }为什么单独隔离?因为这类状态的消费者可能是页面,也可能是数据层。如果混在一起,业务状态更新一次,基础设施层也要收到通知,容易引起不必要的联动。把基础设施状态单独放一个 Provider,业务层只在需要时ref.read或ref.listen,按需使用,互不干扰。
4. 常见问题与排查技巧实录
即使知道原理,实际开发中还是会在这些全局状态上踩坑。我挑几个出现频率最高的,结合排查过程聊聊。
4.1 重启 App 后全局状态恢复顺序混乱
场景:用户已经登录,启动后进入首页,页面上几处要立刻展示用户头像和昵称,但首帧渲染时全局状态还是空值,头像闪了一下旧值或者默认值,然后才跳到新值。
原因:启动时全局状态从本地恢复需要异步读取,或者 Token 校验没完成前,状态被业务代码误写成了未登录。我排查过一个项目,状态里有一个hydrated布尔值,启动流程里没有等待恢复完成,首页就监听了UserSession,导致先渲染空态再更新。
解决:启动流程上做一个初始化屏障。把“从本地加载并刷新会话”放到全局initialize()阶段,完成后才runApp;或者在路由入口处通过ref.listen等待status != unknown再渲染真实 UI。我建议后者更灵活,尤其是遇到慢网络时,用户不会看到首帧空白,而是看到一个加载态。
4.2 某个页面修改了大量全局字段后,相关 UI 大面积重建
场景:用户一次操作触发了UserSession里好几个字段同时更新,结果很多无关页面也跟着重建。排查后发现,这些页面都直接 Watch 了userSessionProvider,但实际只用了其中一个字段。
解决:统一用select或者拆成多个小 Provider。Riverpod 里select很方便:
final nickname = ref.watch( userSessionProvider.select((s) => s.nickname), );这样只有昵称变化时这个 Widget 才会重建。另一招是把 UserSession 按字段拆成avatarUrlProvider、userIdProvider这样的派生 Provider,不过字段多了会很啰嗦,我通常二者组合使用。
4.3 全局状态被业务代码“顺便”塞了私货
这是我最常提醒团队的问题。有些同学在实现某个功能时,顺手就往全局状态类里加字段:“这个页面以后可能也要用”。听起来合理,但这个字段往往最后根本不会被第二个模块使用,却长期占用全局广播成本,还让状态类变得臃肿。
排查方式很简单:全局状态类里每个字段,代码搜索一下引用次数。如果一个字段只有单一页面的单一路径在使用,那它就应该降级为局部状态;如果在页面销毁后确实需要保留,可以用路由级或模块级状态容器,不必提升到最顶层全局。收敛这件事没有捷径,只能定期做“状态清理重构”。
4.4 Token 刷新时多个请求同时回 401
场景:AccessToken 过期后,首页和详情页同时发起接口请求,两个都返回 401,拦截器各自触发刷新 Token,出现重复刷新,甚至一个失败导致另一个被登出。
原因:Token 刷新这个动作本身是全局竞争状态,没有锁住。
解决:在基础设施层,用一个全局的Future<void>? refreshTokenFuture保存当前正在进行的刷新任务,所有 401 都await同一个 Future:
Future<void> _ensureFreshToken() { if (_refreshing != null) return _refreshing!; _refreshing = tokenApi.refresh().whenComplete(() { _refreshing = null; }); return _refreshing!; }这是一类很容易被忽略的“全局状态”——它不是一个数据状态,而是一个异步协调状态。成熟项目里这样的隐式全局状态也得小心设计。
5. 给新手的几个判断方法和收藏建议
如果你正处于“总觉得局部状态不够用,想全部搞成全局”的阶段,我建议先别急着加,用下面的判断流程走一遍:
- 第一,这个状态能不能被页面自行重建?若能,就别全局。
- 第二,这个状态能不能通过路由参数传入?若能,优先传参。
- 第三,这个状态被几个模块依赖?若只有自己,就控制在自己内部。
- 第四,这个状态的生命周期和 App 生命周期是否一致?若不一致,你要考虑清楚它何时销毁、何时重建。
- 第五,这个状态是否需要持久化?若不需要,全局的意义还要再打一个问号。
另外,团队里最好在状态层加一道“审批”机制。我所在的一个稳定团队,代码评审时对新增全局状态是重点看护的:PR 里新增一个全局 Provider,必须有对应的业务说明、消费方列表和生命周期设计。刚开始他们会觉得繁琐,但项目大了以后,这道流程真的挡住了不少后续维护的雷。
还有一个小建议:为全局状态写一份“地图”。把每个全局状态的名字、职责、消费方简单记录在项目文档里。别小看这份地图,新人接手时能少踩很多坑,几个月后你自己再回来看,也能快速想起当初为什么这么设计。我见过太多项目,代码里状态已经换了三轮,但没有文档,后来的人只能靠猜。
6. 我个人在实际维护中的几点体会
其实到后面你会发现,管好全局状态的核心不是工具选型,而是“边界感”。工具只是脚手架,真正决定项目健康的,是每个人心里都清楚:哪些状态属于全局的公共治理范围,哪些状态属于局部事务。这种边界感一旦建立,代码结构会变得非常清爽。
我在几次迭代中,把以前塞进全局的十余个状态最终收敛到四类,项目规模其实没有变小,但重构时明显感觉心里有底了:改用户态时不会担心牵连购物车,改购物车时不会触碰到网络状态,加新功能时首先想的是“它需不需要成为第五类”。这种清晰的边界感,比任何状态管理库的魔法更让人踏实。
如果非要给一个“最后小技巧”:每次往全局状态里加新字段之前,先把它当做一个“PR 的全家桶”来做,把理由、生命周期、消费方写出来贴在代码注释里。哪怕只有一行,未来也会成为救命稻草。全局状态不是越少越好,而是“每一个存在都有充分的全局理由”才好。