目录

KSP 生成 OHOS NAPI 代码

Kotlin/Native 适配鸿蒙,除了改编译器,还有一块很重要:NAPI 桥接代码的自动生成

Kotlin/Native 和 NAPI 的关系

  • Kotlin/Native 的产物是平台二进制,在 OHOS 上就是 Linux ELF。
  • OHOS 上层用 ArkTS 开发,要调 so 里的逻辑,需要一层类似 JNI 的桥,用的就是 Node.js 那套 NAPI
  • 所以「从 ArkTS 调 Kotlin/Native」= 自己写一层 NAPI 桥。

一、为什么需要自动生成 NAPI

先看一个最简例子:K/N 里有个 hello_world(),要在 ArkTS 里调用,就要在 C++ 里注册 NAPI:

static napi_value helloWorld(napi_env env, napi_callback_info info) {
    hello_world();
    // 若有参数和返回值,还要做 napi_get_value_XXX / napi_create_XXX 等转换
    return nullptr;
}

static napi_value Init(napi_env env, napi_value exports) {
    napi_property_descriptor desc[] = {
        {"hello_world", nullptr, helloWorld, nullptr, nullptr, nullptr, napi_default, nullptr},
    };
    napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
    return exports;
}

static napi_module demoModule = {
    .nm_version = 1,
    .nm_flags = 0,
    .nm_filename = nullptr,
    .nm_register_func = Init,
    .nm_modname = "entry",
    .nm_priv = ((void *)0),
    .reserved = {0},
};

extern "C" __attribute__((constructor)) void RegisterEntryModule(void) {
    napi_module_register(&demoModule);
}

可以看出:手写 NAPI 要写大量 C++ 胶水,参数/返回值一多就更繁琐(ArkTS ↔ C++ 类型转换)。此外 ArkTS 侧还需要 d.ts 声明文件 才能通过编译。这类代码高度模板化,很适合 自动生成


二、用 KSP 自动生成 NAPI 的思路

KSP 用来替代 KAPT 处理 Kotlin 注解,用法见官方文档即可。

问题在于:怎么从 Kotlin/Native 的类/方法生成 NAPI? 例如:

class MMKV {
  fun setString(key: String, value: String) { }
}

方案 A:手写 ArkTS 封装

OHOS 上 MMKV 的 ArkTS 侧大致是这样:

export class MMKV {
  private nativeHandle: bigint;
  private static g_rootDir: string;

  private constructor(handle: bigint) {
    this.nativeHandle = handle;
  }

  public static initializeWithPath(rootDir: string): string {
    MMKV.g_rootDir = rootDir;
    native.initialize(rootDir, 0, null);
    return MMKV.g_rootDir;
  }

  public setString(key: string, value: string, expiration?: number): boolean {
    return native.setString(this.nativeHandle, key, value, expiration);
  }
}

特点:直接清晰,但 ArkTS 要手写,KSP 只生成 NAPI 的话,还要再手写一层 ArkTS 并和 NAPI 对上,比较麻烦。

方案 B:对象映射(我们采用的方案)

思路:ArkTS 侧的一个对象 ↔ K/N 侧的一个对象。ArkTS 调方法时,转成对 K/N 对象的调用,这样 ArkTS 侧不用再手写一层封装,只需补 d.ts 声明即可。

示意:

/img/in-post/arkts-kotlin-memory.jpg

难点在于:

  1. 生命周期:ArkTS 对象未释放前,对应的 K/N 对象不能释放,否则会崩。
  2. 类型与映射:K/N 的 class 如何对应到 ArkTS 的 object,且 ArkTS 侧还要有可调用的方法。
  3. d.ts:声明文件如何生成。

三、对象映射方案细节

3.1 用 Kotlin 写 NAPI,用 KSP 生成

NAPI 是 C API,但 Kotlin/Native 有 cinterop,可以从 Kotlin 里直接调 C。因此可以用 Kotlin 写 NAPI 注册与桥接逻辑,再配合 KSP 处理注解 + Kotlin Poet 生成代码,实现「基于注解自动生成 Kotlin 写的 NAPI 桥接代码」。

3.2 对象映射

基础类型

按类型做一一转换即可,值类型,不涉及生命周期。

Class 类型

  • napi_wrap / napi_unwrap 把 ArkTS 对象和 native 对象绑在一起。
  • 维护一个 K/N 对象 ↔ ArkTS 对象 的映射(可用 weak ref),保证一一对应。
  • 在 ArkTS 侧给对象注册 finalize,在 GC 回收时 unwrap 出 native 指针并做释放。

泛型

  • 逆变/协变目前没处理。
  • 普通泛型在 K/N 和 ArkTS 里都是擦除的,桥接时统一按 Any 转,再在具体调用处强转。

函数类型

  • 目前未支持;suspend、Promise 等也没做。

3.3 生命周期

Class 的释放逻辑见上文:napi_wrap + finalize + 映射表,确保 ArkTS 对象回收时再释放 K/N 侧对象(可参考文首示意图)。

3.4 d.ts 生成

目前是在 KSP 汇总阶段 一起生成 d.ts。


四、KSP 实现里遇到的坑

整体就是:写一堆类型转换 util、维护好「ArkTS ↔ K/N」的映射。另外踩了几个 KSP 的坑:

1. 无法跨模块收集信息

场景:两个 K/N 模块 a、b,a 依赖 b,希望只在 a 里一次性汇总并注册所有 NAPI(若 a、b 各打一个 so,会带两份 K/N runtime,所以通常打成一个 so 并汇总注册)。但 KSP 只对当前模块可见,拿不到依赖模块 b 里生成的 NAPI 信息。

绕法:用 getDeclarationsFromPackage,传 package 包名把依赖模块里的声明拉出来再汇总。

2. 拿不到工程目录

生成 index.d.ts 时,KSP 里拿不到工程根目录,只能往 resource 等固定位置写;而 d.ts 是给 ArkTS 用的,打进 so 的 resource 没意义。

绕法:用生成文件的 absolute path 反推工程目录,或在 KSP 参数里显式传 d.ts 输出路径

3. Processor 会多次执行

处理注解时,KSP 会根据你的返回值多次跑同一个 processor,需要自己做「是否已处理过」的判定,避免重复生成或状态错乱。


五、小结

  • Kotlin/Native 上 OHOS 要接 ArkTS,必须写 NAPI;手写 C++ 成本高,适合用 KSP + Kotlin 写 NAPI 的方式做代码生成。
  • 采用 对象映射 可以在 ArkTS 侧尽量「和 K/N 调用方式一致」,少写一层手写 ArkTS,主要代价是生命周期、类型映射和 d.ts 生成要设计好。
  • KSP 侧注意跨模块可见性、工程路径、以及 processor 多次执行带来的状态问题。