React Native 鸿蒙端侧 OCR 实战:TurboModule 与纯 TS 引擎的逐字契约
TurboModule 怎么接系统识别引擎、Metro 预置头的
require坑怎么躲、npm 包和 HAR 双形态怎么交付——这篇文章用一个跑在鸿蒙真机上的 RN 0.84 工程,把这三个问题一次讲透。
仓库地址:https://atomgit.com/lqjmac/react-native-ohos-ocr
版本地址:https://atomgit.com/oh-react-native/ohos_react_native/tree/0.84-main
三方库框架:https://atomgit.com/oh-react-native
0. 环境与实测设备
| 项 | 版本/型号 |
|---|---|
| DevEco Studio | /Applications/DevEco-Studio.app/Contents(HarmonyOS SDK API 26,26.0.0;最低配置 API 17,5.0.5(17)) |
| React Native | 0.84.1(React 19.2.3) |
| RNOH | @react-native-oh/react-native-harmony 0.84.3(OHPM 包 @rnoh/react-native-openharmony 0.84.3) |
| react-native-ohos-ocr | 0.2.0(本仓库,npm 包 + source-style HAR) |
| Node.js / npm | 24.12.0 / 11.12.1(Node 最低 22.11) |
| 测试 | Jest 35/35 + node:test 原生模块 5/5 + typecheck 0 错误 |
| 实测设备 | HUAWEI ALN-AL00,HarmonyOS 7.0.0.109(API 26) |
| 识别引擎 | HarmonyOS Core Vision Kit(textRecognition,端侧离线;简中/英/日/韩/繁中) |
1. 跨语言的引擎怎么说话?——这个问题是怎么找上我的
起因其实很简单。端侧 OCR 这个系列我已经做了两版:KMP 版(kmp-ohos-ocr,Kotlin 引擎)和 Flutter 版(flutter_ohos_ocr,ArkTS + Dart)。轮到 React Native,我的第一反应跟大多数人一样朴素:把 Flutter 版的代码翻译一遍不就完了?——MethodChannel 换成 NativeModules,ArkTS 照抄,一两天的事。
动手才发现完全不是那回事。RN 接原生走的是另一套体系:TurboModule——先写 codegen spec,让代码生成器产出 ArkTS 接口和 C++ 异步桥接,再实现 ArkTS 类、注册 Package、编 CMake target、打 HAR,接入还得走 RNOH 的 autolinking。这还只是"接得上"的部分。真正让我卡住的是真机上第一次启动:
[runtime not ready]: ReferenceError: Property 'require' doesn't exist
Stack: anonymous (bundle.harmony.js:2:27)
JS bundle 都还没执行到业务代码,就在第 2 行炸了。这是鸿蒙 JS 引擎执行 Metro 预置头时还没有全局 require 导致的——RN 生态里没人会踩到这个坑,因为它只存在于"RN 跑在鸿蒙上"这个交叉地带。
所以这个题目的边界其实很清楚。具体拆成三件事:
- TurboModule 怎么接(codegen spec → ArkTS 实现 → C++ 桥接 → HAR,这是核心);
- 双引擎怎么逐字对齐(ArkTS 生产实现 + 纯 TS 回退,同一份 JSON 契约);
- npm 包 + HAR 双形态怎么交付(autolinking 元数据、独立消费方验证)。
下面是干完之后的完整记录,包括我踩的每一个坑。
2. 效果展示
按老规矩,先给效果再讲原理。示例应用的标题直接把架构写在脸上:“端侧 OCR · 确定性演示”,副标题 react-native-ohos-ocr · RNOH TurboModule + 纯 TS 引擎——一个页面,两条引擎。
页面上半部分是**相册图片识别(Core Vision Kit)**卡片:说明文字写着"选择系统相册中的图片,原生 OhosOcr 使用 HarmonyOS Core Vision Kit 识别文字",下面一枚绿色大按钮"选择图片并识别"。点它,系统相册拉起(安全访问图库,无权限弹窗),选一张另一台设备的应用截图:

按钮下方跳出一行绿色结果:识别完成 · 22 个文本块 · 1499ms。卡片中央是被选中图片的预览(一张"Tiamat · OpenHarmony"的导航演示截图),下方"识别文本"列表把 22 段文字逐行列出——从状态栏的"03:20"、信号图标,到标题"Tiamat · OpenHarmony"、副标题"NavController engine (commonMain → ohosArm64/ohosX64)",连按钮上的"freeArgs (optional, e.g. order-7)"这种小字都完整抓了出来。1499ms 全程端侧:相册读字节、RGBA 解码、引擎识别、JSON 回传,图片没有离开过这台 ALN-AL00。
页面的另一半(截图外)是确定性引擎演示区:三个内置场景(收银小票 120ms、增值税发票 180ms、名片 90ms)、revision 递增重识别、置信度过滤滑块。真机验收记录里最关键的一行是 nativeBridge: OK——它表示 JS 侧通过 TurboModuleRegistry.get('OhosOcr') 拿到了原生模块,原生 checks() 与 filter() 返回正常,切换增值税发票耗时 180ms、重新识别后 revision 递增且耗时 181ms。同一份数据,原生 TurboModule 和纯 TS 引擎算出来逐字符一致——这是这个库存在的意义,第 5 节展开。
3. 识别到底从哪来:一条链路,两次对齐
写代码之前得先把原理捋好。
识别链路本身和 Flutter 版同源(都是 Core Vision Kit),但 RN 侧的三个纪律值得先立起来:
JS(React)
└─ src/NativeOhosOcr.ts ← codegen spec:TurboModuleRegistry.get('OhosOcr')
│
▼ (codegen 生成 ArkTS Spec + C++ 异步桥接)
RNOhosOcrTurboModule(harmony/ohos_ocr,ArkTS)
├─ result / filter / checks → OcrEngine + OcrJson(确定性演示引擎)
└─ pickImage / recognizeImage → OcrImage(Core Vision Kit 端侧识别)
│
▼
JSON 字符串 ←—— 逐字符一致 ——→ src/nativeBridge.ts(纯 TS,永不抛错)
纪律一:引擎必须先 init()。 Core Vision Kit 从 API 12 起要求 textRecognition.init() 之后才能 recognizeText。原生实现选择按次 init() / release()——不持有全局单例,用完就还。代价是每次识别多一次初始化开销,收益是零状态残留:连续识别几十张图,不会遇到"上一次的会话没清干净"这类玄学。
纪律二:PixelMap 必须显式 RGBA_8888。 解码时的 decodingOptions.desiredPixelFormat 要指定 image.PixelMapFormat.RGBA_8888——这是 Core Vision Kit 的输入要求,默认格式可能不带 alpha 通道展开,识别前会踩空。
纪律三:资源四连释放。 fileIo 文件、ImageSource、PixelMap、textRecognition 引擎,四个都是原生资源,全部写在 finally 里释放(第 5 节有代码)。RN 的 JS 侧完全看不见这些内存,漏一次就是一次泄漏。
顺手记一笔性能账:确定性引擎是纯内存计算,三个场景的标注耗时 90~180ms,真机上 filter 180ms、重识别 181ms——数字对得上;真实识别贵在解码,相册大图 22 块 1499ms 里,fileIo 读字节和 RGBA_8888 解码占大头,recognizeText 本身很快。换句话说,瓶颈不在识别引擎,在图片进出——要快,先压图。
各条路线对照一下(和系列前两篇同一张表,RN 行换成了本库):
| 方案 | 识别位置 | RN 鸿蒙侧可用性 |
|---|---|---|
| 云服务 OCR | 云端 | 可用,但要网络、密钥、计费,图片出设备 |
| ML Kit | 设备(Android/iOS) | 官方 RN 插件无鸿蒙实现 |
| 自研 TFLite/ONNX | 设备 | RN 侧要自己写 TurboModule + 模型管理,工程量大 |
| Core Vision Kit(本库) | 设备 | 系统自带,离线免密钥免权限,本库已封装成 TurboModule |
| kmp-ohos-ocr | 设备 | Kotlin 入口,RN 无法直接复用,但 JSON 契约是本库的对齐基准 |
| react-native-ohos-ocr 纯 TS 引擎 | —(不识别) | 任意平台可用,离线演示与对账基准 |
能力边界说清楚:recognizeImage 的块列表只携带文本,不回传逐行置信度(确定性引擎的固定语料才带置信度,用于演示过滤);支持的语言是简体中文、英文、日文、韩文、繁体中文,自动判语种;不支持 PDF、批量图片、实时相机流。了解了这个边界,你就知道这个库的定位——单图、离线、隐私敏感的文本抓取,而不是全套 OCR 中台。
4. 工程怎么搭:根包 + HAR + 宿主,三层各司其职
先看整体结构。这是一个标准 RN 三方库布局:根目录是 JS/TS 包(npm 交付物),harmony/ 是鸿蒙原生模块(HAR 交付物),example/ 是可构建的完整 RNOH 应用:
react-native-ohos-ocr/
├─ src/ # 纯 TS 源码
│ ├─ NativeOhosOcr.ts # codegen spec(OhosOcr)
│ ├─ OcrEngine.ts / OcrJson.ts / models.ts # 确定性引擎 + JSON 契约
│ ├─ nativeBridge.ts # 纯 TS 桥接(永不抛错)
│ └─ ohosModule.ts # 原生安全包装(不可用 resolve null)
├─ lib/ # npm run build:CJS / ESM / 类型
├─ harmony/
│ ├─ ohos_ocr.har # 可交付 source-style HAR(约 13 KB)
│ └─ ohos_ocr/
│ ├─ Index.ets / ts.ts
│ └─ src/main/
│ ├─ ets/ # RNOhosOcrPackage / RNOhosOcrTurboModule / 引擎
│ └─ cpp/ # CMake、codegen 异步桥接
├─ example/ # JS 示例 + 可构建 HarmonyOS 应用(RNApp/RNAbility)
├─ templates/ # 宿主手动接线模板
├─ tools/build-har.sh · scripts/ # HAR 组装、HAP 构建、包校验
├─ __tests__/ · tests/ # Jest 35 项 + node:test 原生模块 5 项
└─ VALIDATION.md # 全部验证记录

这次设计最关键的决定有三个:
spec 先行,生成物入库。 src/NativeOhosOcr.ts 是 codegen spec,react-native codegen-lib-harmony 从它生成 ArkTS 的 OhosOcr.Spec 和 C++ 的 ArkTSTurboModule 异步桥接——生成物直接进 HAR。这是一个对使用方极友善的决定:宿主接入时不需要再为自己的工程跑一遍 codegen(第 6 节有反面教训)。
autolinking 元数据长在 package.json 里。 harmony.autolinking 字段声明了 OHPM 包名(react-native-ohos-ocr)、ArkTS/C++ Package 类名(RNOhosOcrPackage)和 CMake target(rnoh_ohos_ocr)——RNOH CLI 据此自动生成链接文件。同时提供 templates/ 手动接线模板,两条路都通。
两条失败语义分工明确。 原生安全包装(resultJson 等)在模块不可用时 resolve null——让"没有原生桥"成为一个可判断的状态;纯 TS 桥接(ocrResultJson 等)永不抛错,失败返回 {"error":"<message>"}——继承自 Kotlin 版 response { } 包装器的语义。调用方按需选择,不用猜。
使用方看到的 API 是这样的:
import {
OcrEngine,
isOhosOcrAvailable,
resultJson,
filterJson,
checksJson,
ocrResultJson,
pickImage,
recognizeImage,
} from 'react-native-ohos-ocr';
// 真实图片识别:系统相册 + Core Vision Kit
const uri = await pickImage(); // 取消时 null
const imageResult = uri === null ? null : await recognizeImage(uri);
console.log(imageResult?.fullText);
5. 把代码讲透
5.1 TurboModule:spec 生成接口,实现只管说话
RNOhosOcrTurboModule 继承 RNOH 的 UITurboModule 并实现 codegen 生成的 OhosOcr.Spec——接口形状由 spec 决定,实现里全是"调引擎、吐 JSON":
// harmony/ohos_ocr/src/main/ets/RNOhosOcrTurboModule.ts(节选)
export class RNOhosOcrTurboModule extends UITurboModule implements OhosOcr.Spec {
static readonly NAME: string = OhosOcr.NAME; // 'OhosOcr'
/** JSON for demo scenario `index` re-run at `revision` (ocrResultJson). */
async result(index: number, revision: number): Promise<string> {
try {
return toJson(OcrEngine.result(index, revision));
} catch (error) {
const message = describeError(error);
Logger.error(`result(${index}, ${revision}) failed: ${message}`);
throw new Error(message);
}
}
/** Opens the system photo picker; resolves an empty string when cancelled. */
async pickImage(): Promise<string> { /* ... 取消返回空串 */ }
/** Runs Core Vision OCR on the selected image and resolves result JSON. */
async recognizeImage(uri: string): Promise<string> {
return imageResultToJson(await recognizeImageFromUri(uri));
}
}
几个值得抄的细节:describeError 把任何抛出的值收敛成英文消息字符串——和 Kotlin 版的校验消息逐字一致,这样 JS 侧 catch 到的错误文案在跨端对照时也不会漂;每个方法自带 Logger.error,真机排查时 hilog 里有现场;方法注释直接写明对应的 JS 包装函数名(ocrResultJson / filterResultJson / runChecksJson),两边代码照着注释对齐。
5.2 识别链路:init、解码、识别、四连释放
OcrImage.recognizeImage 是真实识别的主战场,把三节原理全部落进代码:
// harmony/ohos_ocr/src/main/ets/ocr/OcrImage.ts(节选)
let initialized = false;
let file: fs.File | null = null;
let source: image.ImageSource | null = null;
let pixelMap: image.PixelMap | null = null;
try {
initialized = await textRecognition.init(); // API 12+ 必须先初始化
if (!initialized) {
throw new Error('OCR engine initialization failed');
}
file = fs.openSync(uri, fs.OpenMode.READ_ONLY); // picker 给的是 file://media/... URI
// ... createImageSource → decodingOptions 指定 RGBA_8888 → createPixelMap
await textRecognition.recognizeText(visionInfo, configuration);
// ... 组装 OcrImageResult(uri / source / fullText / durationMs / blocks)
} finally {
await pixelMap.release(); // ① 像素内存
await source.release(); // ② 图片源
// fileIo.close(file) // ③ 文件描述符
await textRecognition.release(); // ④ 引擎会话
}
finally 里的四连释放是这条链路的命门。 RN 的 JS 侧对 ArkTS 的原生内存零感知,连续识别几十张相册大图时漏放任何一项,表现都不是崩溃而是"越用越慢、越用越烫"——这种泄漏在 JS 侧永远查不出来,只能写在原生侧的肌肉记忆里。pickImage 的取消语义也在这里定死:用户关掉选择器返回空字符串(TurboModule 跨语言边界用 null 传空串有歧义),JS 包装层再把空串翻成 null 给调用方——两层各司其职,谁都不吞谁的语义。
5.3 双轨契约:同一句话,两种失败
这个库最核心的承诺写在 TurboModule 的类注释里:每个方法 resolve 的 JSON,与纯 TS 桥 src/nativeBridge.ts 逐字符一致。而两者的失败语义刻意不同:
// src/nativeBridge.ts - 纯 TS 桥:永不抛错
function errorPayload(error: unknown): string {
const message = error instanceof Error && typeof error.message === 'string'
? error.message : 'Native error';
return `{"error":${jsonQuote(message)}}`;
}
export function ocrResultJson(index: number, revision: number = 0): string {
try {
return toJson(OcrEngine.result(index, revision));
} catch (error) {
return errorPayload(error); // 失败也是 JSON,调用方不用 try/catch
}
}
分工逻辑:纯 TS 桥是"永远可用的那条腿",任何平台都能跑,所以它连失败都用 JSON 表达(继承 Kotlin 版 response { } 的语义),调用侧代码在真机和模拟器上可以一字不改;原生 TurboModule 是"真实数据的那条腿",失败走 reject,让 JS 侧能感知到平台级错误并上报。tests/native-module.test.cjs 用 node:test 直接执行生产 ArkTS TurboModule(只替换系统 SDK 与 RNOH 运行时依赖),与纯 TS 引擎逐字符比对——契约不是靠自觉,是靠测试焊死的。
5.4 双形态交付:npm 包管 JS,HAR 管原生
这个库的交付物有两个:artifacts/react-native-ohos-ocr-0.2.0.tgz(npm 包,113 个文件约 72 KB)和 harmony/ohos_ocr.har(source-style HAR,约 13 KB)。package.json 里的关键字段:
{
"exports": {
".": {
"react-native": "./src/index.ts", // Metro 直接吃源码
"import": { "types": "...", "default": "./lib/module/index.js" },
"require": { "types": "...", "default": "./lib/commonjs/index.js" }
}
},
"harmony": {
"autolinking": {
"ohPackageName": "react-native-ohos-ocr",
"etsPackageClassName": "RNOhosOcrPackage",
"cppPackageClassName": "RNOhosOcrPackage",
"cmakeLibraryTargetName": "rnoh_ohos_ocr"
}
}
}
react-native 条件指向源码而不是 lib 产物——Metro 会把 TS 源编译进 bundle,这样业务代码里断点能打、堆栈对得上;CJS/ESM 产物留给 Node 环境的测试和工具链。harmony.autolinking 四个字段就是 RNOH CLI 生成链接文件的全部依据。source-style HAR(保留 ArkTS / C++ 源码而非编译产物)配合已入库的 codegen 生成物,宿主接入时不需要执行 codegen-lib-harmony——少一个步骤就少一类环境问题。
5.5 验证流水线:七条命令一个不用记
库根目录的脚本把验证压成了一条龙,每条都有明确产物:
npm test # Jest 35/35 + node:test 原生模块 5/5
npm run typecheck # 0 错误
npm run build:har # source-style HAR(~13 KB)
npm run build:example # ArkTS + C++ 编译链接,内置 JS bundle,unsigned HAP
npm run verify:package # npm 包/HAR/类型解析/autolinking 元数据校验
npm run verify:integration # 从 npm tarball 安装 → autolinking → 独立消费方 HAP
最值钱的是最后一条:verify:integration 模拟一个陌生消费者——用打包出来的 npm tarball 安装、Metro 按包名解析源码、RNOH link-harmony 生成链接文件、构建出独立的 HAP(约 39.6 MB)。它验证的不是"我的工程能跑",而是"别人拿到这个包也能跑"。发库之前跑它,比发出去之后收 issue 体面得多。
6. 我踩过的坑(每一条都有真实报错)
这一节可能是全文最值钱的部分。以下坑全部来自这次开发的真实报错和排查记录。
坑一:真机启动即炸,Property 'require' doesn't exist。 全文最大的坑,报错是:
[runtime not ready]: ReferenceError: Property 'require' doesn't exist
Stack: anonymous (bundle.harmony.js:2:27) / global (bundle.harmony.js:2:2086)
根因:React Native preset 默认启用 @babel/plugin-transform-runtime,它会把 require("@babel/runtime/helpers/typeof") 注入到 Metro 预置头的顶层——而鸿蒙 JS 引擎执行预置头时还没有全局 require。RN 生态没人踩这个坑,因为只有"RN 跑在鸿蒙上"才同时满足这两个条件。修复分三层:example/babel.config.js 关闭 RN preset 的 runtime helper 注入;scripts/build-harmony.sh 用 --reset-cache 重打 bundle;新增 scripts/check-bundle.cjs 在构建时扫描产物,发现该模式直接拒绝。教训:跨生态组合时,每一层的"默认值"都要重新审一遍——RN 的默认在鸿蒙是错的。
坑二:改了 babel 配置,报错纹丝不动。 关掉 runtime helper 后重新构建,错误依旧——Metro 的持久缓存还在喂旧 bundle。修复:--reset-cache,并把它写死进构建脚本。教训:调试打包链路,先怀疑缓存。
坑三:autolinking 生成了链接文件,应用还是找不到模块。 npx react-native link-harmony 会生成 RNOHPackagesFactory.ets / .h 和 autolinking.cmake,看着像完事了——但宿主还得手动调 createRNPackages(ctx)、在 CMake 里把 OH_MODULES_DIR 指向 Harmony 根的 oh_modules、加 autolink_libraries(rnoh_app)。生成文件不等于完成接线,README 里专门写了一句大写的提醒。
坑四:textRecognition.recognizeText 直接失败。 忘了 API 12+ 的引擎要先 init()。修复:按次 init() / release()(见 5.2),并检查 init() 返回值——初始化失败要抛出可读的错误,而不是让 recognizeText 抛一个含糊的底层错误。
坑五:解码出的 PixelMap 让识别引擎不认。 Core Vision Kit 要求 RGBA_8888 的 PixelMap,默认解码格式不带 alpha 展开。修复:decodingOptions 显式指定 desiredPixelFormat: image.PixelMapFormat.RGBA_8888。系统 API 之间的"隐式约定"必须写成显式代码。
坑六:DevEco 把本地签名写进了工程。 构建示例时 DevEco 自动注入的签名配置(证书路径 + 密码)差点跟着提交。修复:CHANGELOG 0.2.0 里明确"移除示例工程中由 DevEco Studio 注入的本地签名配置"——签名材料永远不入库,和系列前几篇同一块铁律。
坑七:权限的画蛇添足。 相册识别链路走的是 PhotoViewPicker"安全访问图库"——用户选中哪张、应用只能读哪张,无需申请相册读取权限,也无需运行时授权弹窗。如果照着 Android 的肌肉记忆去申请 READ_IMAGEVIDEO,不仅多余,还会让审核多问一个问题。
7. 现在怎么用:从 tarball 到真机
经过上面的整理,这个工程是双形态交付:npm 包给 JS 侧,HAR 给鸿蒙原生侧。接入已有 RNOH 0.84 应用,先安装本地产物:
# 库根目录生成 npm 压缩包
npm run build:har && npm pack
# 应用 JS 根目录安装
npm install /absolute/path/to/react-native-ohos-ocr-0.2.0.tgz
自动链接(推荐):应用工程启用 RNOH autolinking 后按原有流程构建即可;也可用 CLI 显式生成链接文件:
npx react-native link-harmony --harmony-project-path ./harmony
cd harmony && ohpm install
手动链接:harmony/entry/oh-package.json5 增加 HAR 依赖,ArkTS 侧注册 RNOhosOcrPackage,C++ 侧 PackageProvider.cpp 加入同一个 Package,CMake 增加两行——完整的四步模板在 templates/ 和 README 里,HAR 已含 codegen 生成物,使用方不需要执行 codegen-lib-harmony。
构建与验证一条龙在库根目录(npm test / typecheck / build:har / build:example / verify:package / verify:integration),日志保存在 artifacts/。更完整的接入说明在仓库的文档里:
README.OpenHarmony_CN.md:中文,含环境表、autolinking/手动链接双路径、真机验收步骤;README.OpenHarmony.md:英文,内容与中文版一一对应;templates/:宿主手动接线模板(ArkTS Package / C++ Provider / CMake);VALIDATION.md:本轮全部构建、校验与真机记录;example/:可构建的完整 RNOH 0.84 应用(内置 JS bundle)。
当前版本在 RN 0.84.1 / React 19.2.3 / RNOH 0.84.3、DevEco HarmonyOS SDK API 26 环境下验证:Jest 35/35、node:test 原生模块 5/5、typecheck 0 错误,npm 包(113 个文件约 72 KB)与 HAR(约 13 KB)校验通过,独立消费方 HAP 从 tarball + autolinking 构建成功;真机(HUAWEI ALN-AL00,HarmonyOS 7.0.0.109 / API 26)安装签名 HAP 后页面显示 nativeBridge: OK,原生 checks() 与 filter() 正常(增值税发票过滤 180ms、重识别 revision 递增耗时 181ms),相册识别选中图片后 Core Vision Kit 返回 22 个文本块、耗时约 1.5s。require 启动错误已由 babel 配置 + bundle 检查双保险修复——这是 RN 鸿蒙三方库绕不开的第一课。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)