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 声明即可。
示意:

难点在于:
- 生命周期:ArkTS 对象未释放前,对应的 K/N 对象不能释放,否则会崩。
- 类型与映射:K/N 的 class 如何对应到 ArkTS 的 object,且 ArkTS 侧还要有可调用的方法。
- 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 多次执行带来的状态问题。