标签:React Native · HarmonyOS / OpenHarmony · RNOH(@rnoh/react-native-openharmony 0.84.3)· New Architecture · TurboModule · Image Kit · PixelMap · 句柄制 · base64 · methodMap


RNOH (React Native for OpenHarmony) 是 OpenHarmony 系统级的 RN 渲染后端与原生桥接实现。
开发工具: 华为云码道

0. 背景

RN 生态里处理图片,常规做法是丢给 <Image> 显示,或者把图片交给原生的图像库。但有一类需求它覆盖不了:直接读写像素。比如取某个坐标的颜色、把一块区域涂成别的颜色、旋转裁剪后重新编码落盘、生成一张纯色画布再逐像素绘制。

iOS 侧这件事由 UIImage + CoreGraphics 承担,Android 侧是 Bitmap,鸿蒙侧则是 Image Kit 的 PixelMap——它提供解码(ImageSource.createPixelMap)、逐像素读写(readPixels/writePixels)、几何变换(scale/rotate/flip/crop)、透明度与格式转换、以及编码(ImagePacker)。

本文记录 react-native-image-pixelmap 从 0 到 1 的完整实现:平台能力调研 → 架构设计 → ArkTS/C++ 编码 → 宿主接入 → 真机验证 → 踩坑与沉淀。全程在 RNOH084Demo(RNOH 0.84.3 + RN 0.84.1)上实测,最终 11 项能力全部跑通。

image-20260921040651614


1. 三平台位图机制对比(决定架构的起点)

动手前先对齐一件事:PixelMap 和 iOS/Android 的位图对象,在能否跨语言边界这件事上差别极大。

维度iOS(UIImage/CGImage)Android(Bitmap)HarmonyOS(PixelMap)
对象性质Objective-C 对象Java 对象ArkTS 原生对象
能否作为桥接返回值✅ 可包装✅ 可包装不能跨 JSI 边界
原始像素载体CGDataProviderByteBuffer / IntArrayArrayBufferPositionArea.pixels
二进制能否作为桥接参数NSDatabyte[]ArrayBuffer 到 ArkTS 侧变成普通对象
像素格式RGBA8888 等ARGB_8888 等PixelMapFormat 枚举(含 BGRA_8888 等)
区域读写格式约定与位图格式一致与位图格式一致⚠️ 固定 BGRA_8888(见 §7.3)
编码UIImagePNGRepresentationBitmap.compressImagePacker.packToData

两条结论,直接决定了本库的架构:

  1. PixelMap 不能跨 JSI 边界 → 它既不能作为 TurboModule 的返回值,也不能作为参数。必须引入一层句柄(handle):原生侧持有位图,只把不透明的数字 id 交给 JS。
  2. 原始像素缓冲区不能作为 TurboModule 值传递ArrayBuffer 参数到 ArkTS 侧会退化成普通对象,二进制数据传不过去。像素必须走字符串(本库选 base64)。

顺带一个容易忽略的点:PixelMap原生资源,不受 JS GC 管理。iOS/Android 的位图对象通常能靠引用计数或 GC 回收,而鸿蒙这里必须显式 release()——这决定了 API 里必须有生命周期方法。


2. 平台能力调研(核心决策点)

接下来逐个确认"每个能力在鸿蒙上到底有没有对应 API"。这一步比写代码重要——先查清 API 语义,才能避免写完才发现方向错了

调研方法是直接 grep 本地 SDK 的 .d.ts,不依赖记忆与文档:

SDK=/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/ets/api
# PixelMap 的公开方法
awk '/interface PixelMap/,/^    }/' $SDK/@ohos.multimedia.image.d.ts \
  | grep -E "^\s{4,8}(readonly )?[a-zA-Z]+\??\(" | head -50

PixelMap 的能力清单(API 26 SDK)大致分五类:

解码/创建  createPixelMapSync / createPixelMapFromPixels / clone
元信息     getImageInfoSync / getPixelBytesNumber / getBytesNumberPerRow / getDensity
像素读写   readPixelsToBufferSync / readPixelsSync(PositionArea)
           writeBufferToPixelsSync / writePixelsSync(PositionArea)
几何变换   scale / rotate / flip / crop(各含 Sync 与 Promise 两版)
颜色格式   setOpacity / convertPixelFormat / applyColorSpace
编码       (不在 PixelMap 上,而是 ImagePacker.packToData)

关键发现(本库最重要的两个决策点):

发现影响
readPixels/writePixelsPositionArea 版本固定按 BGRA_8888 读写,与位图自身格式无关若要给 JS 统一 RGBA8888 契约,库内必须做 B/R 通道交换
createPixelMapSync(buffer, options) 若不显式给 srcPixelFormat,系统按 BGRA_8888 解释输入缓冲区RGBA 字节会被误读,R/B 互换(实测写纯蓝读回纯红)
onUpdateForm 式的"返回值被忽略"陷阱在 PixelMap 上没有,但 convertPixelFormat 会拒绝部分源/目标组合需要回退路径,不能只依赖 in-place 转换

其余能力对应关系:

能力鸿蒙 API结论
从文件解码image.createImageSource(path).createPixelMap()✅ 完整实现
从内存解码createImageSource(ArrayBuffer)✅ 完整实现(配合 base64 解码)
创建空白位图createPixelMapSync(buffer, options)✅ 完整实现
读像素readAllPixelsToBufferSync / readPixelsSync✅ 但需处理 stride 与 BGRA
写像素writeBufferToPixelsSync / writePixelsSync✅ 需 editable: true
几何变换scale / rotate / flip / crop✅ 均为 in-place
透明度setOpacity✅ 完整实现
格式转换convertPixelFormat⚠️ in-place 可能被拒(见 §7.4),需回退
编码ImagePacker.packToData(pixelMap, option)✅ PNG/JPEG/WebP

3. 架构设计:句柄制 + base64

3.1 两条不可逾越的边界

┌──────────────────────────────┐              ┌───────────────────────────────────┐
│  JS / RN 侧(Hermes)         │              │  ArkTS 侧(RNOH TurboModule)      │
│                              │              │                                   │
│  ImagePixelmap.decodeFromFile│──── 字符串 ──►│  image.createImageSource(path)    │
│                              │              │         .createPixelMap()         │
│                              │              │              │                    │
│  { handleId: 2, width: 800 } │◄── 数字 ─────│  PixelMapRegistry.register()      │
│         ▲                    │              │         │                         │
│         │ 只传 handleId      │              │         ▼                         │
│         │                    │              │  ┌─────────────────────────────┐  │
│  bmp.rotate(90)              │── handleId ─►│  │ Map<number, PixelMap>       │  │
│                              │              │  │  ↑ 原生对象留在这里,不跨界   │  │
│  const b64 = await           │              │  └─────────────────────────────┘  │
│    bmp.readPixels()          │◄─ base64 ────│  readAllPixelsToBufferSync()      │
└──────────────────────────────┘   字符串      └───────────────────────────────────┘

边界一:PixelMap 不能跨界 → 用 PixelMapRegistry 持有一张 Map<number, PixelMap>,只把 handleId 交给 JS。

边界二:二进制不能作为 TurboModule 值 → 像素以 base64 字符串传输。

3.2 为什么用「自增 id」而不是「数组下标」

注册表用单调递增的 id,而不是数组下标:

private static nextId: number = 1;
private static entries: Map<number, RegistryEntry> = new Map();

static register(pixelMap: image.PixelMap): number {
  const handleId = PixelMapRegistry.nextId;
  PixelMapRegistry.nextId += 1;
  PixelMapRegistry.entries.set(handleId, { handleId, pixelMap });
  return handleId;
}

原因:陈旧句柄绝不能静默命中新位图。若用下标,release(0) 后再 register 会复用 id 0,JS 侧一个未清理的旧句柄就会操作到完全无关的新位图——这类 bug 极难定位。自增 id 让陈旧句柄必然落到 require() 的"unknown handle"分支,直接报错。

3.3 为什么像素走 base64,而不是别的方案

方案可行性结论
ArrayBuffer 直接传❌ 到 ArkTS 侧变成普通对象,二进制丢失不可行
数字数组 number[]✅ 但 800×500 位图 = 160 万个数字,JSON 序列化开销巨大不可行
base64 字符串✅ 通用、无歧义、Image Kit 编解码天然对接采用
临时文件中转✅ 但引入 IO 与清理负担,小图不划算仅用于 encodeToFile

base64 的代价是体积膨胀约 33%:800×500 的位图读全图约 2.1MB 字符串。因此 API 里同时提供 readPixels(region) 只读区域,以及 encodeToFile 直接落盘(避免 base64 往返)——这是把"传输代价"的选择权交给调用方。

3.4 统一 RGBA8888 契约

Image Kit 有个不对称之处:readPixels/writePixelsPositionArea 版本固定按 BGRA_8888 工作,与位图自身格式无关;而 readAllPixelsToBufferSync 按位图自身格式工作。

如果不处理,JS 侧就会拿到"有时 RGBA、有时 BGRA"的像素,调用方无法写通用代码。本库的做法是统一成 RGBA8888

  • 全图读取:若位图非 RGBA8888,先 clone() + convertPixelFormat() 到 RGBA8888 再读
  • 区域读写:读出后做 B/R 通道交换(bgraToRgbaInPlace),写入前反向交换

实测验证:写入纯红 rgba(255,0,0,255),读回仍是 rgba(255,0,0,255)

3.5 对外 API 设计

JS 侧暴露 8 个 API 方法 + 3 个生命周期方法,命名对齐位图领域的通用词汇:

方法返回说明
decodeFromFile(path, options?)Promise<Bitmap>从文件/URI 解码
decodeFromBase64(base64, options?)Promise<Bitmap>从 base64 解码
createEmpty(w, h, color?, format?)Promise<Bitmap>分配空白位图(color0xRRGGBBAA
getInfo(target)Promise<PixelMapInfo>元信息
getPixelColor(target, x, y)Promise<number>单像素颜色
readPixels(target, region?)Promise<string>读像素(base64,RGBA8888)
writePixels(target, base64, region?)Promise<boolean>写像素(需 editable)
scale / rotate / flip / cropPromise<PixelMapInfo>几何变换
setOpacity / convertPixelFormatPromise<PixelMapInfo>颜色与格式
encodeToBase64 / encodeToFilePromise<string | boolean>编码
release / releaseAll / getHandleCountPromise<…>生命周期

设计取舍一:target 接受两种形态。 既能传 Bitmap 对象,也能传裸 handleId

const toHandle = (target: Bitmap | number): number =>
  typeof target === 'number' ? target : target.handleId;

这样面向对象风格(bmp.rotate(90))与函数式风格(ImagePixelmap.rotate(id, 90))可以混用,也方便跨组件传递时只存一个数字。

设计取舍二:变换方法返回元信息而不是 void 因为 scale/crop 会改变宽高,调用方需要立刻拿到新尺寸;同时 Bitmap 类上同名方法会自动刷新缓存字段

async rotate(angle: number): Promise<this> {
  return this.applyInfo(await ImagePixelmap.rotate(this, angle));
}

设计取舍三:位图不自动回收。 不给 BitmapFinalizationRegistry 之类的兜底——因为原生资源释放时机必须确定,隐式回收会让内存峰值不可控。取而代之的是:__onDestroy__ 时 RN 实例销毁会 releaseAll(),兜住整实例退出这一种场景;其余靠调用方显式 release(),并在文档里反复强调。


4. 工程结构

react-native-image-pixelmap/
├── src/                                  # JS/TS 侧
│   ├── index.tsx                         # 对外 API + Bitmap 类 + 常量/颜色工具
│   └── NativeImagePixelmap.ts            # TurboModule spec
├── harmony/image_pixelmap/               # 鸿蒙 HAR 模块
│   ├── Index.ets                         # 双导出(命名 + default)
│   └── src/main/
│       ├── cpp/                          # C++ 胶水层(autolinking 必需 + methodMap_)
│       └── ets/
│           ├── ImagePixelmapTurboModule.ets  # ★ TurboModule:21 个方法
│           ├── ImagePixelmapPackage.ets      # RNOHPackage 注册
│           └── pixelmap/
│               ├── PixelMapRegistry.ets      # ★ 句柄注册表
│               ├── PixelMapCodec.ets         # ★ 解码/编码/像素读写/元信息
│               ├── PixelMapTransform.ets     # 几何与颜色变换(含格式转换回退)
│               ├── PixelMapBytes.ets         # base64 与文件 IO
│               ├── PixelMapConstants.ets     # 格式映射、字节数、默认值
│               ├── PixelMapTypes.ets         # 跨边界结构体
│               └── PixelMapLogger.ets        # hilog 封装 + 错误码提取
├── example/                              # 参考测试页(随库发布)
├── tools/build-har.sh                    # 打包源码式 HAR
└── tools/verify-pixelmap-demo.py         # 一键端到端验证

5. 核心实现

5.1 第一课:HAR 必须是"源码式"包

这是整个适配里最容易卡住的地方,先讲。RNOH autolinking 生成的 autolinking.cmake无条件对每个库执行:

function(autolink_libraries target)
    add_subdirectory("${OH_MODULES_DIR}/@rnoh/react-native-image-pixelmap/src/main/cpp" ./rnoh__react_native_image_pixelmap)

由此产生两个硬性要求:

  1. 纯 ArkTS 库也必须提供 src/main/cpp(哪怕只有一个空 .cpp),否则 CMake 直接报目录不存在;
  2. HAR 必须包含 src/main/cpp——但 hvigorw assembleHar 的产物只有编译后的 modules.abc + .d.ets不含 native 源码
# assembleHar 的产物(不含 cpp,autolinking 会失败)
$ tar -tzf image_pixelmap.har
package/Index.d.ets
package/ets/modules.abc
package/ets/sourceMaps.map
package/src/main/ets/ImagePixelmapTurboModule.d.ets

对比一个能在 RNOH 里正常工作的 HAR,它是源码式的:

$ tar -tzf image_pixelmap.har      # 由 tools/build-har.sh 产出
package/Index.ets                        ← 源码,不是 .d.ets
package/src/main/ets/ImagePixelmapPackage.ets
package/src/main/cpp/CMakeLists.txt      ← native 源码
package/src/main/cpp/ReactNativeImagePixelmapPackage.h
package/src/main/module.json
package/oh-package.json5                 ← 含 metadata.sourceRoots

关键差异在 oh-package.json5metadata

{
  "name": "@rnoh/react-native-image-pixelmap",
  "main": "Index.ets",
  "metadata": {
    "sourceRoots": ["./src/main"],   // 标记为源码包,让宿主能解析源码与 cpp
    "debug": true
  }
}

所以 tools/build-har.sh从源码组装 HAR,而不是用 assembleHar

cp "${MODULE_DIR}/Index.ets" "${PKG_DIR}/Index.ets"
cp -R "${MODULE_DIR}/src/main/ets/." "${PKG_DIR}/src/main/ets/"   # ArkTS 源码
cp -R "${MODULE_DIR}/src/main/cpp/." "${PKG_DIR}/src/main/cpp/"   # native 源码(关键)
# ... 生成 module.json / oh-package.json5 / BuildProfile.ets / ResourceTable.txt

# COPYFILE_DISABLE=1 阻止 macOS 注入 ._* AppleDouble 垃圾文件
# (它们会被 CMake 的 file(GLOB) 当成 .cpp 捞进编译,直接构建失败)
( cd "${STAGE_DIR}" && COPYFILE_DISABLE=1 tar --exclude='._*' -czf "${OUTPUT_HAR}" package )

还有一个相关约束:harmony/ 目录下只能有一个 .har。autolinking 在多 HAR 时会追加 --<har名> 后缀:

// react-native-harmony-cli/src/autolinking/Autolinking.ts
const suffix = harFilePaths.length > 1
  ? '--' + pathUtils.basename(harFileName, '.har')
  : '';

一旦变成 @rnoh/react-native-image-pixelmap--image_pixelmap,生成的 RNOHPackagesFactory.ets 就 import 不到。修法:把 build/ 清理掉,只保留根目录的 image_pixelmap.har

5.2 PixelMapRegistry:句柄制的核心

export class PixelMapRegistry {
  private static nextId: number = 1;
  private static entries: Map<number, RegistryEntry> = new Map();

  /** 存入位图并返回句柄 */
  static register(pixelMap: image.PixelMap): number {
    const handleId: number = PixelMapRegistry.nextId;
    PixelMapRegistry.nextId += 1;
    PixelMapRegistry.entries.set(handleId, { handleId, pixelMap });
    PixelMapLogger.info(`register handle=${handleId}, live=${PixelMapRegistry.entries.size}`);
    return handleId;
  }

  /** 取位图;句柄未知或已释放则抛错 */
  static require(handleId: number): image.PixelMap {
    const entry = PixelMapRegistry.entries.get(handleId);
    if (entry === undefined) {
      throw new Error(
        `PixelMap handle ${handleId} is unknown or already released ` +
          `(live handles: ${PixelMapRegistry.entries.size})`
      );
    }
    return entry.pixelMap;
  }

  /** 释放单张;幂等——重复释放返回 false 而非抛错 */
  static release(handleId: number): boolean {
    const entry = PixelMapRegistry.entries.get(handleId);
    if (entry === undefined) {
      return false;
    }
    PixelMapRegistry.entries.delete(handleId);
    try {
      entry.pixelMap.release();
    } catch (error) {
      // 重复释放不应让 JS 调用失败;句柄已经没了
      PixelMapLogger.warn(`release(${handleId}) failed: ${JSON.stringify(error)}`);
    }
    return true;
  }
}

两个刻意的设计:

  • release 幂等:重复释放返回 false 而不抛错。JS 侧常见 try/finally 清理模式,若重复释放报错会污染业务异常栈。
  • replace 保持句柄稳定:某些操作无法原地完成(见 §5.5 的格式转换),需要换掉底层位图但不能改变 JS 手里的句柄:
static replace(handleId: number, pixelMap: image.PixelMap): boolean {
  const entry = PixelMapRegistry.entries.get(handleId);
  if (entry === undefined) {
    // 为已失效句柄建了替换位图:直接释放,避免泄漏原生内存
    pixelMap.release();
    return false;
  }
  PixelMapRegistry.entries.set(handleId, { handleId, pixelMap });
  entry.pixelMap.release();      // 旧位图在此释放
  return true;
}

5.3 TurboModule 实现

export class ImagePixelmapTurboModule extends AnyThreadTurboModule {
  public static readonly NAME = TM_NAME;   // 'ImagePixelmap',与 JS getEnforcing 一致

  async decodeFromFile(path: string, options?: DecodeOptions): Promise<PixelMapInfoResult> {
    if (path === undefined || path === null || path.length === 0) {
      throw new Error('decodeFromFile: path is required');
    }
    const pixelMap: image.PixelMap = await PixelMapCodec.decodeFromFile(path, options);
    return this.register(pixelMap);
  }

  /** 注册新位图并返回 JS 侧元信息;注册失败时释放位图,绝不泄漏 */
  private register(pixelMap: image.PixelMap): PixelMapInfoResult {
    try {
      const handleId: number = PixelMapRegistry.register(pixelMap);
      return PixelMapCodec.info(handleId, pixelMap);
    } catch (error) {
      PixelMapLogger.error(`register failed, releasing bitmap`);
      try {
        pixelMap.release();
      } catch (releaseError) {
        PixelMapLogger.warn(`cleanup release failed: ${JSON.stringify(releaseError)}`);
      }
      throw error as Error;
    }
  }

  __onDestroy__(): void {
    super.__onDestroy__();
    // 位图是原生资源:RN 实例销毁时统一释放,不依赖 JS 侧调用
    const released: number = PixelMapRegistry.releaseAll();
    PixelMapLogger.info(`__onDestroy__ released ${released} bitmap(s)`);
  }
}

一个容易漏的点:register 里若元信息组装失败(比如 getImageInfoSync 抛错),位图已经创建但句柄未注册,必须显式释放,否则这张位图永远无人回收。

5.4 PixelMapCodec:解码 / 像素读写 / 编码

这是体量最大的模块。挑三个最有信息量的实现讲。

① 解码:editable 必须显式下发

private static toDecodingOptions(options?: DecodeOptions): image.DecodingOptions {
  const result: image.DecodingOptions = {};
  // 默认 editable:变换与写像素 API 都需要它,否则后续会以
  // "the bitmap is not editable" 这种不直观的方式失败。
  // 注意:即使调用方完全没传 options 也必须设置——RNOH 把省略的可选参数
  // 作为 `null` 下发,而不是 `undefined`。
  const editable: boolean | undefined = options?.editable;
  result.editable = editable === undefined || editable === null ? true : editable;

  if (options === undefined || options === null) {
    return result;
  }
  // ... desiredSize / desiredPixelFormat / rotate
  return result;
}

这里藏着两个坑,后面 §7 会展开:editable 默认值必须显式给,以及可选参数要同时判 nullundefined

② 全图读取:必须处理 stride

getPixelBytesNumber() 返回的总字节数包含行对齐填充getBytesNumberPerRow() 返回的才是真实行宽。若直接当紧凑缓冲区用,图像会斜切:

private static readAll(pixelMap: image.PixelMap): string {
  const info = pixelMap.getImageInfoSync();
  const width = info.size.width;
  const height = info.size.height;
  const bytesPerRow = pixelMap.getBytesNumberPerRow();
  const totalBytes = pixelMap.getPixelBytesNumber();
  const rowBytes = width * RGBA_BYTES;

  const raw = new ArrayBuffer(totalBytes);
  pixelMap.readAllPixelsToBufferSync(raw);

  // bytesPerRow 可能大于 width*4(行对齐);逐行拷贝成紧凑布局
  if (bytesPerRow === rowBytes) {
    return PixelMapBytes.toBase64(raw);
  }
  const rawView = new Uint8Array(raw);
  const tight = new Uint8Array(rowBytes * height);
  for (let y = 0; y < height; y++) {
    tight.set(rawView.subarray(y * bytesPerRow, y * bytesPerRow + rowBytes), y * rowBytes);
  }
  return PixelMapBytes.toBase64(tight.buffer as ArrayBuffer);
}

实测的 800×500 位图:bytes=1600000(= 800×500×4,无填充)、stride=3200(= 800×4)。这个例子恰好无填充,但代码不能假设如此。

③ 区域读写:BGRA → RGBA 通道交换

Image Kit 的区域 API 固定输出 BGRA_8888,所以读完要交换 B/R:

/** 原地交换 B 与 R 通道(BGRA_8888 <-> RGBA_8888) */
private static bgraToRgbaInPlace(buffer: ArrayBuffer): void {
  const view = new Uint8Array(buffer);
  for (let i = 0; i + 3 < view.length; i += RGBA_BYTES) {
    const b = view[i];
    view[i] = view[i + 2];
    view[i + 2] = b;
  }
}

写入时反向交换,且先拷贝再交换——绝不能污染调用方传进来的缓冲区:

// 区域 API 基于 BGRA_8888(与读路径对称),因此 RGBA8888 负载要先交换。
// 先 slice 拷贝:调用方的 buffer 不能被改动。
const payload: ArrayBuffer = buffer.slice(0);
if (info.pixelFormat === image.PixelMapFormat.RGBA_8888) {
  PixelMapCodec.bgraToRgbaInPlace(payload);
}

④ 单像素取色:复用区域读取,而不是新写一套

static async getPixelColor(pixelMap: image.PixelMap, x: number, y: number): Promise<number> {
  const info = pixelMap.getImageInfoSync();
  if (x < 0 || y < 0 || x >= info.size.width || y >= info.size.height) {
    throw new Error(`getPixelColor: (${x}, ${y}) is outside the bitmap ${info.size.width}x${info.size.height}`);
  }
  const base64 = await PixelMapCodec.readPixels(pixelMap, { x, y, width: 1, height: 1 });
  const bytes = new Uint8Array(PixelMapBytes.fromBase64(base64));
  // `>>> 0` 保证结果是 JS 侧的无符号 32 位
  return (((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0);
}

复用 readPixels(region) 的好处是:非 RGBA8888 位图的格式归一化、BGRA 交换、边界校验全部走同一条已验证路径,不必重复实现。

⑤ 编码:ImagePacker 的释放必须放在 finally

static async encode(pixelMap: image.PixelMap, format: string, quality: number): Promise<ArrayBuffer> {
  const packer: image.ImagePacker = image.createImagePacker();
  try {
    const option: image.PackingOption = {
      format: format.length > 0 ? format : DEFAULT_ENCODE_FORMAT,
      quality: PixelMapCodec.clampQuality(quality),
    };
    return await packer.packToData(pixelMap, option);
  } finally {
    packer.release();   // 编码失败也要释放,否则泄漏
  }
}

5.5 PixelMapTransform:格式转换的回退路径

几何变换(scale/rotate/flip/crop)本身是直接调用,但有两个工程化处理值得说。

① 统一的可编辑性前置检查

变换 API 对只读位图会抛一个语焉不详的原生错误,所以先自查:

private static assertEditable(pixelMap: image.PixelMap, operation: string): void {
  if (!pixelMap.isEditable) {
    throw new Error(
      `${operation}: the bitmap is not editable, decode/create it with editable: true`
    );
  }
}

crop 的越界预校验

系统对越界裁剪的错误码同样不直观,先在库内算清楚:

if (region.x < 0 || region.y < 0 ||
    region.x + region.width > info.size.width ||
    region.y + region.height > info.size.height) {
  throw new Error(
    `crop: region (${region.x}, ${region.y}, ${region.width}x${region.height}) ` +
      `exceeds the bitmap ${info.size.width}x${info.size.height}`
  );
}

③ 格式转换的两段式:in-place 失败则重建

这是本库唯一一处"主动兜住平台缺陷"的设计。convertPixelFormat 的 in-place 版本在部分源/目标组合下会被系统拒绝:

code=62980115, message=source format is wrong!

实测(HarmonyOS 6.0.0 模拟器 API 26)在解码位图新建位图上、对 BGRA_8888/ARGB_8888/RGB_565 三种目标全部复现。既然是系统行为,就不能只把它记成"不可用",而是补一条等效路径:

static async convertPixelFormat(pixelMap: image.PixelMap, format: string): Promise<image.PixelMap> {
  PixelMapTransform.assertEditable(pixelMap, 'convertPixelFormat');
  const target = resolvePixelFormat(format);
  const current = pixelMap.getImageInfoSync().pixelFormat;
  if (current === target) {
    return pixelMap;
  }
  try {
    await pixelMap.convertPixelFormat(target);
    return pixelMap;                       // 同实例,句柄无需变
  } catch (error) {
    PixelMapLogger.warn(
      `convertPixelFormat ${format} in-place failed (${PixelMapLogger.describe(error)}), falling back to rebuild`
    );
    return PixelMapTransform.rebuildAs(pixelMap, target, format);
  }
}

/** 用 createPixelMapSync 在分配时完成转换 */
private static rebuildAs(pixelMap, target, format): image.PixelMap {
  const info = pixelMap.getImageInfoSync();
  const sourceBytes = PixelMapCodec.readNativeTight(pixelMap);
  return image.createPixelMapSync(sourceBytes, {
    size: { width: info.size.width, height: info.size.height },
    // 字节是位图自身格式;必须声明,否则系统按默认 BGRA_8888 误读
    srcPixelFormat: info.pixelFormat,
    pixelFormat: target,
    editable: true,
  });
}

回退返回的是新位图,因此 TurboModule 侧要把它注册回同一个句柄:

const result = await PixelMapTransform.convertPixelFormat(pixelMap, format);
if (result !== pixelMap) {
  PixelMapRegistry.replace(handleId, result);   // 句柄不变,旧位图在此释放
}
return PixelMapCodec.info(handleId, PixelMapRegistry.require(handleId));

对 JS 完全透明——调用方拿到的 handleId 始终不变。实测日志:

convertPixelFormat RGB_565 in-place failed (code=62980115, message=source format is wrong!), falling back to rebuild
convertPixelFormat RGB_565 (rebuilt) -> ...
replace handle=4, live=1

5.6 错误可读性:把 [object Object] 变成错误码

ArkTS 的 BusinessError 直接字符串化会得到 [object Object],JS 侧完全无法定位问题。本库在 Logger 里统一提取:

static describe(error: Object): string {
  const err = error as BusinessError;
  if (err !== undefined && err !== null && err.code !== undefined) {
    return `code=${err.code}, message=${err.message}`;
  }
  return `${error}`;
}

/** 记录日志并抛出带上下文的 JS 友好错误 */
static fail(operation: string, error: Object): Error {
  const detail = PixelMapLogger.describe(error);
  PixelMapLogger.error(`${operation} failed: ${detail}`);
  return new Error(`${operation} failed: ${detail}`);
}

这个改动看似小,实际是定位问题的关键——正是靠它才拿到 code=62980115, message=source format is wrong!,从而区分出"系统限制"而非"参数拼装错误"。

5.7 C++ 胶水层:两件事,少一件就挂

src/main/cpp 需要三样东西:CMakeLists.txtempty.cpp(凑编译单元)、Package 头文件。CMakeLists.txt 的 target 名必须与 autolinking 推导一致:

file(GLOB image_pixelmap_SRC CONFIGURE_DEPENDS *.cpp)
add_library(rnoh__react_native_image_pixelmap SHARED ${image_pixelmap_SRC})
target_include_directories(rnoh__react_native_image_pixelmap PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(rnoh__react_native_image_pixelmap PUBLIC rnoh)

target 命名规则:npm 包名转 snake 并加 rnoh__ 前缀;含 scope 时形如 rnoh__<scope>__<name>

头文件里则是最关键的一段 C++——方法表:

/**
 * 方法表是必须的:裸 ArkTSTurboModule 不暴露任何方法,
 * 少了它 JS 调用一律报 "TypeError: undefined is not a function"。
 */
class ImagePixelmapArkTSTurboModule : public ArkTSTurboModule {
 public:
  ImagePixelmapArkTSTurboModule(const ArkTSTurboModule::Context ctx, const std::string name)
      : rnoh::ArkTSTurboModule(ctx, name) {
    methodMap_ = {
        ARK_ASYNC_METHOD_METADATA(addListener, 1),
        ARK_ASYNC_METHOD_METADATA(removeListeners, 1),
        ARK_ASYNC_METHOD_METADATA(decodeFromFile, 2),
        ARK_ASYNC_METHOD_METADATA(decodeFromBase64, 2),
        ARK_ASYNC_METHOD_METADATA(createEmpty, 4),
        ARK_ASYNC_METHOD_METADATA(getInfo, 1),
        ARK_ASYNC_METHOD_METADATA(getPixelColor, 3),
        ARK_ASYNC_METHOD_METADATA(isReleased, 1),
        ARK_ASYNC_METHOD_METADATA(readPixels, 2),
        ARK_ASYNC_METHOD_METADATA(writePixels, 3),
        ARK_ASYNC_METHOD_METADATA(scale, 3),
        ARK_ASYNC_METHOD_METADATA(rotate, 2),
        ARK_ASYNC_METHOD_METADATA(flip, 3),
        ARK_ASYNC_METHOD_METADATA(crop, 2),
        ARK_ASYNC_METHOD_METADATA(setOpacity, 2),
        ARK_ASYNC_METHOD_METADATA(convertPixelFormat, 2),
        ARK_ASYNC_METHOD_METADATA(encodeToBase64, 3),
        ARK_ASYNC_METHOD_METADATA(encodeToFile, 4),
        ARK_ASYNC_METHOD_METADATA(release, 1),
        ARK_ASYNC_METHOD_METADATA(releaseAll, 0),
        ARK_ASYNC_METHOD_METADATA(getHandleCount, 0),
    };
  }
};

第二件事是 TurboModuleFactoryDelegate。官方 codegen 生成的 Base 包会自动提供它,手动适配必须自己实现,否则运行期报 Couldn't find Turbo Module on the ArkTs side ... on the CPP side

class ImagePixelmapTurboModuleFactoryDelegate : public TurboModuleFactoryDelegate {
 public:
  SharedTurboModule createTurboModule(Context ctx, const std::string& name) const override {
    if (name == "ImagePixelmap") {
      return std::make_shared<ImagePixelmapArkTSTurboModule>(ctx, name);
    }
    return nullptr;
  }
};

注意宏的选择:返回 Promise 的方法用 ARK_ASYNC_METHOD_METADATA,同步方法用 ARK_METHOD_METADATA(RNOH 没有同步专用宏,同步方法也用这个)。


6. 宿主接入:三步

RNOH084Demo 为宿主(RNOH 0.84.3 + RN 0.84.1)。

6.1 装 JS 依赖并接入 autolinking

库的 package.json 已声明 autolinking 配置:

{
  "harmony": {
    "autolinking": {
      "ohPackageName": "@rnoh/react-native-image-pixelmap",
      "etsPackageClassName": "ImagePixelmapPackage"
    }
  }
}

宿主 harmony/entry/oh-package.json5 增加依赖后执行 ohpm install --all

{
  "dependencies": {
    "@rnoh/react-native-image-pixelmap": "file:../node_modules/react-native-image-pixelmap/harmony/image_pixelmap.har"
  }
}

ohpm install 会触发 RNOH autolinking,自动生成两样东西:

// harmony/entry/src/main/ets/RNOHPackagesFactory.ets(自动生成)
import ImagePixelmapPackage from '@rnoh/react-native-image-pixelmap';
export function createRNOHPackages(ctx: RNPackageContext): RNOHPackage[] {
  return [ /* ... */ new ImagePixelmapPackage(ctx) /* ... */ ];
}
# harmony/entry/src/main/cpp/autolinking.cmake(自动生成)
add_subdirectory("${OH_MODULES_DIR}/@rnoh/react-native-image-pixelmap/src/main/cpp" ./rnoh__react_native_image_pixelmap)

注意 import 名是 @rnoh/react-native-image-pixelmapoh-package 名),不是 npm 包名。写成 npm 名会编译报 Failed to resolve OhmUrl

6.2 无需额外权限

本库只操作应用沙箱内的图片文件与内存位图,不申请任何权限。若要从相册读图,需宿主自行申请 ohos.permission.READ_IMAGEVIDEO 并传入可访问路径——这是宿主的职责,库不做假设。

6.3 业务代码

import ImagePixelmap, { unpackColor } from 'react-native-image-pixelmap';

// 解码
const bmp = await ImagePixelmap.decodeFromFile('/data/.../files/card_test.png');
console.log(bmp.handleId, bmp.width, bmp.height, bmp.pixelFormat);

// 取色
const { r, g, b, a } = unpackColor(await bmp.getPixelColor(0, 0));

// 变换 + 编码
await bmp.rotate(90);
await bmp.encodeToFile('/data/.../files/out.jpg', 'image/jpeg', 80);

// 释放(原生资源,不受 GC 管理)
await bmp.release();

本库的参考测试页 example/PixelMapTestApp.tsx 覆盖 11 项能力,可直接复制到宿主工程:

cp node_modules/react-native-image-pixelmap/example/PixelMapTestApp.tsx <宿主根目录>/

在宿主 index.js 注册为独立页面,用 rnAppKey 单独启动:

import PixelMapTestApp from './PixelMapTestApp';
AppRegistry.registerComponent('PixelMapTestApp', () => PixelMapTestApp);
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey PixelMapTestApp

7. 踩坑实录(10 个)

说明:这 10 个坑分两类,区别很重要——
① 本库实测踩到的(7 个):有本库自己的设备日志或提交记录佐证,见坑 1–4、8–10。
② 开发前已规避的(3 个,坑 5/6/7):这三条是 RNOH 适配的通用陷阱,我在动手前
已经从姊妹库 react-native-ohos-form 的经历里知道,因此本库首次提交就已写对
git show 87bca61 可验证)。列出来是因为它们对任何 RNOH 适配都适用,
提前知道就能少踩——而不是本库事后补救的结果。

坑 1:srcPixelFormat 默认是 BGRA_8888,写纯蓝读回纯红(最隐蔽)

  • 现象createEmpty(32, 24, 0x0000ffff) 建了一张"纯蓝"画布,getPixelColor(2,2) 读回来却是 rgba(255,0,0,255)——纯红。

  • 根因image.createPixelMapSync(buffer, options) 若只给 pixelFormat 而不给 srcPixelFormat,系统会按 BGRA_8888 解释输入缓冲区。我按 RGBA 顺序填的字节被当成 BGRA,于是 R/B 互换。

  • 修复:显式声明输入格式:

    const options: image.InitializationOptions = {
      size: { width, height },
      srcPixelFormat: image.PixelMapFormat.RGBA_8888,   // 关键
      pixelFormat: image.PixelMapFormat.RGBA_8888,
      editable: true,
    };
    
  • 经验:这个坑的隐蔽之处在于不报错,只是颜色不对。凡是"颜色通道看起来反了"的现象,第一反应就该查 srcPixelFormatpixelFormat 是否配对。

坑 2:区域读写 API 固定 BGRA_8888

  • 现象readPixels({x,y,width,height}) 读出的像素 R/B 与全图读取不一致。
  • 根因:Image Kit 文档明确 readPixels/writePixelsPositionArea 版本按 BGRA_8888 读写,与位图自身格式无关;而 readAllPixelsToBufferSync 按位图自身格式。两条路径语义不同。
  • 修复:区域读写统一做 B/R 通道交换(bgraToRgbaInPlace),使 JS 侧永远拿到 RGBA8888。写入时先 slice(0) 拷贝再交换,避免污染调用方缓冲区。
  • 经验:同一个能力有多个 API 入口时,先确认它们的格式/单位约定是否一致,不要假设同源。

坑 3:RNOH 对省略的可选参数传 null,不是 undefined

  • 现象bmp.readPixels()(不传 region,意图读全图)抛 TypeError: Cannot read property width of null

  • 根因:JS 省略可选参数时,ArkTS 侧收到的是 null。我写的是 if (region === undefined),判断失效,走进了区域分支并访问 region.width

  • 修复:所有可选参数判断同时检查两者:

    if (region === undefined || region === null) {
      return PixelMapCodec.readAll(source);
    }
    
  • 经验:这是 RNOH 的通用行为,任何 ArkTS TurboModule 的可选参数都要防。建议统一写成 x === undefined || x === null,或抽一个 isAbsent() 帮助函数。

坑 4:editable 默认值必须显式下发,否则后续写像素失败

  • 现象:解码后 writePixelswritePixels: the bitmap is not editable, decode/create it with editable: true

  • 根因toDecodingOptions 里我把 editable 的设置放在了 options == null提前返回之后,导致调用方不传 options 时 editable 从未被设置(系统默认只读)。

  • 修复:把 editable 提到提前返回之前,并对 null 一并处理:

    const editable: boolean | undefined = options?.editable;
    result.editable = editable === undefined || editable === null ? true : editable;
    
    if (options === undefined || options === null) {
      return result;      // 提前返回时 editable 已经设置好了
    }
    
  • 经验:默认值的设置位置要在所有提前返回之前。这类 bug 的表现是"不传参数时坏、传参数时好",很容易被误判为"参数格式问题"。

坑 5:assembleHar 的产物不含 src/main/cpp(开发前已规避)

  • 现象:宿主构建报 CMake 找不到 oh_modules/@rnoh/react-native-image-pixelmap/src/main/cpp
  • 根因autolinking.cmake无条件 add_subdirectory(<har>/src/main/cpp),而 hvigorw assembleHar 产出的是编译包(modules.abc + .d.ets),不含 native 源码。
  • 修复:写 tools/build-har.sh 从源码组装"源码式 HAR"(含 src/main/cpp + 源 .ets + metadata.sourceRoots)。
  • 经验:判断 HAR 是否可用,看解包后有没有 src/main/cpp/CMakeLists.txtIndex.ets(而不是 Index.d.ets)。

坑 6:harmony/ 下两个 HAR → 包名被追加 --image_pixelmap 后缀(开发前已规避)

  • 现象:编译报 Cannot find module '@rnoh/react-native-image-pixelmap--image_pixelmap'
  • 根因:autolinking 在多 HAR 时按 ohPackageName + '--' + har文件名 命名。当时 build/ 里留着 assembleHar 产物,根目录又有手打包的 HAR,被判定成两个。
  • 修复rm -rf harmony/image_pixelmap/build,只保留 harmony/image_pixelmap.har.gitignore 忽略 harmony/**/build/ 并显式 !harmony/*.har 保留根 HAR。
  • 经验harmony/ 下只能有一个 .har

坑 7:C++ 胶水层缺 methodMap_,JS 调所有方法都 “undefined is not a function”(开发前已规避)

  • 现象TM created: ImagePixelmap 注册成功、TurboModule created 也打印了,但一点按钮就报:

    [pixelmap-test] isFormSupported error: TypeError: undefined is not a function
    
  • 根因:C++ 侧返回的是裸 ArkTSTurboModulestd::make_shared<ArkTSTurboModule>(ctx, name)),它不暴露任何方法。方法可见性完全由 methodMap_ 决定。

  • 修复:定义子类 ImagePixelmapArkTSTurboModule,在构造函数里填 methodMap_(见 §5.7)。

  • 经验TM created 只代表注册成功,不代表方法可用。方法型库排查第一顺位永远是 methodMap_

坑 8:convertPixelFormat in-place 被系统拒绝

  • 现象:对解码位图与新建位图、对 BGRA_8888/ARGB_8888/RGB_565 三种目标全部code=62980115, message=source format is wrong!
  • 根因:系统 PixelMap.convertPixelFormat 内部限制(62980115 - Invalid image parameter),并非参数拼装问题——同一参数在两类位图上结果一致。
  • 修复:改为两段式,in-place 失败则回退重建(readNativeTight 读像素 → createPixelMapSyncsrcPixelFormat=<当前>/pixelFormat=<目标> 让系统在分配时转换),并用 PixelMapRegistry.replace 把新位图注册回同一句柄。
  • 经验:遇到平台 API 报"参数无效"但参数明显正确时,用两类不同来源的对象交叉验证(这里是解码位图 vs 新建位图),能快速区分"我传错了"和"系统不支持"。前提是错误信息可读——这就是 §5.6 那个 Logger 改造的价值。

坑 9:修复过程中引入的编译错误——缺 import

  • 现象:回退实现首次编译报 Cannot find name 'PixelMapCodec'(2 处)。
  • 根因PixelMapTransform.ets 新增了对 PixelMapCodec.readNativeTight 的调用,但没加 import。ArkTS 不像某些语言会自动推断同目录模块。
  • 修复:补 import { PixelMapCodec } from './PixelMapCodec';,同时把 readNativeTightprivate 改为 static(供跨模块复用)。
  • 经验:ArkTS 的模块导入是显式的;新增跨文件调用时先补 import 再编译,能省一轮失败。

坑 10:lib/ 没提交,库不自包含

  • 现象:从远程克隆后,Metro 报 The resolution for "react-native-image-pixelmap" defined in "exports" is .../lib/module/index.js, however this file does not exist
  • 根因package.jsonmain/exports 指向 ./lib/module/index.js,但 .gitignore 忽略了 lib/——编译产物从未入库。此前 demo 侧是靠 bundler alias 指向 src/index.tsx 才能跑,掩盖了这个问题
  • 修复:取消忽略 lib/ 并提交编译产物(含 types 字段指向的 lib/typescript/src/*.d.ts),然后移除 demo 的 alias 重新完整验证
  • 经验file: 依赖或 clone 方式引入的库必须自包含。验证方法就是 §8.4 ③ 的"全新克隆再跑"——本地工作副本永远看不出这类问题。

8. 真机验证(HarmonyOS 6.0.0 模拟器,API 26)

验证环境:HarmonyOS 6.0.0 模拟器(Pura X View,API 26)+ RNOH 0.84.3 + RN 0.84.1,
宿主 RNOH084Demo,隔离测试页 PixelMapTestApp

8.1 自动化:一键脚本 + 14 项断言

人工点按钮验证不可复现,所以写了一个端到端脚本 tools/verify-pixelmap-demo.py
串起完整链路:

构建 HAP → 安装 → 启动隔离测试页 → uitest 解析按钮坐标
        → uinput 逐项点击 → 抓 hilog → 14 项断言

脚本里一个值得说的设计:所有外部命令都带显式超时。因为 hdc 偶发挂起,
早期版本没有超时保护时整个验证会卡死。加超时后失败也能拿到部分输出。

def run(cmd, timeout=120, cwd=None):
    """Run a command, never raise on failure, always return (code, output)."""
    try:
        proc = subprocess.run(cmd, cwd=cwd, env=env,
                              stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                              timeout=timeout)
        return proc.returncode, proc.stdout.decode("utf-8", errors="replace")
    except subprocess.TimeoutExpired as exc:
        partial = (exc.stdout or b"").decode("utf-8", errors="replace")
        log(f"!! timeout after {timeout}s: {' '.join(cmd)}")
        return 124, partial

8.2 脚本自身也踩了一个坑

首轮运行只通过了 1/14 项,日志显示所有按钮都"找不到"

!! 找不到按钮: 解码+元信息
!! 找不到按钮: base64 往返
...

排查发现是坐标解析 bug:uitest 返回的 bounds 形如 [30,265][645,358]
含逗号。我原来的实现按空白字符切分再取数字,逗号导致整串被当成一个 token,
解析结果为空。

# 错误:bounds 里的逗号没处理
nums = [int(x) for x in hit.replace('[', ' ').replace(']', ' ').split() if x.isdigit()]

# 正确:用正则把所有整数抽出来
nums = [int(n) for n in re.findall(r"\d+", hit)]

修复后 14/14 通过。教训:解析第三方输出时不要假设分隔符,用正则提取比按分隔符切分更稳。

8.3 逐项实测结果

能力实测日志
TurboModule 注册Creating Turbo Module: ImagePixelmap + TM created: ImagePixelmap
解码(文件)decodeFromFile -> handle=2 800x500 format=RGBA_8888 alpha=ALPHA_TYPE_PREMUL density=0 bytes=1600000 stride=3200
元信息getInfo -> 800x500 RGBA_8888
单像素取色getPixelColor(0,0) -> rgba(255,255,255,255)
编码 base64encodeToBase64(png) -> 34504 chars
base64 往返decodeFromBase64 -> 800x500 RGBA_8888
读像素(区域)readPixels(10x10 区域) -> 536 chars
读像素(全图)readPixels(全图) -> 2133336 chars ≈ 1600002 bytes(期望 1600000)
写像素writePixels -> truebefore rgba(255,255,255,255)after rgba(255,0,0,255)断言通过
缩放scale(0.5,0.5) -> 800x500 => 400x250
旋转rotate(90) -> 800x500 => 500x800(宽高互换)
翻转+裁剪flip(horizontal) -> OKcrop(400x250) -> 400x250
透明度setOpacity(0.5) -> OK
格式转换convertPixelFormat[decoded](BGRA_8888) -> BGRA_8888[created](RGB_565) -> RGB_565(回退重建生效)
空白位图createEmpty(32x24, 蓝) -> handle=15像素 rgba(0,0,255,255)颜色通道断言通过
编码落盘PNG 25877 字节 / JPEG(80) 20649 字节;重新解码 PNG -> 800x500
生命周期getHandleCount -> 1release 后 isReleased -> truereleaseAll: before=2 released=2 after=0

8.4 三类独立证据

只靠 hilog 属于"运行时自报",所以另外补了两类可独立复核的证据。

① 产物级:unzip 可查

$ unzip -l entry-default-signed.hap | grep -i pixelmap
    33576  09-20-2026 23:50   libs/arm64-v8a/librnoh__react_native_image_pixelmap.so

.so 的存在证明:HAR 的 src/main/cpp 被 autolinking 正确 add_subdirectory
methodMap_TurboModuleFactoryDelegate 通过 C++ 编译,产物进入 HAP。
配合中间产物还可看到编译链每一环:

RNOHPackagesFactory.ets   → import ImagePixelmapPackage from '@rnoh/react-native-image-pixelmap'
autolinking.cmake         → add_subdirectory(.../src/main/cpp)
ReactNativeImagePixelmapPackage.cpp.o  (331928 字节)   ← C++ 源确实被编译
librnoh__react_native_image_pixelmap.so (33856 字节)  ← 链接产物
HAP libs/arm64-v8a/...so  (33576 字节)                ← 打进 HAP

② 可视化:屏幕截图 + 页面渲染文本

$ hdc shell snapshot_display -f /data/local/tmp/shot.jpeg
success: snapshot display 0 ... width: 1320, height: 2232

再用 uitest dumpLayout 抓页面上的真实文本,能看到测试页自身的 UI 状态

'status: releaseAll: OK'
'info: handle=2 800x500 format=RGBA_8888 alpha=ALPHA_TYPE_PREMUL density=0 bytes=1600000 stride=3200'
'  断言通过:颜色通道正确'

这些是 RN 视图上屏渲染的内容,不是日志——说明 11 项点击的结果确实反映到了界面上。

③ 消费者视角:全新克隆远程仓库再跑一遍

前面所有验证都基于本地工作副本。为确认推送出去的仓库本身可用,从远程全新克隆后
替换 demo 依赖再验证:

$ git clone --depth 1 git@atomgit.com:oh-react-native/react-native-image-pixelmap.git /tmp/pixelmap_clone
$ tar -tzf /tmp/pixelmap_clone/harmony/image_pixelmap.har | grep -c "cpp/"
5                                        # autolinking 无条件引用的目录,必须在
$ ls /tmp/pixelmap_clone/lib/module/index.js     # package.json 的 main
$ ln -sfn /tmp/pixelmap_clone node_modules/react-native-image-pixelmap
$ hvigorw ... assembleHap --no-daemon
> hvigor BUILD SUCCESSFUL in 13 s 886 ms

随后 11 项能力全部通过。这一步验证了库是自包含的(根因与修复见 §7 坑 10)。


9. 成果与沉淀

9.1 交付物

位置
库仓库oh-react-native/react-native-image-pixelmap
版本tag v1.0.0 + Release(HAR 作为附件)
参考测试页example/PixelMapTestApp.tsx(11 项能力)+ example/README.md
验证证据docs/pixelmap-verification.md(11 节)+ 屏幕截图
双语文档README.OpenHarmony.md / README.OpenHarmony_CN.md
验证宿主oh-react-native/RNOH084Demo

9.2 关键结论

  1. 鸿蒙位图 API 的两条边界决定了架构PixelMap 不能跨 JSI 边界(→ 句柄制),
    二进制不能作为 TurboModule 值(→ base64)。这两个约束不是"设计选择",是硬约束。
  2. HAR 必须是源码式包assembleHar 的产物不含 src/main/cpp,而 autolinking
    无条件引用它。这是 RNOH 适配里最高频的卡点。
  3. 手动适配必须自己实现 TurboModuleFactoryDelegate + methodMap_
    否则分别是"找不到 TM"和"undefined is not a function"。
  4. 错误信息可读性不是小事:正是把 [object Object] 改造成 code=..., message=...
    才定位出 62980115 是系统限制而非自己的 bug,进而设计出回退重建。
  5. 平台不支持 ≠ 功能不可用convertPixelFormat in-place 被拒,但用
    createPixelMapSync 的分配期转换可以等效实现。关键是区分"能力不存在"与"某条路径不通"。

9.3 方法论沉淀

  • 先调研后编码:grep SDK .d.ts 确认 API 语义(尤其单位、格式、默认值),比读文档更快更准;
  • 用交叉验证区分"我错了"和"平台限制":同一参数在两类不同来源对象上测试,结论立刻清晰;
  • 产物级证据优于日志自报unzip 看 HAP 内的 .so、看 .o 目标文件,比"日志说成功"可信;
  • 消费者视角复验:从远程全新克隆再跑一遍,才能发现"本地靠 alias 掩盖"的不自包含问题;
  • 脚本化验证要带超时hdc 会挂起,无超时的脚本等于没有脚本。
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐