这两年我一直有意把一些跨平台项目往 OpenHarmony 上搬,刚好遇到一个适合练手的场景:数学练习。大概背景是,我手上有一份用 React Native 写的口算练习 Demo,之前在 iOS 和 Android 上都跑得很顺,后来一次技术交流中看到 React Native for OpenHarmony 的适配层已经能支撑常见业务组件,就决定拿它做一次完整迁移实战。这篇文章把整个过程拆开讲:从工程初始化、功能实现到真机调试,覆盖我自己踩过,也估计你们大概率会踩的坑。适合三类人看:已经在 RN 上写过业务、想了解它能不能跑 OpenHarmony 的开发者;刚接触 OpenHarmony、需要快速上手跨平台方案的开发者;以及单纯想找一个工具型 App 完整落地案例的学生。
1. 项目定位与整体拆解
1.1 数学练习这个场景为什么适合做实验田
先说业务背景。我要做的不是那种复杂题库系统,而是面向小学低年级的口算训练工具:随机出加减乘除题,孩子输入答案,系统判断对错,一轮做完给正确率和用时,错题自动进错题本。这类应用有个特点,业务逻辑几乎都集中在计算和状态流转上,UI 以文本、按钮、输入框为主,不需要地图、相机、高性能渲染这些重度原生能力。换句话说,它是典型的“逻辑驱动型”应用,非常适合验证一个跨平台框架在一个新系统上的成熟度。
如果把应用换成视频剪辑或者带大量自定义画布的工具,那 RN 适配层的压力会大很多,很多原生能力可能还没铺齐,容易把框架短板全部暴露出来。数学练习不一样,它需要的组件无非是 View、Text、TextInput、FlatList、TouchableOpacity,适配层只要把这些映射稳了,开发体验和运行效果就不会差。所以我说它是 OpenHarmony 上做 RN 迁移的理想“小白鼠”项目。
还有一个隐性好处:教育类工具对 UI 动态反馈要求高,比如答对时背景闪绿、答错时抖动,这些动画用 RN 的 Animated 或 LayoutAnimation 就能处理,不需要深入 ArkUI 写自定义动画。这样我可以把精力放在验证跨平台逻辑上,而不是和系统 UI 框架死磕。
1.2 为什么不是直接用原生 ArkUI 开发
这个问题我在动手前问过自己。如果团队里已经有人熟悉 OpenHarmony 原生开发,直接上 ArkUI 肯定是最稳的,毕竟没有中间层损耗。但我这边的现实情况是:核心业务代码已经用 React Native 写好,团队对 RN 生态也更熟,ArkUI 虽然声明式写起来不复杂,但为了一个工具型应用重新维护一套原生代码,成本并不划算。
更重要的是,React Native for OpenHarmony 的适配方式不是套个 WebView 的壳,而是把 RN 的 JS 逻辑跑在系统自带的 JS 引擎上,再通过适配层把虚拟节点映射成 OpenHarmony 的原生组件。换句话说,业务层写的是 TypeScript / JavaScript,渲染层走的是原生组件,不是 HTML 页面。这保证了性能下限和原生交互体验,和传统的 Hybrid 方案有本质区别。
当然,跨平台不是免费的。适配层目前还没有覆盖 iOS / Android 上的全部 API,尤其是一些偏门原生模块,可能需要我写自定义原生桥接。而数学练习这个项目基本不碰这些偏门能力,所以我只需要关注 RN 标准组件在 OpenHarmony 上的行为差异,成本可控。
1.3 功能范围与版本规划
我给自己定的第一个版本功能清单如下:
| 模块 | 功能点 | 优先级 |
|---|---|---|
| 出题引擎 | 随机生成加减乘除题,支持难度分级 | P0 |
| 答题交互 | 输入答案、提交、即时反馈 | P0 |
| 练习会话 | 每轮 10 题,计时,完成后统计正确率 | P0 |
| 错题本 | 自动记录错题,支持重新练习 | P1 |
| 历史成绩 | 本地保存最近 20 条练习记录 | P1 |
| 设置页 | 选择题型范围、题目数量、是否计时 | P2 |
我不建议第一个版本就把家长端、老师端、云端同步全做进去,那会冲淡“验证 RN on OpenHarmony”这个主目标。先把单机版跑通,再把 P1、P2 功能加上,整个项目节奏会舒服很多。
2. 环境准备与工程骨架搭建
2.1 工具链准备与版本搭配
OpenHarmony 应用开发绕不开 DevEco Studio,这是官方 IDE,工程构建、签名、真机安装都靠它。RN 侧则需要 Node.js 环境,以及社区提供的 OpenHarmony 适配脚手架和 npm 依赖包。动手之前,我建议先把以下工具装齐:
- Node.js,建议 LTS 版本,我这里用的是 18,后面跑 Metro 和打包都要靠它;
- DevEco Studio,用当前正式发布版本即可;
- OpenHarmony SDK,在 DevEco Studio 里通过 SDK Manager 下载;
- 一台 OpenHarmony 真机或模拟器,模拟器调试方便,但有些硬件能力测不了。
版本搭配是最容易被忽略的坑。RN 社区迭代快,适配层一般会跟着 RN 版本走,比如某个适配版本只支持某个 RN 大版本。我一开始图省事直接装最新版 RN,结果适配包还没跟上,最后只能降级到受支持的版本。建议先查一下适配包说明里写的“已支持 RN 版本范围”,再决定初始化用哪个模板,别盲目追新。
2.2 初始化工程:把 RN 工程和 OpenHarmony 工程串起来
这里有两种路径:一种是从零创建,用社区脚手架直接生成同时包含 RN 和 OpenHarmony 壳的工程;另一种是已有 RN 工程,再补一个 OpenHarmony 工程接入。我这次因为手头有旧 Demo,走的是第二种。
从零创建的大致命令是这样(以我用的脚手架版本为例,具体命令名以你拉到的版本为准):
npx @react-native-ohos/generator-cli init MathPracticeApp --template @react-native-ohos/template执行完会生成一个同时包含harmony目录和标准 RN 目录的工程。如果已有 RN 工程,需要手动在工程里加入 OpenHarmony 壳工程,并把node_modules/@react-native-ohos/...下对应的底层库引入壳工程。
初始化完成后的第一件事,不是急着写业务代码,而是先跑一个空模板,确认 “RN 的 JS 层打包出来的 bundle 能被 OpenHarmony 壳加载”。这个环节通了,后面所有开发才有意义。我当时第一次跑的时候卡在签名上,没签名装不到真机,模拟器又不稳定,折腾了小半天才把链路打通。
2.3 工程结构与目录说明
生成后的工程结构大致长这样:
MathPracticeApp ├── App.tsx # RN 业务入口 ├── harmony # OpenHarmony 壳工程 │ ├── entry │ │ ├── src/main/ets # 原生入口和桥接代码 │ │ └── src/main/resources # 应用图标、字符串等资源 │ ├── hvigorfile.ts # 构建脚本 │ └── oh-package.json5 # 原生依赖 ├── index.js ├── package.json └── tsconfig.json理解这个结构的关键是:RN 代码是业务主体,harmony目录只是一个壳,负责把应用跑在 OpenHarmony 系统上,并提供 JS 引擎和渲染桥接。日常开发绝大部分时间在App.tsx和 src 目录下的业务组件里度过,不太需要动原生壳。
有一点需要注意:不要随便改harmony目录下的构建文件名和包名配置。包名(bundleName)是 OpenHarmony 应用的唯一标识,牵扯到安装和更新,我见过有人改了 bundleName 后旧数据读不到,排查半天才发现是包名变了,AsyncStorage 的数据跟着失效。
3. 数学练习核心功能设计与实现
3.1 题目生成器:避开这几个数学坑
数学练习的灵魂是出题。看似简单,实际需要考虑三个原则:不能出现“不够减”的减法、除法必须整除、不同运算符难度要可控。如果忽略这些,二年级小朋友练到一半发现 3 - 8 = -5,或者 7 ÷ 2 = 3.5,产品就没法用了。
我实现了一个可配置的题目生成器,核心思路是先确定运算规则,再根据规则生成合法数字:
interface Problem { id: string; left: number; right: number; operator: '+' | '-' | '×' | '÷'; answer: number; expression: string; } function generateProblem( maxNumber: number, operator: '+' | '-' | '×' | '÷', ): Problem { const left = Math.floor(Math.random() * (maxNumber + 1)); const right = Math.floor(Math.random() * maxNumber) + 1; if (operator === '-') { // 保证结果不为负 const a = Math.max(left, right); const b = Math.min(left, right); return { id: `${Date.now()}-${Math.random()}`, left: a, right: b, operator, answer: a - b, expression: `${a} - ${b}`, }; } if (operator === '÷') { // 通过“被除数 = 除数 * 商”来构造整除题 const divisor = right; const quotient = Math.floor(Math.random() * (maxNumber / divisor)) + 1; const dividend = divisor * quotient; return { id: `${Date.now()}-${Math.random()}`, left: dividend, right: divisor, operator, answer: quotient, expression: `${dividend} ÷ ${divisor}`, }; } // 加法和乘法直接算即可 const answer = operator === '+' ? left + right : left * right; return { id: `${Date.now()}-${Math.random()}`, left, right, operator, answer, expression: `${left} ${operator} ${right}`, }; }减法的关键是用Math.max和Math.min把两个数对调,从根上避免负数。除法的关键是用divisor * quotient反推出被除数,而不是随机出两个数再用浮点数相除。顺便提一句:Math.random()的随机性足够出题用,不需要用加密级随机数,别过度设计。
出题引擎写完后,我建议花十分钟补几个边界测试:比如maxNumber=5时连续生成 200 题,确认没有结果超范围;除法题确认所有answer都是整数。这些测试虽然简单,但能拦住后面改代码时不小心的回归。
3.2 答题交互与校验逻辑
答题界面这边,最需要小心的是输入框和键盘。数学练习只需要数字输入,所以 TextInput 要设置keyboardType="numeric"。但注意,在 OpenHarmony 的原生键盘适配还不是很完美的情况下,numeric有时候会调起支持小数点的键盘,孩子误按小数点了就容易提交非法内容。
我的校验逻辑是:把用户输入先用Number(input)转换,再用Number.isNaN排除异常值,最后比较答案时用一个极小误差阈值。为什么不用parseInt?因为parseInt("12abc")会返回 12,把用户输入的脏数据当合法值处理了,不适合做严格校验。
function checkAnswer(problem: Problem, input: string): boolean { const value = Number(input); if (Number.isNaN(value)) { return false; } return Math.abs(value - problem.answer) < 1e-6; }提交后的反馈也要设计好。我这里做了三档:答对了按钮变成绿色,提示“正确”;答错了按钮抖一下,显示正确答案;空提交直接忽略,不判对也不判错。这里有个交互细节:空提交不该消耗一次答题机会,否则孩子误触就白白错了,会打击积极性。
3.3 练习轮次与难度配置
练习过程我定义成三个状态:ready(准备开始)、doing(答题中)、finished(本轮结束)。从doing到finished的切换条件是当前题号达到总题数,而不是用一个单独的“结束”按钮。这个设计更贴近实际使用:孩子把最后一道题做完,结果页直接出现,中间不需要额外操作。
难度配置放在设置页,影响的是题目生成器参数。比如:
| 难度 | 最大数字 | 可用运算符 |
|---|---|---|
| 入门 | 10 | +、- |
| 基础 | 20 | +、-、× |
| 进阶 | 100 | +、-、×、÷ |
配置信息会在每次生成题目时传入,但我建议在做题中途不改难度。最简单的方式是:每次点“开始”时读取一次配置,锁定到本轮上下文里。如果你用全局变量存难度,做题中途孩子去设置页切了难度,当前这轮题目就乱了。
4. 状态管理与数据持久化
4.1 练习会话的状态建模:useState 还不够
一开始我用三个useState分别存当前题、答对数、计时器,结果发现代码很乱:提交答案时需要同时更新题号、正确数、答案列表,三个 setState 经常漏一个。后来我把一轮练习的所有状态合并成一个对象,用useReducer管理,思路立刻清晰了。
type SessionState = { index: number; problems: Problem[]; answers: (string)[]; correctCount: number; startTime: number; elapsedSeconds: number; }; type Action = | { type: 'START'; problems: Problem[] } | { type: 'SUBMIT_ANSWER'; answer: string; isCorrect: boolean } | { type: 'FINISH' };SUBMIT_ANSWER时把index + 1、correctCount、answers一并更新,结果页只需要读取这一个 state 对象,UI 和逻辑完全对应。不要小看这一步,跨平台项目最怕的就是业务状态分散在多个useState里,最后调 UI 时互相打架。
4.2 AsyncStorage 存取练习记录
OpenHarmony 适配层提供了 AsyncStorage 的兼容实现,接口和 RN 端一致,因此业务代码不需要针对平台写差异。我用它存两样东西:历史成绩列表和错题记录。
存储层我做了一个很薄的封装,统一处理读取、写入、JSON 序列化:
import AsyncStorage from '@react-native-async-storage/async-storage'; const RECORD_KEY = 'math_practice_records'; export async function loadRecords(): Promise<PracticeRecord[]> { const raw = await AsyncStorage.getItem(RECORD_KEY); if (!raw) return []; try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch (e) { return []; } } export async function appendRecord(record: PracticeRecord): Promise<void> { const list = await loadRecords(); list.push(record); // 只保留最近 20 条,避免内容无限膨胀 const trimmed = list.slice(-20); await AsyncStorage.setItem(RECORD_KEY, JSON.stringify(trimmed)); }这里有个容易踩的坑:AsyncStorage 的读写是异步的,如果连续快速调用,可能出现后写覆盖先写的情况。我在提交本轮结果时只用appendRecord一次,不在循环里频繁调用。另外,JSON 解析失败要返回空数组,不能直接 throw,否则历史记录一坏,整个 App 启动就崩了。
4.3 错题本:不只是记一句“做错了”
错题本如果只存“3 + 5 做错了”,后续重练根本没有可用的上下文。要保证错题本有价值,每条记录必须包含:题目表达式、正确答案、用户当时的错误答案、出错时间。这样才能做到错题重练时按原题复现。
我的错题记录结构:
interface WrongRecord { id: string; expression: string; correctAnswer: number; wrongAnswer: string; timestamp: number; }错题重练和普通练习共用一个出题引擎,但参数不同:直接给定expression和答案,不再走随机生成器。这个复用比单独写一套“错题练习”逻辑省事得多,新功能也就一个页面的事。
有一点要特别提醒:错题本存储必须做去重。孩子同一道题错三次,不要存三条,而是更新原记录的timestamp和wrongAnswer。我判断重复的标准是expression + correctAnswer相同就合并。否则跑一个月后错题本会膨胀到没法看。
5. 原生能力衔接与性能优化
5.1 可以接入的原生能力与接入方式
数学练习不依赖摄像头、定位这类敏感权限,但还是有两个原生能力值得接入:震动和语音报题。答对时轻轻震一下,答错时长震动,这个反馈对低龄用户非常友好;语音报题则可以让孩子不用盯着屏幕读题,听题作答。
震动在 OpenHarmony 适配层里可以通过桥接调用原生模块实现,但 RN 原生模块在 OpenHarmony 上的注册方式和 Android 不完全一样,需要在harmony/entry/src/main/ets里写一个原生模块,再在 JS 侧调用。这部分代码量不大,我建议放到业务功能全部跑通之后再做,前期别让原生桥接干扰主流程。
老实说,如果你只做单机练习,不接语音和震动也完全够用。但如果你也想验证一下 RN 原生桥接在 OpenHarmony 上的完整链路,那值得试一次,毕竟这是跨平台方案绕不开的话题。
5.2 bundle 打包与首屏优化
RN 应用在 OpenHarmony 上有两种加载 bundle 的模式:开发模式连 Metro,运行时按需拉取;发布模式把 bundle 打包进 hap 包,安装后离线加载。首屏优化最关键的是把发布模式的预打包做对。
我的做法是先在工程里配置好assets/index.bundle的输出路径,然后在构建 hap 之前先执行打包脚本,让 JS bundle 进到entry/src/main/resources/rawfile下。这个过程有点绕,如果发现真机上长时间白屏,优先怀疑 bundle 文件有没有真的打进包里。
另一个优化点是拆分“首屏等待”。数学练习首页启动时需要读本地设置,如果每次都在页面上 await,用户会看到白屏。我改成先渲染默认设置,再异步加载本地配置并更新界面。虽然差了零点几秒,但启动感知差别明显。这套思路在 RN for OpenHarmony 上同样有效,因为它不涉及原生性能调优,而是从用户体验出发做取舍。
5.3 列表卡顿与渲染优化
历史成绩和错题本都用 FlatList 实现。FlatList 在 RN on OpenHarmony 上能用,但需要注意三件事:
第一,每条列表项的数据结构要尽量扁平,不要在 renderItem 里做复杂计算。比如历史成绩里的“正确率”和“时长”字段,我是在写入记录时就算好存下来,渲染时直接读字段,不对源数据做二次计算。
第二,必须设置稳定且唯一的keyExtractor。我用的是记录里的id字段,不用数组 index。原因很简单:列表项如果只是 index,插入或删除时会引发整页重渲染,OpenHarmony 上的表现会更敏感。
第三,避免在renderItem里使用内联箭头函数。写成renderRecord={this.renderRecord}而不是renderItem={() => <Item/>},这样列表复用节点时才能命中优化路径。这个点在 Android 上不明显,但我在 OpenHarmony 上测试时,内联函数和高频刷新同时出现,滚动确实会掉帧明显。
6. 真机调试与问题排查
6.1 从白屏到跑起来:调试基础操作
RN on OpenHarmony 的调试思路和 Android 类似,但也有自己的怪脾气。真机连接 DevEco Studio 后,先确认设备能安装 hap 包,再确认 Metro 服务是否正常启动。我碰到过的情况是:Metro 没开,应用装上了也白屏,日志里一堆“bundle load failed”。
开发模式下,手机和电脑必须在同一局域网,而且 Metro 的监听地址不要用localhost,否则真机访问不到。我直接在命令行里跑react-native start --host 0.0.0.0,这样手机可以通过电脑的局域网 IP 访问 Metro。
日志这块也有坑。OpenHarmony 的 hilog 和 RN 的 console 不一定完全打通,有时候业务代码报错了,DevEco 控制台看不到信息。我的做法是给关键流程加一份本地日志写入,比如记录“bundle 加载完成”“题目生成成功”“存储执行成功”这些打点,真机出问题时先看这些打点,再回头查 hilog。
6.2 常见问题速查表
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 真机安装失败 | 包签名不对 | 在 DevEco Studio 里重新配置签名,确认 bundleName 和证书匹配 |
| 白屏且无报错 | bundle 未打进 hap 或 Metro 未启动 | 检查 rawfile 下有没有 bundle 文件,确认 Metro 可用 |
| 中文输入法无法调起 | TextInput 的键盘类型与系统冲突 | 用keyboardType="numeric"并测试不同输入法版本 |
| 样式不一致 | 部分 RN 样式属性未完全映射 | 查看适配层支持的样式列表,用boxShadow等属性时预留降级方案 |
| AsyncStorage 读不到数据 | bundleName 或应用被卸载 | 确认包名未改变,卸载重装会清空本地数据 |
| 滚动列表卡顿 | renderItem 内联函数或 key 不稳定 | 设置稳定 key,抽离 renderItem 为独立方法 |
这张表不是从文档里抄的,都是我自己在真机上一个一个试出来的。尤其是样式不一致那条,boxShadow在 Android 上已经支持得不错,但 OpenHarmony 适配层早期版本并不支持,代码里不写降级,界面上就是干巴巴一个平面。我最后统一抽了个Card组件,内部做平台兼容判断,业务层不用关心细节。
6.3 我踩过的两个关键坑
第一个坑是数字键盘调起后,中文输入法还是能输入字母。孩子做题时切到手写输入法,TextInput 拿到的是“三”而不是“3”,Number("三")返回NaN,校验直接判错。这个问题的根子在键盘类型只影响“弹出的面板”,不影响输入法引擎。我最后的处理是:校验时只接受数字字符串,检测到非数字字符立即清空并提示“请输入数字”。这个方法看着暴力,但对低龄用户其实最有效,因为错误输入被即时纠正了。
第二个坑是提交按钮快速点击导致一次答题被记录两次。原因很简单,点击提交后 setState 还没触发重渲染,按钮一直处于可点击状态,第二次点击进来时当前题还是同一道,答案被重复记录。解决办法是在提交入口加一个短锁:提交后立刻把按钮disabled置为 true,同时用一个 ref 记录isSubmitting,直到当前题索引变化后再恢复。这个问题的排查过程也提醒我:RN on OpenHarmony 的重渲染时机和 Android / iOS 略有差异,不要完全依赖 UI 状态做并发控制。
最后再说一个个人心得:数字和引号之类的小问题,在 iOS / Android 上可能影响不大,但在新适配平台上都会更容易暴露。不要假设某个交互在当前平台表现正常,一定要在真机上按真实用户操作路径完整跑一遍,尤其是键盘、触摸、焦点这类和系统强相关的交互。数学练习这个项目做完,我最大的收获不是 RN 代码本身,而是学会了把所有“跨平台想当然”都当成风险来处理。
这个项目后续还可以继续扩展的方向很多,比如把错题本导出成本地报告、增加多用户切换,或者加入语音听题功能。如果你们也在做类似迁移,建议保持耐心,先把最小功能完整跑通一遍,再逐步叠加能力。