@ohos/aki 源码解读(一):一行绑定背后,宏、模板与索引分发

@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++ 函数,是一件包裹。
  • 函数的签名 (返回值 + 参数类型)是包裹的型号。
  • 类型转换那段代码(取参、转类型、包装返回)是流水线。

分拣中心的做法是:

  1. 同型号的包裹,走同一条流水线。因为处理方式完全一样,没必要为每件包裹单独建一条线。
  2. 流水线是"型号级"的,不是"包裹级"的。建线的时候只看型号,建好后同型号的包裹都往这条线上放。
  3. 包裹上线时贴一个编号。流水线末端按编号取件,不会拿错。

对应到 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
flowchart LR subgraph A[&#34;流水线 A:int(*)(int,int)&#34;] direction TB inv0[&#34;编号 0:add&#34;] inv1[&#34;编号 1:multiply&#34;] end subgraph B[&#34;流水线 B:string(*)(string)&#34;] inv2[&#34;编号 0:greet&#34;] end add[&#34;add(3,5)&#34;] -->|编号 0| inv0 mul[&#34;multiply(3,5)&#34;] -->|编号 1| inv1 gr[&#34;greet('hi')&#34;] -->|编号 0| inv2

同一条流水线上的包裹靠编号区分,不同流水线各自独立。 这就是"同签名不冲突、不同函数能区分"的完整答案。

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 包展开------这些复杂度都集中在一份模板实例里,由编译器替所有同签名函数消化。业务侧看到的才是"一行"。

如果要你自己评估这个库,最值得带走的三条判断:

  1. 同签名函数不会冲突 ,因为按签名共享 Binder 单例;但不要试图在运行时动态增删注册------invokers_ 索引一旦打乱就全错。
  2. functionNumber 是数组下标,不是全局唯一 ID 。它由注册顺序决定,所以绑定顺序(宏展开顺序)会影响分发,调整代码顺序时要注意。
  3. 文档说的"重载"不是 C++ 重载 。要按参数个数分发得用 NapiOverloader,同名不同参类型的 C++ 重载不会被自动选择。

下一篇讲这条链路里的类型系统:TypeFlag 位掩码如何描述 C++ 类型的类别与所有权、ValueDefiner 去 cv/ref 的必要性、以及为什么参数校验要放在取参之前而不是之后。


项目地址:@ohos/aki

相关推荐
贾伟康36 分钟前
【解压时刻|13】HarmonyOS ArkTS 启动链路审计:从 EntryAbility、AppStore 到 Navigation 首屏
harmonyos·arkts·appstorage·应用启动·entryability
贾伟康2 小时前
【古诗词闯关|12】HarmonyOS ArkTS 单页组件拆分实战:降低题库、闯关与结果弹层耦合
harmonyos·arkts·状态管理·arkui·组件化
威哥爱编程4 小时前
模拟面试智能体开发实战 07:答完一题就点评一题,结构化输出给分建议,让每道题都变成复盘
python·agent·harmonyos
威哥爱编程4 小时前
模拟面试智能体开发实战 08:总评报告先过三道闸,防泄题防夸大防错建议,才敢发到候选人手上
python·agent·harmonyos
熊猫钓鱼>_>20 小时前
普通用户视角的端侧智能体验手记【鸿蒙心迹】
开发语言·人工智能·华为·智能手机·性能优化·ai编程·harmonyos
KevinChaos21 小时前
阳台小花园开发手记 05|HarmonyOS 7.0 互动卡片:动效进出衔接
华为·harmonyos
WHMONY1 天前
大一独立开发鸿蒙原生 AI Agent:聊聊我为什么死磕端侧模型与“技术平权”
harmonyos
todoitbo1 天前
把 K-Lite「什么都能播」搬上鸿蒙 PC:影匣播放器的 FFmpeg + libass 原生移植实录
华为·ffmpeg·harmonyos·鸿蒙
威哥爱编程1 天前
模拟面试智能体开发实战 05:给面试官接上题库和检索,题目结构化、大纲可溯源,AI 不再凭印象出题
python·agent·harmonyos