ArkTS 与 C++ 跨语言传递复杂数据结构:从固定缓冲区截断到可扩展的 NAPI 桥接层

前言

把已有的 C++ 算法库(图像处理、音视频编解码、推理引擎)集成进 HarmonyOS 应用时,几乎所有人都会经历同一个过程:先用 int、string 做一次 hello world,跑通了,觉得 NAPI 不过如此;然后开始传真实的业务参数——一个带嵌套配置的对象、一个 Map、一批像素数据,于是桥接层在一天之内膨胀到几百行,还伴随着「数据莫名丢失」「Native 侧偶发崩溃」「改了配置却像没生效」的诡异现象。

这些问题的根源不是 NAPI 难,而是很多人把它当成了一门「类型映射 DSL」——试图把 ArkTS 的 HashMap 一一对应到 std::unordered_map,把 ArkTS 的类对应到 C++ 的 struct。但 NAPI 提供的原语其实非常少:Number、String、Boolean、Object、Array、ArrayBuffer、TypedArray。所有复杂结构在跨语言边界上都只是这几种原语的组合。理解这一点,桥接层的设计思路就会从「为每种结构写一个转换函数」转向「写一个通用解析器 + 稳定的数据边界」。

本文从几个实际会出错的细节出发,给出一套可以直接落到工程里的通用转换层,并说明像素、矩阵这类大块数据该怎么处理才不会有性能与生命周期问题。

问题描述

一个典型的集成场景:ArkTS 侧要把图像处理参数和像素数据一起交给 C++ 库。

// ArkTS 侧期望的调用形态
interface FilterOptions {
  brightness: number
  contrast: number
}

interface ImageProcessConfig {
  width: number
  height: number
  format: string
  options: FilterOptions
  filters: string[]
}

const config: ImageProcessConfig = {
  width: 1920,
  height: 1080,
  format: 'rgba8888',
  options: { brightness: 0.5, contrast: 1.2 },
  filters: ['blur', 'sharpen']
}

nativeLib.processImage(config, pixelBuffer)

开发者在 Native 侧遇到的具体困难通常集中在这几类:

  1. 简单参数没问题,复杂结构无从下手:napi_get_value_int32 会用了,但对象里有嵌套对象、数组,不知道该套几层。
  2. HashMap 传不过去:Map 序列化后变成空对象,或者 C++ 侧收到一堆看不懂的东西。
  3. 字符串偶发解析失败:明明配置没改,昨天能跑今天报 JSON 语法错误。
  4. 大块像素数据性能崩坏:逐元素传递,一张 4000×3000 的图片要几十秒。
  5. Native 侧偶发崩溃或数据错乱:异步处理后指针失效,或者读到了缓冲区之外的数据。

下面逐条拆解,其中第 3、第 5 条是排查成本最高、也最容易被忽略的。

细节解析

一、固定长度缓冲区会静默截断字符串

这是最高频的隐形 bug。很多示例代码这样取字符串:

// ⚠️ 存在静默截断风险
char configJsonBuf[4096];
size_t configJsonLen;
napi_get_value_string_utf8(env, args[1], configJsonBuf, sizeof(configJsonBuf), &configJsonLen);
std::string configJsonStr(configJsonBuf, configJsonLen);

当传入字符串超过缓冲区容量时,napi_get_value_string_utf8 只写入 size - 1 个字节并补 '\0',返回值仍然是 napi_ok。于是下游的 json::parse() 拿到的是被砍断的 JSON,抛出的错误信息与真实原因毫无关系。图像处理配置里带几个滤镜数组,几千字节很容易就超了。

正确做法是分两步:先把 nullptr 作为缓冲区传入以查询长度,再按需分配。

// ✅ 动态长度
static bool GetUtf8String(napi_env env, napi_value value, std::string& out) {
  size_t len = 0;
  if (napi_get_value_string_utf8(env, value, nullptr, 0, &len) != napi_ok) {
    return false;
  }
  out.resize(len);
  size_t copied = 0;
  if (napi_get_value_string_utf8(env, value, &out[0], len + 1, &copied) != napi_ok) {
    return false;
  }
  out.resize(copied);
  return true;
}

同样的模式适用于所有「先查长度、再取数据」的 NAPI 接口(数组、TypedArray 等)。

二、ArkTS 的 Map 不能直接 JSON 序列化

「复杂结构序列化成 JSON 传递」是一个合理方案,尤其适合纯配置数据。但它有两个必须知道的限制。

第一,Map 用 JSON.stringify 会得到 {},因为 Map 不是普通对象,没有可枚举的自有属性:

const m = new Map<string, number>()
m.set('brightness', 0.5)
console.info(JSON.stringify(m))   // 输出 "{}",数据丢失且不报错

第二,JSON.parse 得到的对象在 ArkTS 侧是动态对象,如果要用强类型,需要显式转换。推荐做法是先转成 Record:

function mapToRecord(m: Map<string, number>): Record<string, number> {
  const r: Record<string, number> = {}
  m.forEach((v: number, k: string) => { r[k] = v })
  return r
}

nativeLib.processImage(JSON.stringify(mapToRecord(m)))

如果走的是 NAPI 对象直传(下一节),Map 同样需要先转 Record 或对象字面量——NAPI 没有读取 Map 内部条目的接口,napi_get_all_property_names 对 Map 实例只会枚举出它的原型方法。要传真正的键值对,只能用键值数组表达:

// 键值数组方案:C++ 侧组装成 std::unordered_map
const keys: string[] = []
const values: number[] = []
m.forEach((v: number, k: string) => { keys.push(k); values.push(v) })
nativeLib.setConfigKV(keys, values)

三、逐字段手写解析会让桥接层迅速膨胀

如果为每个业务结构都写一遍 napi_get_named_property,桥接层的代码量会随字段数线性增长,而且每次配置加字段都要改 C++。更好的做法是写一个通用的中间模型,只解析一次,业务代码从中间模型取值。

虽然 ArkTS 侧的结构是强类型的,但 NAPI 侧看到的是动态对象,因此可以用 napi_get_all_property_names 做键枚举,递归地把任意深度的对象/数组摊平成统一的 JsValue 结构。这样:

  • 新增字段(包括新增嵌套层)时,C++ 桥接层完全不用改;
  • 类型校验和错误处理集中在一处;
  • 日志里可以直接打印整棵结构树,排查数据问题时非常直观。

四、属性访问前必须做类型判定

napi_get_value_double 等取值接口对类型不匹配的参数会返回 napi_number_expected,而 C++ 侧如果忽略返回值继续用未初始化的变量,就会读到一个随机值;更糟的是某些情况下异常会直接抛到 ArkTS 层,表现为「调用 native 方法时突然抛出无关异常」。

因此顺序必须是:napi_typeof 判定类型 → 类型分支内取值 → 每个 napi_status 都检查。对不存在的属性,napi_get_named_property 会成功返回一个 undefined 值(不会报错),所以**「属性不存在」不能靠返回值判断,必须先判类型**。

五、ArrayBuffer 直通的两个真实坑

像素、矩阵、音频采样这类大块数据,逐元素传递是不可接受的。一张 4000×3000 的 RGBA 图有 4800 万个字节,逐元素跨语言调用的开销是毫秒级乘以千万次。正确做法是用 ArrayBuffer / TypedArray 直通,Native 侧拿到指针直接处理。

但有两个坑:

坑一:TypedArray 的视图偏移。 如果 ArkTS 侧传的是 Uint8Array,它可能是一个更大 ArrayBuffer 上的视图。此时必须用 napi_get_typedarray_info 同时取到 data 和 byteOffset,实际起始地址是 data + byteOffset。直接用 napi_get_arraybuffer_info 得到的指针,会读到视图范围之外的数据——这类 bug 表现为「图片左边正常、右边花屏」或「能跑但结果偏移了若干像素」,极难定位。

坑二:指针生命周期。 napi_get_arraybuffer_info 得到的裸指针只在 ArkTS 侧对象存活期间有效。如果把它交给 native 子线程、或存进一个长期存在的 ImageContext,必须先用 napi_create_reference 把参数对象保活(用完配 napi_delete_reference 释放),或者干脆 memcpy 到 native 自己管理的内存。楼上很多方案说「不能长期保存」,但没给具体手段,napi_create_reference 就是那个手段。

另外,处理完成要回调 ArkTS 时,不要从 native 子线程直接调 napi_call_function,必须通过 napi_create_threadsafe_function 投递回 JS 线程。

六、更进一步的边界设计

当接入的是 OpenCV、FFmpeg、推理引擎这类库时,跨语言传递整棵配置树和整块像素本身就不划算。更稳的边界是 Native Handle 模式:ArkTS 只拿到一个 number(句柄),后续所有操作都基于句柄。

const imageId: number = nativeLib.createImage(pixelBuffer, width, height)
nativeLib.blur(imageId, 3.0)
nativeLib.rotate(imageId, 90)
nativeLib.exportToFile(imageId, '/data/storage/el2/base/files/out.png')
nativeLib.releaseImage(imageId)

Native 侧用一张 std::unordered_map<uint64_t, std::shared_ptr<ImageContext>> 维护句柄与上下文。这样做的好处是:跨语言传递的数据量恒定(只有一个整数),资源生命周期由 Native 侧统一管理,也避免了 ArkTS 对象的 GC 时机与 Native 使用时机耦合。

代价是需要自己设计句柄的分配、回收与并发保护(多线程访问同一句柄要加锁),并且要做好「ArkTS 侧忘了调 release」的兜底。

示例代码

1. 通用转换层(C++)

// native/include/napi_convert.h
#ifndef NAPI_CONVERT_H
#define NAPI_CONVERT_H

#include <napi/native_api.h>
#include <string>
#include <vector>
#include <map>

// 统一中间模型:NAPI 的所有复杂结构都摊平成这一个类型
struct JsValue {
  enum class Type { Null, Bool, Number, String, Array, Object };
  Type type = Type::Null;
  bool b = false;
  double num = 0;
  std::string str;
  std::vector<JsValue> arr;
  std::map<std::string, JsValue> obj;   // 用 map 而非 unordered_map:日志输出时键有序,便于比对

  // 带默认值的读取:缺字段、类型不符时返回默认值,不抛异常
  double GetNumber(const std::string& key, double def = 0) const {
    auto it = obj.find(key);
    return (it != obj.end() && it->second.type == Type::Number) ? it->second.num : def;
  }
  bool GetBool(const std::string& key, bool def = false) const {
    auto it = obj.find(key);
    return (it != obj.end() && it->second.type == Type::Bool) ? it->second.b : def;
  }
  std::string GetString(const std::string& key, const std::string& def = "") const {
    auto it = obj.find(key);
    return (it != obj.end() && it->second.type == Type::String) ? it->second.str : def;
  }
  const std::vector<JsValue>* GetArray(const std::string& key) const {
    auto it = obj.find(key);
    return (it != obj.end() && it->second.type == Type::Array) ? &it->second.arr : nullptr;
  }
};

// ---- 基础读取 ----

inline bool NapiOk(napi_env env, napi_status st, const char* what) {
  if (st != napi_ok) {
    std::string msg = std::string("NAPI failed at: ") + what;
    napi_throw_error(env, nullptr, msg.c_str());
    return false;
  }
  return true;
}

inline bool GetUtf8String(napi_env env, napi_value value, std::string& out) {
  size_t len = 0;
  if (napi_get_value_string_utf8(env, value, nullptr, 0, &len) != napi_ok) {
    return false;
  }
  out.resize(len);
  size_t copied = 0;
  if (napi_get_value_string_utf8(env, value, &out[0], len + 1, &copied) != napi_ok) {
    return false;
  }
  out.resize(copied);
  return true;
}

// ---- 递归解析 ----

inline JsValue ParseStringValue(napi_env env, napi_value v) {
  JsValue r;
  r.type = JsValue::Type::String;
  GetUtf8String(env, v, r.str);
  return r;
}

inline JsValue ParseValue(napi_env env, napi_value v);

inline JsValue ParseArrayValue(napi_env env, napi_value v) {
  JsValue r;
  r.type = JsValue::Type::Array;
  uint32_t len = 0;
  if (napi_get_array_length(env, v, &len) != napi_ok) {
    return r;
  }
  r.arr.reserve(len);
  for (uint32_t i = 0; i < len; ++i) {
    napi_value el = nullptr;
    if (napi_get_element(env, v, i, &el) != napi_ok) {
      continue;
    }
    r.arr.push_back(ParseValue(env, el));
  }
  return r;
}

inline JsValue ParseObjectValue(napi_env env, napi_value v) {
  JsValue r;
  r.type = JsValue::Type::Object;
  // 键名靠枚举得到,因此新增字段无需修改 C++ 代码
  napi_value names = nullptr;
  napi_status st = napi_get_all_property_names(
      env, v, napi_key_own_only, napi_key_enumerable,
      napi_key_numbers_to_strings, &names);
  if (st != napi_ok) {
    return r;
  }
  uint32_t cnt = 0;
  if (napi_get_array_length(env, names, &cnt) != napi_ok) {
    return r;
  }
  for (uint32_t i = 0; i < cnt; ++i) {
    napi_value keyVal = nullptr;
    if (napi_get_element(env, names, i, &keyVal) != napi_ok) {
      continue;
    }
    std::string key;
    if (!GetUtf8String(env, keyVal, key) || key.empty()) {
      continue;
    }
    napi_value prop = nullptr;
    if (napi_get_named_property(env, v, key.c_str(), &prop) != napi_ok) {
      continue;
    }
    r.obj[key] = ParseValue(env, prop);
  }
  return r;
}

// 必须先判类型再取值:对 undefined 调 napi_get_value_double 会返回类型错误
inline JsValue ParseValue(napi_env env, napi_value v) {
  napi_valuetype t = napi_undefined;
  if (napi_typeof(env, v, &t) != napi_ok) {
    return JsValue();
  }
  switch (t) {
    case napi_number: {
      JsValue r;
      r.type = JsValue::Type::Number;
      napi_get_value_double(env, v, &r.num);
      return r;
    }
    case napi_boolean: {
      JsValue r;
      r.type = JsValue::Type::Bool;
      napi_get_value_bool(env, v, &r.b);
      return r;
    }
    case napi_string:
      return ParseStringValue(env, v);
    case napi_object: {
      bool isArr = false;
      if (napi_is_array(env, v, &isArr) == napi_ok && isArr) {
        return ParseArrayValue(env, v);
      }
      return ParseObjectValue(env, v);
    }
    default:
      return JsValue();
  }
}

#endif  // NAPI_CONVERT_H

2. 业务入口(C++)

// native/image_bridge.cpp
#include <napi/native_api.h>
#include "napi_convert.h"

namespace {

// 从 ArrayBuffer 或 Uint8Array 中安全取出像素起始地址与字节数
bool GetPixelBuffer(napi_env env, napi_value v, uint8_t** outData, size_t* outBytes) {
  bool isBuffer = false;
  if (napi_is_arraybuffer(env, v, &isBuffer) == napi_ok && isBuffer) {
    void* data = nullptr;
    if (napi_get_arraybuffer_info(env, v, &data, outBytes) != napi_ok) {
      return false;
    }
    *outData = static_cast<uint8_t*>(data);
    return true;
  }

  bool isTyped = false;
  if (napi_is_typedarray(env, v, &isTyped) == napi_ok && isTyped) {
    napi_typedarray_type type = napi_uint8_array;
    size_t length = 0;
    void* data = nullptr;
    size_t byteOffset = 0;
    if (napi_get_typedarray_info(env, v, &type, &length, &data,
                                 nullptr, &byteOffset) != napi_ok) {
      return false;
    }
    if (type != napi_uint8_array) {
      return false;
    }
    // 关键:必须叠加 byteOffset,否则会读到视图范围之外的数据
    *outData = static_cast<uint8_t*>(data) + byteOffset;
    *outBytes = length;
    return true;
  }
  return false;
}

}  // namespace

napi_value ProcessImage(napi_env env, napi_callback_info info) {
  size_t argc = 2;
  napi_value argv[2] = { nullptr, nullptr };
  if (!NapiOk(env, napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr), "get_cb_info")) {
    return nullptr;
  }
  if (argc < 2) {
    napi_throw_type_error(env, nullptr, "expect (config: object, pixels: ArrayBuffer|Uint8Array)");
    return nullptr;
  }

  // 1) 解析配置:一次摊平成 JsValue,之后新增字段无需改这里
  JsValue cfg = ParseValue(env, argv[0]);
  const int32_t width = static_cast<int32_t>(cfg.GetNumber("width", 0));
  const int32_t height = static_cast<int32_t>(cfg.GetNumber("height", 0));
  const std::string format = cfg.GetString("format", "rgba8888");
  const double brightness = cfg.GetNumber("brightness", 0.0);
  const std::string contrastKey = "contrast";
  const double contrast = cfg.GetNumber(contrastKey, 1.0);

  // 嵌套对象与数组:走 GetArray,逐项自行解释
  const JsValue* options = nullptr;
  if (cfg.obj.find("options") != cfg.obj.end()) {
    options = &cfg.obj.at("options");
  }
  const double optBrightness = options ? options->GetNumber("brightness", 0.0) : 0.0;

  const std::vector<JsValue>* filters = cfg.GetArray("filters");
  const size_t filterCount = filters ? filters->size() : 0;

  // 2) 取出像素缓冲区并做长度校验
  uint8_t* pixels = nullptr;
  size_t bytes = 0;
  if (!GetPixelBuffer(env, argv[1], &pixels, &bytes)) {
    napi_throw_type_error(env, nullptr, "pixels must be ArrayBuffer or Uint8Array");
    return nullptr;
  }
  if (width <= 0 || height <= 0) {
    napi_throw_range_error(env, nullptr, "invalid width/height");
    return nullptr;
  }
  const size_t need = static_cast<size_t>(width) * static_cast<size_t>(height) * 4;
  if (bytes < need) {
    napi_throw_range_error(env, nullptr, "pixel buffer too small");
    return nullptr;
  }

  // 3) 调用真实算法(此处省略)
  (void)format;
  (void)brightness;
  (void)contrast;
  (void)optBrightness;
  (void)filterCount;

  // 4) 返回结果:组装对象用 napi_create_object + napi_set_named_property
  napi_value result = nullptr;
  if (napi_create_object(env, &result) != napi_ok) {
    return nullptr;
  }
  napi_value ok = nullptr;
  napi_get_boolean(env, true, &ok);
  napi_set_named_property(env, result, "success", ok);

  napi_value used = nullptr;
  napi_create_int32(env, static_cast<int32_t>(bytes), &used);
  napi_set_named_property(env, result, "bytesProcessed", used);

  return result;
}

EXTERN_C_START
static napi_value RegisterModule(napi_env env, napi_value exports) {
  napi_property_descriptor desc[] = {
    { "processImage", nullptr, ProcessImage, nullptr, nullptr, nullptr, napi_default, nullptr },
  };
  napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
  return exports;
}
EXTERN_C_END

static napi_module imageBridgeModule = {
  .nm_version = 1,
  .nm_flags = 0,
  .nm_filename = nullptr,
  .nm_register_func = RegisterModule,
  .nm_modname = "imagebridge",
  .nm_priv = nullptr,
  .reserved = { 0 },
};

extern "C" __attribute__((constructor)) void RegisterImageBridgeModule(void) {
  napi_module_register(&imageBridgeModule);
}

3. ArkTS 调用侧

// entry/src/main/ets/pages/Index.ets
import imageBridge from 'libimagebridge.so'
import { image } from '@kit.ImageKit'

interface FilterOptions {
  brightness: number
  contrast: number
}

interface ImageProcessConfig {
  width: number
  height: number
  format: string
  options: FilterOptions
  filters: string[]
}

interface ProcessResult {
  success: boolean
  bytesProcessed: number
}

@Entry
@Component
struct Index {
  @State result: string = '未处理'

  private async run(): Promise<void> {
    const source: image.ImageSource = image.createImageSource(
      'file:///data/storage/el2/base/haps/entry/files/in.jpg')
    const pixelMap: image.PixelMap = await source.createPixelMap()
    const info: image.ImageInfo = await pixelMap.getImageInfo()

    // 取出像素数据:readPixelsToBuffer 写入 ArrayBuffer
    const bufferSize: number = info.size.width * info.size.height * 4
    const pixelBuffer: ArrayBuffer = new ArrayBuffer(bufferSize)
    await pixelMap.readPixelsToBuffer(pixelBuffer)

    const config: ImageProcessConfig = {
      width: info.size.width,
      height: info.size.height,
      format: 'rgba8888',
      options: { brightness: 0.2, contrast: 1.1 },
      filters: ['blur', 'sharpen']
    }

    const res: ProcessResult = imageBridge.processImage(config, pixelBuffer) as ProcessResult
    this.result = `success=${res.success}, bytes=${res.bytesProcessed}`

    pixelMap.release()
    source.release()
  }

  build() {
    Column({ space: 12 }) {
      Button('处理图片').onClick(() => { this.run() })
      Text(this.result).fontSize(14)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

配套的 entry/src/main/cpp/CMakeLists.txt 关键部分:

cmake_minimum_required(VERSION 3.5.0)
project(imagebridge)

set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})

include_directories(${NATIVERENDER_ROOT_PATH} ${NATIVERENDER_ROOT_PATH}/include)

add_library(imagebridge SHARED image_bridge.cpp)
target_link_libraries(imagebridge PUBLIC libace_napi.z.so)

4. 验证步骤

  1. 分层验证:先只传 width/height/format(纯 number/string),再加 options 嵌套对象,再加 filters 数组,每加一层打印 JsValue 的结构树确认层级正确。
  2. 边界用例必测:字段缺失、字段类型传错(传 undefined 或 null)、数组里混类型、ArrayBuffer 长度不足、超过 4096 字节的大配置(专门验证动态长度读取生效)。
  3. TypedArray 偏移验证:构造一个 Uint8Array 视图,使其 byteOffset 非 0,对比直接传 ArrayBuffer 的处理结果,两者必须完全一致——这一步能直接暴露视图偏移 bug。
  4. 性能定位:用 DevEco Studio 的 Profiler 观察跨语言调用耗时。配置解析正常应在微秒级;若占比明显偏高,说明 JSON 序列化出现在了热点路径上,需要把配置解析提到循环外或改为 Native Handle 模式。
  5. 长稳与泄漏:连续调用数千次,确认 napi_create_reference 与 napi_delete_reference 严格配对。用 hdc shell hidumper --mem <pid> 观察进程内存是否稳定。

总结

跨语言传递复杂数据,核心不是把 ArkTS 类型和 C++ 类型一一对应,而是设计一条稳定的数据边界。本文的几条结论可以直接落到工程约定里:

场景推荐做法需要注意
简单参数Number / String / Boolean 直传每个 napi_status 都要检查
业务配置Object 直传,或用 napi_get_all_property_names 递归摊平成 JsValue不要为每个结构手写解析,否则加字段就要改 C++
Map / HashMap先转 Record,或转键值对数组JSON.stringify(Map) 得到 {},静默丢数据
字符串先查长度再分配固定缓冲区超过容量时静默截断,napi_ok 不报错
像素 / 矩阵 / 大数组ArrayBuffer / TypedArray 直通Uint8Array 必须叠加 byteOffset;异步前保活或拷贝
长生命周期资源Native Handle(uint64 句柄)Native 侧统一管理分配与回收,做好并发保护
子线程回调 ArkTSnapi_create_threadsafe_function不要在 native 子线程直接调 napi_call_function

两个最容易被忽略、也最值得记住的坑:固定长度缓冲区会静默截断字符串,以及 Uint8Array 视图的 byteOffset 不加会导致读错内存。这两类问题的共同特征是「不崩溃、不报错、结果只是不对」,一旦流入生产环境,排查成本远高于事先花十分钟写对代码。

把转换层收敛成一个通用解析器、把大数据走二进制直通、把长生命周期资源收敛成句柄,桥接层的代码量就不会随业务复杂度增长,这也是判断跨语言边界设计是否合格的实用标准。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐