ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

React Native 应用迁移 OpenHarmony 实战:数学练习应用开发与调试

React Native 应用迁移 OpenHarmony 实战:数学练习应用开发与调试

这两年我一直有意把一些跨平台项目往 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 代码本身,而是学会了把所有“跨平台想当然”都当成风险来处理。

这个项目后续还可以继续扩展的方向很多,比如把错题本导出成本地报告、增加多用户切换,或者加入语音听题功能。如果你们也在做类似迁移,建议保持耐心,先把最小功能完整跑通一遍,再逐步叠加能力。

RELATED READING

延伸阅读

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