@ohos/aki 源码解读(一):一行绑定背后,宏、模板与索引分发
先从一个你可能遇到过的场景说起
如果你写过鸿蒙的 Native 插件,大概率经历过这样的时刻:只是想暴露一个 C++ 函数给 ArkTS 调用,结果要在 napi_init.cpp 里写上一大段------
💡 NAPI 样板代码示意(非库源码原文)
cpp
// 取参数
size_t argc = 2;
napi_value args[2];
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
// 转类型
int a, b;
napi_get_value_int32(env, args[0], &a);
napi_get_value_int32(env, args[1], &b);
// 调用
int result = add(a, b);
// 包装返回值
napi_value ret;
napi_create_int32(env, result, &ret);
return ret;
这还只是 add(int,int) 一个函数。要绑第二个、第三个、第五十个?同样的四段代码,复制粘贴五十遍。
@ohos/aki 这个库的宣传语是"一行代码完成 ETS 与 C/C++ 互调"。也就是说,上面那四段,你只需要写 JSBIND_FUNCTION(add) 一行。
它是怎么把这几十行"省"掉的? 这不是魔法,是三层机制在起作用。读懂这三层,你就读懂了 AKI 的大半。
本文内容 :先用一个"分拣中心"的类比建立整体图景,再分三节分别验证宏、模板、索引各自怎么工作,最后把图景落到 add(3, 5) 的一次真实调用上。读完你会确切知道:同签名的函数为什么不会冲突、functionNumber 这个编号是从哪来又被谁消费的、以及文档里说的"重载"为什么不是 C++ 语义上的重载。
📦 仓库地址 | 本文基于 v1.3.3 头文件在
include/aki/,实现文件在src/,下文路径分别相对这两个目录。
一、先建立一个图景:分拣中心
在贴任何代码之前,先用一个类比把 AKI 的做法讲清楚。
把 AKI 想象成一个快递分拣中心:
- 每个要绑定的 C++ 函数,是一件包裹。
- 函数的签名 (返回值 + 参数类型)是包裹的型号。
- 类型转换那段代码(取参、转类型、包装返回)是流水线。
分拣中心的做法是:
- 同型号的包裹,走同一条流水线。因为处理方式完全一样,没必要为每件包裹单独建一条线。
- 流水线是"型号级"的,不是"包裹级"的。建线的时候只看型号,建好后同型号的包裹都往这条线上放。
- 包裹上线时贴一个编号。流水线末端按编号取件,不会拿错。
对应到 AKI:
| 分拣中心 | AKI |
|---|---|
| 包裹 | 要绑定的 C++ 函数 |
| 型号 | 函数签名 R(*)(Args...) |
| 按型号建流水线 | 按签名做模板实例化,得到 Binder 单例 |
| 包裹编号 | invokers_ 数组下标,也就是 functionNumber |
| 末端按编号取件 | 调用时 GetInvoker(functionNumber) 取出函数指针 |
"同签名会不会冲突"这个问题,在分拣中心的类比里根本不成立------同型号的包裹本来就该走同一条线,不会互相覆盖,因为线是按型号建的,不是按包裹建的。
而"不同函数怎么区分",靠的就是那个编号。
好,图景有了。下面分三节,分别验证这个类比的三个环节。
二、宏:把"我要绑这个函数"翻译成一次注册
先看 JSBIND_FUNCTION 这个宏长什么样。它在 include/aki/jsbind.h 里,定义只有一行:
📄 jsbind.h
cpp
#define JSBIND_FUNCTION(__name, ...) \
aki::FunctionDefiner JSBIND_UNIQUE(definer, __LINE__)(aki::AliasName(#__name, ##__VA_ARGS__), &__name)
你在第 42 行写 JSBIND_FUNCTION(add),预处理器会把它变成:
💡 宏展开推演(非库源码原文)
cpp
aki::FunctionDefiner definer42(aki::AliasName("add"), &add)
用人话说:宏把你的函数声明,变成了"构造一个 FunctionDefiner 对象"这件事。 至于构造这个对象会发生什么,那是下一站的事。
这里有三个小细节,顺带解释一下,免得后面看到时疑惑:
definer42里的42是行号 。JSBIND_UNIQUE把__LINE__拼进变量名,所以你在第 42 行、第 57 行、第 100 行各写一个JSBIND_FUNCTION,生成的变量名都不一样。这就是"可以绑很多个而不冲突"的第一层保障。AliasName支持起别名 。JSBIND_FUNCTION(add, "sum")暴露给 ArkTS 的名字是sum,C++ 函数还叫add。文档里说的"可指定导出别名"就来自这个可变参数模板。&__name取的是函数地址 ,类型是R(*)(Args...)。这个类型签名是下一站的关键------流水线按它建。
所以宏只做了半件事:把"我要绑这个函数"翻译成"构造一个 Definer"。真正建流水线的是下一站。
三、模板:同签名的函数共享一条流水线
FunctionDefiner 的全部工作都在构造函数里:
📄 definer/function_definer.h
cpp
class FunctionDefiner {
public:
template <typename R, typename... Args>
FunctionDefiner(const char* name, R(*func)(Args...))
{
typedef NapiFunctionBinder<decltype(func), R, Args...> Binder;
Binding::RegisterFunction(name,
Binder::AddInvoker(func),
&Binder::GetInstance());
}
};
这个构造函数的参数是函数指针 R(*func)(Args...),所以 R 和 Args... 在编译期就确定了。 typedef 据此生成 NapiFunctionBinder<decltype(func), R, Args...>------这就是"按型号建流水线"。
然后它做了两件事:
💡 注册两步示意(非库源码原文)
cpp
Binder::AddInvoker(func) // ① 把函数指针放进数组,拿到下标
Binding::RegisterFunction(name, ...) // ② 把"名字 + 下标 + 单例地址"登记进注册表
第①步就是"给包裹贴编号",第②步是"登记在册"。
现在看流水线本体。它在 binder/binder.h 里,是一个持有数组的模板类:
📄 binder/binder.h
cpp
template <class BinderType, typename I, typename R, typename... P>
class TemplatedBinder : public Binder {
public:
static BinderType& GetInstance()
{
static BinderType instance; // 静态单例:每个签名一份
return instance;
}
static int32_t AddInvoker(I invoker)
{
auto& invokers = GetInstance().invokers_;
invokers.emplace_back(std::move(invoker)); // 只追加,不删除
return static_cast<int32_t>(invokers.size() - 1);
}
private:
/// 所有签名为 R(*)(P...) 的原生函数都由同一个 Binder 转发
/// Invoker Id 用于区分;数组存 Invoker 是为了索引性能
std::vector<InvokerInfoType> invokers_;
};
源码里那句英文注释,用中文讲就是:
所有签名为
R(*)(P...)的函数都由同一个 Binder 转发;Invoker Id 用于区分;用数组存 Invoker 是为了索引性能。
为什么"按签名建流水线"是合理的? 因为流水线要做的事------取参数、把 napi_value 转成 C++ 类型、调用、把返回值包回去------只跟签名有关,跟具体是哪个函数无关。
add(int,int) 和 multiply(int,int) 的签名都是 int(*)(int,int),所以:
- 它们 typedef 出同一个
NapiFunctionBinder<int(*)(int,int), int, int, int> GetInstance()返回同一个静态单例- 类型转换的模板代码只实例化一份
这就是"同签名不冲突"的原因:它们本来就该共享一条流水线,因为处理方式完全一样。
那下一个问题自然来了:既然共享一条流水线,调用时怎么知道这次该取哪件包裹?
四、索引:流水线末端按编号取件
答案就是 invokers_ 这个数组,加上注册时拿到的下标。
假设你绑了三个函数:
💡 绑定示例(非库源码原文)
cpp
JSBIND_FUNCTION(add) // int add(int, int)
JSBIND_FUNCTION(multiply) // int multiply(int, int)
JSBIND_FUNCTION(greet) // std::string greet(std::string)
按前面的机制推演,结果是这样:
| 函数 | 流水线(Binder 单例) | 编号(invokerId) |
|---|---|---|
add |
Binder<int,int,int> 单例 A |
0 |
multiply |
同一个单例 A | 1 |
greet |
Binder<string,string> 单例 B |
0 |
同一条流水线上的包裹靠编号区分,不同流水线各自独立。 这就是"同签名不冲突、不同函数能区分"的完整答案。
AddInvoker 的实现保证了编号稳定:
📄 binder/binder.h
cpp
static int32_t AddInvoker(I invoker)
{
auto& invokers = GetInstance().invokers_;
invokers.emplace_back(std::move(invoker)); // 只追加,不删除
return static_cast<int32_t>(invokers.size() - 1);
}
注意它只提供追加,不提供删除。 这不是功能缺失,是刻意设计:一旦允许删除或中间插入,已有元素的下标就会移位,编号就失效了。"编号永不被打乱"是这套机制成立的前提。
五、把图景落到一次真实调用上
前面三节分别讲了宏、模板、索引。现在把它们串起来,看 add(3, 5) 从 ArkTS 发出后,实际发生了什么。
5.1 挂载:函数什么时候能被 ArkTS 看到?
绑定的函数还只在注册表里,得先挂到 exports 上。入口在 JSBIND_ADDON:
📄 jsbind.h
cpp
extern "C" __attribute__((constructor)) void Register##addonName(void)
{
napi_module_register(&_module);
}
__attribute__((constructor)) 让这段代码在 so 被加载时自动执行 ,不需要你在 Ability 里写任何初始化。随后 napi_module_register 触发 Init:
📄 src/napi/napi_init.cpp
cpp
for (auto& function : aki::Binding::GetFunctionList()) {
auto binder = function.GetBinder();
auto wrapper = reinterpret_cast<NapiWrapperFunctionInfo>(binder->GetWrapper());
aki::BindInfo* info = new aki::BindInfo();
info->functionNumber = function.GetInvokerId(); // ← 编号写进 BindInfo
napi_property_descriptor desc = DECLARE_NAPI_FUNCTION(function.GetName(), wrapper, info);
napi_define_properties(env, exports, 1, &desc);
}
每个函数做三件事:取 Wrapper、新建 BindInfo 写入编号、napi_define_properties 挂到 exports。
info 会作为属性的 data 字段传给 NAPI ,这样调用时 NAPI 能把 BindInfo 连同编号一起传回给 Wrapper。Init 里 new 出来的 BindInfo 由 ModuleCleanup 统一回收,不泄漏。
5.2 调用:先校验,再取参
ArkTS 执行 add(3, 5),NAPI 找到 Wrapper。它做的第一件事不是取参,而是两道校验:
📄 binder/napi/napi_function_binder.h
cpp
if (!Checker<P...>::IsArityValid(env, info)) {
// 参数个数不对,报错:expected: 2
napi_throw_error(env, nullptr, msg.c_str());
return nullptr;
}
if (!Checker<typename ValueDefiner<P>::RawType...>::TypesAreValid(env, info)) {
// 类型不对,报错并带每个参数的通过标记
napi_throw_type_error(env, "JSBind: Wrong type of arguments", msg.c_str());
return nullptr;
}
return SafetyWrapper(env, info, ...);
个数校验很直接:
📄 checker/napi/napi_checker.h
cpp
return argc == sizeof...(P); // 参数包长度 == 实际传入个数
类型校验会构造详细的错误信息:
📄 checker/napi/napi_checker.h
cpp
for (size_t i = 0; i < flagList.size(); i++) {
flagMsg.append(" ").append(flagList[i] ? "true" : "false");
}
flagMsg.append(" ], expected: [").append(GetExceptedTypes()).append(" ]");
报错长这样:flags: [ true false ], expected: [ int string ]------能直接看出第几个参数类型不对。
BooleanAnd 用的是按位与 &,不是逻辑与 &&:
📄 checker/napi/napi_checker.h
cpp
return flag & BooleanAnd(rest...);
对 bool 结果一样,但不会短路------所有参数的类型检查都会执行完。这是刻意的:为了报出完整的 flags,而不是遇到第一个错就停。
校验通过后才取参,napi_get_cb_info 的最后一个参数把 BindInfo 带回来,编号就在里面。
5.3 分发:用编号取出真正的函数
📄 binder/function_binder.h
cpp
static R InnerWrapper(aki::BindInfo* bindInfo, Args&&... args)
{
const auto& invokerInfo = BinderType::GetInvoker(bindInfo->functionNumber);
return Invoker<R (*)(P...)>::InvokeFunction(invokerInfo.invoker, std::forward<Args>(args)...);
}
GetInvoker(bindInfo->functionNumber) 用下标从数组取出 InvokerInfo ,invokerInfo.invoker 就是注册时的函数指针。
Invoker 执行最终调用:
📄 invoker/invoker.h
cpp
static R InvokeFunction(F func, Args&&... args)
{
return (*func)(std::forward<P>(args)...);
}
(*func)(3, 5) 被执行 ,C++ 的 add 被调用,返回值再经 NapiValue<R>::ToNapiValue 转回 napi_value 交给 ArkTS。
六、回到最初的问题
开头的疑问是:add 和 multiply 签名一样,NAPI 怎么区分?
现在可以完整回答了:
| 阶段 | 机制 | 解决的疑问 |
|---|---|---|
| 建线 | 按签名实例化 Binder 单例 | 同签名共享流水线,所以不冲突 |
| 贴号 | AddInvoker 返回数组下标 |
编号在注册时固定,且只追加不打乱 |
| 挂载 | Init 把编号写进 BindInfo |
编号随 NAPI 属性 data 字段绑定 |
| 取件 | GetInvoker(functionNumber) |
不同函数靠下标区分,O(1) |
所以这套设计的核心是:类型适配按签名共享,函数分发按索引区分。两层各管各的,互不干扰。
七、顺带澄清:文档说的"重载"不是 C++ 的重载
📄 binder/binder.h
cpp
enum class Type: unsigned int {
...
Overload // 重载回调:按参数个数区分,而不是按参数类型
};
注释写得很直白:按参数个数区分,不按参数类型 。因为分发索引只认"第几个 invoker",要支持重载就得按 arity 建组,走另一套 NapiOverloader:
📄 overloader/napi/napi_overloader.h
cpp
struct OverloadGroup {
std::vector<FuncPtr> methods;
napi_callback externalWrapper;
};
static void AddMethod(int32_t groupId, int32_t arity, FuncPtr method);
所以 add(1,2) 和 add(1,2,3) 走不同实现,要用 NapiOverloader 按参数个数注册两组。C++ 的 int add(int,int) 和 double add(double,double) 这种同名不同参的重载,AKI 并不按 C++ 重载规则去选------这一点从机制上就决定了。
八、这套设计为什么能做到"一行绑定"
回头看,AKI 把 NAPI 的样板代码拆成了三层各自可复用的部分:
| 层 | 职责 | 复用方式 |
|---|---|---|
| 宏 | 声明 → 注册调用 | 每个绑定一行,__LINE__ 造唯一变量名 |
| 模板 | 签名 → Binder 实例 | 同签名共享单例,类型转换代码只实例化一份 |
| 索引 | 调用 → 具体函数 | 数组下标 O(1),无需按名字查找 |
裸写 NAPI 时,每个函数都要重复写"取参 → 转类型 → 调用 → 包装返回值"这四段。AKI 把这段按签名抽象成模板,同签名的函数天然共享,所以绑定 N 个函数只需 N 行宏。
这也是为什么 AKI 的类型转换代码看起来"绕"------三层模板嵌套、tag dispatch、std::index_sequence 包展开------这些复杂度都集中在一份模板实例里,由编译器替所有同签名函数消化。业务侧看到的才是"一行"。
如果要你自己评估这个库,最值得带走的三条判断:
- 同签名函数不会冲突 ,因为按签名共享 Binder 单例;但不要试图在运行时动态增删注册------
invokers_索引一旦打乱就全错。 functionNumber是数组下标,不是全局唯一 ID 。它由注册顺序决定,所以绑定顺序(宏展开顺序)会影响分发,调整代码顺序时要注意。- 文档说的"重载"不是 C++ 重载 。要按参数个数分发得用
NapiOverloader,同名不同参类型的 C++ 重载不会被自动选择。
下一篇讲这条链路里的类型系统:TypeFlag 位掩码如何描述 C++ 类型的类别与所有权、ValueDefiner 去 cv/ref 的必要性、以及为什么参数校验要放在取参之前而不是之后。
项目地址:@ohos/aki