说实话,把 React Native 工程搬到鸿蒙设备上跑通,本身并不是最费劲的部分,真正让我在模拟项目X里卡了最久的是状态管理。项目里用 MobX 做 Observable 数据监听时,刚开始想得太简单:Android 和 iOS 上跑得好好的,鸿蒙环境应该也不会差。结果一上真机就遇到各种“不刷新”,数据明明变了,界面却像冻住一样,甚至有时候状态被搞出多份,页面之间各说各话。如果你也在搞 RN 鸿蒙开发,准备用或者已经在用 MobX,这篇东西应该能帮你少走不少弯路。
这篇文章不会只贴代码,我会把 Observable 数据监听的机制、在鸿蒙运行时里的特殊之处、以及最容易踩的坑都翻出来讲清楚。它能解决什么问题?一句话概括:让 RN 鸿蒙工程里的跨页面、跨组件共享数据变成一个可追踪的响应式数据流,任何一份数据变化,UI 都按预期刷新,原生侧能力也能被监听驱动。适合谁看?适合已经跑通 RN 鸿蒙基础工程,正在选型状态管理,或者被 MobX 不刷新问题折磨的开发者。
1. 为什么在鸿蒙上用React Native还要单独考虑状态管理?
1.1 鸿蒙接入RN的现状与特殊之处
现在要在鸿蒙设备上运行 RN,靠的是开源社区维护的鸿蒙适配层,把 RN 生成的组件树映射到鸿蒙的声明式 UI 体系上。这个过程涉及两套 UI 框架,RN 的虚拟组件在 JS 线程里计算,最终要跨过桥接到原生线程,再绘制到鸿蒙组件树上。也就是说,一次 UI 刷新至少经过 JS 层计算、桥接传输、原生层渲染三段路径。
这跟 Android/iOS 上传统 RN 的体验不太一样。Android 上 setState 之后,Shadow Tree 同步和原生渲染虽然也有延迟,但整体链路非常成熟,你基本感知不到状态管理层的存在。鸿蒙适配层还在快速迭代,很多原生组件映射、事件回调时序都跟传统 RN 不同,如果状态管理再写得乱,UI 刷新问题就会被放大成肉眼可见的“不刷新”。
所以在鸿蒙上单独考虑状态管理不是多此一举。你要的不只是“某个全局变量被改掉了”,而是“这个变量被哪些组件依赖,发生变化后应当通知谁刷新”。React 自带的 setState 只能解决组件内部状态,跨页面共享和生命周期长的全局数据,只能靠独立的状态管理方案。MobX 的 Observable 数据监听,恰好就是来解决这一层的。
1.2 状态管理在RN鸿蒙工程里的定位
很多人在工程初期会把状态管理理解成“放全局数据的容器”,这其实是个误区。状态管理的核心不是存数据,而是定规矩:哪些数据需要被监听,谁可以修改它,修改之后哪些页面要自动刷新,哪些副作用要被触发。
RN 鸿蒙工程里,我建议把业务状态完全放在 JS 侧,原生侧只通过桥接暴露设备能力接口。这样状态逻辑和 UI 逻辑都能在 JS 层统一处理,MobX 的 Observable 监听模型可以顺畅工作。反过来,如果状态放在原生侧,JS 侧每次读取都要过一次桥,性能会打折,而且监听链路被切断,MobX 根本感知不到原生侧的变化,最后只能靠手动事件去通知,代码会很散。
用个形象的类比:Observable 数据源就像一个广播站,数据变化就是广播信号;组件是收音机,只有调到对应频道的收音机才能收到信号并更新界面。你负责决定哪些数据进入广播站、广播站在什么时机发信号,以及每台收音机该听哪个频道。这套机制理顺了,后面接鸿蒙原生事件、做持久化、做跨设备同步,都会变得很清晰。
1.3 为什么选MobX而不是Redux或其他
状态管理方案不少,Redux、Zustand、MobX 都是成熟选择。我在这个项目里选 MobX,主要原因是它处理“数据监听”的方式最直接。
Redux 的问题是样板代码太多。你要定义 action type、action creator、reducer、selector,还要写 connect 或者 useSelector 把 store 数据映射到组件。在鸿蒙适配层还不算非常稳定的时候,多一层手写映射就意味着多一个出问题的环节。Zustand 其实也很轻,但它的更新模型更接近“订阅整个 store 片段”,遇到深层对象嵌套时,反而不如 MobX 自动追踪依赖来得省心。
MobX 强调的是“自动追踪依赖”。组件 render 里读取了哪些 observable,MobX 会自动记录下来;下次这些 observable 变化时,只有真正依赖它的组件才会收到通知。这个粒度在复杂业务里非常舒服,尤其在鸿蒙真机上,每次多余的 UI 刷新都可能放大为明显的掉帧。
有一点要提醒:MobX 6 之后不再推荐装饰器写法。如果你在网上看到大量@observable、@action的旧代码,照搬到鸿蒙 RN 工程很容易出问题,因为装饰器语法转译额外依赖 Babel 插件,而鸿蒙适配层的构建链路对实验性语法的支持并不一致。老老实实用makeAutoObservable,省心得多。
2. 环境准备与依赖接入:把MobX跑在鸿蒙设备上
2.1 工程基础与依赖版本
我这边模拟项目X的基础工程是这样的:React Native 0.72 左右的版本,配合鸿蒙适配层,DevEco 相关工具链跑在 Windows 开发机上,真机调试选的是当前主流的鸿蒙开发板。MobX 相关依赖装的是:
npm install mobx npm install mobx-react-lite如果你用类组件,可以选mobx-react,但函数组件已经成为主流,mobx-react-lite更轻,也没有历史包袱。建议别把两个包混着装,因为二者都导出了observer,混用会导致观察上下文不一致,排查起来很折磨人。
依赖版本方面,不要照抄最新版本就去升。MobX 的 API 在 4/5/6 之间有一些破坏性变化,尤其是 Proxy 支持和装饰器配置。在我用下来的搭配里,mobx@6.x配合mobx-react-lite@3.x在鸿蒙工程里比较稳重。安装完依赖后,我建议顺手跑一次依赖树检查:
npm ls mobx npm ls mobx-react-lite确认没有出现两个 MobX 主版本同时存在的局面。MobX 与 React 的集成有个底层细节:如果两个地方分别引入不同副本,observable 数据源的身份判断会错乱,组件以为自己在订阅这个 store,实际更新来自另一个 store,表现出来就是“改了数据不刷新”。
2.2 Observable的最小可运行示例
先写一个最简单的 store。MobX 6 最稳的用法是makeAutoObservable:
import { makeAutoObservable } from "mobx"; class CounterStore { count = 0; constructor() { makeAutoObservable(this); } increment() { this.count += 1; } } export default new CounterStore();makeAutoObservable做的事情很关键:它会在实例上自动把对象的所有自身属性变成 observable,把方法变成 action,把 getter 变成 computed。如果你忘了调用它,整个 store 就是普通对象,你改count一百次,MobX 也不知道,UI 更不会刷新。
然后写一个 observer 组件:
import { observer } from "mobx-react-lite"; import { View, Text, Button } from "react-native"; import counterStore from "./counterStore"; const Counter = observer(() => { return ( <View> <Text>{counterStore.count}</Text> <Button title="加一" onPress={() => counterStore.increment()} /> </View> ); }); export default Counter;observer包裹一个函数组件,把它变成响应式组件。关键点是,组件 render 过程中读取了counterStore.count,MobX 就把这个组件记为该数据的观察者。之后 increment 触发 count 变化,组件自动重渲染。
这个最小示例在鸿蒙真机上的表现会和 Android/iOS 基本一致。因为 MobX 本身是纯 JS/TS 库,不依赖任何 Android或 iOS 原生模块,所以桥接差异影响不到它。
2.3 鸿蒙平台上的原生库兼容排查
接入早期最容易出问题的反而不是 MobX 本身,而是工程里已经存在的其他原生依赖。鸿蒙适配层不是百分之百兼容所有 React Native 原生模块的。某些 npm 包在内部引用了 Android 的Log、Toast,或者 iOS 的UIKit,在整个鸿蒙工程编译时就会直接失败。
MobX 是纯 JS 库,这一点是它的天然优势。遇到报错先做一次“字母表归零”排查:把mobx和mobx-react-lite注释掉,重新构建,如果鸿蒙侧编译恢复,说明问题不在 MobX;如果依旧报错,问题多半出在原生依赖或者适配层配置上。
还要检查 JS 引擎的 Proxy 支持。MobX 6 的默认实现里,observable 依赖 Proxy 来拦截属性读取和写入。鸿蒙的 JS 引擎一般支持 ES6 Proxy,但如果你用了比较老的兼容模式或者特殊构建参数,控制台会看到 Proxy 相关的异常。遇到这种环境,可以在入口文件加:
import { configure } from "mobx"; configure({ useProxies: "never" });强制走旧版的 defineProperty 拦截机制。但要注意,这样设置之后,新增属性监听的能力会变弱:对象原本没有的字段,直接赋值不会自动变成 observable。所以还是优先确认引擎支持 Proxy,别轻易走降级。
3. Observable数据监听的核心机制与使用细节
3.1 Observable、action、computed的关系
“数据监听”这四个字听起来抽象,拆开看其实就三个概念。
observable 是数据源。它负责把普通对象变成可追踪对象,任何属性的读和写都会被记录。action 是修改数据的入口。MobX 并不强制要求所有修改都得走 action,但走了 action 之后,几次连续修改可以被包装在一个事务里,只触发一次通知,性能更稳。computed 是派生值。比如购物车里的商品列表变了,总价不需要手动更新,computed 会在依赖项变化时重新计算,并且缓存结果。
下面是一个相对完整的示例:
import { makeAutoObservable } from "mobx"; class CartStore { items = []; constructor() { makeAutoObservable(this); } addItem(item) { this.items.push(item); } get totalPrice() { return this.items.reduce((sum, item) => sum + item.price, 0); } }组件里可以直接读取cartStore.totalPrice。由于 totalPrice 是 computed,它内部依赖了this.items,MobX 会自动建立“items -> totalPrice -> 组件”的依赖链。items 里 push 一个新商品,组件刷新;totalPrice 本身被多处读取,也只会重算一次。
3.2 监听视图更新:observer如何工作
你现在应该能感觉到,MobX 做数据监听的关键不是“轮询数据有没有变”,而是“依赖收集”。当 observer 组件渲染时,MobX 会进入一个追踪上下文,把组件读取过的 observable 属性全部记录下来。组件下次是否要刷新,取决于它读过的那些属性有没有变化。
听起来很完美,但实际使用中有一个高频陷阱:在组件里解构 store 属性会导致追踪中断。
错误示范:
const ProductCard = observer(({ store }) => { const { name, price } = store.product; return ( <View> <Text>{name}</Text> <Text>{price}</Text> </View> ); });这段代码看起来没问题,甚至运行也不会报错,但它有一个致命缺陷:store.product在 render 开始时被读取后,解构出来的name和price是普通字符串数字快照,它们和后续 observable 更新已经没有任何关系。即使store.product.name被修改,这个组件也不会刷新,因为跨越了“解构”这一步。
正确写法是直接访问:
const ProductCard = observer(({ store }) => { return ( <View> <Text>{store.product.name}</Text> <Text>{store.product.price}</Text> </View> ); });这种“直接访问属性”的习惯,是 MobX 项目里必须养成的肌肉记忆。
3.3 响应式更新的坑:数组、Map、嵌套对象
MobX 文档里最常强调的就是“不要替换整个 observable,尽量在原有数据上做变更”,但真实业务里我们经常拿到的是一大坨后端 JSON,直接塞进 store 就出问题。
嵌套对象场景:你从接口返回了一个用户信息对象,存储到 store 里:
this.user = response.data;这种替换操作本身会让依赖store.user的组件刷新一次。但如果你之后又更新:
this.user.name = "新名字";这里大概率不会触发任何 UI 刷新。原因很简单:response.data是从网络返回的普通对象,不是 observable,MobX 只能感知到user这个引用的替换,感知不到user.name的深层变化。
解决方法是把返回对象也变成 observable:
import { observable, runInAction } from "mobx"; const user = observable(response.data); runInAction(() => { this.user = user; });这样替换之后,user.name的修改也会被追踪,依赖它的组件会刷新。同理,如果你在 store 里初始化时就声明好所有嵌套对象字段,makeAutoObservable 会递归地把它们变成响应式结构,但运行时新塞进来的对象不会自动变 observable。
数组和 Map 的细节也不少。数组按下标赋值是支持的,但如果你是在旧版 MobX 的 defineProperty 模式里使用,直接this.items[0] = newItem可能不会被检测,建议用items.splice(0, 1, newItem)或者数组方法操作。对于 Map,尽量用set/delete方法,别直接给map.size之类的属性做无意义修改。
4. 在鸿蒙的RN运行时里做性能优化
4.1 对象粒度与最小渲染单元
MobX 的自动追踪粒度已经够细了,但如果你把所有业务状态都塞进一个全局 store,依然会出现问题。
一是初始化成本。makeAutoObservable需要遍历对象属性建立拦截,全局 store 越大,初始化越慢。二是依赖扩散。当多个业务模块共享同一个大 store 时,组件为了拿到某个字段,会隐式订阅整个 store 对象,哪怕其它字段变化也会让这个组件重新渲染。
我后来把模拟项目X的全局 store 拆成了几个独立领域:用户状态、配置状态、业务列表状态、设备事件状态。每个 store 只负责自己那块数据,组件也只订阅它真正需要的最小数据块。拆分之后,真机上的 UI 刷新明显更干脆。
另外一个技巧是:在组件里尽量使用 computed 派生需要的数据,不要在 render 里自己写循环过滤。比如一个长列表,你只想展示已完成的任务,可以在 store 里定义一个 computed:
get doneTodos() { return this.todos.filter((todo) => todo.done); }组件读取store.doneTodos,MobX 会精确跟踪 todos 数组和每个 todo 的 done 属性,而不是让组件每次渲染都重新推导。
4.2 用reaction/when做副作用监听
observer 是 UI 层的响应式监听,但 MobX 并不只服务 UI。很多时候我们需要在状态变化时调用鸿蒙原生能力,比如更新原生侧存储、切换页面状态、发送网络请求。这个时候就该用reaction。
import { reaction } from "mobx"; const disposeAuthListener = reaction( () => authStore.token, (token, previousToken) => { if (token !== previousToken) { NativeModules.HarmonyAuth.updateToken(token); } } );reaction的第一个函数是数据源,返回你关心的 observable 值;第二个函数是副作用回调。只有数据源的值发生变化时,回调才会执行。相比直接在组件里用 useEffect 监听,reaction 更专注数据层,不依赖组件生命周期。
when则适合一次性监听。比如页面登录后要等待用户数据加载完成,再执行某段逻辑:
when( () => authStore.userLoaded, () => { startSync(); } );这段监听只会在条件满足时执行一次,然后自动销毁。要注意的是,reaction和when返回的 disposer 函数必须被管理起来,否则会产生内存泄漏。后面会专门讲清理问题。
4.3 延迟更新、事务与批处理
MobX 对 action 内的多次状态修改默认做了延迟通知。这意味着在一次点击事件里,你先改 loading 为 true,再改列表数据,再把 loading 改成 false,最后 UI 只会刷新一次。这是非常好用的特性。但如果你在异步回调里直接修改 observable,而不是在 action 里修改,这个批处理效果可能就失效了。
解决方式是使用runInAction。看一个网络请求的标准写法:
class ListStore { list = []; loading = false; error = ""; constructor() { makeAutoObservable(this); } async fetchList() { this.loading = true; try { const data = await requestData(); runInAction(() => { this.list = data; this.loading = false; }); } catch (e) { runInAction(() => { this.error = e.message; this.loading = false; }); } } }这里如果把this.loading = false直接写在 try 的 await 之后,从时序上看好像也没错,但建议还是放进 runInAction。因为 await 之后已经跳出 action 上下文,MobX 无法自动把它和之前的修改事务合并。用 runInAction 包一层,能保证多次状态修改依然是批处理的。
4.4 与鸿蒙原生事件总线协作
鸿蒙工程里免不了要接收来自原生侧的事件:设备状态、网络状态、传感器数据、蓝牙扫描结果等等。这些事件通常通过桥接模块以回调形式传给 JS 侧。在 RN 里一般用原生模块的 DeviceEventEmitter,鸿蒙适配层也沿用了类似机制。
我通常会在一个专门的事件桥接文件里做处理,把原生事件先归一化成普通 JS 数据,再交给 MobX action:
import { DeviceEventEmitter } from "react-native"; import sensorStore from "./sensorStore"; DeviceEventEmitter.addListener("nativeSensorData", (payload) => { sensorStore.updateSensorData(payload); });这里的要点是:原生事件比较高频时,不要每一条都去改 observable,更不要每条都驱动 UI。比如传感器数据一秒可能来几十次,页面却不需要每秒刷新几十次。可以在 action 内部做节流或者合并:
updateSensorData(payload) { this.latestRawData = payload; const now = Date.now(); if (now - this.lastFlushTime > 100) { this.displayData = payload; this.lastFlushTime = now; } }这样界面保持在 10 FPS 左右刷新,但原始数据仍然被记录,不会丢。
5. 常见问题与排查技巧实录
5.1 observer不刷新的5个原因
我在鸿蒙真机上排查 UI 不刷新时,总结过五个最可能的原因。
第一,组件没有用 observer 包裹。函数组件如果直接导出,没有包一层 observer,MobX 的依赖收集就完全失效。第二,render 里解构了 observable 对象。这个问题前面重点提过,破坏依赖链的最常见原因。第三,makeAutoObservable 没调用,或者调用时发生在对象属性初始化之前。第四,修改的字段本身不是 observable。尤其是网络返回的普通对象被直接塞进 store,深层字段改了不触发。第五,修改发生的位置不在 action 或 runInAction 里,导致变更被 React 与 MobX 的批处理机制绕过去。
排查时先对照这五个原因,基本能覆盖九成问题。不要一上来就怀疑鸿蒙适配层,很多时候是我们在 JS 侧破坏了响应式链接。
5.2 状态被修改但UI无响应的排查路径
如果已经确认是某个具体数据“改了但不刷新”,我给你一套排查顺序。
先在组件或者 store 外层加一个autorun调试:
import { autorun } from "mobx"; autorun(() => { console.log("当前值", sensorStore.displayData); });如果 autorun 里的值随着修改而变化,说明 observable 本身没问题,问题出在组件读取链路。如果 autorun 也不变化,那说明修改操作根本没有作用到这个 observable 对象上,需要检查你是不是创建了第二个 store 实例、或者把普通对象误当 observable 了。
autorun 确认没问题后,再看组件是不是 observer 包裹、render 内访问的是不是同一份引用、是否因为父组件重新渲染导致子组件被卸载重建。我在模拟项目X里遇到过一个情况:页面 A 和页面 B 各自通过 get 方法获取同一个单例 store,但鸿蒙侧页面栈没有按预期管理,导致页面 A 创建了一份新 store,页面 B 读的是另一份。所以建议 store 导出方式使用模块单例,别用工厂函数。
最后,试一次冷启动。鸿蒙适配层的热更新机制还不算非常稳定,热替换后可能出现 store 对象还是旧的、observable 代理链断裂的假象。真机调试时,改完 Store 结构,冷启动再验证,这个习惯能省下大量“疑难杂症”的时间。
5.3 内存泄漏与清理
observer 组件卸载时,MobX 会自动取消订阅,这一点不用操心。但reaction、autorun、when、DeviceEventEmitter的监听,都不会随组件生命周期自动清理,必须手动处理。
一个典型错误是:在组件里直接创建 reaction,却没有在 unmount 时调用 disposer。页面切走再切回来,reaction 还在监听,数据一变就执行旧页面的副作用,严重时还会引起鸿蒙原生模块重复调用。
正确的做法:
useEffect(() => { const disposer = reaction( () => authStore.token, (token) => { NativeModules.HarmonyAuth.updateToken(token); } ); return () => disposer(); }, []);如果 store 本身需要长期存活,我会在 store 里维护一个 disposer 列表,在应用退出或者用户注销时统一清理。
class UserStore { disposers = []; constructor() { makeAutoObservable(this, { disposers: false }); } startListening() { this.disposers.push(reaction(..., ...)); } destroy() { this.disposers.forEach((dispose) => dispose()); this.disposers = []; } }5.4 实战建议与避坑清单
最后整理几条实战建议,都是我在鸿蒙真机上一步步试出来的。
不要用装饰器语法。不管网上老代码多好看,在鸿蒙 RN 工程里优先用makeAutoObservable。保持 Store 单例。一个领域一个模块级实例,别到处 new。直接访问属性。不在 render 阶段解构 observable 对象。网络数据进入 store 时先做 observable 包装。异步修改状态一律 runInAction 或调用 action 方法。原生高频事件进 JS 层先节流合并。所有 reaction 和原生监听都要记录 disposer,页面销毁时清理。
汇总成一张速查表:
| 现象 | 可能原因 | 处理方法 |
|---|---|---|
| 数据变了,UI 不刷新 | 组件没包 observer / 解构了对象 | 包 observer,直接访问属性 |
| autorun 也没变化 | 修改的是普通对象或错误 store 实例 | 用 observable 包装,检查实例来源 |
| 页面切换后副作用重复触发 | reaction 未销毁 | 在 useEffect cleanup 中调用 disposer |
| 异步接口返回后 loading 不消失 | 修改不在 action/runInAction 上下文 | 用 runInAction 包裹异步更新 |
| 数组按下标赋值不刷新 | defineProperty 降级模式下索引监听受限 | 用 splice 或数组方法替换元素 |
| 新增对象字段不触发更新 | 字段不是声明时的 observable | 初始化时预留字段,或使用 map/observable 包装 |
最后再说几句
我个人在实际操作中的体会是,MobX Observable 数据监听在 RN 鸿蒙工程里,最核心的一条准则是:只有“observable 到 observer”的链路完整,才是真正的响应式。遇到任何 UI 不刷新问题,先从这条链路上找断点,而不是去折腾鸿蒙原生渲染。
最后再分享一个小经验:鸿蒙真机调试时,每次修改 Store 结构后,最好冷启动一次。我试过热更新之后直接改数据属性,界面经常“装死”,冷启动后一切正常。后来我干脆把热更新当作普通日志刷新工具,真正验证状态逻辑时一律冷启动。这个习惯,能帮你省掉很多不必要的“不刷新”错觉。
如果后续你还要做更复杂的跨设备场景,可以试着把 MobX 的状态和鸿蒙的分布式能力结合起来:计算好的最终状态只通过桥接同步到原生侧,原生侧再决定是否写入分布式数据。这个方向我还在摸索,但 MobX 的监听机制已经为它铺好了路。