做到第二十五篇,基础功能已经差不多铺完了:网络层封装好了,本地缓存能用,路由体系也搭了起来,列表页、详情页、收藏页、设置页都跑得通。但就在这个阶段,我遇到了比功能更难缠的问题——代码开始变乱。不是编译层面的乱,是状态在到处飞的乱:收藏页面改了红心,列表页没联动;深色模式一切换,整个界面的临时状态全部错乱。说实话,这类问题在资讯类App里特别容易被放大,因为页面多、列表多、入口碎片化严重,状态一不小心就成了一团乱麻。
这一篇我打算把Flutter for OpenHarmony开发中的状态管理完整梳理一遍,包括方案选型、分层模型、关键场景的状态流转、鸿蒙真机调试时遇到的那些坑。内容不会只讲某个框架的API,而是从“资讯类App到底哪些状态需要管”这个根本问题出发,一步一步带出我的设计思路和实操经验。不管你是刚把Flutter跑上开源鸿蒙的小白,还是已经在做中大型App的开发者,这篇都应该能给你一些可落地的参考。
1. 资讯类App的状态到底复杂在哪
1.1 我遇到的第一道坎:列表和收藏的数据不同步
事情是这样的。某天我在导航栏的两个Tab之间切换,发现收藏页里刚点的红心,回到推荐列表页依然是未收藏状态。当时我以为是列表缓存的问题,查了半天发现根本不是——是收藏状态被复制了两份,一份留在收藏页的页面State里,一份留在首页Card组件的State里,两者互不知道对方的存在。这就是典型的“状态放错了位置”。
资讯App的状态和工具类App不太一样,它的状态种类特别多,而且互相之间有联动。比如文章列表的加载状态会影响骨架屏的展示;收藏状态会影响列表项右下角的小红心;深色模式会影响整个主题,进而影响标题颜色、卡片底色、图片占位符;阅读历史又会影响列表中哪些条目被标成“已读”。如果每个组件各自为政,各存一份状态,联动的复杂度会随着页面数量几何级增长。
1.2 第二道坎:深色模式切换后界面状态崩溃
还有一次,我在设置页把深色模式打开,回到列表页后,发现下拉刷新的loading指示器还停留在旧主题的颜色上,部分已经滚动到底部的列表也出现了跳动。表面上看这只是主题重建的时序问题,但深入一看,其实是加载状态、页面滚动偏移、以及主题配置这三类不同生命周期的状态被强行揉在了一起,切换主题时整个页面State被重建,临时状态也跟着丢了。
这些坑让我意识到一个核心问题:在资讯类App里,状态管理不是“选个状态管理库”这么简单,而是要先回答清楚——哪些状态是全局的、哪些是路由级的、哪些是页面级的、哪些只是组件私有的。回答清楚了,工具只是顺手的事。
1.3 资讯App状态清单:先盘点再动手
在做状态设计之前,我先把项目里所有可能的状态拉了个清单,分成四类:
| 状态类别 | 典型例子 | 生命周期 | 影响范围 |
|---|---|---|---|
| 用户偏好类 | 深色模式、字体大小、列表密度 | App启动到退出 | 全局 |
| 账号数据类 | 登录态、用户信息、收藏的文章ID集合 | 登录到登出 | 全局 + 跨页面 |
| 业务数据类 | 推荐列表、分类列表、搜索词、分页游标 | 页面存在期间,部分需要缓存 | 页面级或路由级 |
| 组件瞬时类 | 加载中的loading、展开/收起、输入框草稿 | 组件生命周期 | 组件内部 |
这个分类不是凭空来的,它直接影响后面状态管理方案的选择。比如用户偏好和账号数据必须放到全局,因为任何一个页面切换都有可能读取;而组件的展开状态如果也塞进全局,就会导致无关页面频繁重建,性能直线下降。这个“该放哪就放哪”的原则,是本次重构中最重要的指导思想。
2. 三个主流方案实测:setState、Provider、Riverpod
2.1 setState硬写:能活,但撑不久
在最开始的几篇文章里,我的状态管理基本是靠setState硬写的。单一页面内、组件层级不超过两三层的时候,setState完全够用,而且直白好调试。但一旦涉及跨页面同步,比如收藏状态要从收藏页同步到列表页、从列表页同步到详情页,setState就扛不住了。
我当时采取的办法是“回调函数层层传”,把setState的触发函数从A页面传到B页面,再从B页面传到子组件。这个做法在小范围内是可以工作的,但代码阅读性极差,而且每次传的时候都心惊胆战——生怕漏了一层,或者某个回调被重复触发导致多余的setState。在这个阶段,我特别认同一个观点:setState本身不是问题,问题是在哪儿setState、怎么让别的页面知道你setState了。
2.2 Provider:简单方案,但大型项目里有隐患
后来我把状态管理切到了Provider。老实说,Provider在一般项目里是非常好的方案,它上手快,心智负担小,把ChangeNotifier和InheritedWidget的细节都藏了起来。但在我们这个资讯App上,我遇到了几个具体的痛点。
第一个痛点是运行时报错。Provider在编译期无法发现“你在某个页面使用了某个不存在的Provider”,只能在运行时通过ProviderNotFoundException告诉你。页面一多,团队协作时这个报错会频繁出现,排查起来非常消耗时间。
第二个痛点是MultiProvider嵌套。Provider多了以后,根节点的MultiProvider层级会变得很深,视觉上就是一个巨型括号套娃。每当需要新增一个全局Provider,都得跑回根Widget去改嵌套结构,这本身不复杂,但很打断心流。
第三个痛点是异步状态的表达力不够。资讯列表天然需要处理loading、error、data三种状态,Provider本身不提供标准的异步状态模型,我得自己用枚举或者sealed class去模拟,写出来的代码不够统一,每个页面处理异步的方式还不一样。
2.3 Riverpod:我最终的选择
后来接触了Riverpod,在资讯App里做了一次试点,效果超出了我的预期。它本质上是对Provider模式的重新设计,解决了我在Provider上遇到的绝大部分痛点。
首先是编译安全。Riverpod的Provider是通过全局对象引用的,如果你在Widget里引用了一个不存在的Provider,编译期就能发现,这在大型项目里非常重要。
其次是异步状态的一体化支持。Riverpod的AsyncValue直接封装了loading、error、data三种状态,UI层只需要写一次处理逻辑,所有异步Provider都能复用。
第三是组合能力强。同一个Provider可以被覆盖、被叠加、被局部替换,比如测试时可以很轻松地注入假的网络客户端,这在调试和写测试时非常顺手。
不过我也得说句公道话,Riverpod的上手门槛比Provider要高一截,尤其是各种Provider类型的区分,刚开始会有点晕。但一旦把“Notifier负责业务状态、AsyncValue负责异步状态、select负责局部监听”这三个概念理清楚,后面写起来是非常顺畅的。
2.4 一个表格看明白差异
| 维度 | setState 硬写 | Provider | Riverpod |
|---|---|---|---|
| 起步成本 | 最低 | 低 | 略高 |
| 编译期检查 | 天然安全但全手动 | 运行时报错多 | 编译期可查 |
| 异步状态模型 | 不提供 | 不提供,需要自己造轮子 | 内置 AsyncValue |
| 跨页面同步 | 回调层层传,极难维护 | 全局可用,但嵌套复杂 | 全局可用,组合自由 |
| 测试友好度 | 一般 | 一般 | 很好 |
这套对比做完之后,我心里基本有了定论:资讯App这种页面多、异步密集、需要大量跨页面共享状态的场景,Riverpod是最合适的选择。但我不想把这篇文章写成“Riverpod安利文”,因为真正让项目跑起来的,不是用了哪个库,而是后面几章讲的状态模型设计——那个才是“艺术”的部分。
3. 状态模型设计:先分层,再谈工具
3.1 四个层级:全局、路由、页面、组件
在动手写Provider之前,我花了整整一个下午把项目里所有的状态按层级重新归了一遍类。这个工作看起来不起眼,但它决定了后面所有代码的形态。
全局层放的是ThemePreference(深色模式、字体缩放)和AccountSession(登录态、用户信息)。路由层放的是当前选中的Tab、搜索关键词、分类筛选条件——这些状态在页面切换时要保留,但不需要在整个App生命周期里常驻。页面层放的是列表数据、分页游标、加载状态;组件层放的是卡片展开、图片加载失败占位等瞬时状态。
这个分层最大的好处是:你知道某个状态应该去哪里找,也知道某个状态变更会影响多少东西。比如深色模式变了,一定是全局层通知,MaterialApp选择重建;而某个卡片的展开状态变了,只影响那一个组件,连页面层都不用惊动。
3.2 资讯业务的四张核心状态表
分层落实到具体业务上,我抽象出了四张核心状态表,基本上覆盖了资讯App的主要功能:
- 资讯流FeedState:推荐列表、分类列表的数据和分页游标,对应页面级。
- 收藏状态集合FavoriteState:文章ID到收藏状态的映射,跨页面共享,对应全局/账号级。
- 阅读历史ReadHistory:文章ID列表,用于标记已读,本地持久化,对应账号级。
- 偏好设置PreferenceState:深色模式、字体大小、列表密度,对应全局级。
这四张表之间不是完全独立的。比如收藏状态会影响资讯流里某个Card的图标渲染;阅读历史会影响列表项的“已读”标记;偏好设置中的深色模式会影响所有卡片的设计。在设计状态模型时,我把这些联动关系全部整理成了单向数据流:状态变更 → 通知监听者 → UI重建对应部分。
3.3 代码落地:Feed状态的核心实现
这一节我会用简化的代码展示资讯流状态的核心实现,完整的网络层代码在之前的内容里已经写过了。
class Article { final String id; final String title; final String summary; final bool isFavorite; const Article({ required this.id, required this.title, required this.summary, this.isFavorite = false, }); Article copyWith({bool? isFavorite}) { return Article( id: id, title: title, summary: summary, isFavorite: isFavorite ?? this.isFavorite, ); } }Article用了不可变对象,所有变更都通过copyWith生成新实例。这一点很重要,因为Riverpod监听的是对象引用变化,不可变对象能让状态变更容易被侦测,也避免了“改了字段但界面没刷新”的诡异问题。
final feedProvider = AsyncNotifierProvider<FeedNotifier, List<Article>>( FeedNotifier.new, ); class FeedNotifier extends AsyncNotifier<List<Article>> { int _page = 0; @override Future<List<Article>> build() async { return _fetchArticles(); } Future<List<Article>> _fetchArticles() async { // 网络请求封装,前文已经有实现 final result = await NewsApi.fetch(page: _page); return result.articles; } Future<void> refresh() async { _page = 0; final articles = await _fetchArticles(); state = AsyncData(articles); } Future<void> loadMore() async { final current = state.valueOrNull ?? []; _page++; final more = await _fetchArticles(); state = AsyncData([...current, ...more]); } }我特别说明一下这里的两个设计细节。
第一,refresh和loadMore都用了AsyncData来包裹列表,而不是用一个单独的isLoading布尔变量。这样UI层可以通过AsyncValue.isLoading来判断当前状态,同时也能区分首次加载和刷新加载——首次加载显示骨架屏,刷新时保留旧列表并在顶部画一个进度条。
第二,loadMore把新数据展开合并到现有列表,而不是替换整个列表。这是资讯流最基本的“分页追加”逻辑,用展开运算符就可以实现。这里要特别小心的是,如果你的分页接口返回的数据带了重复项,你需要在外层做一次去重,否则列表会越翻越脏,这个我在1.2.3节踩过坑。
3.4 收藏状态:跨页面的联动关键
收藏状态最大的特点是“跨页面一致性”,从列表页点收藏,详情页、收藏页都要同步更新。所以它必须放在全局层,用文章ID作为维度来管理。
final favoritesProvider = NotifierProvider<FavoritesNotifier, Map<String, bool>>( FavoritesNotifier.new, ); class FavoritesNotifier extends Notifier<Map<String, bool>> { @override Map<String, bool> build() { return {}; } void toggle(String articleId) { state = { ...state, articleId: !(state[articleId] ?? false), }; } }在UI组件里,监听收藏状态有一个很重要的性能技巧:不要直接监听整个Map,而是用select只监听当前文章ID对应的值。
class ArticleCard extends ConsumerWidget { const ArticleCard({super.key, required this.article}); final Article article; @override Widget build(BuildContext context, WidgetRef ref) { final isFavorite = ref.watch( favoritesProvider.select((s) => s[article.id] ?? false), ); return ListTile( title: Text(article.title), subtitle: Text(article.summary), trailing: IconButton( icon: Icon(isFavorite ? Icons.favorite : Icons.favorite_border), onPressed: () => ref.read(favoritesProvider.notifier).toggle(article.id), ), ); } }这里的select是性能关键。如果不加select,列表里任意一个文章收藏状态变化,所有列表项都会重建。加上select之后,只有红心状态真正变化的那个Card会重建,其他Card完全不受影响。在一屏能展示5到6个卡片的资讯流界面里,这个优化能明显降低无谓的build开销。
4. 关键场景的状态流转设计
4.1 下拉刷新、上拉分页与状态一致性
资讯流是资讯App最核心的场景,它的状态流转看起来简单,但坑都在细节里。
先看下拉刷新。刷新时我保留了旧列表,用AsyncValue的isRefreshing状态来让UI展示一个顶部进度条,而不是清空列表重新加载。用户刷新的心理预期是“在现有内容上更新”,如果刷新时列表突然变空,体验会非常糟糕。
再看上拉分页。分页的状态流转最需要注意的问题是“分页游标放在哪里”。我第一次实现时把游标放在页面State里,结果从推荐Tab切到分类Tab再切回来,游标被重置了,用户看到的是列表回到第一页,之前的翻页进度全部丢失。后来我把游标收到FeedNotifier内部,让分页游标跟业务数据绑在一起,切Tab时Provider会按照页面级生命周期自动处理,这才解决了“翻页翻到一半切走再回来回到顶部”的问题。
还有一个细节:加载更多时如果网络失败,不能把旧列表也清掉。正确做法是保留旧数据,把错误信息单独呈现给用户。在实现里,loadMore只要捕获到异常,就通过AsyncValue的error状态通知UI在列表底部展示一个“点击重试”提示,用户点击后再调用一次loadMore。
4.2 收藏与取消收藏的乐观更新
收藏/取消收藏在资讯App里是个高频操作,如果每次点击都先等网络请求返回再更新UI,用户会明显感觉到卡顿。所以这里我用了“乐观更新”策略:先更新本地状态让红心立即变化,再发请求给服务端,如果请求失败再回滚。
具体实现分三步。第一步,在FavoritesNotifier里增加一个Set pending集合,记录正在同步中的文章ID;第二步,在toggle方法里先改本地state,然后把文章ID加入pending,异步请求结束后移出pending;第三步,如果请求失败,回滚state,同时更新一个错误提示状态,在SnackBar里告诉用户“收藏失败”。
乐观更新的副作用是可能产生“点了很多次导致状态错乱”的问题。比如用户快速点击红心五次,实际发出去的请求可能也是五次,最后一次结果如果先返回,状态就可能跟用户的最终期望不一致。我的处理方案很简单:pending集合里的文章ID存在时,后续点击只在本地切换,不再重复请求;等第一次请求结束后再恢复正常逻辑。这个防抖策略对新手来说可能有点过度设计,但在真实项目中几乎一定会遇到。
void toggle(String articleId) { // 先同步更新本地状态 state = { ...state, articleId: !(state[articleId] ?? false), }; // 如果有正在进行的同步请求,不重复发 if (pending.contains(articleId)) return; pending.add(articleId); _syncToServer(articleId).whenComplete(() => pending.remove(articleId)); } Future<void> _syncToServer(String articleId) async { try { await UserCenterApi.saveFavorite(articleId, state[articleId] ?? false); } catch (_) { // 失败则回滚 state = { ...state, articleId: !(state[articleId] ?? false), }; // 同时可以发一个事件给 SnackBar } }4.3 深色模式与主题切换的“重建时机”
深色模式切换是个典型“全局状态影响所有页面”的场景,处理不好会引发连锁问题。
我的设计是ThemePreferenceProvider作为全局Provider,持有themeMode字段。切换时调用MaterialApp的themeMode,让整个组件树重建。这里有个关键点:哪些状态需要保留,哪些允许重建?
需要保留的是:资讯流数据、分页游标、收藏状态、阅读历史。这些是业务数据,跟主题无关。允许重建的是:页面里正在展示的加载提示、骨架屏、弹出框。这些是瞬时UI状态,切主题时重新构建一遍完全没问题。
在Riverpod里,这自然就实现了:业务数据Provider在MaterialApp之上,主题切换重建的是MaterialApp内部,Provider数据不受影响。但如果你用了组件内的局部State来存业务数据,主题切换后就会被重建丢掉。所以“全局状态放Provider,局部状态放State”这个原则,不只是代码风格问题,它直接关系到功能正确性。
我在鸿蒙真机上还发现一个现象:主题切换时如果列表页的图片缓存没有复用,会出现明显的白屏闪烁。解决办法是给图片组件统一加上缓存层,让所有页面共用一个ImageCache,这个在后面的鸿蒙适配章节还会提到。
5. 鸿蒙真机调试:状态更新卡顿与热重载失效
5.1 一次卡顿排查实录:状态监听粒度太粗
在开源鸿蒙真机上跑资讯流,我遇到过一次明显的卡顿:滑动列表时帧率掉到20帧以下,滑动跟手度很差。一开始我怀疑是图片解码慢,后来用性能工具一抓,发现是列表项build次数异常——每帧都在重建大量Card,根因就是状态监听粒度太粗。
问题出在Card顶层我直接watch了整个feedProvider,又watch了favoritesProvider的整个Map。这两个provider只要有一点点变化,所有Card都会重建。修复方式就是我前面写的select,只监听当前Card需要的字段。这个修正做完后,帧率回到55帧以上,滑动体验基本赶上原生。鸿蒙真机在当前阶段性能优化跟主流旗舰安卓机还有差距,所以状态监听粒度对体验的影响会被放大,这个优化在鸿蒙上做比在安卓上做收益更明显。
5.2 热重载失效与ProviderScope的“记忆残留”
开发过程中有个坑必须单独说:在鸿蒙上跑Flutter,热重载的稳定度跟我之前的开发习惯有明显差别。有时候改一行UI代码按热重载,界面纹丝不动,非得冷重启整个App才能看到效果。
本来热重载失效只是多了几次重启,不算大事,但它会引出一个隐蔽的状态问题:冷重启之后ProviderScope是全新初始化的,所有Provider回到初始状态。这意味着你在调试时如果“手动构造了一些数据”,重启后全部消失。我一开始以为是bug,后来才意识到这是正确的初始化行为——App重启本来就是全新状态。
反过来说,如果你的App在冷启动后需要恢复用户之前的浏览状态,比如回到上次滚动的位置、恢复上次的搜索关键词,这些是不能靠Provider的默认初始化完成的,必须依赖本地持久化存储。我在这个项目中专门做了一个StateRestorer,把全局级、路由级的关键状态序列化到本地,冷启动后重新灌入Provider,算是补上了“状态管理 + 持久化”的最后一环。
5.3 内存占用与页面销毁时的状态清理
资讯类App的列表页有一个通病:只要你不主动销毁页面,它就会一直占着内存。在鸿蒙真机上,我观察到一个现象:从首页进详情页,再从详情页返回,内存涨个二三十MB算正常;但如果你用深链接从详情页直接跳到另一个详情页,页面会越来越多,内存涨上去就不下来。
原因一方面是详情页的图片没有及时释放,另一方面是有些Provider没有跟着页面生命周期销毁。我现在的做法是:区分ProviderScope级别的全局Provider和页面级的Provider,页面级的Provider在页面退出时通过ref.invalidate主动置为无效,让Riverpod释放对应的数据。这样既能保证页面切换时数据还在,又避免页面堆积导致内存膨胀。
我还在ListAdapter级别做了一层图片内存缓存上限控制,单张图片不超过某个体积阈值的话,优先复用内存缓存,而不是每次重新解码。实测下来,在连续浏览二三十篇详情页后,内存增量控制在可接受范围内。
6. 最后再分享几条实战经验
6.1 状态尽量靠近使用它的Widget
这是我在这次重构中最大的感悟。很多刚接触状态管理的人,恨不得把所有的状态都塞进全局Provider里,觉得这样“随时能取”。但后果是——任何一个小状态的变动,都能引起全局的连锁重建。
我在项目里给自己定了一条规则:如果一个状态只有一个组件用到,就放在那个组件里;如果一个状态只有页面和它的子组件用到,就放在页面级Provider里;只有当你确定“这个状态在多个路由下都要用,而且它们需要保持一致”时,才把它放到全局Provider。这条规则看起来很简单,但它能拦住绝大多数“为了全局而全局”的状态滥用。
6.2 select是不可变数据的好朋友
这篇里我反复提到select,因为它在ListView这种高频重建场景下就是神兵利器。但select能发挥价值的前提,是你的状态对象是不可变的。
试想一下,如果你直接修改了Map里的某个key对应的value,而不是生成新的Map,那select的监听条件就很难判断——因为对象引用没变,Riverpod可能压根不会通知UI更新。反过来,如果你每次都生成新的Map,select又能根据“这个key对应的值是否变化”来精确决定要不要重建对应组件。所以我的建议是:状态集合一律按不可变方式更新,配合select,这是性能稳定剂的组合拳。
6.3 不要把“请求状态”和“数据缓存”混在一个变量里
最后一条经验是踩坑踩出来的。早期我做过一个很糟糕的设计:用一个Map同时保存“是否正在加载”和“加载出来的数据”,结果下拉刷新时清空了这个Map,列表瞬间变成空白;网络请求失败时又把错误信息也塞进同一个Map,导致UI同时展示旧数据、loading、错误提示,状态错乱到没法看。
现在的设计是彻底拆分:AsyncValue负责所有异步生命周期状态,业务数据本身作为独立字段存于Provider内部,两者互不干扰。刷新失败时,AsyncValue变成error状态,但列表的数据还在,UI可以选择展示旧数据加一条错误通知,而不是把用户好不容易加载出来的内容直接抹掉。
状态管理这件事,做到最后你会发现框架只是骨骼,模型设计和边界划分才是血肉。尤其在Flutter for OpenHarmony这种还在快速演进的环境里,多花点时间把状态模型想清楚,后面所有的业务迭代都会顺畅很多。希望这篇能让正在做类似项目的你少走几个弯路。