ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

React Native鸿蒙跨端状态同步:@State映射与@Link联动实践

React Native鸿蒙跨端状态同步:@State映射与@Link联动实践 我之前把一个React Native的智能设备控制App往鸿蒙迁移最先崩的不是列表渲染不是路由而是状态同步。RN这边useState改得好好的鸿蒙ArkUI侧就是纹丝不动页面白在那里查了一整天才发现两套状态机制根本不认识对方需要一层“映射层”把它们接起来。最终落地的思路就是这个标题讲的一套做法——React Native鸿蒙跨平台通过State装饰器映射React状态通过Link实现父子组件状态联动。这篇文章我把从原理到实操的完整链路拆开讲重点覆盖状态源怎么定、bridge如何对接ArkUI、Link在什么场景下值得用、以及我在真实设备迁移中踩过的白屏和深层对象不刷新问题。适合正在做React Native鸿蒙适配的前端工程师也适合刚接触ArkUI状态管理的鸿蒙应用开发者对照理解。1. React状态模型与ArkUI装饰器模型的本质冲突1.1 setState和State一个是快照一个是挂钩先看最底层的差异。React的状态管理是纯函数式的你用useState拿到一个状态值和一个更新函数每次更新都必须传入一个全新的不可变数据然后React从根组件开始重新执行一次渲染流程通过Fiber节点的diff来决定哪些DOM或原生组件需要变更。也就是说React对“更新”的定义是替换快照。setState之后旧状态对象被整体丢弃新对象参与下一轮渲染。ArkUI完全不同。State装饰器是给普通变量挂上一个“响应式钩子”只要变量被赋值框架就会自动找到依赖这个变量的UI节点执行最小范围内的界面更新。你不需要创建一个新的不可变对象也不需要对整个组件树做reconcile。它的核心机制是依赖收集 脏值通知更接近Vue的响应式实现而不是React的不可变数据流。这个差异决定了迁移时的第一道坎很多RN开发者习惯写setXxx(newValue)到了ArkTS里依然下意识地去外面包一层更新函数结果代码写得很别扭状态还不更新。实际上在ArkUI里你只需要判断“哪一个是真正的状态容器”然后直接给State变量赋新值框架自己会找到依赖点去刷新。反过来如果习惯了直接改对象字段又会踩到另一层问题这个后面单独说。1.2 一份业务状态RN侧和ArkUI侧各有一份“投影”React Native应用跑在鸿蒙上时应用里其实存在两棵组件树一棵是React渲染出来的业务视图树状态由useState、useReducer、Redux这些JS侧的东西持有另一棵是鸿蒙宿主的ArkUI页面它们的原生控件底部导航、系统弹窗、悬浮球、原生顶部标题栏等状态由State、Prop、Link这些装饰器持有。业务上来讲这两棵树的很多状态是同一个东西。比如设备列表里的“当前选中设备”“当前Tab索引”“开关状态”RN列表要用、鸿蒙原生导航也要用。可是技术上讲这两个状态是两个独立的存储位不会自动同步。如果不做映射常见的表现有两种。第一种是RN页面正常渲染但鸿蒙原生UI不更新比如切换了RN内的Tab鸿蒙底部导航的高亮还停留在原来的位置。第二种更恶性就是首页白屏RN的初始化数据没有准时交给ArkUI侧ArkUI的页面先把空的State渲染了一遍RN再拿到数据时已经错过了首帧渲染的窗口。所以“通过State装饰器映射React状态”这句话的本质是解决两份状态投影之间的同步协议。它不是让React和ArkUI用同一份内存而是让一边发生变更时另一边的装饰器变量能跟着变并且变动的时机要赶在UI绘制之前。1.3 迁移前先建立一张映射对照表我在做迁移时列过一张很简单的对照关系照着这个去设计状态就不会混乱React侧ArkUI侧语义useStateState组件自己持有的响应式状态props父传子、只读Prop父组件单向下发给子组件受控组件的value onChangeLink父子组件共享状态、双向联动useContext / ProviderProvide / Consume跨多级组件传递状态useReducer 深层对象Observed ObjectLink跟踪复杂嵌套对象的属性变化这个表格里最容易被忽视的是最后一行。React的useState更新深层对象时只要整体换一个新引用就能触发渲染但ArkUI的State默认只追踪对象第一层的属性变化嵌套属性改了不会通知UI。后面“深水区”部分我会用一个具体案例说清楚。2. State接入React状态更新从bridge到UI刷新的完整链路2.1 先定谁是“状态源”再谈同步跨端状态同步最容易翻车的地方不是代码写不出来而是没有定义清楚谁是状态的最终所有者。我自己的原则是谁产生的数据谁做源头另一端只做投影。具体来说有三种常见场景处理方式截然不同数据场景推荐的状态源映射方式应用启动配置、首次进入列表的基础数据RN侧通过启动props下发ArkUI侧初始化时用State缓存RN业务数据在JS侧频繁变化如列表刷新RN useState持有每次变更通过bridge事件推给ArkUI鸿蒙原生组件产生的交互导航切换、系统选择器ArkUI State持有通过bridge事件回传给React举一个实际例子。设备列表的数据由RN的业务层负责拉取和过滤那么listData这个状态就应该归React管。RN每刷新一次数据就调用一个同步方法把新的数组发到鸿蒙侧鸿蒙侧维护一个 State deviceList 作为投影。反过来鸿蒙原生底部导航的高亮索引是用户点击产生的它应该由ArkUI侧持有点完以后通过bridge告诉RN“切换到了哪个Tab”RN再去拉对应Tab的数据。这种“单源投影”的设计最大的好处是任何时候出了问题你都能快速定位到底哪一方的变量才是源头另一方只是被动跟随。如果两边都在改同一个业务状态那就成了双向回环等着踩线和抖动。2.2 鸿蒙侧的状态接收器一个方法维护一个State具体到代码我在鸿蒙侧封装了一个统一的“状态接收器”。RN侧任何状态更新都走同一个bridge事件拆包以后分发给不同的State变量。ArkTS侧类似这样// ArkUI侧监听RN状态更新更新State投影 Entry Component struct RNContentPage { State appTitle: string ; State deviceList: DeviceItem[] []; aboutToAppear(): void { rnBridge.onStateUpdate(device_list, (value: DeviceItem[]) { this.deviceList value; // 直接赋值由State触发最小渲染 }); rnBridge.onStateUpdate(app_title, (value: string) { this.appTitle value; }); } build() { Column() { Text(this.appTitle) .fontSize(20) DeviceList({ deviceList: this.deviceList }) } } }RN侧的推送只需要一行// RN侧把useState的新值同步给ArkUI import { NativeModules } from react-native; export const syncStateToArkUI (key: string, value: unknown) { NativeModules.ArkUIBridge?.emitStateUpdate(key, value); };这段代码的核心思路是RN侧每一次setState拿到新值后顺手调用syncStateToArkUI把同样一份数据推到鸿蒙侧鸿蒙侧收到后系到一个State变量上ArkUI的依赖UI自动刷新。你在RN里更新多少次鸿蒙侧就同步多少次次数要尽量收敛但内容必须是一致的。有一个细节值得注意bridge底层做的是序列化传输RN里的Date会被转成字符串、Map和Set会被降级成普通数组。交叉到ArkTS侧以后所有数据都是“新复制的普通对象”不是原来的引用。所以鸿蒙侧的State不要指望能感知到RN内部对象那个属性的修改——你在RN里改了一个数组项的name字段得重新发整个数组过去ArkUI侧重新赋值列表才能刷新。2.3 启动时序白屏问题有一半出在这里“react native 启动白屏”这个搜索词热度一直很高。就我观察大多数白屏跟RN代码本身没关系而是启动时序里的状态初始化窗口没有对齐。鸿蒙侧的页面加载流程大致是这样的ArkUI页面先创建执行build然后RN容器开始加载bundle、初始化JS引擎。如果你在ArkUI的build里直接渲染一个依赖数据的列表而数据还没到这一帧就是空的。即使几分钟后RN把数据发过来了用户看到的仍然是“首屏白了一下”的体验。我的解决方法是加一个“就绪闸门”。鸿蒙侧不要急着渲染真正的业务UI而是先渲染一个loading状态等RN侧把initialProps和数据都推过来、State真正拿到了第一份数据以后再把页面切换到业务界面State isReady: boolean false; State deviceList: DeviceItem[] []; build() { Stack() { if (this.isReady) { DeviceList({ deviceList: this.deviceList }); } else { LoadingProgress() Text(设备加载中...) } } }等bridge把首批数据赋值给deviceList后再把isReady置为true。这样首帧渲染的就是loading而不是白屏。这个“先给空状态、再切业务状态”的闸门模式同时解决了initialProps还没有到达和bridge尚未就绪两个问题是状态映射里最基础但也最容易被忽略的一层。3. Link让父子组件状态联动React受控组件的鸿蒙写法3.1 从“value onChange”到“$引用”React里的父传子联动标准做法是受控组件父组件持有useState把状态值和更新函数一起传给子组件子组件想要变更时调用父组件传来的回调。组件层级一深props里就会堆一堆onChange、onValueChange、onSelect。鸿蒙用Link彻底简化了这个过程。父组件用$state语法把一个State变量的引用传给子组件子组件用Link声明接收。之后子组件内部对变量的赋值父组件和其他联动组件都能在同一时间感知到。看一个最直接的对照组。React侧示例如下const SwitchCell ({ value, onChange }) { return ( Switch value{value} onValueChange{(v) onChange(v)} / ); }; const ParentPage () { const [active, setActive] useState(false); return SwitchCell value{active} onChange{setActive} /; };ArkTS侧等价写法如下Component struct SwitchCell { Link active: boolean; build() { Toggle({ type: ToggleType.Switch, isOn: this.active }) .onChange((isOn: boolean) { this.active isOn; // 直接改Link两端自动同步 }); } } Entry Component struct ParentPage { State active: boolean false; build() { SwitchCell({ active: $active }); } }两种模式实现了同样的效果父组件是真正状态所有者子组件可以改变它、也能观察它。差别在于React要求子组件每次都要显式调用回调ArkUI的Link把“发通知”这件事内置到赋值操作里了。用习惯以后你会觉得Link真的很省事。但这里有一个容易忽略的约束Link不是“复制一份给子组件”而是“子组件能拿到父组件同一块状态的引用”等于把原来React里父子和兄弟之间的通讯完全打通了。兄弟组件之间通过同一个父组件的State做同步和React里状态提升的做法是一样的。3.2 多子组件共享同一份State点击任意一个大家同时变Link一个很典型的优势场景是多个子组件共享同一个状态源。比如我需要两个卡片组件操作同一个计数器React里必须把setCount传到两个组件各自调用鸿蒙里让两个子组件都声明Link同一个父组件变量即可Component struct ChildA { Link count: number; build() { Button(A当前${this.count}) .onClick(() { this.count; }); } } Component struct ChildB { Link count: number; build() { Button(B当前${this.count}) .onClick(() { this.count 2; }); } } Entry Component struct ParentPage { State count: number 0; build() { Row() { ChildA({ count: $count }); ChildB({ count: $count }); } } }点击ChildA里的按钮ChildB组件显示的count也会跟着变反过来点ChildBChildA也同步。这在React里需要做状态提升把count放进公共父组件然后通过props下发。ArkUI的语法糖让这个场景更直观但它背后的状态所有权和React的路数是一致的——状态放到公共父级所有子组件都依赖它。实际迁移时我建议遵循这个决策模型如果子组件只是展示状态用Prop单向传如果子组件必须修改状态且改动要反馈到父级和其他兄弟这时才用Link如果子组件内部有临时草稿、动画过程中的瞬态值绝不放进Link否则每次敲击输入法里的候选字都可能触发父组件重渲染。3.3 Prop、Watch、Link怎么选很多刚接触ArkUI的人会把Prop和Link搞混其实一句话就能分清Prop是单向复制Link是双向引用。我用一个表格说明装饰器方向子组件修改的影响适用场景Prop父 → 子子组件修改不会通知父组件展示型子组件如卡片文本Link父 ↔ 子子组件修改立即同步父组件和其他兄弟可交互的表单项、开关、选中态Watch触发回调配合State或Link监听值变化后执行副作用联动请求、校验、日志上报Watch我特别提一句它是Link的好搭档。如果你想在父子联动的同时做点额外动作比如值变化后上报统计或者过滤列表可以在Link的声明上挂WatchLink Watch(onStatusChange) status: number 0; onStatusChange(propName: string) { // 状态变化后的副作用可以放在这里 this.loadDataByStatus(); }但要注意Watch函数会在每次赋值后同步执行。如果子组件连续改了三次值Watch就会连续触发三次。对于较重的副作用一定要先节流或去重否则性能会很难看。4. 实战鸿蒙底部导航与RN设备列表的跨端状态联动4.1 场景和状态划分拿一个我实际改过的场景说一个RN设备控制应用本身有完整的业务列表但鸿蒙侧想用原生底部导航替代RN里的导航条因为原生导航栏的返回手势、角标、动画都比RN侧更贴近鸿蒙设计规范。这就产生了典型的“跨端状态联动”。需求拆开看只有三条用户点击鸿蒙原生底部导航的某个TabRN侧要切换到对应的设备列表。RN列表内选中一台设备鸿蒙底部导航要能显示角标还要记录当前选中设备。以上都是从一端发起另一端实时跟随双方数据不能错位。我先把状态划分好状态源头ArkUI侧RN侧currentTabArkUI底部导航State currentTabRN接收tab_change事件后useState更新deviceListRN业务层State deviceListRN useState持有selectedDeviceId父级页面共享父组件State子组件Link发布事件同步4.2 鸿蒙侧ArkTS实现父级页面持有三个核心状态currentTab、deviceList、selectedDeviceId。底部导航的点击事件更新currentTab然后通过bridge回传给RNdeviceList通过事件接收器由RN更新selectedDeviceId用Link传到列表子组件。Entry Component struct MainTabPage { State currentTab: number 0; State deviceList: DeviceItem[] []; State selectedDeviceId: string ; aboutToAppear(): void { // RN列表就绪后接收listData投影 rnBridge.onStateUpdate(deviceList, (value: DeviceItem[]) { this.deviceList value; }); } build() { Tabs({ barPosition: BarPosition.End }) { TabContent() { DeviceList({ deviceList: $deviceList, selectedDeviceId: $selectedDeviceId }) } .tabBar(设备) TabContent() { // 第二个Tab对应另一个场景 } .tabBar(场景) } .onChange((index: number) { this.currentTab index; // 通知RN侧切换Tab并拉取新数据 rnBridge.sendToRN(tab_change, { tab: index }); }) } }DeviceList组件内部再拆一个DeviceListItem让列表项通过Link拿到selectedDeviceId点击选中后就写回父组件Component struct DeviceList { Link deviceList: DeviceItem[]; Link selectedDeviceId: string; build() { List({ space: 8 }) { ForEach(this.deviceList, (item: DeviceItem) { DeviceListItem({ device: item, isSelected: this.selectedDeviceId item.id, onSelect: () { this.selectedDeviceId item.id; } }) }, (item: DeviceItem) ${item.id}) } } }这里的onSelect是列表组件内部的业务动作但它修改的是父组件下沉下来的Link变量。一旦修改MainTabPage里的selectedDeviceId、底部导航的角标以及任何依赖它的兄弟组件都会同步刷新。4.3 RN侧配合和防回环设计RN侧为了跟上原生底部导航要监听tab_change事件并把新的列表数据推给鸿蒙侧const App () { const [currentTab, setCurrentTab] useState(0); const [deviceList, setDeviceList] useState([]); useEffect(() { const unsubscribe rnBridge.onMessage(tab_change, ({ tab }) { const nextList fetchDeviceListByTab(tab); setCurrentTab(tab); setDeviceList(nextList); // 推给ArkUI更新State投影 syncStateToArkUI(deviceList, nextList); }); return () unsubscribe(); }, []); return DeviceListView data{deviceList} /; };实际联调时最容易踩的坑是“回环”。RN把deviceList推过去后如果ArkUI侧又把deviceList原样回传RNRN的setState会触发一次新的渲染再次把deviceList推过去形成无限循环。我处理这个问题的办法是单向收口RN和ArkUI之间约定某个业务状态的变更只能由源头那一端发起同步另一端收到投影后只更新UI绝不回传。如果鸿蒙侧临时改过deviceList也不需要让RN状态跟着变宁可让RN在下一个事件被动获取也不要让数据流变成两头拉锯。4.4 改完之后的整体数据流整套改造完成后的数据流是这样的用户点击ArkUI底部导航 Tab 2Tabs组件的onChange触发ArkUI侧currentTab先更新同时bridge消息发给RN。RN收到tab_change后useState更新currentTab拉取Tab 2的设备列表调用syncStateToArkUI把新数组推给鸿蒙侧。ArkUI侧onStateUpdate收到deviceList赋值给State deviceListDeviceList组件通过Link感知到数组引用变化重新ForEach渲染列表。用户在列表里点击某项DeviceListItem的onClick触发父级selectedDeviceId的Link更新ArkUI侧UI立刻高亮同时如果业务需要可以再发一个事件给RN告知当前选中的设备。这套链路看起来环节很多但每个环节都只有单向箭头。排查问题的时候从箭头起点一路捋到终点很快就能找到断点。5. 状态映射深水区白屏、深层对象、双向抖动怎么破5.1 RN启动白屏先查状态初始化的时序我要再强调一次白屏RN在鸿蒙上的白屏十次里面有八次是状态时序问题不是RN代码崩了。排查顺序建议是这样先看RN bundle有没有正常加载再查ArkUI页面的build有没有在数据就绪之前执行最后看initialProps有没有通过bridge完整传给RN。三个环节里最容易出问题的其实是第二个。ArkUI页面一旦build完成首帧就固定了如果首帧跑的是空数据列表后面数据到了列表内容也要等下一次刷新才会重新进入首帧的视觉窗口。这跟Web端“先渲染DOM再异步拉数据”的体验完全不同原生UI对首帧更敏感。我的建议是把“业务状态就绪”当作一个独立状态不要默认为true。设备列表没到位loading闸门就一直开着。这样用户看到的是加载动画而不是一片死白。5.2 State只盯第一层深层对象不刷新是常态个别场景下你明明看到bridge已经收到了数据console也打出来了可ArkUI的UI就是不动。这时候十有八九是踩了State的深层监听限制。看这个例子State device: DeviceItem new DeviceItem(); // 这种写法可能不触发UI刷新 this.device.online true;ArkUI的State对对象属性监听默认只到第一层。如果你在JS侧通过bridge更新的是一个嵌套较深的结构鸿蒙侧拿到新引用直接赋值外层引用变化能触发渲染但如果你在鸿蒙侧直接修改这个对象的第二层字段State并不会感知到。正确的做法有两个分支。如果嵌套结构不大每次完整构造一个新的DeviceItem再整体赋值靠引用变化触发刷新。如果嵌套很深就使用Observed装饰对象类子组件里用ObjectLink接收这样能逐层追踪属性变化Observed class DeviceDetail { name: string ; config: DeviceConfig new DeviceConfig(); } Component struct DetailView { ObjectLink detail: DeviceDetail; build() { Text(this.detail.config.voltage.toString()); } }从RN侧传来的数据想走这条路必须先在鸿蒙侧把普通数据对象转换成被Observed装饰过的类实例。这也是我把它单独列为一条经验的原因——大多数RN开发者根本没意识到ArkUI侧存在“装饰器可见性”这一层。5.3 Link双向联动造成的控件抖动怎么收口Link很好用但双向绑定有一个典型的副作用如果ArkUI侧用户手势刚改了值马上把这个新值回传RNRN又基于这个值重新计算、再推回ArkUI你会在极短时间内看到控件跳回原值再跳到新值视觉上就是抖动严重时开关甚至拉不动。我在一个调光器的例子里遇到过。用户拖动SliderArkUI侧每个滑动回调都更新Link同时bridge把value发到RNRN又回来一个“设置确认”事件ArkUI侧把值再次强制设置。结果就是滑块在跟手跳变之间反复打架。收口的方法很朴素区分“用户临时手势”和“业务状态确认”。用户滑动过程中Link对应的本地值可以实时刷新给用户即时反馈但bridge事件只在手势结束onChange结束后发送避免高频双向同步。RN侧收到后再广播给其他端ArkUI侧也只在收到最终广播时做一次强制设置。高频临时值归UI管低频最终值归业务管一分开就不抖了。5.4 状态同步和性能之间只留一条红线跨端状态同步的性能问题集中在“同步频率”和“同步粒度”两个维度。频率方面RN里网络请求返回一个大列表后过几百毫秒又有个别设备状态变化如果你每条数据变化都发一次bridge鸿蒙侧频繁赋值State整条链路都吃不消。我的做法是先合并再发送RN侧攒一批变更用setTimeout按16ms窗口合并成一次上报ArkUI侧一帧最多处理一次投影更新。粒度方面不要把一个大型业务对象整体塞进State并下发给每一个子组件。列表里一千台设备每一台都在父组件级通过Link绑到底意味着任何一台状态变化父组件都要过一遍所有依赖节点。正确的做法是列表项内部用普通函数回调上报父组件只保存“当前选中的ID”不要保存一千个设备的完整现场。5.5 调试跨端状态同步的简易方法论最后给一套调试方法。我自己排查状态不同步时不依赖一行一行打断点而是给每一次跨端状态变更打上“方向标签”从RN到ArkUI的标记为R2A从ArkUI到RN的标记为A2R。两端的日志统一格式R2A deviceList length128 ts000001 A2R tab_change tab2 ts000002然后看同一条业务链路里两个方向的日志时间戳是否对得上。如果在ArkUI侧收到了值、UI却没有刷新问题基本锁死在装饰器监听层如果RN侧发出去了、鸿蒙侧收不到问题锁死在bridge通道和初始化时序上。这套日志排查法帮我省掉了大量两边各查各的无效时间。跨端问题最怕的就是两个工程师对着各自的IDE看各自觉得“我这边没问题”最后发现是事件名拼写不一致这种低级但高频的坑统一日志格式是最好的防火墙。我自己在实际项目里最大的体会是状态映射本身不难难的是时刻记住“React的更新是对旧值的否决ArkUI的更新是对新值的广播”。把这句话想透从useState到State的迁移就通了一半再把“谁产生数据谁是源头”的约定落进代码规范整条状态联动链路就不会乱。如果你正在做RN到鸿蒙的迁移我建议先不要急着改UI先花半天把两侧的状态源和方向画出来再动手写代码。这半天省下来的调试时间绝对不止半天。
返回列表