在 Flutter for OpenHarmony 上做规模化应用,我踩得最深的坑不是 UI 渲染,而是状态管理。尤其是登录、Token 刷新、首页多接口并发这一类带异步副作用的逻辑,写起来特别容易失控:setState 满天飞、回调嵌套好几层、页面退了还在更新数据。后来我把状态层彻底切到 Redux,引入 redux_thunk 中间件,才真正理顺了这套逻辑。这篇文章就围绕 Flutter for OpenHarmony 加 redux_thunk 这套组合,讲清楚它怎么解决鸿蒙应用状态管理中的复杂异步副作用,也会把环境搭建、代码骨架、竞态处理和常见坑都过一遍。如果你正在给鸿蒙应用搭状态层,或者从纯 Flutter 跨到鸿蒙生态,这篇应该能帮上忙。
1. 状态管理的痛点:异步副作用为什么难搞
先聊一个问题:为什么 Flutter 应用里,异步逻辑一多,代码就烂得特别快?表面上是因为 setState 要写在回调里,但根本原因是状态分散——每个 Widget 各自持有数据,页面与页面之间的共享状态没有一个统一出口。鸿蒙上跑 Flutter 也是如此,甚至更明显,因为你要同时兼容手机、平板等不同设备的生命周期,异步回调回来的时机根本不可控。
1.1 从 setState 失控现场说起
很多项目早期的写法大概是这样:
void _login() { setState(() => _isLoading = true); Api.login(_username, _password).then((result) { setState(() { _token = result.token; _user = result.user; _isLoading = false; }); }).catchError((e) { setState(() => _error = e.toString()); _isLoading = false; }); }单看一个登录没问题,但一旦页面还要同时刷新用户资料、拉取配置、检查版本更新,这些 setState 就会交叉在一起。更麻烦的是,如果用户退出登录后又登录另一个账号,页面可能还持有上一个账号的临时状态,这时候排查问题,你会发现根本不知道"当前这份数据到底是谁触发的、从哪个接口来的"。
鸿蒙应用经常涉及跨设备协同,状态同步的要求比单端更高,这种散乱写法撑不过两个迭代。
1.2 Redux 的定位:全局唯一数据源
Redux 的核心思想就一句话:全局只有一个 Store,所有状态通过 Action 来修改,修改逻辑收敛在 Reducer 里。如果你用过 Vue 生态的 Vuex,会很容易理解——Vuex 的 State、Getter、Mutation、Action 在 Redux 里大致对应 State、Selector、Reducer、Middleware 链路,只不过 Redux 更纯粹,它不规定 UI 框架怎么触发,只保证数据流是单向的。
在 Flutter 里用 flutter_redux 配合 Redux,页面可以这样拿到状态:
StoreProvider<AppState>( store: store, child: StoreConnector<AppState, LoginViewModel>( converter: (store) => LoginViewModel( token: store.state.auth.token, isLoading: store.state.auth.isLoading, ), builder: (context, vm) => LoginPage(viewModel: vm), ), )这里的StoreConnector负责把全局状态映射成页面需要的 ViewModel,状态一变,就自动触发 rebuild,页面完全不需要手动 setState。这解决了一半问题——状态从哪来,但另一半问题还没解决:异步副作用——状态怎么被异步地改——这就要靠 middleware 了。
1.3 为什么是 redux_thunk,而不是 BLoC 或者 rxdart
Flutter 社区的状态管理方案很多,Provider、BLoC、GetX、Riverpod,每个都有自己的拥趸。Redux 在 Flutter 里不算最潮的,但我选择它,是因为它在鸿蒙场景下有独特的优势:
- 它纯 Dart,没有对平台原生代码的依赖,天然能在 OpenHarmony 的 Flutter 引擎里跑,不用额外写鸿蒙的 NAPI 桥接。
- 数据流极其确定,Review 代码时只要从 Action 到 Reducer 一路看下去,就知道状态是怎么变的。
- BLoC 和 rxdart 的学习曲线更陡,团队里每个成员都要理解 Stream 的冷热流概念。而 Redux 加 thunk,只需要理解"dispatch 一个函数"这一个技巧。
redux_thunk是专门解决 Redux 异步问题的轻量中间件,它不引入 Stream、不引入副作用管理框架,只把 Action 从"纯对象"扩展到"可以是函数"。就这一个扩展,足以覆盖大多数业务场景里的异步编排需求,代价几乎为零。
2. redux_thunk 核心机制拆解:中间件到底做了什么
要理解 redux_thunk,先得理解 Redux 的数据流:dispatch(action) -> reducer -> new state -> notify listeners。这个流程全部是同步的,reducer 必须是纯函数,不能打日志、不能发请求、不能等 IO。那异步怎么办?答案就是:在 dispatch 和 reducer 之间加一个中间件层。
2.1 Thunk 的本质:dispatch 一个函数
普通 Redux 里,你 dispatch 的是一个对象,比如{type: 'login_start'}。而 redux_thunk 允许你 dispatch 一个函数,这个函数就是 thunk。中间件拦截到这个函数后,不会把它交给 reducer,而是调用它,并把store作为参数传进去:
ThunkAction<AppState> login(String username, String password) { return (Store<AppState> store) async { store.dispatch(LoginStartAction()); try { final result = await Api.login(username, password); store.dispatch(LoginSuccessAction(result.token, result.user)); } catch (e) { store.dispatch(LoginFailureAction(e.toString())); } }; }ThunkAction<AppState>这个类型,本质上是一个返回函数(闭包)的函数。你dispatch(login(用户名, 密码))时,实际被执行的是内部那个闭包。这个闭包相当于拿到了 store 的全部能力,便能在一个函数里编排多次 dispatch,把"开始加载、请求成功、请求失败"这些状态按顺序发出去。
你可以这样理解:普通 Action 是"让 Reducer 改状态的通知",Thunk 是"一段可以被 dispatch 挂起的业务脚本"。这个脚本里可以发多个普通 Action,互相之间可以等、可以并行、可以串联。
2.2 中间件注册:Reducer 之外的拦截层
光写出 ThunkAction 函数还不够,必须在构建 Store 时注册thunkMiddleware,否则 dispatch 一个函数的时候,reducer 会收到一个函数类型,然后一脸懵。
final store = Store<AppState>( appReducer, initialState: AppState.initial(), middleware: [ thunkMiddleware<AppState>(), ], );中间件在 Redux 里的执行顺序是有讲究的。多个 middleware 会按数组顺序包裹 dispatch,通常 redux_thunk 要放在其他中间件的合适位置。如果你还接了日志中间件(比如 redux_logger),一般建议把 thunk 放在 logger 之前,这样thunk内部后续 dispatch 出来的普通 Action 也能被 logger 打印。
我这里画一个执行顺序的示意:
view 层调用 store.dispatch(thunkAction) -> thunkMiddleware 拦截,发现是函数,执行 thunk(thunkAction) -> thunk 内部 dispatch LoginStartAction -> 继续走 middleware 链 -> reducer 处理,更新状态 -> thunk 内部 await 异步结果 -> thunk 内部 dispatch LoginSuccessAction -> reducer 处理,更新状态关键在于,thunk 内部每一次 dispatch 都会重新走一遍完整的 middleware 链,所以在一个 thunk 里 dispatch 另一个 thunk 也是可以的,中间件会逐层处理。
2.3 从 Action 到 State 的完整链路
我经常用一句话给团队讲 Redux 加 thunk 的状态流:
UI 只调用
store.dispatch(某个意图),剩下的流程全部在 Action 层里编排,Reducer 只负责根据 Action 类型算出新状态,UI 通过 StoreConnector 拿到新状态。
这条链路的优点是可预测。登录按钮点击后,UI 不需要关心接口返回后要先改哪个字段,哪里出错要发什么 Action,这些都在 thunk 里写死了。页面 viewModel 的变化完全是 reducer 算出来的,出错、重试、取消,都不会让 UI 层陷入回调嵌套。
3. Flutter for OpenHarmony 环境搭建与依赖接入
接下来到实操环节。要在 OpenHarmony 设备上跑通一套 redux_thunk 状态管理,环境准备是第一道坎。鸿蒙的 Flutter SDK 不是官方 pub.dev 直接下载的,需要单独从 OpenHarmony SIG 仓库获取。
3.1 准备 OpenHarmony 的 Flutter SDK
当前社区常见的做法是从 gitee 上的 OpenHarmony SIG Flutter 仓库拉取带 ohos 分支的 SDK,比如:
git clone -b 3.7.12-ohos https://gitee.com/openharmony-sig/flutter_flutter.git flutter_ohos export PATH="$PWD/flutter_ohos/bin:$PATH" flutter --version注意,分支版本会随着社区迭代变化,比如新版本可能有3.13.x-ohos之类,具体以仓库的 release 分支为准。拉下来之后,还需要确保本机装了 DevEco Studio 以及配套的 OpenHarmony SDK,flutter doctor能看到相关提示。
这里有几个容易踩的点:
- 装了两个 Flutter SDK(官方版和 ohos 版)时,PATH 顺序要小心,IDE 里也要能指定正确的 Flutter SDK 路径。
- 鸿蒙构建 HAP 时,需要通过 DevEco Studio 的 SDK Manager 下载对应的 API 版本,不同 API 版本可能影响 NAPI 接口是否可用。
- 如果你之前用官方 Flutter 创建过工程,需要用
flutter create --platforms ohos .再补一次 ohos 平台文件。
创建并构建:
flutter create --platforms ohos . flutter build hap --release当然,也可以用 DevEco Studio 打开工程,图形化界面构建 HAP 包,但命令行方式在 CI 环境更实用。
3.2 在 pubspec.yaml 中接入 redux_thunk
环境就绪后,接入依赖就简单了。在pubspec.yaml里加三个包:
dependencies: flutter: sdk: flutter redux: ^5.0.0 flutter_redux: ^0.10.0 redux_thunk: ^0.1.0这里我特意选了纯 Dart 的 redux 生态,没有引入任何平台 channel 的原生依赖,所以在 OpenHarmony 上几乎没有适配风险。flutter_redux是 Flutter 的 Redux 绑定,提供 StoreProvider 和 StoreConnector;redux_thunk提供中间件和 ThunkAction 类型。
执行flutter pub get,如果网络不稳定,记得配置好国内镜像,否则依赖拉不下来。加入依赖后,最好先跑一次flutter build hap --debug,确认整个工程在鸿蒙环境下的依赖解析和编译链路是通的,再做状态层改造。这一步能隔离掉"到底是环境问题还是代码问题"的干扰。
3.3 搭建最小状态管理骨架
下面给一个最小可运行的骨架。首先是 AppState 和 reducer:
class AppState { final AuthState auth; final HomeState home; AppState({required this.auth, required this.home}); factory AppState.initial() => AppState( auth: AuthState.initial(), home: HomeState.initial(), ); AppState copyWith({AuthState? auth, HomeState? home}) { return AppState( auth: auth ?? this.auth, home: home ?? this.home, ); } } AppState appReducer(AppState state, dynamic action) { return AppState( auth: authReducer(state.auth, action), home: homeReducer(state.home, action), ); }然后是创建 Store 和注入页面:
void main() { final store = Store<AppState>( appReducer, initialState: AppState.initial(), middleware: [thunkMiddleware<AppState>()], ); runApp(OpenHarmonyApp(store: store)); } class OpenHarmonyApp extends StatelessWidget { final Store<AppState> store; const OpenHarmonyApp({Key? key, required this.store}) : super(key: key); @override Widget build(BuildContext context) { return StoreProvider<AppState>( store: store, child: MaterialApp( home: LoginPage(), ), ); } }再写一个 reducer 示例:
AuthState authReducer(AuthState state, dynamic action) { if (action is LoginStartAction) { return state.copyWith(isLoading: true, error: null); } if (action is LoginSuccessAction) { return state.copyWith( isLoading: false, token: action.token, user: action.user, ); } return state; }这个骨架跑起来后,LoginPage 里点击按钮只需要store.dispatch(login(username, password)),页面状态自动由 reducer 驱动更新。
4. 复杂异步副作用实战:三个最常见业务场景
骨架搭好了,下面用三个真实高频场景展示 redux_thunk 的实战能力。
4.1 登录、Token 过期刷新与会话恢复
登录流程往往不只是"请求一次登录接口"这么简单。实际业务里,App 重启后要先读本地缓存恢复会话,然后校验 Token 是否过期,过期了要用 refreshToken 换新的,换完再拉取用户资料。这一套编排如果写在页面里,会非常痛苦。
用 thunk 可以这样拆:
Future<void> _doRefreshToken(Store<AppState> store) async { store.dispatch(RefreshTokenStartAction()); try { final token = await AuthApi.refresh(store.state.auth.refreshToken); store.dispatch(RefreshTokenSuccessAction(token)); } catch (e) { store.dispatch(LogoutAction()); } } ThunkAction<AppState> restoreSession() { return (Store<AppState> store) async { final cached = await LocalStorage.getSession(); if (cached == null) { store.dispatch(NeedLoginAction()); return; } store.dispatch(RestoreSessionAction(cached)); if (store.state.auth.tokenExpired) { await _doRefreshToken(store); } if (store.state.auth.token != null) { store.dispatch(fetchUserProfile()); } }; }注意_doRefreshToken是一个普通的异步函数,不是 ThunkAction,这样在同文件里可以直接await调用,避免踩"dispatch 返回类型是 void 还是 Future"的坑。这里其实也可以写成await store.dispatch(refreshToken()),redux_thunk 中间件会把 thunk 函数的返回值原样返回,但为了类型更清晰,我用普通函数包装。
这个场景的重点是:thunk 天然支持串行编排。会话恢复先读缓存、再判断过期、再刷新、再拉资料,每步之间用 await 衔接,出错就跳转到对应状态,逻辑一目了然。
4.2 搜索场景中的请求竞态处理
竞态问题不需要多复杂的场景,一个搜索框就够了:用户快速输入"鸿蒙",实际发出的请求是h、ho、hon、hong、hongm、hongme、hongmen、hongmeng八个,网络返回顺序完全不可控,可能最后回来的是第三次请求的结果,而不是最后一次。UI 上就表现为搜索结果一会儿正确、一会儿错误。
thunk 里用序列号可以很简单地解决:
int _searchSeq = 0; ThunkAction<AppState> search(String keyword) { return (Store<AppState> store) async { final current = ++_searchSeq; store.dispatch(SearchLoadingAction(keyword)); try { final results = await GoodsApi.search(keyword); if (current == _searchSeq) { store.dispatch(SearchSuccessAction(results)); } } catch (e) { if (current == _searchSeq) { store.dispatch(SearchFailureAction(e.toString())); } } }; }每次调用 search,_searchSeq就自增一次,current记录下本次请求的编号。当请求返回时,只有编号等于最新编号的请求才有资格 dispatch 结果,过期请求直接丢弃。这个方法比"取消上一个请求"要简单可靠得多,因为取消请求本身还可能触发异常回调。用序列号拦截,不管底层请求是否真的被取消,结果都不会污染状态。
4.3 首页多接口并行编排
首页一般是最难写的页面——要同时拉 Banner、商品列表、公告,三个接口耗时不同,如果串行请求会拖慢首屏,如果并行请求又要处理"三个回调都到齐才渲染"的逻辑。thunk 里用 Future.wait 可以优雅地做并行编排:
ThunkAction<AppState> loadHome() { return (Store<AppState> store) async { store.dispatch(HomeLoadingAction()); try { final results = await Future.wait([ HomeApi.fetchBanners(), HomeApi.fetchGoods(), HomeApi.fetchNotices(), ]); store.dispatch(HomeLoadedAction( banners: results[0] as List<BannerItem>, goods: results[1] as List<GoodsItem>, notices: results[2] as List<NoticeItem>, )); } catch (e) { store.dispatch(HomeLoadFailureAction(e.toString())); } }; }Future.wait的语义是"要么全部成功,要么第一个异常"。如果三个接口相互独立,一个挂了就要整个首页降级,可以接受;但如果想做到"某个模块挂了不影响其他模块",就用Future.wait加catchError分别兜底:
final bannersFuture = HomeApi.fetchBanners() .then<List<BannerItem>>((v) => v) .catchError((e) => <BannerItem>[]);然后把兜底后的 Future 放进 wait,这样即使接口挂了,也返回一个空列表,首页主体依旧能渲染。实际项目中,这种"部分成功"的策略往往更好。
5. 常见问题与排查技巧实录
用下来,我把最常踩的坑整理成一套排查清单,遇到了可以直接对照。
5.1 鸿蒙构建与依赖冲突排查
flutter build hap报错的原因很多,但最常见的是 SDK 版本和插件平台不匹配。
| 现象 | 常见原因 | 处理办法 |
|---|---|---|
| 构建时找不到 ohos SDK | DevEco Studio 未安装或 SDK 路径未配置 | 检查local.properties中ohos.sdk.dir指向 |
| 报 NAPI 符号找不到 | Flutter SDK 分支版本与 DevEco SDK 版本不匹配 | 统一升级 Flutter ohos 分支和 DevEco SDK 到对应版本 |
| 依赖包下载失败 | 网络问题或镜像未配置 | 配置 PUB_HOSTED_URL 和 FLUTTER_STORAGE_BASE_URL |
| redux_thunk 报错找不到类型 | 版本过低或依赖冲突 | 升级到最新版本,执行flutter pub upgrade |
README 里如果写了某个版本的 Flutter SDK 对应哪个 API Level,一定要照着来,我吃过一次亏:用了老分支的 Flutter 配了新版本的 DevEco SDK,构建时各种原生符号对不上,后来统一版本后才通过。
5.2 Thunk Action 不触发或状态不更新的排查
最诡异的问题往往是这个:store.dispatch(search('a'))调了,但页面没有任何反应。
第一反应一定是查 middleware:
middleware: [thunkMiddleware<AppState>()],如果漏掉了这一行,dispatch 一个函数时 reducer 收到的 action 是函数类型,等于没匹配到任何分支,状态自然不会变。
第二个检查点是 reducer 的不可变性。Redux 要求返回一个新对象,很多人写成:
if (action is LoginSuccessAction) { state.token = action.token; // 错误!直接修改了原对象 return state; }store 比较状态更新用的是identical(state, newState),如果返回同一个对象,即使字段变了,StoreConnector 也不会触发 rebuild。正确的写法是state.copyWith(...),返回新对象。
第三,检查 StoreConnector 的 converter 是否返回了新对象。如果 converter 直接返回一个临时构造的 viewModel,每次 rebuild 都会创建新实例,页面会在每次状态变化时不必要的重建,看起来像"卡",实际是 rebuild 太频繁。可以通过StoreConnector的distinct: true参数或者自定义shouldSkip来控制。
5.3 内存泄漏与性能优化注意事项
thunk 里最常见的隐性泄漏是:异步任务还没回来,Store 已经被销毁(虽然 Flutter 里 Store 一般是全局单例,不太容易出现,但如果你在页面级创建 Store 就会遇到)。另外,thunk 里创建了 Timer 或 StreamSubscription 却忘记在释放时取消,也会泄漏。
一个相对保险的惯例是:所有异步 thunk 内部尽量用短生命周期资源,定时器和订阅必须在结束时销毁。比如轮询接口的 thunk:
ThunkAction<AppState> startPolling() { return (Store<AppState> store) async { _pollTimer?.cancel(); _pollTimer = Timer.periodic(const Duration(seconds: 10), (timer) { store.dispatch(refreshOrderStatus()); }); }; }在 logout 或者页面销毁时,应该再发一个 stopPolling 的 thunk 把 timer 取消掉。如果模块太多,可以考虑在 AppLifecycle 里统一管理这些常驻任务,避免应用退到后台还一直轮询。
性能方面,redux_thunk 本身非常轻量,真正的性能瓶颈在 reducer 的拷贝开销。鸿蒙上的 Flutter 应用如果 state 树很大,每个 Action 都全量拷贝整个 state,就会产生 GC 压力。建议把 state 拆成多个子模块,让每个 reducer 只管理自己那片区域,AppState 只做浅层组合,这样拷贝成本可控。
另外,如果异步任务本身很重(大文件解析、图片处理),不要在 thunk 里直接用 await 阻塞 UI isolate,考虑使用Isolate.run或者compute把计算任务挪到后台 isolate,然后再把结果 dispatch 回 store。thunk 只负责编排,不负责背重活。
结尾:这套异步架构给我的最大改变
我在实际用 redux_thunk 的过程中,最大的改变不是代码风格,而是思考方式:所有副作用的入口都被收拢到一个一个 Action Creator 里,页面层不需要知道接口怎么调、Token 怎么刷,它只负责 dispatch 一个意图,剩下的全部交由 thunk 去编排。这种收口能力在团队协作里也很有价值,Code Review 的路径变短了,新人在接业务时只需要看 Action 层就能理解整个流程,不需要把每个页面翻一遍。
如果你也在 Flutter for OpenHarmony 上做应用,我的建议是别一开始就全面改造,先挑一个最痛的模块,比如登录流程或者首页加载,用 thunk 重写一遍,跑几个迭代感受一下。踩过几次坑之后你会发现,异步架构这件事,提前算计,总比事后救火划算。