在这里插入图片描述

摘要

本文围绕鸿蒙 HarmonyOS 的 NDK 原生模块开发,讲清从 C++ 算法到 ArkTS 调用的完整链路:如何用 llvm 工具链与 build-profile.json5 编译出多架构 .so,如何通过 NAPI 把 C++ 函数暴露给 ArkTS,以及最关键的线程模型与跨语言内存边界。文中给出一份可直接复用的 NAPI 桥接骨架,逐行拆解 napi_get_cb_info 取参、GetBuffer 取裸内存、WrapOut 回传结果等关键步骤,并进一步用 napi_create_async_work 完成异步化改造,把重计算搬到 NAPI 线程池,避免同步调用卡死 JS 线程导致 UI 冻结与 ANR。最后总结三类高频崩溃(线程误用 ArkTS 对象、对象被提前回收、跨语言所有权错配)的定位与规避方法,适合正在把音视频、图像处理、加密等成熟 C++ 资产搬上鸿蒙的团队参考。

目录

一、背景与痛点

前面讲了三方库"接入与包体积"和"状态管理/网络框架版本治理",但遇到计算密集或已有 C/C++ 资产时,必须走 NDK 原生模块——把成熟算法(音视频编解码、图像处理、加密)用 C++ 写,再通过 NAPI 暴露给 ArkTS。难点在于:同一份 C++ 要适配 ARM64/x86、要处理 NAPI 生命周期、要在真机调试崩溃。

我第一次接 FFmpeg 裁剪版,编译过、跑起来却随机崩,日志只有 SIGSEGV,查了两天才发现是 NAPI 线程里误用了 ArkTS 对象。本质是没理清 NAPI 线程模型与跨语言内存边界。本文讲清 NDK 模块的编译、NAPI 桥接与崩溃定位。

二、环境与能力规格

维度规格
原生能力@ohos.napi 的 NAPI 桥接 ArkTS ↔ C++
编译工具llvm 工具链 + build-profile.json5 配置 .so
目标架构arm64-v8a 为主,模拟器补 x86_64
线程模型NAPI 回调默认在 JS 线程,重活需切 native 线程
适配建议复杂对象用 napi_create_reference 保活

NDK 模块最坑的是"能编译 ≠ 能稳定运行",跨语言边界是雷区。

三、核心代码(可复用骨架)

// 1) NAPI 导出:把 C++ 函数暴露给 ArkTS
static napi_value Process(napi_env env, napi_callback_info info) {
  size_t argc = 1; napi_value args[1];
  napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
  // 重活在 native 线程跑,勿占 JS 线程
  uint8_t* data = GetBuffer(env, args[0]);
  auto out = HeavyAlgo::run(data);          // C++ 计算
  return WrapOut(env, out);
}

// 2) ArkTS 侧调用
import nativeApi from 'libnative.so';
const out = nativeApi.process(pixelBuffer); // 像调普通方法

代码解析

这段骨架虽短,但每一行都对应一个跨语言边界的关键决策,逐段拆开看:

1)napi_get_cb_info 取参:先声明再填充

size_t argc = 1; napi_value args[1];
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

argc 是入参兼出参:调用前告诉 NAPI「我最多接收 1 个参数」,调用后 NAPI 把实际传入个数写回 argc。所以务必先赋初值,否则 NAPI 会按栈上的随机值往 args 里写,直接越界。args 里拿到的是 napi_value 句柄,不是 C++ 指针,不能直接解引用。

2)GetBuffer 取数据:句柄 → 裸内存

uint8_t* data = GetBuffer(env, args[0]);

ArkTS 侧传进来的 pixelBuffer 在 native 侧只是一个 napi_value,必须通过 napi_get_arraybuffer_info(或 napi_get_typedarray_info)才能拿到真正的 uint8_t* 与长度:

static uint8_t* GetBuffer(napi_env env, napi_value value) {
  void* raw = nullptr; size_t len = 0;
  napi_status st = napi_get_arraybuffer_info(env, value, &raw, &len);
  if (st != napi_ok || raw == nullptr) {
    napi_throw_error(env, nullptr, "invalid buffer");
    return nullptr;
  }
  return static_cast<uint8_t*>(raw);
}

注意两点:必须校验 napi_status,失败要 napi_throw_error 抛回 ArkTS,而不是返回空指针让上层继续跑;拿到的指针只在本次调用期间有效,不能缓存到全局,否则 ArkTS 侧 GC 后就是悬空指针——这正是踩坑 2 的根源。

3)HeavyAlgo::run 计算:纯 C++,不碰 NAPI

auto out = HeavyAlgo::run(data);

这是整个模块里唯一「干净」的部分:纯 C++ 计算,不持有任何 napi_env / napi_value。把 NAPI 相关代码全部收敛在入口和出口两端,中间算法层与鸿蒙完全解耦,这样同一份 HeavyAlgo 既能编进鸿蒙 .so,也能直接跑单元测试,还能复用到其他平台。

4)WrapOut 回传:结果包成 ArkTS 能认的对象

return WrapOut(env, out);

napi_value 是返回值类型,C++ 的 out 必须显式包装。若结果是二进制,推荐用 napi_create_arraybuffer 把内存所有权交给 ArkTS:

static napi_value WrapOut(napi_env env, const std::vector<uint8_t>& out) {
  napi_value ab = nullptr; void* buf = nullptr;
  napi_create_arraybuffer(env, out.size(), &buf, &ab);
  std::memcpy(buf, out.data(), out.size());   // 拷贝,所有权归 ArkTS
  return ab;
}

这里用拷贝而非转移指针,是为了让所有权彻底清晰:C++ 的 out 随函数返回析构,ArkTS 拿到的是独立内存,谁都不欠谁。若追求零拷贝,才改用 napi_create_external_arraybuffer 并配 finalize 回调,但那是另一套复杂度,新手别一上来就用。

5)ArkTS 侧调用:同步阻塞的代价

import nativeApi from 'libnative.so';
const out = nativeApi.process(pixelBuffer);

process 是同步 NAPI 方法,调用期间 JS 线程被占住。上面注释写「重活在 native 线程跑」,但这段骨架其实是同步版——真正落地时要么用 napi_create_async_work 把 HeavyAlgo::run 丢到线程池,要么用 napi_threadsafe_function 从 native 线程回调 JS。同步版只适合毫秒级小计算,一旦是 4K 解码这种量级,必须异步化,否则就是踩坑 1 的 UI 冻结。

6)异步化实战:napi_create_async_work 完整落地

先看整体时序,再对照代码:

"CompleteCallback" "ExecuteCallback" "NAPI 线程池" "ProcessAsync" "ArkTS JS 线程" "CompleteCallback" "ExecuteCallback" "NAPI 线程池" "ProcessAsync" "ArkTS JS 线程" "调用 processAsync(pixelBuffer)" "napi_create_promise 创建 Promise/deferred" "napi_queue_async_work 提交任务" "立即返回 Promise(JS 线程空闲)" "在 native 线程执行" "HeavyAlgo::run 纯 C++ 计算" "计算完成,回调 complete" "回到 JS 线程,napi_resolve_deferred / napi_reject_deferred" "Promise 落定,await 拿到结果"

图中只有 ProcessAsync 与 CompleteCallback 两段运行在 JS 线程上,napi_env 与 napi_value 只在这两处合法;中间的 ExecuteCallback 跑在 NAPI 线程池里,既不能调用任何 napi_* 接口,也不能读写 napi_value 或 ArkTS 对象,否则就是 SIGSEGV。换句话说,napi_env 是「线程绑定」的,跨线程传递它本身不报错,但一旦在非 JS 线程上使用就会崩溃。

既然同步版会卡 UI,下面给出可直接抄的异步版。核心思路:Process 只做「参数校验 + 创建 Promise + 提交任务」,真正的 HeavyAlgo::run 在 execute 回调里由 NAPI 线程池执行,算完在 complete 回调里回到 JS 线程 resolve。

#include <napi/native_api.h>
#include <vector>
#include <cstring>

// 异步任务上下文:在 JS 线程创建,跨线程传递,必须自己管理生命周期
struct AsyncContext {
  napi_env env = nullptr;              // 仅 complete 回调里可用
  napi_async_work work = nullptr;
  napi_deferred deferred = nullptr;    // 用于 resolve/reject
  uint8_t* data = nullptr;             // 输入数据(execute 里只读)
  size_t length = 0;
  std::vector<uint8_t> result;         // 输出结果(execute 写,complete 读)
  bool success = false;
};

// ① execute:运行在 NAPI 线程池,绝对不能碰 napi_env / napi_value / ArkTS 对象
static void ExecuteCallback(napi_env env, void* data) {
  auto* ctx = static_cast<AsyncContext*>(data);
  // 这里只做纯 C++ 计算,不调用任何 napi_* 接口
  ctx->result = HeavyAlgo::run(ctx->data, ctx->length);
  ctx->success = true;
}

// ② complete:回到 JS 线程,此时才能安全使用 env 与 deferred
static void CompleteCallback(napi_env env, napi_status status, void* data) {
  auto* ctx = static_cast<AsyncContext*>(data);

  if (status != napi_ok || !ctx->success) {
    napi_value errMsg = nullptr;
    napi_create_string_utf8(env, "native compute failed", NAPI_AUTO_LENGTH, &errMsg);
    napi_value error = nullptr;
    napi_create_error(env, nullptr, errMsg, &error);
    napi_reject_deferred(env, ctx->deferred, error);   // 失败走 reject
  } else {
    // 结果包成 ArrayBuffer,所有权交给 ArkTS
    napi_value ab = nullptr; void* buf = nullptr;
    napi_create_arraybuffer(env, ctx->result.size(), &buf, &ab);
    std::memcpy(buf, ctx->result.data(), ctx->result.size());
    napi_resolve_deferred(env, ctx->deferred, ab);     // 成功走 resolve
  }

  // 清理:work 必须删,context 自己 new 的自己 delete
  napi_delete_async_work(env, ctx->work);
  delete ctx;
}

// ③ 对外导出:返回 Promise,而不是同步返回值
static napi_value ProcessAsync(napi_env env, napi_callback_info info) {
  size_t argc = 1; napi_value args[1];
  napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

  // 创建 Promise 并拿到 deferred
  napi_value promise = nullptr;
  napi_deferred deferred = nullptr;
  napi_create_promise(env, &deferred, &promise);

  auto* ctx = new AsyncContext();
  ctx->env = env;
  ctx->deferred = deferred;
  ctx->data = GetBuffer(env, args[0]);   // 注意:指针仅在本次调用期间有效
  ctx->length = GetBufferLength(env, args[0]);

  napi_value resourceName = nullptr;
  napi_create_string_utf8(env, "HeavyAlgoAsync", NAPI_AUTO_LENGTH, &resourceName);
  napi_create_async_work(env, nullptr, resourceName,
                         ExecuteCallback, CompleteCallback,
                         ctx, &ctx->work);
  napi_queue_async_work(env, ctx->work);   // 提交到线程池,立即返回

  return promise;   // ArkTS 侧拿到的是 Promise
}

ArkTS 侧调用就变成 await,JS 线程在等待期间完全空闲,UI 不冻:

import nativeApi from 'libnative.so';

async function decode(pixelBuffer: ArrayBuffer): Promise<ArrayBuffer> {
  // 返回 Promise,await 期间 JS 线程可继续处理渲染与手势
  const out = await nativeApi.processAsync(pixelBuffer);
  return out as ArrayBuffer;
}

为什么同步版会卡 UI: ArkTS 的 UI 渲染、手势响应、事件分发全部跑在同一个 JS 线程上。同步 process 调用时,JS 线程被 native 计算整段占住,期间既不能刷新帧也不能响应点击——4K 解码动辄几十上百毫秒,直接表现为掉帧、白屏甚至 ANR。napi_create_async_work 把计算搬到 NAPI 线程池,JS 线程只负责「发起」和「收结果」,中间空出来继续跑渲染循环,这才是重活该有的姿势。

三个必须记住的边界:

  • execute 回调里禁止调用任何 napi_* 接口、禁止访问 napi_value,它不在 JS 线程上,碰了就是 SIGSEGV。
  • ctx->data 指向的 ArkTS buffer 内存,在异步执行期间可能被 GC 回收,稳妥做法是在 ProcessAsync 里先拷贝一份到 ctx 自己的 std::vector,别直接持有裸指针。
  • napi_async_work 与 ctx 的生命周期必须成对管理:complete 里 napi_delete_async_work + delete ctx,漏一个就是内存泄漏。

一句话总结这段代码的设计原则:NAPI 只做「翻译」,不做「计算」;内存所有权只归一端,绝不共享。

四、运行效果与踩坑

  • 踩坑 1:JS 线程跑重活卡死 UI。 把 HeavyAlgo::run 挪到 native 线程 + 回调回 JS,主线程不再冻。
  • 踩坑 2:ArkTS 对象在 native 被提前回收。 用 napi_create_reference 保活,回调用完再 napi_delete_reference,SIGSEGV 消失。
  • 踩坑 3:x86 模拟器缺 .so。 build-profile 补 x86_64 abi,真机仍只打 arm64,包体不涨。
  • 效果: FFmpeg 裁剪模块解码稳定,真机 4K 软解不掉帧,崩溃率归零。

五、小结与适用边界

NDK 模块的价值是"复用成熟 C++ 资产 + 榨干硬件",但前提是吃透 NAPI 线程与内存边界。适合音视频、图像、加密、物理仿真;不适合简单业务逻辑(ArkTS 写更快更省心)。

六、实战清单(可直接抄)

  • NAPI 回调里的重活务必切 native 线程,别占 JS 线程,否则 UI 冻、ANR。
  • 跨语言持有的 ArkTS 对象用 napi_create_reference 保活,用完即删,杜绝悬空指针崩溃。
  • build-profile.json5 按架构分别配 abi,真机只打 arm64-v8a,模拟器补 x86_64 不影响包体。
  • 原生崩溃先抓 SIGSEGV 栈,优先怀疑"线程误用 ArkTS 对象"和"buffer 越界"两类。
  • 复杂结构体用扁平 buffer 传参,避免 NAPI 深拷贝大对象拖慢桥接。
  • 真机压测长时间运行,观察是否有内存缓慢上涨(native 泄漏最隐蔽)。

NDK 是把双刃剑:用好了性能飞起,用不好天天 SIGSEGV。把线程和内存边界钉死,C++ 资产才能真正为我所用。

再多说一个深坑:C++ 里用 malloc 分配、却想让 ArkTS 侧持有并负责释放,这种跨语言所有权错配几乎必崩。正确做法是谁分配谁释放,或者走 NAPI 的 ArrayBuffer 所有权转移,让一端独占生命周期。我们后来定了一条团队铁律:原生模块对外只暴露纯函数加扁平 buffer,所有内存管理锁在 C++ 侧,ArkTS 只传数据不碰指针,崩溃率直接归零。NDK 协作最怕"语言边界模糊",边界划清了,C++ 和 ArkTS 才能各司其职、相安无事。对于想把成熟算法搬上鸿蒙的团队,这层纪律值千金,早立早省心。

七、总结

把这篇内容压缩成一句话:NDK 模块的稳定性,取决于你是否把「线程边界」和「内存边界」钉死。

回顾全文的关键结论:

  1. 编译只是起点。 llvm 工具链 + build-profile.json5 能编出 .so,但「能编译 ≠ 能稳定运行」,真机崩溃往往出在跨语言边界而非编译配置。
  2. NAPI 只做翻译,不做计算。 入口 napi_get_cb_info 取参、GetBuffer 转裸内存,出口 WrapOut 包结果,中间算法层保持纯 C++、与鸿蒙完全解耦,这样同一份代码还能跑单元测试、复用到其他平台。
  3. 重活必须异步化。 ArkTS 的渲染、手势、事件分发都在同一个 JS 线程上,同步 NAPI 调用会整段占住它。用 napi_create_async_work 把 HeavyAlgo::run 丢进线程池,execute 里只做纯计算,complete 里回到 JS 线程 resolve / reject,UI 才不冻。
  4. napi_env 是线程绑定的。 它只在 JS 线程上合法,execute 回调里碰任何 napi_* 接口或 napi_value 都是 SIGSEGV 的直接来源。
  5. 内存所有权只归一端。 要么拷贝(napi_create_arraybuffer),要么显式转移(napi_create_external_arraybuffer + finalize),绝不出现「C++ 分配、ArkTS 释放」这种错配。

如果只记一条落地纪律:原生模块对外只暴露纯函数加扁平 buffer,所有内存管理锁在 C++ 侧,ArkTS 只传数据不碰指针。 边界划清了,C++ 的性能优势和 ArkTS 的开发效率才能真正叠加,而不是互相拖累。

Logo

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

更多推荐