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 Native0.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-ocr0.2.0(本仓库,npm 包 + source-style HAR)
Node.js / npm24.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 鸿蒙三方库绕不开的第一课。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐