《狗狗之家》这个项目,本质上是个宠物内容社区,我负责的其中一个模块叫“品种测试”——通过一组交互式题目,帮用户找到最适合自己养的狗狗品种。这个功能本身不算复杂,但难就难在它跑在 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 Studio | 4.0 Release |
| OpenHarmony SDK | API 10 |
| React Native | 0.72.x |
| react-native-openharmony | 对应 0.72.x 的官方发布版本 |
| Node.js | 18 LTS |
| hvigor | 工程默认版本 |
不要小看这张表,任何一项不一致都可能让你卡在编译阶段。特别是 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 - 打开 App,RN 容器会加载 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 是犬种 ID,value 是这道题选这个选项时给该犬种的加分。这样做的好处是,新增一道题只需要在题库 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: Record<string, BreedMeta> ): QuizScoreItem[] { const scoreMap: Record<string, { 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 的LinkingAPI:
import { 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 上加新功能,我心里就比较有底了。