最近在搞OpenHarmony上的原生应用开发,碰上一个挺折腾的需求:用React Native(以下简称RN)搭跨端业务层,同时要用Redux管理全局状态。本来Redux这套东西在Android和iOS上已经跑得很熟了,结果一迁到OpenHarmony上,异步Action的处理就出了各种幺蛾子。
这篇东西就把我踩过的坑、试过的方案、最后稳定跑起来的一套完整做法整理出来。内容涵盖为什么在OpenHarmony上选RN加Redux、异步Action的核心机制拆解、从零到一的完整实现代码,以及一份能直接照着排查的问题清单。适合正在做OpenHarmony跨端开发、或者刚把RN引入到鸿蒙生态里的团队参考,也适合那些对Redux异步流程只停留在“会用thunk”但没深究过原理的开发者。
1. 项目背景与整体设计思路
1.1 OpenHarmony上为什么会选RN
先说选型背景。我们当时做的是一个跨端业务系统,要同时覆盖OpenHarmony设备、Android和iOS。如果三端各写一套原生逻辑,人力成本直接炸裂。RN在这种场景下的价值在于业务层代码可以复用,只需要把原生底层能力通过桥接暴露出去。
OpenHarmony上跑RN,严格来说不是官方标准支持,而是通过鸿蒙的方舟运行时和RN框架之间做适配。所以选型时首先确认了三件事:
- 这套RN框架在OpenHarmony上能正常加载JS Bundle并执行JS逻辑。
- 原生UI组件能被RN正确映射和控制,这个牵扯到组件树的同步和事件回调。
- 网络请求、本地存储这些基础能力可以通过桥接或系统API打通。
确认这三点没问题之后,我们才敢把业务层押在RN上。需要强调一点,OpenHarmony的RN适配不是“装个包就能跑”,前期最好先在目标设备上跑通一个最简单的Hello World,验证JS引擎和渲染链路是通的,再往上堆业务。
1.2 为什么需要Redux管理状态
业务层用RN后,紧接着就是状态管理问题。跨端业务的特点是页面多、状态共享频繁,比如登录态、用户配置、全局设置。如果每个页面自己保存一份状态,同步起来就是灾难。
一开始也考虑过用RN自带的Context或者useReducer撑一撑,但很快发现两个问题:一是深层组件传参会变得非常啰嗦,二是业务状态分散在各处,排查Bug时根本不知道当前全局是什么状况。
Redux的优势在于单一数据源和可预测的状态变更。所有状态集中在一个Store里,组件只能通过派发Action来修改状态,这样状态的来龙去脉都是清晰可控的。对于跨端项目来说,这种约束尤其重要,因为一段代码要在三个平台上行为一致,状态的流转必须严格统一。
1.3 异步Action在Redux里的定位
纯Redux是同步的:reducer接收Action立即返回新状态。但真实业务里请求接口、读取本地缓存、等待用户操作都是异步的。这些场景不能直接往里塞,需要借助中间件来处理。
Redux异步Action就是在中间件层做文章。常用的中间件有redux-thunk和redux-saga,核心思路都是把异步操作从组件层抽离出来,让组件只需要派发一个动作,然后等待状态更新。
在OpenHarmony这种平台上,异步Action有个额外好处:可以把网络请求逻辑集中管理,方便统一处理超时、错误码、会话失效等跨端逻辑。这套逻辑在Android和iOS上早就跑通了,搬到OpenHarmony上只是换了个运行环境。
2. 环境准备:OpenHarmony上的RN集成
2.1 基础环境搭建
先说基础环境。我们用的是某开源社区的OpenHarmony适配版本RN框架,这个框架提供了针对鸿蒙系统的原生模块映射。搭建步骤大致如下:
- 准备OpenHarmony SDK,建议使用4.0及以上版本,API Level足够覆盖我们需要的系统能力。
- 创建一个鸿蒙原生工程,然后在工程里集成RN的鸿蒙适配库。
- 配置RN的Bundle加载路径,开发阶段可以从本地服务器加载JS Bundle,发布时则把Bundle打包进应用资源里。
- 把ArkTS侧的入口配置好,让RN框架能启动并渲染页面。
这里有个特别要注意的地方:OpenHarmony的权限体系和Android不完全一样。网络请求权限如果没配好,RN里的fetch或者axios会自动失败但报错信息可能很隐晦,容易被误判成JS代码问题。我当时折腾了大半天,最后发现是module.json5里少声明了一个权限。
2.2 依赖安装与版本锁定
RN生态最让人头疼的就是依赖版本。在OpenHarmony上更是如此,因为适配版RN框架的版本通常要对应特定的OpenHarmony SDK版本,偏差一点就可能编译不过。
我们的package.json里核心依赖大致是这样:
{ "dependencies": { "react": "18.2.0", "react-native": "0.72.x", "redux": "4.2.1", "react-redux": "8.1.3", "redux-thunk": "2.4.2" } }版本这块我的原则是锁死精确版本,不用^和~这种范围符号。因为跨端项目要保证所有开发者的环境一致,差一个小版本都可能引发诡异问题。另外,新增任何依赖前先检查一下它是否依赖原生模块,如果依赖原生模块,还要确认这个模块在OpenHarmony的RN适配环境里是否可用。
2.3 Redux在RN里的基本接入
Redux在RN里的接入思路和常规React项目没本质区别。需要一个Provider包裹根组件,然后把Store传进去。核心代码如下:
import { Provider } from 'react-redux'; import { createStore, applyMiddleware } from 'redux'; import thunk from 'redux-thunk'; import rootReducer from './reducers'; const store = createStore(rootReducer, applyMiddleware(thunk)); function App() { return ( <Provider store={store}> <MainNavigator /> </Provider> ); }在OpenHarmony环境里,唯一区别是Store的持久化策略。建议不要直接依赖redux-persist的默认配置,因为底层存储实现需要适配鸿蒙的轻量级偏好数据库。后来我写了一个自定义的storage适配器,通过RN桥接调用ArkTS侧存储能力,才解决了跨端持久化的问题。
3. Redux异步Action机制深度拆解
3.1 同步Action和异步Action的本质区别
聊异步Action之前,先把同步和异步的分界线说清楚。同步Action是一个纯对象,结构长这样:
{ type: 'LOGIN_SUCCESS', payload: { token: 'xxx', userId: 123 } }这个对象从派发到进入reducer,中间没有任何延迟,reducer拿到它后同步返回新状态。
异步Action则是“生成这个Action的过程是异步的”。组件不能直接获得最终的Action对象,而是先发起一个请求,等响应回来后才能确定Action里的payload是什么。所以异步Action本质上是“在响应回来之前不知道要派发什么Action”。
Redux本身不处理这个过程,必须借助中间件在dispatch前后做拦截。
3.2 中间件选型:redux-thunk还是redux-saga
异步Action的经典实现方案有两套:redux-thunk和redux-saga。我用一个网购买东西的例子来解释区别:
- redux-thunk:相当于你给店员说“我要买东西”,然后自己站在柜台前等店员把东西拿过来,东西拿到了再自己拎回家。整个过程是线性的,代码好写也好理解。
- redux-saga:相当于你说“我要买东西”,店员给了你一个取货凭证,你可以回家干别的,货到了再凭凭证来取。代码结构更复杂,但可以做很多并发、取消、重试的高级操作。
对于OpenHarmony这种新平台,我的建议是优先用redux-thunk。原因有两点。一是thunk的代码更直白,跨端排查问题时心智负担小,你不用理解Generator那一套。二是saga的一些高级特性在跨端场景里大部分用不上,徒增复杂度。
3.3 一个完整的redux-thunk异步Action
下面直接上一个完整示例,这个场景是登录并拉取用户信息:
// actions/user.js import { fetchLogin, fetchUserInfo } from '../api/user'; export const LOGIN_START = 'LOGIN_START'; export const LOGIN_SUCCESS = 'LOGIN_SUCCESS'; export const LOGIN_FAILURE = 'LOGIN_FAILURE'; export const FETCH_USER_INFO_SUCCESS = 'FETCH_USER_INFO_SUCCESS'; export function login(username, password) { return async function (dispatch) { dispatch({ type: LOGIN_START }); try { const loginResult = await fetchLogin(username, password); const token = loginResult.token; const userInfo = await fetchUserInfo(token); dispatch({ type: LOGIN_SUCCESS, payload: { token, user: userInfo } }); } catch (error) { dispatch({ type: LOGIN_FAILURE, payload: { message: error.message } }); } }; }这段代码的核心价值在于:组件不需要关心请求细节,只需要调用一个login函数,然后就等着state变化。请求中的loading状态、成功后的数据写入、失败时的错误记录,全部在这个异步Action里闭环了。
4. 从零实现:登录流程全流程实操
4.1 状态结构和Action定义
先从State结构说起。一个典型的用户状态切片长这样:
// reducers/user.js const initialState = { isLoggingIn: false, token: null, userInfo: null, loginError: null }; export default function userReducer(state = initialState, action) { switch (action.type) { case LOGIN_START: return { ...state, isLoggingIn: true, loginError: null }; case LOGIN_SUCCESS: return { ...state, isLoggingIn: false, token: action.payload.token, userInfo: action.payload.user }; case LOGIN_FAILURE: return { ...state, isLoggingIn: false, loginError: action.payload.message }; default: return state; } }这个结构有三个关键约定:
- isLoggingIn用来驱动页面上的加载圈。
- token一旦写入,就代表登录态生效。
- loginError是最近一次登录失败的错误信息,用于在页面上回显。
这些字段不是随便设计的,它们分别对应了UI的三个不同状态:请求中、成功、失败。Redux状态设计的基本原则就是:把UI需要展示的每一种变化都建模出来,宁可多一个字段,也不要让组件去推断状态。
4.2 请求层封装
在实际跨端项目里,请求层不能直接裸用fetch。我封装了一个统一的请求模块,核心代码:
// api/request.js const BASE_URL = 'https://api.example.com'; export function request(path, options = {}) { const url = BASE_URL + path; const config = { method: options.method || 'GET', headers: { 'Content-Type': 'application/json', ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}) }, body: options.data ? JSON.stringify(options.data) : undefined }; return fetch(url, config).then(async (response) => { const data = await response.json(); if (data.code !== 0) { throw new Error(data.message || '请求失败'); } return data.data; }); }这里的核心思想是统一后端返回结构。如果后端返回的code字段不为0,就抛异常交给调用方处理。这样异步Action里只需要写try/catch,不用每个接口都重复解析响应体的逻辑。
4.3 页面组件里的完整使用
组件层通过useDispatch和useSelector来与Redux交互。下面是一个登录页面的核心逻辑:
// pages/LoginPage.js import React, { useState } from 'react'; import { View, TextInput, TouchableOpacity, Text, ActivityIndicator } from 'react-native'; import { useDispatch, useSelector } from 'react-redux'; import { login } from '../actions/user'; export default function LoginPage() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const dispatch = useDispatch(); const isLoggingIn = useSelector(state => state.user.isLoggingIn); const loginError = useSelector(state => state.user.loginError); const handleLogin = () => { dispatch(login(username, password)); }; return ( <View> <TextInput value={username} onChangeText={setUsername} placeholder="用户名" /> <TextInput value={password} onChangeText={setPassword} placeholder="密码" secureTextEntry /> <TouchableOpacity onPress={handleLogin} disabled={isLoggingIn}> {isLoggingIn ? ( <ActivityIndicator size="small" color="#666" /> ) : ( <Text>登录</Text> )} </TouchableOpacity> {loginError && <Text>{loginError}</Text>} </View> ); }这段代码里特别注意style相关的内容,我在示例里省略了样式,实际项目中可以通过StyleSheet创建。最重要的模式是:页面不持有用户数据,只从Redux里读,登录成功后的页面跳转和用户信息展示由别的组件负责。
4.4 异步Action扩展:带参数和附带操作
实际项目中,异步Action不只是简单请求。我举一个更复杂的场景:刷新用户信息,并在成功后提示用户。为了不把UI操作耦合到业务逻辑里,我在组件里通过监听状态变化来触发提示:
// actions/user.js export function refreshUserInfo() { return async function (dispatch, getState) { const { token } = getState().user; if (!token) return; try { const userInfo = await fetchUserInfo(token); dispatch({ type: FETCH_USER_INFO_SUCCESS, payload: { user: userInfo } }); } catch (error) { // 只在错误码是特定值时提醒,避免频繁打扰用户 if (error.code === 'TOKEN_EXPIRED') { dispatch({ type: LOGOUT }); } } }; }这种写法利用了thunk中间件的另一个特性:可以读当前的getState()。这样异步Action内部可以根据需要读取其它状态,做逻辑分支。
5. 常见问题与实战避坑
5.1 问题速查表
整理一下我在OpenHarmony上跑Redux异步Action时遇到的典型问题。
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 异步Action不执行,Store状态完全不变 | thunk中间件没有引入 | 检查createStore是否传了applyMiddleware(thunk) |
| Action报错TypeError: dispatch is not a function | 导出Action时忘记包一层函数 | 确认组件里调用的是login(username, pwd),不是直接login |
| 组件状态更新了但UI不刷新 | useSelector的selector返回了新引用 | 确保reducer里返回的新对象是全量展开后的新对象 |
| 请求成功但store里迟迟没数据 | 网络请求被OpenHarmony权限拦截 | 检查module.json5网络权限配置 |
| 状态在Android正常,OpenHarmony上丢失 | 持久化方案不适配鸿蒙存储 | 自定义storage适配器替代默认localStorage方案 |
5.2 关于selector返回引用的深坑
这个问题最具迷惑性,我单独拎出来说。看下面这段错误示例:
// 错误写法 const state = useSelector(state => ({ userInfo: state.user.userInfo, isLoggingIn: state.user.isLoggingIn }));这段写法每次执行都会生成一个全新的对象引用。React-Redux内部会比较前后两次select的返回值,因为引用不同,所以它会认为状态变了,强制组件重新渲染。结果就是:任何状态变化都会让这个组件无脑刷新,性能损耗很大,严重的会出现无限循环更新。
正确做法是分开选择:
// 正确写法 const userInfo = useSelector(state => state.user.userInfo); const isLoggingIn = useSelector(state => state.user.isLoggingIn);5.3 网络层适配鸿蒙的坑
OpenHarmony的RN环境里发起网络请求,和标准RN有一个微妙差别:少数系统版本上fetch对TLS证书校验的策略不同。开发环境如果后端用的自签名证书,在Android上可以通过trustAllCerts配置临时绕过,但在OpenHarmony上可能连这个配置入口都没有。
我当时卡了很久,最后发现是后端证书链不完整。解决办法是让后端补齐证书链,而不是在端上想办法绕过校验。这里也给大家一个建议:跨端项目不要用自签名证书做联调,统一用正规HTTPS证书环境,能省掉大量平台差异的排查时间。
5.4 关于调试的建议
最后分享一个调试技巧。在OpenHarmony上调试RN的Redux流程,不要只看console.log。日志打印位置容易误导人,因为异步回调里的console可能在请求完才统一输出。
我用的调试方法是在中间件层加一个日志中间件:
const loggerMiddleware = store => next => action => { if (typeof action === 'function') { console.log('[async action]', action.name); } else { console.log('[action]', action.type, action.payload); } return next(action); };这个中间件能清楚看到哪些异步Action被派发了、哪些普通Action最终进入了reducer。配合React DevTools的Redux插件使用,基本能覆盖九成以上的状态流转问题。
6. 更多实践心得与扩展方向
6.1 关于整体架构的忠告
做完这个项目,我对OpenHarmony加RN的技术组合有了一些更深的体会。OpenHarmony上的RN生态还比不上Android和iOS成熟,很多原生组件库需要重新适配或者找替代方案,但核心的JS逻辑层基本是可以直接复用的。
如果你想长期维护一套跨端业务,建议把RN只当作“渲染层”,真正的业务状态、网络请求、数据转换逻辑尽量放在Redux的异步Action层里。这样一个Action从写出来到跑在三个平台上,除了底层request的差异,其余逻辑完全一致,排查问题也只需定位到具体Action而不是某一端的特定代码。
6.2 后续可以扩展的方向
这个项目的异步Action目前只处理了基本请求逻辑。后续有几个方向可以继续演进:
- 接入redux-saga处理需要并发控制的场景,比如多个请求竞态、取消未完成的请求、自动重试等。
- 增加请求队列机制,处理登录态过期后的统一续期和请求重放。
- 把持久化存储迁移到鸿蒙的分布式数据库,实现一台设备登录后其他设备同步状态。
6.3 最后一个实操技巧
在结束前,再分享一个容易被忽略的小操作。在OpenHarmony上调试时,JS引擎的崩溃现场往往没有详细的堆栈信息。建议把Redux的state定期序列化到日志里,特别是请求失败前后。不要依赖Redux DevTools的远程调试,在鸿蒙设备上它经常连不上。
我一般在关键异步Action的开始和结束各打一条带标记的日志:
console.log('[login-flow:start]', username); console.log('[login-flow:end]', token || error);这样无论出了什么问题,至少能定位到是请求前出错、请求中出错还是状态写入出错。比起你盯着黑屏设备束手无策,多少是个可靠的保底手段。