让 RN 也能直接改像素:鸿蒙 Image PixelMap 三方库 0 到 1——react-native-image-pixelmap × RNOH 0.84
标签: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 项能力全部跑通。

1. 三平台位图机制对比(决定架构的起点)
动手前先对齐一件事:PixelMap 和 iOS/Android 的位图对象,在能否跨语言边界这件事上差别极大。
| 维度 | iOS(UIImage/CGImage) | Android(Bitmap) | HarmonyOS(PixelMap) |
|---|---|---|---|
| 对象性质 | Objective-C 对象 | Java 对象 | ArkTS 原生对象 |
| 能否作为桥接返回值 | ✅ 可包装 | ✅ 可包装 | ❌ 不能跨 JSI 边界 |
| 原始像素载体 | CGDataProvider | ByteBuffer / IntArray | ArrayBuffer(PositionArea.pixels) |
| 二进制能否作为桥接参数 | ✅ NSData | ✅ byte[] | ❌ ArrayBuffer 到 ArkTS 侧变成普通对象 |
| 像素格式 | RGBA8888 等 | ARGB_8888 等 | PixelMapFormat 枚举(含 BGRA_8888 等) |
| 区域读写格式约定 | 与位图格式一致 | 与位图格式一致 | ⚠️ 固定 BGRA_8888(见 §7.3) |
| 编码 | UIImagePNGRepresentation | Bitmap.compress | ImagePacker.packToData |
两条结论,直接决定了本库的架构:
PixelMap不能跨 JSI 边界 → 它既不能作为 TurboModule 的返回值,也不能作为参数。必须引入一层句柄(handle):原生侧持有位图,只把不透明的数字 id 交给 JS。- 原始像素缓冲区不能作为 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/writePixels 的 PositionArea 版本固定按 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/writePixels 的 PositionArea 版本固定按 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> | 分配空白位图(color 为 0xRRGGBBAA) |
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 / crop | Promise<PixelMapInfo> | 几何变换 |
setOpacity / convertPixelFormat | Promise<PixelMapInfo> | 颜色与格式 |
encodeToBase64 / encodeToFile | Promise<string | boolean> | 编码 |
release / releaseAll / getHandleCount | Promise<…> | 生命周期 |
设计取舍一: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));
}
设计取舍三:位图不自动回收。 不给 Bitmap 加 FinalizationRegistry 之类的兜底——因为原生资源释放时机必须确定,隐式回收会让内存峰值不可控。取而代之的是:__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)
由此产生两个硬性要求:
- 纯 ArkTS 库也必须提供
src/main/cpp(哪怕只有一个空.cpp),否则 CMake 直接报目录不存在; - 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.json5 的 metadata:
{
"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 默认值必须显式给,以及可选参数要同时判 null 和 undefined。
② 全图读取:必须处理 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.txt、empty.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-pixelmap(oh-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, }; -
经验:这个坑的隐蔽之处在于不报错,只是颜色不对。凡是"颜色通道看起来反了"的现象,第一反应就该查
srcPixelFormat与pixelFormat是否配对。
坑 2:区域读写 API 固定 BGRA_8888
- 现象:
readPixels({x,y,width,height})读出的像素 R/B 与全图读取不一致。 - 根因:Image Kit 文档明确
readPixels/writePixels的PositionArea版本按 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 默认值必须显式下发,否则后续写像素失败
-
现象:解码后
writePixels报writePixels: 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.txt和Index.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++ 侧返回的是裸
ArkTSTurboModule(std::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读像素 →createPixelMapSync以srcPixelFormat=<当前>/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';,同时把readNativeTight从private改为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.json的main/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) |
| 编码 base64 | encodeToBase64(png) -> 34504 chars |
| base64 往返 | decodeFromBase64 -> 800x500 RGBA_8888 |
| 读像素(区域) | readPixels(10x10 区域) -> 536 chars |
| 读像素(全图) | readPixels(全图) -> 2133336 chars ≈ 1600002 bytes(期望 1600000) |
| 写像素 | writePixels -> true;before rgba(255,255,255,255) → after rgba(255,0,0,255),断言通过 |
| 缩放 | scale(0.5,0.5) -> 800x500 => 400x250 |
| 旋转 | rotate(90) -> 800x500 => 500x800(宽高互换) |
| 翻转+裁剪 | flip(horizontal) -> OK;crop(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 -> 1;release 后 isReleased -> true;releaseAll: 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 关键结论
- 鸿蒙位图 API 的两条边界决定了架构:
PixelMap不能跨 JSI 边界(→ 句柄制),
二进制不能作为 TurboModule 值(→ base64)。这两个约束不是"设计选择",是硬约束。 - HAR 必须是源码式包:
assembleHar的产物不含src/main/cpp,而 autolinking
无条件引用它。这是 RNOH 适配里最高频的卡点。 - 手动适配必须自己实现
TurboModuleFactoryDelegate+methodMap_,
否则分别是"找不到 TM"和"undefined is not a function"。 - 错误信息可读性不是小事:正是把
[object Object]改造成code=..., message=...,
才定位出62980115是系统限制而非自己的 bug,进而设计出回退重建。 - 平台不支持 ≠ 功能不可用:
convertPixelFormatin-place 被拒,但用
createPixelMapSync的分配期转换可以等效实现。关键是区分"能力不存在"与"某条路径不通"。
9.3 方法论沉淀
- 先调研后编码:grep SDK
.d.ts确认 API 语义(尤其单位、格式、默认值),比读文档更快更准; - 用交叉验证区分"我错了"和"平台限制":同一参数在两类不同来源对象上测试,结论立刻清晰;
- 产物级证据优于日志自报:
unzip看 HAP 内的.so、看.o目标文件,比"日志说成功"可信; - 消费者视角复验:从远程全新克隆再跑一遍,才能发现"本地靠 alias 掩盖"的不自包含问题;
- 脚本化验证要带超时:
hdc会挂起,无超时的脚本等于没有脚本。
更多推荐




所有评论(0)