ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

React Native for OpenHarmony实战:从零构建数学练习App的完整指南

React Native for OpenHarmony实战:从零构建数学练习App的完整指南

近一年我一直在折腾 React Native for OpenHarmony,前后做了几个小工具练手,最完整、踩坑最多、最后能放心拿出去演示的,就是这个数学练习 App。如果你正打算在 OpenHarmony 设备上跑 React Native 应用,又不想从零啃 ArkTS 原生 UI,那这篇实战总结应该能帮你省掉不少弯路。我会把项目从设计、初始化、核心功能到问题排查的整个流程拆开来讲,重点不是贴官方文档,而是把我实测过的方案、碰过的雷、改过的代码原样交代清楚。

数学练习这个选题很适合作为 OpenHarmony 上 RN 实战的切入点:业务逻辑清晰、界面交互集中、不依赖复杂系统能力,又能充分用到 RN 的布局、状态管理、列表渲染和原生模块桥接。做完这一个项目,你对 React Native for OpenHarmony 的开发手感基本就建立起来了。

1. 项目背景与整体设计思路

1.1 为什么在 OpenHarmony 上用 React Native

先说结论:不是为了炫技,是因为团队之前的前端技术栈主要集中在 React Native 上,如果 OpenHarmony 设备全部用 ArkTS 重写一遍,人力成本和维护成本都吃不消。而 React Native for OpenHarmony 这个方向,正好可以把已经写好的业务逻辑、状态管理、工具函数直接搬过来,UI 层做少量适配就能跑。

这里要澄清一个常见误解:很多人以为把 RN 项目"迁移"到 OpenHarmony 就是改几个配置,实际上 React Native for OpenHarmony 并不是 React Native 官方分支,而是一个独立的框架实现。它复用了 React Native 的上层 JavaScript 架构,但把渲染层、原生模块层全部对接到了 OpenHarmony 的 ArkUI 和 Native API 上。也就是说,你写的 JS 代码大概率原封不动,但底层组件映射、样式对齐、原生模块调用方式都需要重新验证。

举一个最简单的例子:RN 原生的ScrollView在 OpenHarmony 上对应的是 ArkUI 的Scroll组件,两者滚动事件和嵌套路逻辑几乎一致,但滚动条样式、惯性效果的参数并不完全一样。这种"看似兼容、细节不同"的情况,在整个实战过程中比比皆是。所以选 RN for OpenHarmony 的前提是:你愿意花力气去验证每个用到的组件和 API。

1.2 数学练习 App 的功能拆解

我做的这个数学练习 App,目标用户是小学阶段的孩子,核心场景是每天在家完成 10 到 20 道口算题,家长能在事后看到正确率和耗时。功能可以拆成四个模块:

  • 随机出题引擎:支持加减乘除四种运算,按难度控制运算数范围、是否出现负数、是否出现除法余数。
  • 练习会话流程:连续答题、每题即时判定、显示得分和用时,支持中途暂停。
  • 错题本与历史记录:把错题单独归档,记录每次练习的时间、题数、正确率和总耗时。
  • 简单的统计视图:用列表展示最近七天的练习量趋势和正确率。

从技术角度看,这个功能集刚好覆盖了 RN 开发的常用能力面:组件渲染、用户输入、定时器、列表、本地持久化,以及一个自定义原生模块(用来读写 OpenHarmony 侧的文件存储)。没有复杂的动画和网络需求,非常适合作为上手机型的第一个完整项目。

1.3 整体架构与目录设计

我采用的三层结构,目录如下:

src/ ├── components/ # 纯 UI 组件 │ ├── QuestionCard.js │ ├── AnswerInput.js │ └── ScoreBoard.js ├── engine/ # 核心业务逻辑,不依赖 RN │ ├── generator.js # 出题算法 │ ├── difficulty.js # 难度配置 │ └── validator.js # 答案校验 ├── storage/ # 数据持久化封装 │ ├── nativeBridge.js # 原生模块调用层 │ └── recordStore.js # 练习记录读写 ├── screens/ │ ├── PracticeScreen.js │ ├── HistoryScreen.js │ └── StatsScreen.js └── App.js

这个设计的核心思想是:把纯 JS 业务逻辑和 RN UI 层完全解耦。出题算法、答案校验这些模块不 import 任何react-native组件,将来就算要移植到别的平台,这批代码可以原样带走。storage层做了一层封装,上层调用时只会看到saveRecord、loadHistory这类方法,不会感知底层到底是走 AsyncStorage 还是走原生文件读写。

2. 环境准备与工程初始化

2.1 版本选型与踩坑

项目初始化遇到的第一个坑就是版本对齐。React Native for OpenHarmony 对 OpenHarmony SDK、构建工具链、Node 版本都有明确要求,不是随便配一套就能跑。我一开始用了较新的 Node 22,结果构建时出现一串依赖编译报错,翻了下社区讨论才发现当时框架对 Node 20 LTS 的兼容性最好。

我的环境配置:

  • OpenHarmony SDK:使用设备对应的正式发布版本,不要用 Beta。
  • Node.js:20 LTS。
  • DevEco Studio:保持最新稳定版。
  • 框架包版本:和 SDK 大版本配套的发布版本。
  • 构建工具:使用工程自带的 hvigor,版本号以工程模板为准。

版本踩坑的关键教训:先跑通官方提供的模板工程,再往上叠加业务代码。如果你连 hello world 都起不来,多半是环境问题而非代码问题,此时优先检查版本组合,而不是去改业务代码。

2.2 OpenHarmony 工程搭建

OpenHarmony 工程的创建比标准 React Native 工程多一个步骤:你需要在 DevEco Studio 里先创建一个标准的 OpenHarmony 空工程,然后手动接入 React Native 相关依赖。工程结构大概是这样的:

MyPracticeApp/ ├── entry/ # OpenHarmony 应用入口 │ ├── src/main/ │ │ ├── ets/ # ArkTS 原生代码 │ │ │ ├── MainAbility/ │ │ │ └── pages/ │ │ ├── resources/ │ │ └── module.json5 │ ├── build-profile.json5 │ └── oh-package.json5 ├── hvigor/ ├── node_modules/ ├── package.json └── App.js

在oh-package.json5中添加依赖时,需要把 React Native 相关的原生模块都加上,包括主框架包、核心组件模块,以及你想用的原生能力模块(比如存储、网络)。这里建议直接用官方模板生成的版本号,不要手写,手写版本极容易出现依赖解析失败。

接入完成后,原生侧还要做两步配置:一是把 RN 的入口界面注册到 OpenHarmony 的路由表里,二是在module.json5里声明相关权限(比如需要在本地写文件,就得声明对应的存储权限)。我第一次漏掉了权限声明,结果运行期写入数据时直接静默失败,不会有报错弹窗,排查了很久。

2.3 RN 侧入口与启动配置

RN 侧入口和标准 RN 项目基本一样:

// index.js import { AppRegistry } from 'react-native'; import App from './src/App'; AppRegistry.registerComponent('MathPractice', () => App);

需要注意MathPractice这个名字必须和原生侧加载组件时使用的名字严格一致,否则会出现"请求的组件不存在"的报错,而且这个报错是发生在启动阶段,页面会直接白屏。

开发阶段,我使用 Metro 作为 JS Bundle 的本地服务。OpenHarmony 真机/模拟器连接 Metro 时,IP 地址要手动配置到原生工程里。启动命令不需要特殊魔改,标准 RN 的npx react-native start就能跑。运行期框架会通过 Metro 拉取 JS Bundle,因此要保证设备与开发机在同一个局域网,并关闭防火墙限制。

我踩过的一个典型现象是:代码改了,Metro 也热更新了,但真机上界面毫无变化。后来发现是原生侧的 bundle 加载模式还停留在"从本地文件加载"而非"从 Metro 服务加载"。这个配置项位于原生代码的研发生命周期逻辑中,必须改成 debug 模式才会请求 Metro。

3. 数学题生成引擎实现

3.1 难度分级模型

数学练习 App 的核心竞争力在出题质量,而不是 UI 花哨。我的出题引擎按三个难度等级设计:

  • 基础(easy):20 以内加减法,结果不出现负数,适合幼小衔接。
  • 进阶(medium):100 以内加减法 + 乘法口诀表,不出现负数,结果不超过 100。
  • 挑战(hard):混合四则运算,允许出现负数,除法要求整除、无余数。

用代码体现一个可配置的模型:

export const DIFFICULTY_LEVELS = { easy: { operators: ['+', '-'], operandRange: [1, 20], allowNegative: false, maxResult: 100, }, medium: { operators: ['+', '-', '×'], operandRange: [2, 99], allowNegative: false, maxResult: 100, division: false, }, hard: { operators: ['+', '-', '×', '÷'], operandRange: [2, 99], allowNegative: true, maxResult: 200, divisionIntegerOnly: true, }, };

这种配置驱动的方式有个好处:后面想加难度等级(比如"两位数乘法"),只需要新增一个配置对象,不需要改动生成算法的主逻辑。项目上线后我实际加过一个advanced等级,整个改动不到十行代码。

3.2 随机出题算法

出题算法的核心不是简单的Math.random(),而是需要满足几类约束:减法结果不能为负、除法必须整除、乘法结果不能超过学段认知范围。我在generator.js里是这样处理的:

import { DIFFICULTY_LEVELS } from './difficulty'; function randBetween(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } function pickOperator(operators) { return operators[Math.floor(Math.random() * operators.length)]; } export function generateQuestion(level = 'medium') { const config = DIFFICULTY_LEVELS[level]; const op = pickOperator(config.operators); let operandA = randBetween(config.operandRange[0], config.operandRange[1]); let operandB = randBetween(config.operandRange[0], config.operandRange[1]); // 减法结果不为负 if (op === '-' && !config.allowNegative && operandA < operandB) { [operandA, operandB] = [operandB, operandA]; } // 除法必须整除:先随机出商,再反推被除数 if (op === '÷') { operandB = randBetween(2, 9); operandA = operandB * randBetween(2, 12); } const answer = calculateAnswer(op, operandA, operandB); return { id: Date.now() + Math.random(), text: `${operandA} ${op} ${operandB}`, answer }; } function calculateAnswer(op, a, b) { switch (op) { case '+': return a + b; case '-': return a - b; case '×': return a * b; case '÷': return a / b; default: return 0; } }

这里最关键的反直觉点在于:除法题目的生成顺序是"先定商,再反推被除数",而不是先随机两个数再做除法判断。前者能保证 100% 整除,后者会产生大量需要丢弃的随机数,而且当范围较大时效率很低,生成的题目分布也不均匀。

3.3 答案校验与防呆设计

答案校验这块,我踩过一个很经典的浮点坑。calculateAnswer里除法返回的是 JS 数字,整除场景下如36 / 6 = 6是精确的,但19 / 5这类不整除的题目在hard模式下并不允许出现,所以我强制了divisionIntegerOnly。即便如此,JS 的浮点运算仍然可能出现0.1 + 0.2 !== 0.3这类问题,为了稳妥,校验时我做了容差比较:

export function isAnswerCorrect(expected, userInput) { if (userInput === '' || userInput === undefined || userInput === null) { return false; } const numericInput = Number(userInput); if (Number.isNaN(numericInput)) { return false; } return Math.abs(numericInput - expected) < 0.0001; }

答题交互上的防呆设计也很重要。孩子做题的时候经常会在输入框里乱点、快速提交空数据,或者输入前导零。我的处理是:

  • 输入框的内容在提交前用String.trim()去掉空格。
  • 空输入、非数字输入直接判为错误,但只做提示,不计入错题本,防止误统计。
  • 每题首次答错后允许再次尝试,当次尝试仍算错题,但最终得分以首次结果为准。
  • 提交按钮在收到响应前会短暂置灰(300ms),避免双击触发重复判定。

这些看似很细的东西,实际试运行几天后就会发现是刚需,孩子的操作习惯和成人完全不一样,防呆做得好不好直接决定 App 能不能真正被日常使用。

4. UI 实现与组件适配实战

4.1 RN 组件与 OpenHarmony 组件的映射关系

React Native for OpenHarmony 在架构上做了一个关键的桥接层:JS 侧的组件声明最终会映射到 ArkUI 的原生组件上。这就意味着,你在写 RN 的<View>时,原生侧渲染的其实是 ArkUI 的某个布局容器,如果你对 ArkUI 组件体系有不了解的地方,UI 适配时容易出现无法理解的"细节偏差"。

我在项目里用到的组件映射关系如下:

React Native 组件OpenHarmony ArkUI 侧实现实际适配注意点
ViewColumn / Row / StackflexDirection 的表现逻辑与 CSS 接近,但默认主轴方向不同,建议显式声明
TextText字体渲染差异明显,中文字号建议用默认值,手写小字号会显示过小
TextInputTextInput获取焦点、弹出键盘的行为需要原生侧配置 adjustResize
ScrollViewScroll嵌套滚动、到达底部事件的命名和参数位置不同
FlatListList 容器 + ForEach大数据量滚动更接近原生 List,但 item 更新逻辑需要手动做 key 规划
Pressable触摸事件封装组件按压态样式需要传入额外的 state 映射
ModalDialog / bindSheet弹出动画、遮罩层参数完全不一致,尽量少用复杂弹窗

特别注意FlatList的keyExtractor:要保证 key 的唯一性和稳定性。我发现如果 key 里有日期字符串(如2025-03-01),只要列表跨天刷新,key 不变但数据却变了,会导致列表项内容不刷新的诡异问题。后面统一改用"日期 + 索引 + 随机短码"的组合才解决。

4.2 练习页面的布局实现

练习页面的布局逻辑:顶部是进度条和得分栏,中间是题目大卡片,底部是答案输入区和操作按钮。核心布局如下:

<View style={styles.container}> <View style={styles.header}> <Text style={styles.progressText}>第 {currentIndex + 1} / {totalCount} 题</Text> <Text style={styles.scoreText}>得分:{score}</Text> </View> <View style={styles.questionCard}> <Text style={styles.questionText}>{currentQuestion.text} = ?</Text> </View> <AnswerInput value={inputValue} onChangeText={setInputValue} onSubmit={handleSubmit} /> <View style={styles.buttonRow}> <Pressable style={styles.submitBtn} onPress={handleSubmit}> <Text style={styles.btnText}>提交</Text> </Pressable> <Pressable style={styles.nextBtn} onPress={handleNext}> <Text style={styles.btnText}>下一题</Text> </Pressable> </View> </View>

样式上有一个非常重要的适配细节:flexDirection 一定要显式写。标准 RN 默认主轴是 column,但 ArkUI 的 Row/Column 组件天然有主轴方向,如果 RN 侧的样式没有明确声明,某些版本会把布局方向搞反。我建议在每个容器上都显式声明flexDirection: 'column'或'row',不要把默认值交给框架去猜。

另一个适配细节是边距和尺寸单位。RN 的style中数值默认按逻辑像素理解,OpenHarmony 侧使用的是 vp(virtual pixel),实际映射基本是 1:1,但某些系统默认字体大小会额外乘一个缩放系数,导致 Text 实际渲染尺寸比设计稿大一圈。遇到文本溢出、卡片内文字截断的情况,优先检查的不是布局代码,而是系统字体设置。

4.3 键盘交互与输入体验

数学练习场景里,数字输入是最高频的操作。我测试下来发现一个现象:OpenHarmony 上 RN 的TextInput弹出系统键盘之后,底部按钮被遮挡,而 RN 自带的KeyboardAvoidingView在这套框架上的表现不稳定,有时避让过度,有时完全无效。

我最终的方案是弃用KeyboardAvoidingView,改用布局上的"固定操作区 + 滚动内容区":

<View style={styles.root}> {/* 上方可滚动区域 */} <ScrollView style={styles.scrollArea} contentContainerStyle={styles.scrollContent}> {/* 题目卡片和提示 */} </ScrollView> {/* 底部固定输入与提交区,不参与 ScrollView 滚动 */} <View style={styles.fixedBottomArea}> <TextInput ... /> <Pressable onPress={handleSubmit}>提交</Pressable> </View> </View>

这样键盘弹出时,固定区会被顶上去,但不会和滚动区的内容重叠,体验上反而比传统KeyboardAvoidingView更直接。代价是题目区域在键盘弹出时会缩小,需要在scrollContent里设置足够的底部 padding。

输入法还有个细节:TextInput的keyboardType只设置'numeric'在部分定制键盘上不生效。我在hard模式允许负数时,用户需要输入负号,而数字键盘经常没有负号键。解决方法是额外提供一个 "-" 按钮,点击后在前缀增加负号,这样绕开了键盘类型限制。

5. 状态管理与数据持久化

5.1 练习会话状态管理

数学练习的会话状态里,需要跟踪的数据包括:当前题目序号、用户输入内容、得分记录、错题列表、计时器秒数。状态量不算特别大,但相互关联,直接用多个useState会让逻辑变得很散。我用useReducer把会话状态收敛成单一对象:

const initialState = { currentIndex: 0, questions: [], answerMap: {}, score: 0, wrongQuestions: [], elapsedSeconds: 0, isFinished: false, }; function practiceReducer(state, action) { switch (action.type) { case 'START_SESSION': return { ...initialState, questions: action.payload.questions }; case 'SUBMIT_ANSWER': { const { index, input, isCorrect } = action.payload; const score = (state.score || 0) + (isCorrect ? 10 : 0); const wrongQuestions = isCorrect ? state.wrongQuestions : [...state.wrongQuestions, { question: state.questions[index], userInput: input }]; return { ...state, score, wrongQuestions }; } case 'NEXT_QUESTION': return { ...state, currentIndex: state.currentIndex + 1 }; case 'TICK': return { ...state, elapsedSeconds: state.elapsedSeconds + 1 }; case 'FINISH': return { ...state, isFinished: true }; default: return state; } }

使用useReducer的好处不只是代码看着清爽,更重要的是所有状态变更都走同一个入口,方便记录操作日志、调试、甚至做"操作回放"。后面我排查一个"得分莫名丢失"的问题时,就是靠打印完整的 reducer action 列表迅速定位的。

5.2 原生存储桥接

React Native for OpenHarmony 内置的存储方案和标准 RN 的 AsyncStorage 并不完全相同。我刚开始直接使用了框架自带的存储封装,结果发现它只能存轻量键值对,而我需要保存的是结构化的练习历史 JSON。为了保险,我选择写一个自定义原生模块桥接 ArkTS 侧的文件读写能力。

原生侧 ArkTS 模块的核心逻辑:

import { fileIo } from '@kit.CoreFileKit'; import { common } from '@kit.AbilityKit'; export class StorageModule { private context: common.UIAbilityContext; constructor(context: common.UIAbilityContext) { this.context = context; } writeFile(fileName: string, content: string): void { const filePath = this.context.filesDir + '/' + fileName; const file = fileIo.openSync(filePath, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE); fileIo.writeSync(file.fd, content); fileIo.closeSync(file); } readFile(fileName: string): string { const filePath = this.context.filesDir + '/' + fileName; const file = fileIo.openSync(filePath, fileIo.OpenMode.READ_ONLY); const buffer = new ArrayBuffer(4096); const readLen = fileIo.readSync(file.fd, buffer); fileIo.closeSync(file); return String.fromCharCode.apply(null, new Uint8Array(buffer.slice(0, readLen))); } }

RN 侧封装成 Promise 风格:

import { NativeModules } from 'react-native'; const { StorageModule } = NativeModules; export async function saveRecord(record) { try { const fileName = `practice_${record.date}.json`; await StorageModule.writeFile(fileName, JSON.stringify(record)); } catch (e) { console.warn(`保存记录失败: ${e?.message}`); } } export async function loadRecords(dateRange) { // 查询指定日期范围内的所有练习记录 // 这里通过原生侧批量读取文件内容,并解析 JSON }

这条链路是打通"JS 层业务逻辑 -> 原生模块 -> 文件系统"的标准姿势。实际开发中,RN 的NativeModules在 OpenHarmony 框架下的调用方法和标准 RN 差别不大,但回调风格有区别。我建议统一用Promise风格,并且在原生模块侧写清楚错误回调,不要依赖框架自动兜底,否则 JS 侧容易出现永久 pending 的 Promise,UI 就卡住了。

5.3 历史记录与统计聚合

每天一次练习产生一条记录,字段结构如下:

{ "date": "2025-03-15", "level": "hard", "total": 20, "correct": 17, "elapsedSeconds": 325, "wrongQuestions": [ { "question": "56 - 89", "userInput": "-33", "correctAnswer": "-33" } ] }

历史记录页用FlatList展示,每条记录显示日期、难度、正确率、耗时。统计页则把最近七天的数据聚合成一组柱状图——我没有引入重型图表库,而是用 View 的宽度百分比手绘简单柱子,这在 OpenHarmony 的 RN 环境下更稳、更可控。

统计聚合逻辑:

export function aggregateStats(records, days = 7) { const result = []; for (let i = days - 1; i >= 0; i--) { const date = formatDate(Date.now() - i * 86400000); const dayRecords = records.filter(r => r.date === date); const total = dayRecords.reduce((sum, r) => sum + r.total, 0); const correct = dayRecords.reduce((sum, r) => sum + r.correct, 0); result.push({ date, total, correct, rate: total === 0 ? 0 : Math.round((correct / total) * 100), }); } return result; }

这里有个细节:折线、柱状图这类视觉元素如果坚持用 RN View 拼,图表越复杂维护成本越高。但对这种只展示两组数据的统计场景,手绘方案反而比引入图表库省心得多,因为 OpenHarmony 的 RN 图表库支持度参差不齐,轻量数据不值得背这个兼容性包袱。

6. 高频问题与排查实录

6.1 页面白屏与 JS Bundle 加载失败

这类问题在开发期出现频率最高。表现就是 App 启动之后直接白屏,没有任何报错。排查步骤按顺序走:

  1. 先看 Metro 窗口是否正常打印了 bundle 请求日志,如果没有请求,说明设备根本没有访问到 Metro。
  2. 确认原生工程的调试开关是否开启,没开启时会直接尝试从本地路径读 bundle。
  3. 确认 IP 配置是不是正确,我踩过最蠢的一个坑是把 IP 填成了localhost,模拟器上没问题,真机上当然连不上。
  4. 看设备侧的系统日志,OpenHarmony 应用崩溃或加载失败通常会在日志里给出原因,但需要打开对应的日志过滤关键词,比如RnAbility、Metro等。

白屏问题有一个特点:一旦出现,往往不是代码 bug,而是配置或环境问题。如果代码逻辑有致命错误,通常至少会看到部分渲染或者红屏错误,而不是干净的白屏。所以白屏优先排查环境,不要先怀疑自己的代码。

6.2 键盘弹出导致布局错乱

前面提到过KeyboardAvoidingView在 OpenHarmony 上的行为不稳定,这里再补充一个细节:我在调测时发现,当键盘弹出时,windowSoftInputMode如果没有设置成adjustResize,整个窗口高度不会变化,而 RN 框架却会重新计算Dimensions.get('window').height,导致底部固定区不按预期移动。解决方案是双管齐下:

  • 原生工程的页面配置里明确设置windowSoftInputMode = adjustResize。
  • JS 侧不依赖Dimensions的动态变化,而是用固定的底部操作区布局方案配合ScrollView内容区 padding。

如果这两个都做了还是有遮挡,可以尝试用onLayout事件监听容器高度变化,然后手动调整底部操作区的marginBottom。这个方案最粗暴,但在 OpenHarmony 上反而最可靠。

6.3 列表性能与包体积控制

FlatList在渲染大量练习历史记录时,首次滚动会出现明显卡顿。我分析后发现根因是每条记录中嵌套了错误题数组,导致 item 渲染成本偏高。优化方案:

  • 列表项中只展示摘要信息(日期、正确率、耗时),详情进入二级页面再展示错题数组,避免在列表中序列化整个错误列表。
  • getItemLayout显式声明每项高度,跳过动态高度测量。
  • removeClippedSubviews在部分版本上会导致滚动时白块,我最后关闭了这个属性,改用减少 item 复杂度来换流畅度。

包体积方面,这个项目没引入第三方大库,所以整体体积可控。但我注意到 Release 构建时如果开启了多设备架构支持,产物体积会明显增大。对于只跑一种设备的场景,建议在构建配置里关掉多余架构产物,能减少约三分之一体积。

注意:不要为了减小包体积把必要的原生模块拆掉。React Native for OpenHarmony 的模块注册是静态检查的,你拆掉某个原生模块,JS 侧调用时不会立即报错,但运行到相关功能时会崩溃,排查成本非常高。

6.4 自定义原生模块无法调用的排查

自定义原生模块是 OpenHarmony 上 RN 项目的常见扩展点,但很容易出现"JS 侧明明已经NativeModules.XXX了,运行却拿不到模块"的情况。我的排查经验是:

  1. 确认原生模块类名是否与 JS 侧引用名严格一致,注意大小写和命名空间。
  2. 确认模块在原生侧是否被正确注册到框架的模块列表里,不是写了类就完事,还要在注册表中登记。
  3. 确认方法名是否导出,RN 的NativeModules只能访问导出过的方法。
  4. 在原生侧方法入口打日志,看方法到底有没有被调用。如果原生日志都没有,说明调用链路在更早的地方断了;如果原生有日志但 JS 拿不到返回值,重点检查回调参数格式。

7. 实测经验与调优心得

这个项目从搭框架到能稳定跑完一整套练习流程,大概用了两周半的时间。整体体验下来,我对 React Native for OpenHarmony 的评价是:能干活,但别指望完全无缝迁移。如果你是从标准 React Native 项目过来,JS 侧的代码手感非常熟悉,框架最核心的心智模型没有变,但组件映射、键盘交互、原生模块这些"接缝"位置,必须花时间逐个验证。

几个可以帮你少走弯路的具体建议:

  • 新建项目第一天,先把官方模板的所有内置示例跑一遍,确认每个核心组件在目标设备上是真实可用的,再开始写业务。
  • 出题算法这种"平台无关"模块,一定要先用 Node 单独写一遍单元测试,再接入 RN。我有好几次在 OpenHarmony 上排查半天,最后发现问题其实是出题算法的纯逻辑 bug。
  • 数据持久化建议尽早做封装,后面接入原生文件读写、换成自带存储,影响面都被控制在storage目录下。
  • 调试期优先连真机,模拟器的键盘行为、系统字体、滚动流畅度和真机有明显差异,特别是键盘相关的问题,模拟器上很可能复现不了。

最后分享一个我实际踩过、也最容易被忽略的小技巧:每天练习生成的 JSON 文件名里带上日期,像practice_2025-03-15.json这样。这样做的好处不仅仅是方便按日期索引,更重要的是当数据写坏、需要手动清理时,你能在文件系统里一眼看出哪条文件是坏的。我在测试阶段曾经因为一条脏数据把整个历史记录列表搞崩,当时就是靠这种可读文件名快速定位并删除问题数据的。这种"为排查留后门"的小习惯,在原生与 RN 混合项目里非常值得养成。

数学练习只是第一步,这个 App 后续可以继续加的功能还挺多:多用户切换、每日目标提醒、错题强化重练、家长端报告页。架构上因为已经把引擎、存储、UI 分离,每加一个功能基本都是往固定位置填代码,不会牵一发动全身。如果你也在做 OpenHarmony 上的 RN 项目,或者正打算用这个方向练手,希望这篇记录能把那些"文档不会写、但实际一定会碰"的坑提前展示给你。

RELATED READING

延伸阅读

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