React Native for OpenHarmony实战:狗狗品种测试模块开发全记录
《狗狗之家》这个项目本质上是个宠物内容社区我负责的其中一个模块叫“品种测试”——通过一组交互式题目帮用户找到最适合自己养的狗狗品种。这个功能本身不算复杂但难就难在它跑在 React Native for OpenHarmony 这条新出的技术链路上。当时团队里几乎没人接触过 React Native 在开源鸿蒙系统上的移植方案官方资料少社区踩坑贴也不多我们完全是靠试错一点点趟过来的。这篇文章不打算讲太多概念重点是把“品种测试”从设计到落地、再到 OpenHarmony 适配和联调避坑这条路径完整复盘一遍。无论你是刚接触 RN for OpenHarmony 的新手还是准备把一个存量 RN 应用迁到鸿蒙生态的开发者这篇都能提供一套可以照着走的参考方案。1. 品种测试模块前期设计先搞清楚“测什么”和“怎么测”1.1 模块定位与需求拆解狗狗之家 App 里内容很多犬种百科、喂养指南、周边商城都有。品种测试这个模块位置比较轻入口在首页顶部的一个 banner 位用户点进来做五六道题最后输出三个推荐犬种。产品对它的定位是“拉新钩子 内容分发入口”所以结果页必须能跳转到对应的犬种百科页给用户继续逛下去的理由。需求拆完之后技术上其实只有四块题库数据题目、选项、选项对应的犬种倾向权重答题交互页面流转、选中态、进度条、结果过渡动画匹配算法根据选项权重计算得分排序出推荐犬种结果展示与跳转推荐卡片、匹配度、详情跳转、收藏这四块里最核心的是匹配算法。它必须是一个纯 TypeScript 模块不依赖任何 RN 或 OpenHarmony 原生 API这样既能单测也能在不同端上保持一致行为。界面和交互在 RN 层做权重数据和计算逻辑完全和 UI 解耦是这次设计里我做的最早、也是收益最大的一个决定。1.2 测试流程与交互设计题目一共设计了 8 道每道都是单选题覆盖几个维度居住环境公寓、带院子、租房每天能陪伴狗的时间运动量接受程度家里是否有幼童或老人能否接受掉毛养狗预算养狗经验对狗性格的偏好安静、粘人、独立每道题选项固定三个到四个。我给每种犬种在每条选项上都打了权重分分数区间 0~3。比如“每天运动时间”这道题选项“每天能遛两次以上”给边牧记 3 分、柯基记 2 分选“工作太忙没太多时间”给巴哥记 3 分。这样每一道题都会对最终结果产生影响比“根据选项直接映射品种”要更自然也不容易被用户摸到规律。交互上面我刻意把答题页做成了单页切换而不是多页面 push。因为采用单页内滑切题手感更接近现在主流答题 App动画过渡也更容易控制。结果页出现前加了一个大概 800ms 的“计算中”动画这个动画纯本地执行不会真的发请求只是给用户一个“算法在工作”的感知。1.3 结果匹配的大致思路结果匹配不是简单取最高分。我加了三层逻辑硬性条件过滤不符合条件的品种直接剔除。比如城市禁养的大型犬无论得分多高都不能出现在结果列表里。总分排序将所有剩余品种按累计得分降序排列取前三名。匹配度归一化最高分作为满匹配即 100%其他品种得分除以最高分得到相对匹配度。这里有个细节总分最高的那个品种不一定就是“最适合”的。因为有些犬种在各道题上都很通用比如中华田园犬可能在每个选项上都有 1~2 分总分很高但它其实缺乏个性特征。所以我额外加了一个“典型特征加分”机制某些犬种只在特定选项上拿高分、其他项得分很平均匹配结果反而更有辨识度。这个逻辑在后面算法部分详细讲。2. 基于 RN for OpenHarmony 的工程搭建2.1 环境准备与版本匹配RN 在 OpenHarmony 上的官方移植项目叫react-native-openharmony社区一般简称 RNOH。目前生态还比较早期版本匹配非常重要不是随便装一个 React Native 版本就能跑的。我们当时踩过最大的坑就是用了 RN 0.72 的新项目模板结果 RNOH 运行时还不完全支持编译报错报得人头皮发麻。经过反复测试我们最终锁定的版本大概是这样的依赖版本DevEco Studio4.0 ReleaseOpenHarmony SDKAPI 10React Native0.72.xreact-native-openharmony对应 0.72.x 的官方发布版本Node.js18 LTShvigor工程默认版本不要小看这张表任何一项不一致都可能让你卡在编译阶段。特别是 SDK 版本太高、DevEco 版本太低组合时连工程向导都过不去。DevEco Studio 装好后先创建一个标准的 OpenHarmony 应用工程这个工程就是容器壳工程RN 页面最终会作为一个 Ability 或 Fragment 挂进去。我们不直接在这个工程里写业务代码真正的业务代码全部放在 RN 侧的 JS 工程里。2.2 创建容器工程与接入 RN容器工程建好后要做三件事在 oh-package.json5 里声明 RNOH 依赖RN 运行时对鸿蒙的适配包以 HAR 方式集成需要通过 ohpm 安装。这个操作就相当于 Android 工程里通过 Gradle 引进 androidx 一样不装的话工程无法编译。在工程的 module 里注册 RN 实例RNOH 提供了一些 ArkTS 侧的 API用来加载 JS bundle、创建组件树。我们要在entry/src/main/ets/pages/Index.ets里创建一个RNInstaller或使用官方库封装的容器组件把 RN 应用挂载到 ArkTS 页面里。这个操作本质上是在原生侧“开一个洞”让 RN 的 JS 代码能渲染原生组件。配置 Metro 打包器RN 开发离不开 Metro。RNOH 官方仓库提供了一套 metro 配置预设需要在项目根目录的metro.config.js里显式引用。const { mergeConfig } require(react-native/metro-config); const { getDefaultConfig } require(react-native-oh/metro-config); const config { transformer: { getTransformOptions: async () ({ transform: { experimentalImportSupport: false, inlineRequires: true, }, }), }, }; module.exports mergeConfig(getDefaultConfig(__dirname), config);我们这边一开始没引入react-native-oh/metro-config结果 Metro 启动后能正常编译但真机加载 bundle 时大量模块解析失败报错说找不到react-native-openharmony的索引模块。这个问题排了两天才定位到其实就是少了这个预设。2.3 开发调试链路RNOH 的调试链路跟普通 RN 非常像唯一的区别是设备连接工具从 adb 换成了 hdc。流程大概是这样的用 DevEco Studio 把 hap 包安装到模拟器或真机上在项目目录跑npm start启动 Metro通过 hdc 做端口转发让设备能访问到开发机的 Metro 端口hdc fport tcp:8081 tcp:8081打开 AppRN 容器会加载 Metro 提供的 bundle如果是 release 包则需要把 bundle 打包进 hap 里不能依赖 Metro 热更新。开发阶段我们通常用 debug 包改完 JS 代码后 Metro 会增量编译在设备上快捷键触发 reload 即可。这里要特别提醒DevMenu 在 OpenHarmony 上的唤起方式跟 Android 不一样没有摇一摇这种重力感应方案。我们的做法是在 ArkTS 侧的页面里加了一个隐藏的调试按钮点击后调用 RNOH 的 dev menu 接口打开 reload 面板。这个按钮只在 debug 构建里生效release 包会自动移除。3. 品种测试核心功能实现3.1 题库数据模型题库数据我选择放在本地 TS 文件里而不是直接放到服务端。原因很简单首发版本题目相对稳定本地化可以减少一次网络请求实现起来也更快。后续如果要动态更新扩展一个远程拉取配置的接口就行。数据结构设计如下export interface WeightMap { [breedId: string]: number; } export interface QuizOption { label: string; weight: WeightMap; } export interface QuizQuestion { id: string; title: string; options: QuizOption[]; } export interface BreedMeta { id: string; name: string; enName: string; avatar: string; size: 小型 | 中型 | 大型; temperament: string[]; exerciseLevel: 1 | 2 | 3 | 4 | 5; groomingLevel: 1 | 2 | 3 | 4 | 5; trainability: 1 | 2 | 3 | 4 | 5; bannedInCity: boolean; description: string; }每个QuizOption里带一个weight映射表key 是犬种 IDvalue 是这道题选这个选项时给该犬种的加分。这样做的好处是新增一道题只需要在题库 JSON 里加一段数据算法层完全不用动。3.2 答题页交互实现答题页的核心交互是显示当前题号和进度条、展示题目和选项、点击选项后高亮选中态并自动延迟跳转到下一题、最后一题答完后进入计算动画。进度条我用了简单的Animated.View宽度动画const progress useRef(new Animated.Value(0)).current; const goNext () { Animated.timing(progress, { toValue: (currentIndex 1) / totalQuestions, duration: 300, useNativeDriver: false, }).start(); };这里有个性能上的注意点useNativeDriver: false是我在 OpenHarmony 上实测后改的。RNOH 对useNativeDriver: true的支持还不够完善某些时候 View 宽度动画会不刷新哪怕在 JS 层已经改了值界面上纹丝不动。改成 false 后由 JS 驱动每一帧的样式更新虽然性能不如原生驱动但在这样一个小页面上完全够用。选项点击的反馈我只用了透明度变化和边距微调没有用 Android 上常见的 ripple 水波纹效果因为 RNOH 并不支持android_ripple属性。这是移植中的一个小妥协但不影响整体体验。3.3 计分匹配逻辑计分算法是整个模块的灵魂。我单独抽了一个src/domain/quizEvaluator.ts文件不依赖任何 RN API方便用 Jest 跑单测。算法核心代码大概是这样的export interface QuizScoreItem { breedId: string; totalScore: number; typicalScore: number; matchRate: number; } export function evaluateQuiz( questions: QuizQuestion[], answers: number[], breeds: Recordstring, BreedMeta ): QuizScoreItem[] { const scoreMap: Recordstring, { total: number; typical: number } {}; questions.forEach((q, qIndex) { const option q.options[answers[qIndex]]; if (!option) return; Object.entries(option.weight).forEach(([breedId, score]) { if (!scoreMap[breedId]) { scoreMap[breedId] { total: 0, typical: 0 }; } scoreMap[breedId].total score; if (score 3) { scoreMap[breedId].typical 1; } }); }); const filteredBreeds Object.keys(scoreMap).filter((breedId) { const meta breeds[breedId]; return meta !meta.bannedInCity; }); let maxScore 0; const rawList filteredBreeds.map((breedId) { const { total, typical } scoreMap[breedId]; if (total maxScore) maxScore total; return { breedId, total, typical }; }); rawList.sort((a, b) { if (b.total ! a.total) return b.total - a.total; return b.typical - a.typical; }); return rawList.slice(0, 3).map((item) ({ breedId: item.breedId, totalScore: item.total, typicalScore: item.typical, matchRate: maxScore 0 ? Math.round((item.total / maxScore) * 100) : 0, })); }这里typicalScore就是我前面提到的“典型特征分”。一个品种如果在很多题目里都拿到中低分它的total可能很高但typical很低另一个品种在少数几个选项上拿到 3 分说明用户在这些关键特征上和它高度契合。排序时先比总分总分相同再比典型分这样既保证了普适性又兼顾了个性化。匹配度不做归一化处理而是直接以最高分作为 100%其余品种按比例折算。比如第一名 18 分第二名 15 分那第二名的匹配度就是 83%。用户看到的结果更直观也避免了“所有品种匹配度都很低”的尴尬场景。3.4 结果页与后续动作结果页主要展示三张卡片第一张最大最显眼是匹配度最高的品种后面两张略小。每张卡片上包含品种图片、中文名、英文名、匹配度百分比以及体型、运动量、美容难度三个维度的评分条。这三个评分条我用的是自定义组件不是第三方库。理由有两个一是第三方评分条组件大多没验证过 RNOH 兼容性引入之后可能又要踩坑二是个性化 UI 自己画反而更可控。评分条的核心就是用五个圆点表示等级颜色深浅表示程度高低代码量不大维护成本极低。卡片底部放两个操作按钮查看详情和联系送养人。查看详情会 push 到狗狗之家 App 的犬种百科原生页面跨端通信通过事件总线完成。联系送养人则使用Linking.openURL(tel:10086)调起电话能力这里就涉及到原生系统能力调用了后面适配章节再细说。结果页还有一个“重新测试”的入口这个按钮的作用是重置答题状态并回到第一题。为了方便用户对比我额外做了一个滑动切换不同推荐品种的小功能类似轮播图用户左右滑动就能同时比较三只狗的差异。3.5 历史记录本地存储用户测完一次之后结果需要保存在本地方便用户下次进来直接查看历史记录。RN 社区最常用的方案是 AsyncStorage。在 OpenHarmony 上AsyncStorage 不是开箱即用的需要额外集成原生实现。RNOH 官方仓库里的 AsyncStorage 兼容实现对应react-native-async-storage/async-storage的 API但只能在原生侧提供 HAR 包之后才能正常调用。我们集成时遇到的问题是JS 端已经正常import AsyncStorage from react-native-async-storage/async-storage运行时却报“Native module cannot be null”这就是典型的原生模块没挂上。排查方式是先确认oh-package.json5里有没有引入对应的 HAR 依赖然后确认原生工程里是否注册了安装器。这两个环节缺一个都不行。我们最后在 ArkTS 侧手动初始化模块列表时才彻底解决。存储的数据结构很简单interface QuizHistory { id: string; createdAt: number; result: QuizScoreItem[]; }写入时用AsyncStorage.setItem读取用getItem历史记录页就是渲染一个列表。这里没什么黑科技但要注意序列化和反序列化的健壮性老版本的数据字段可能在升级后缺失所以读出来之后一定要做一次数据校验字段缺失就丢弃。4. 移植到 OpenHarmony 的适配实测4.1 组件与样式兼容差异RNOH 虽然目标是“React Native 的 OpenHarmony 实现”但它目前还没有做到 100% 的组件覆盖。我们实践中遇到的组件兼容问题集中在这几个方面Pressable 的 ripple 效果失效Android 上android_ripple完全不起作用OpenHarmony 上也不支持。建议用style配合opacity做按下反馈。Modal 组件不稳定在某些 API 版本上Modal 弹层会出现背景不透明或关闭动画卡住的问题。我们改为使用自绘的全屏半透明遮罩来实现弹窗效果。KeyboardAvoidingView 行为异常当页面中有 TextInput 时键盘弹出后视图避让效果不如 Android 稳定。我们品种测试模块恰好没有输入场景这块没多折腾但如果你有搜索输入框一定要在真机上验证。SafeAreaView 支持有限在刘海屏设备上顶部安全区可能计算不准。我们最后统一用StatusBar.currentHeight作为顶部偏移没有用 SafeAreaView。以上差异不致命但都是真机跑起来才能发现的问题看文档看不到。没条件真机测试的话至少要把这些点写进测试用例里。4.2 图片资源与网络权限RN 开发中通过require(../assets/dog.png)引用图片是很常见的操作。在 RNOH 上这种做法不是简单地“把图片放在 JS 目录里”就行。我们的做法是在 ArkTS 容器工程的src/main/resources/base/media目录下放入所有需要使用的静态图片RNOH 会为 JS 侧的资源引用生成一个映射关系如果图片没有被打进 hap 包运行时就会出现图片空白而且控制台不一定报错。排查这个问题特别浪费时间因为你看到的是“View 有布局空间但图片不渲染”。后来我们把所有图片都改为网络图统一走 CDN才彻底绕开这个问题。网络图模式也更接近真实业务毕竟犬种头像和百科详情图本身就应该由服务端下发。网络请求相关的坑也很典型。OpenHarmony 应用默认没有网络访问权限必须在module.json5文件里显式声明{ module: { requestPermissions: [ { name: ohos.permission.INTERNET, }, ], }, }不声明这个权限所有 fetch 请求都会静默失败而且 RN 层抛出来的错误信息非常隐晦只显示“Network request failed”。一开始我们甚至怀疑是fetchpolyfill 的问题后来才发现纯粹是权限没声明。另外还要注意如果请求的是 http 明文地址需要在网络安全配置文件里放行域名。OpenHarmony 在这块跟 Android 类似用 https 就一劳永逸。4.3 动画性能调优品种测试里用到的动画不少进度条平移、题目切换滑入、选项高亮、结果卡片弹入。开始我全部使用了AnimatedAPI在模拟器上表现还行但低端真机上能感觉到掉帧特别是结果卡片同时做了三张卡片的 scale 和 opacity 动画时。经过排查主要问题在于同时启动多个Animated动画实例时JS 线程的计算量暴涨。RNOH 的架构里JS 线程和 UI 线程的通信效率还比不上 RN 在 Android 上的成熟度所以更要注意控制动画数量。最终优化方案多个动画合并成一个Animated.parallel减少帧回调次数能用 transform 完成的动画不要用 width/height因为 transform 不会触发布局计算对结果页卡片的入场动画做了错峰处理三张卡片依次延迟 100ms 弹入而不是同时弹出关键动画关闭useNativeDriver避免原生驱动时的兼容问题最终效果在真机上基本流畅虽然没有 iOS 那种顺滑感但作为工具型页面已经合格了。4.4 系统能力调用拨打电话与返回手势前面提到结果页有“联系送养人”按钮实现时直接使用 React Native 的LinkingAPIimport { Linking } from react-native; const call (phone: string) { Linking.openURL(tel:${phone}).catch((err) { console.warn(can not open dialer, err); }); };RNOH 对Linking的支持还算完整openURL能正常唤起系统的拨号能力。这个功能在开发机上没有实际 SIM 卡所以验证方式只能看到拨号界面是否弹起。另一个容易被忽略的能力是返回手势。Android 的硬件返回键在鸿蒙设备上对应的是侧滑返回手势。RNOH 对BackHandler事件做了适配但在答题页这种“单页切换”的设计里需要特别处理返回时的状态恢复。我们的做法是在答题页监听BackHandler如果当前题号不是第一题就阻止默认返回行为改为回退到上一题如果是第一题才允许退出页面。useEffect(() { const sub BackHandler.addEventListener(hardwareBackPress, () { if (currentIndex 0) { goPrev(); return true; } return false; }); return () sub.remove(); }, [currentIndex]);这个交互细节让产品在鸿蒙上的体验更像原生应用不然用户侧滑一下就直接退出答题页数据全丢体验非常差。5. 测试与问题排查5.1 单测保障先给算法上一道锁匹配算法是整个模块里业务逻辑最重的部分我第一时间用 Jest 给它写了一圈单测。核心覆盖三个场景基本匹配构造一份固定答案断言返回的品种顺序和匹配度是否符合预期。禁养过滤某项答案让某个烈性犬得最高分但该犬种打了bannedInCity标记断言结果列表里绝不出它。平分场景两个品种总分一致断言typicalScore更高的品种排在前面。单测的价值在后续调优中体现得特别明显。有一次我调整了选项权重跑了一遍测试立刻发现一个品种的分组排序出了问题如果没有这层保障这种逻辑回归只能靠手工测试碰运气效率太低了。5.2 真机手工测试 Checklist单测跑的是纯逻辑但页面交互、动画和系统能力只能靠真机验证。我整理了一份手工测试清单每次发版前照着过一遍正常答题流程从首页入口进入答题页答完 8 题结果页正确展示 3 个推荐品种中途退出答到第 5 题时侧滑返回确认回到首页再进入答题页时状态重置快速点击选项连击选项确认不会出现重复跳题或卡死结果页滑动三张结果卡片左右切换流畅无白屏按钮跳转点击“查看详情”能正常跳转到犬种百科页“联系送养人”能唤起拨号界面断网环境无法加载网络图时页面有占位图不出现崩溃深色模式UI 颜色在深色模式下可读不至于出现黑色文字配黑色背景的惨剧低端机型在配置较低的测试机上过一遍整体流程重点看结果页动画的流畅度这些用例不一定全但覆盖了核心链路。因为手工测试耗时我还把纯逻辑部分都抽到了 Jest 单测里尽量减少手工回归的量。5.3 常见问题速查表我把在开发过程中遇到的高频问题整理成了下面这个表格如果你的项目也跑在 RNOH 上建议直接收藏问题现象可能原因解决办法编译失败提示找不到 RNOH 依赖oh_modules 未同步或 HAR 未安装在 oh-package.json5 里添加依赖后执行 ohpm install页面白屏Metro 未启动、端口未转发或 bundle 加载失败检查 Metro 终端日志确认 hdc fport 已执行图片不显示静态资源未打包进 hap将图片放入容器工程 media 目录或改用网络图网络请求失败未声明 INTERNET 权限在 module.json5 的 requestPermissions 里添加动画不动useNativeDriver 在某些场景下不兼容改为 useNativeDriver: false点击无反应组件点击事件在自适应/平板模式下有差异用 Pressable 替代 Touchable并检查命中区域DevMenu 打不开构建类型为 release使用 debug 构建并保留隐藏调试入口键盘弹出遮挡输入框KeyboardAvoidingView 兼容性差手动监听键盘高度并调整布局这个表不是一次性整理完的而是边开发边往里追加。很多问题在当时解决了就忘了过两周同事遇到同样的问题又来问我表格化的记录能省下大量重复答疑时间。5.4 体验与调优记录在设备上整体跑通之后我做了一轮针对“首屏渲染速度”的优化。品种测试模块因为集成在 App 内部首屏加载依赖 bundle 解析速度所以我没有用复杂的路由懒加载而是把题库数据和匹配算法直接随页面一起打包。页面代码体积大约增加了 120KB但换来的是用户点击入口后几乎瞬间看到第一道题体感非常好。另一个体验优化是“计算中”动画。原本我预留了 800ms 的假加载动画后来实测发现算法执行实际上不到 50ms于是把动画时间缩短到 400ms既留出了品牌感知的时间又不至于让用户等待太久。这种微小的体验调优普通文档里不会写只能靠真机试出来。最后分享几个实际操作中的心得如果让我重新做一遍这个模块我会在一开始就先把容器工程和 RN 侧的版本锁定好不要用最新要用官方仓库验证过的组合。RNOH 生态还在快速迭代网上很多教程是基于不同版本的千万别直接抄照着抄完大概率编译不过。另外静态资源的处理策略一定要早点定。我们一开始图省事直接把图片塞在 RN 工程里后来踩了资源打包的坑才统一改到 CDN来回改了两天。如果你现在准备做 RNOH 项目我建议业务图片全部走网络本地只放 App 启动必须的资源这样能少走一大段弯路。最后就是算法逻辑和 UI 解耦这件事。刚开始做的时候我也觉得没必要一个问卷测试的算法能有多复杂但后来调权重、加过滤条件、适配新犬种每次都只改 TS 模块Jest 测试一把过UI 完全不受影响这个设计带来的收益就体现出来了。把纯逻辑隔离出来不仅是为了测试更是为了未来业务的快速迭代。品种测试这个模块只是狗狗之家 App 里很小的一块但它完整地跑通了“RN 业务代码 OpenHarmony 原生能力 系统权限 交互适配”这条链路。后续如果再往 RNOH 上加新功能我心里就比较有底了。