ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

React Native跨端开发OpenHarmony:桥接层设计与英雄联盟助手实践

React Native跨端开发OpenHarmony:桥接层设计与英雄联盟助手实践 1. 为什么我选择用RN for OpenHarmony来做英雄联盟助手接这个项目之前我对OpenHarmony的印象还停留在“又一个新系统生态不全”上面。但手里这本英雄联盟助手App原本就是基于React Native开发的双端一套代码跑得好好的如果为了OpenHarmony单独用ArkUI重写工作量太大后面维护也是两份逻辑。后来发现社区已经有了React Native for OpenHarmony的移植方案果断先试了试水结果比我预想的乐观。这套方案不是简单地把RN的JS引擎跑起来而是通过搭建桥接层把OpenHarmony的原生控件映射成RN的组件。最让我惊喜的是之前RN代码里大部分业务组件和第三方纯JS库都能直接用真正需要改动的只有涉及原生模块的部分。对于英雄联盟助手这种重UI、重交互的工具类App来说等于白省了2个月开发量。具体到符文预设这个功能它的业务复杂度其实比想象中高。玩家需要从主系、副系、基石符文、普通符文和碎片里做多级选择还要把选择结果保存成模板下次直接套用。这个功能在RN里要处理好树形数据、多选互斥、本地持久化还要适配OpenHarmony上的原生交互体验。说实话如果用ArkUI硬着头皮写光是那个符文树选择器就要折腾一周。RN的组件模型和状态管理机制反而更适合快速实现这类业务。这篇不写基础教程直接讲我在OpenHarmony上实现符文预设时怎么拆解需求、怎么设计数据模型、怎么搞定原生能力调用以及新老架构切换时遇到的几个坑。如果你也正在考虑RN跨端到OpenHarmony或者正在做类似的工具类App这些经验应该能让你少走弯路。1.1 先搞清楚RN for OpenHarmony目前能做什么OpenHarmony的RN适配已经有社区版本核心思路是让JS端跑在ArkTS运行时之上通过桥接层把RN组件映射到ArkUI组件。当前版本同时存在新架构和旧架构两种模式旧架构就是经典的Bridge AsyncMessage批量异步传递消息新架构则上了JSIJS可以直接持有C对象引用调用原生能力更快更直接。实际项目里我建议优先跑通旧架构因为社区里很多第三方原生组件还停留在旧架构的适配状态。比如我们后面要用到的图片裁剪库旧架构下一切正常切到新架构后就会遇到对象生命周期问题。后面我会单独用一节来聊这个坑。从这个项目总结RN for OpenHarmony适合做这类以数据展示和表单交互为主的应用。如果你的App重度依赖系统级API比如蓝牙、NFC、分布式能力那还是老老实实用ArkUI更有保障。像我们这个英雄联盟助手主要就是网络请求、本地存储、图片挑选和裁剪、拨号跳转这些都有现成原生模块可以桥接。2. 符文预设的数据模型先把规则理清楚再写代码很多人一上来就写界面结果发现符文选择逻辑乱七八糟。符文系统看上去是一个树形选择器但它的业务规则比普通树复杂得多。英雄联盟的符文分主系、副系和碎片三层主系有精密、主宰、巫术、坚决、启迪五个流派主系下面有基石符文位、大型符文位和三个普通符文位副系只能选两个普通符文碎片又是独立的三选一。预设功能本质上是把任意一个符文页配置保存成一个模板。模板要记录的不只是选中了哪些符文ID还要记录它属于哪个英雄、哪种定位、版本号是多少。因为游戏版本更新后符文效果会调整老模板可能失效必须带版本校验。2.1 核心数据结构设计我用TypeScript定了这样一组模型// 符文基础节点对应游戏里每个可选择的符文 interface RuneNode { id: string; // 符文ID全局唯一 name: string; // 符文名称 category: string; // 所属孔位keystone, primary, secondary, shard runeTree: string; // 所属系别precision, domination, sorcery, resolve, inspiration active: boolean; // 当前版本是否可用 version: string; // 引入该符文的版本 } // 一个完整的符文页配置 interface RunePage { pageId: string; heroName: string; role: string; // 上单、中单、打野等 primaryTree: string; // 主系 secondaryTree: string; // 副系 keystoneId: string; // 基石符文 primarySlots: string[]; // 主系其他符文按孔位顺序 secondarySlots: string[]; // 副系符文 shards: string[]; // 碎片长度3 version: string; // 预设对应的游戏版本 } // 预设模板把配置元信息打包 interface RunePreset { presetId: string; presetName: string; tags: string[]; page: RunePage; createdAt: number; updatedAt: number; isFavorite: boolean; }这里有个值得注意的点primarySlots和secondarySlots要保留孔位顺序不能定义成集合因为UI渲染时需要知道哪个位置没选。我在第一版就是用数组长度来隐式标定孔位结果发现选择器里频繁的插入删除操作很容易把顺序搞乱后来干脆每个孔位用固定索引存数组UI层按索引读取边界清晰很多。2.2 本地持久化AsyncStorage在OpenHarmony上的表现RN的标准做法是用AsyncStorage做轻量级KV存储。在OpenHarmony上社区适配版本已经实现了AsyncStorage的Java/Kotlin桥接到系统数据库的能力但有个细节旧架构下AsyncStorage自带SQLite实现迁移到OpenHarmony后默认落到了分布式数据管理服务里读写速度比SQLite略慢但胜在无需额外配置。符文预设这种数据量单次保存只有几百字节完全够用。我封装了一个简单的存储工具import AsyncStorage from react-native-async-storage/async-storage; const PRESET_KEYS rune_presets_v1; export async function loadPresets(): PromiseRunePreset[] { const raw await AsyncStorage.getItem(PRESET_KEYS); if (!raw) return []; try { const parsed JSON.parse(raw); // 做一次版本清洗过滤掉当前版本不可用的模板 return parsed.filter((p: RunePreset) isPageUsable(p.page)); } catch { return []; } } export async function savePreset(preset: RunePreset): Promisevoid { const list await loadPresets(); const index list.findIndex(item item.presetId preset.presetId); if (index 0) list[index] preset; else list.unshift(preset); await AsyncStorage.setItem(PRESET_KEYS, JSON.stringify(list)); }这里我做了版本清洗虽然会影响启动速度但能防止用户看到一堆废模板。实际测试在OpenHarmony上存储100个预设毫无压力序列化也就十几毫秒。3. 树形符文选择器的UI实现从递归组件到状态管理符文选择器是整个预设功能最复杂的UI部分。主系、副系、碎片三个区域虽然维度不同但交互逻辑相似都要求选中后高亮、同系互斥、跨系联动。我一开始想用一个大FlatList把整棵树渲染出来试了两版发现滑动流畅度还行但状态更新时整棵子树都会重新渲染卡顿非常明显。3.1 拆成独立组件按选区隔离渲染最终我把选择器拆成了三个独立组件PrimaryTreePicker、SecondaryTreePicker、ShardPicker。每个组件只接收自己的selectedIds和onChange回调内部用FlatList渲染该选区的可选项。三个组件平铺在页面里互不干扰。主系选择器要用到嵌套数据先选系别五个大图标再展开系别下的符文孔位。我没有做复杂的展开动画而是用一个expandedTree状态来控制当前展开的系别展开后直接渲染一个网格。这样用户每次只会看到两个层级系别横向列表和符文网格。这里有个经验不要贪图美观去做多级可折叠树移动端用户习惯先看到所有系别再点进具体系别选择符文。折叠层数超过两级误触概率大幅上升。3.2 状态管理用useReducer比Context更顺整个页面状态包括当前选中的主系、副系、基石符文、普通符文、碎片、预设名称、标签等加起来有十几个字段。如果每个字段单独useState联动逻辑会非常散。我选择用useReducer统一管理。type State { primaryTree: string | null; secondaryTree: string | null; keystoneId: string | null; primarySlots: (string | null)[]; secondarySlots: (string | null)[]; shards: (string | null)[]; version: string; }; type Action | { type: SELECT_KEYSTONE; id: string } | { type: SELECT_PRIMARY_SLOT; index: number; id: string } | { type: SELECT_SECONDARY_SLOT; index: number; id: string } | { type: SELECT_SHARD; index: number; id: string } | { type: SWITCH_PRIMARY_TREE; tree: string } | { type: SWITCH_SECONDARY_TREE; tree: string } | { type: RESET_PAGE; } | { type: LOAD_PAGE; page: RunePage }; function reducer(state: State, action: Action): State { switch (action.type) { case SWITCH_PRIMARY_TREE: // 切换主系时基石和主系符文全部清空 return { ...state, primaryTree: action.tree, keystoneId: null, primarySlots: [null, null, null] }; case SELECT_KEYSTONE: return { ...state, keystoneId: action.id }; case SELECT_PRIMARY_SLOT: { const slots [...state.primarySlots]; slots[action.index] action.id; return { ...state, primarySlots: slots }; } // 其他action略 default: return state; } }因为所有联动都是单向数据流reducer里写起来清清楚楚。测试时也方便直接dispatch一个LOAD_PAGE就能回填整个预设比逐个setState靠谱得多。Context在页面级不必要页面内部用useReducer加useMemo把回调函数传下去就行。如果以后预设功能要跨页面共享比如从战绩页跳到预设页再把reducer提升到全局也不迟。3.3 交互细节禁用态、复位和错误提示符文选择的规则里有几个容易忽略的坑主系选定了基石符文后基石孔位要锁定不能再点换其他基石防止误触副系不能和主系同系别碎片任意位置都可以重选。这些规则在UI层要明确反馈不能只是点击无效没提示。我做了三件事禁用项用半透明样式展示点击时触发轻微抖动动画同系别切换时提示用户“切换主系将清空已选符文”需要二次确认每次选择后自动把RunePage对象序列化到reducer里方便“保存预设”按钮直接拿数据。4. 调起OpenHarmony原生能力拨号、图片裁剪和FTP更新英雄联盟助手里有几个功能绕不开原生能力一键拨号给开黑队友、保存符文配置为图片并裁剪裁剪、从服务器拉取最新符文库数据。RN的生态里有老牌库但在OpenHarmony上能不能跑、怎么跑需要仔细验证。4.1 拨号功能从RN通路到OpenHarmony的Intent开黑页需要点击队友手机号直接跳转拨号。RN中常规做法是使用Linking.openURL(tel:10086)但OpenHarmony并不支持这种URL scheme直接拉起拨号盘。我找到的办法是写一个原生桥接模块在ArkTS侧调用call.makeCall// OpenHarmony原生侧ArkTS import { call } from kit.TelephonyKit; import { BusinessError } from kit.BasicServicesKit; export function dialNumber(number: string): void { const callParams: call.CallParams { number: number }; call.makeCall(callParams) .then(() console.log(call success)) .catch((err: BusinessError) console.error(call err: ${JSON.stringify(err)})); }RN侧通过TurboModule注册这个原生函数JS里直接调用。这里有个权限坑需要在module.json5里申请ohos.permission.CALL_TELEPHONY权限等级为system_basic普通应用默认拿不到所以我这边最终改成了用ACTION_DIAL拉起拨号盘而不是直接拨出避开高危权限。就想提醒一句App Store审核还有权限隐私要求直接拨号权限很容易被拒展示拨号界面反而安全。4.2 图片裁剪社区库在OpenHarmony上的适配套路保存符文配置时用户希望生成一张符文页截图分享出去。RN社区常用库是react-native-image-crop-picker它从相册选图并裁剪。但OpenHarmony上这个库并没有原生实现需要自己桥接系统裁剪能力。我走的是另一条路用RN自带的CameraRoll能力拿到图片临时路径再调用OpenHarmony的ImageKit做裁剪。在ArkTS侧裁剪接口是Image.createImageSource和PixelMap.save最终把裁剪后的图存入应用沙箱再把路径回传RN。实际遇到过一个问题从社区拿到的相机拍照返回的图片过大3000x4000直接送去裁剪内存暴涨。我的处理办法是先压缩到目标尺寸1100x1400再裁OpenHarmony的ImageKit自带decodeToPixelMap和缩放参数比RN端压缩效果更稳定。底层用高压缩率JPEG格式内存能省60%左右。4.3 FTP服务器更新符文库RN网络层直接拉还是走原生我们有一批历史符文预设数据放在内网FTP服务器上客户端需要按版本下载最新的rune_meta.json。最开始我想直接写一个ArkTS的FTP下载原生模块后来发现RN的fetch不支持FTP协议只能走原生。这个原生模块的处理思路是NK用kit.NetworkKit里的TCP Socket库按照FTP协议格式发送命令先USER/PASS认证再CWD切换目录接着TYPE I设置二进制模式最后RETR下载文件。核心代码逻辑不多但要重点关注被动模式下数据连接的建立否则会有连接超时的坑。另外提一句安全OpenHarmony默认不允许明文FTP连接。需要在网络配置里打开/data/app/el2/100/base/com.example.app/ohos_config.json的“允许明文流量”开关否则会被系统直接拦掉。5. 新老架构切换我在符文预设模块踩过的三个坑RN for OpenHarmony社区同时维护新架构和旧架构这让我踩了整整一个下午的坑。表面上看都是同一个RN版本切个开关底层桥接方式完全不同。这个小节只聊最影响进度的三个问题。5.1 问题一旧架构AsyncStorage能跑新架构下读写直接崩溃我们的存储逻辑里用到了AsyncStorage。在旧架构下桥接正常切到新架构后启动就崩报错信息是找不到RNAsyncStorageModule。翻了一遍源码发现新架构的TurboModule需要注册到RNOHCorePackage但社区适配版本对AsyncStorage的TurboModule实现还不成熟默认还是走旧桥。解决办法也很直接在MainAbility初始化里面手动注册一个兼容层模块把AsyncStorage的调用转发到旧通道。或者干脆用react-native-shared-preferences这种更底层封装新老架构都支持。最后我给团队定的方案是项目暂时锁定旧架构所有原生模块都按旧桥方式写等社区把TurboModule补齐了再统一升级。5.2 问题二自定义裁剪组件的生命周期混乱图片裁剪那个原生模块我在新架构下调通了一次结果旋转屏幕后再裁剪偶发闪退。定位到原因新架构下RN的组件实例和原生组件绑定关系重构屏幕旋转触发Activity重建RN的虚树重建顺序跟不上原生ViewGroup的销毁导致PixelMap对象被提前回收。这个问题的排查链路很典型先是崩溃日志里看到NativePointer访问越界再往底层查是PixelMap的引用计数问题。最后我放弃了在新架构下保有原生裁剪View改成纯ArkTS页面用uiAbility的startAbilityForResult拉起系统裁剪页把裁剪结果回传RN。这样虽然多一次页面跳转但规避了生命周期的坑稳定性大大提升。5.3 问题三FTP下载模块在新架构下的线程卡死FTP原生模块使用TaskPool执行下载下载完成回调给JS侧。新架构下RN的JS引擎跑在独立线程回调必须通过emit到JS执行上下文但我在原生侧忘了切线程直接在主线程里调用了RN的ReactInstanceManager接口导致死锁。排查的时候抓mprofile发现主线程一直在等锁TaskPool线程又卡在RN接口上。改法是把数据下载完成后先放到一个阻塞队列再通过HandlerPoster到JS线程去消费。这个坑提醒我新架构虽然性能好但多线程模型更严格任何原生调JS的操作都必须确保线程模型对。6. 性能调优与真机发布心得符文预设页在真机OpenHarmony 4.0RK3568板卡带屏上首帧渲染要600ms滑动却不流畅。PNG图标加载是主要瓶颈一屏十几个符文图标每个都是200x200的PNG解码压力大。我这边能做的优化方案是用FlatList的getItemLayout锁定行高避免测量抖动图标统一走react-native-fast-image社区有OpenHarmony适配版本磁盘缓存打开次级里面做一次按系别分组索引只渲染当前展开系的符文列表状态更新时用memo包裹选中项图标组件减少无效重渲染。实测下来主线程UI占用从45%降到15%滑动基本不掉帧。包体积也值得优化。RN for OpenHarmony的产物包含多个架构的so文件默认会连带arm64-v8a、armeabi-v7a、x86_64。在发布OpenHarmony应用包HAP时只要保留arm64-v8a就够了其他删掉能减小超过60MB。真机调试时还有个小技巧OpenHarmony设备上无法像Android那样直接adb reverse需要手动打开开发者模式然后在DevEco Studio的“Device Manager”里添加远程设备。RN的Metro服务地址不能用localhost要写开发机的局域网IP并在项目的聚合变体配置里把debug dev server host指过去。测试环境里我还发现OpenHarmony的悬浮窗权限和Toast在RN层存在兼容性问题弹出一次性提示最好用原生Dialog模块封装。尤其符文预设保存成功后的反馈如果用RN的ToastAndroid在部分设备上不显示改成了OpenHarmony的promptAction.showToast桥接后稳定多了。个人建议如果你只在Android端做过RN开发转型到OpenHarmony时要提前想好原生桥接层的设计。尽量把所有系统能力调用都收敛到一个HarmonyBridge.js模块里给上层暴露Promise接口。这样后续升级新架构只需替换桥层内部实现业务代码不用动。我们之后把拨号、裁剪、FTP下载全部收进来心里踏实很多。最后分享一个跟业务强相关的小经验符文预设功能因为要匹配游戏版本更新频率很高。不要每次发版都走应用商店审核可以在后台放一个rune_meta.json的版本号客户端启动时走FTP或者HTTPS对比有更新就静默下载覆盖本地数据。这样就避免了“新符文已经上线玩家还在用旧数据”的问题。注意下载校验要做SHA256避免中间人篡改安全这块真不能省。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进