C++ 和 Kotlin 的互操作,JNI 封装技巧

C++ 和 Kotlin 的互操作,JNI 封装技巧

C++ 和 Kotlin 的互操作,JNI 封装技巧


C++ 和 Kotlin 的互操作,JNI 封装技巧


JNI 这玩意,Android 开发者都绕不开。Native 层有现成的 C++ 库要复用,或者性能敏感场景必须下到底层,总得打交道。但 JNI 的原始 API 写起来太折磨人了——FindClassGetMethodIDNewGlobalRef 这些操作繁琐不说,一不留神就是局部引用泄漏、线程附着问题,或者更隐蔽的类型签名写错导致运行时崩溃。这篇文章想聊的,不是 JNI 的基础教程,而是我在实际项目里用过的几套封装方案和工具,它们各自解决了什么问题,又在哪些地方埋了坑。


原始 JNI 的痛点:一个具体的崩溃案例


去年维护一个音视频项目时,我遇到过一个典型的 JNI 崩溃。场景很简单:Kotlin 层频繁调用 Native 方法传递 ByteArray,C++ 层处理完回调结果。代码大致长这样:


JNIEXPORT void JNICALL
Java_com_example_native_process(JNIEnv* env, jobject thiz, jbyteArray data) {
    jbyte* bytes = env->GetByteArrayElements(data, nullptr);
    // ... 处理数据 ...
    env->ReleaseByteArrayElements(data, bytes, 0);
}

看起来没问题?但线上崩溃日志显示 JNI ERROR (app bug): local reference table overflow (max=512)。追查后发现,某个分支路径里 ReleaseByteArrayElements 没被执行到,局部引用没释放。更隐蔽的是,这个 Native 方法被放在了一个循环里高频调用,512 个局部引用的上限很快耗尽。


修复很简单,加个 RAII 包装或者确保异常安全。但这件事让我意识到,手写 JNI 代码在复杂场景下太容易出错。Android 官方文档里关于 JNI 的局部引用、全局引用、弱全局引用的规则,读起来都明白,写起来一忙就忘。我们需要更高层次的抽象。


Djinni:跨语言接口生成的务实方案


Dropbox 开源的 Djinni 是我第一个认真评估的工具。它的核心思路很直接:用一个 IDL 文件定义接口,自动生成 C++ 和 Java/Kotlin 的双向绑定代码。项目地址在 GitHub 上,搜索 dropbox/djinni 就能找到,MIT 协议,没有商业限制。


Djinni 的 IDL 语法很简洁。比如定义一个接口:


my_interface = interface +c {
    process_data(data: binary): i32;
    get_status(): string;
}

+c 表示实现放在 C++ 侧,Kotlin 侧调用。跑一遍代码生成器,C++ 侧得到纯虚基类和 JNI 胶水代码,Kotlin 侧得到自动生成的接口类和 native 方法声明。你不需要手写任何 FindClass 或方法签名。


我实际用 Djinni 重构了前面提到的音视频模块。生成的 C++ 代码里,ByteArray 的传递被自动处理成了 std::vector<uint8_t>,引用管理也藏在生成的辅助类里。那段线上崩溃的代码,换成 Djinni 生成后,局部引用泄漏的问题从根本上消失了——生成的胶水代码里每个 GetByteArrayElements 都有对应的 Release,而且用 RAII 包装确保异常安全。


但 Djinni 的坑也很明显。首先是维护状态:Dropbox 官方已经不太活跃地维护主仓库了,社区 fork 有几个,比如 cross-language-cpp/djinni,但生态分裂意味着你得多花时间评估该跟哪个分支。其次是 Kotlin 支持:Djinni 最初为 Java 设计,Kotlin 的 Unit 返回、可空类型、协程这些特性,生成的代码需要额外适配。我项目里最后写了个 Gradle 任务,在 Djinni 生成后做字符串替换,把 Java 的 Void 改成 Kotlin 的 Unit,把 @NotNull 注解换成 Kotlin 的可空类型声明。这很丑,但能用。


更大的局限是 Djinni 的类型系统。它支持的类型有限:基本数值类型、string、binary、list、map、自定义接口和 record(结构体)。复杂场景比如 C++ 侧返回一个 std::variant 或者 std::optional,Djinni 没有原生支持,你得绕路用 optional<T> 拆成 Tbool has_value 的 record,或者干脆放弃类型安全用 binary 序列化传递。我项目里有个配置结构体,字段多且嵌套深,最后实在受不了 Djinni 的 IDL 冗长度,那部分改用手写 JNI 了。


SWIG:老牌代码生成器的 Android 适配


SWIG 比 Djinni 历史久得多,支持的语言也更多。官网 swig.org,4.x 版本开始实验性支持 Android,能把 C++ 接口直接包装成 Java/Kotlin 可调用的形式。


SWIG 的卖点是"几乎不用改现有 C++ 代码"。你有现成的 C++ 库,写个 .i 接口文件,SWIG 解析 C++ 头文件自动生成绑定。比如:


%module my_native
%{
#include "my_engine.h"
%}
%include "my_engine.h"

my_engine.h 里的类和方法,直接映射到 Kotlin 侧。对于已有大量 C++ 代码要快速接入 Android 的场景,这比 Djinni 的 IDL 重写更省事。


但我实际用下来的体验是:SWIG 对 C++ 现代特性的支持总是慢半拍。C++11 的 std::shared_ptr 需要 %shared_ptr 宏显式启用,C++17 的 std::string_view 到 SWIG 4.1 才部分支持,C++20 的 concept 基本别想了。我项目里有个依赖库用了 std::optional,SWIG 解析直接报错,最后那部分接口只能手写 JNI 绕过。


SWIG 生成的代码体积也是个问题。它走的是"大而全"路线,一个中等规模的 C++ 模块可能生成出几万行的 Java 和 C++ 胶水代码,包体积增加明显。Djinni 相对精简,因为它只生成你 IDL 里显式定义的东西。另外 SWIG 的异常处理默认不太符合 Android 需求:C++ 异常抛到 Java 层,默认是转成 java.lang.RuntimeException 的子类,但类型信息丢了不少,调试时堆栈跟踪很乱。需要写额外的 %exception 指令来定制。


SWIG 是免费开源的,没有授权顾虑。但文档散落在各个版本里,有些特性要翻源码才能确认行为。我花了两个下午才搞明白 %typemap 的优先级规则,这时间成本得算进去。


手写 JNI 的现代化:我的 RAII 封装层


工具都不是银弹,有些场景必须回到手写 JNI。但手写不代表要忍受原始 API 的繁琐。我维护了一套内部用的 C++ 封装,核心思路是把 JNIEnv 的操作全部 RAII 化,同时用模板减少重复代码。


关键设计有几个。第一个是 JniLocalFrame,管理局部引用帧:


class JniLocalFrame {
    JNIEnv* env_;
    bool pushed_;
public:
    explicit JniLocalFrame(JNIEnv* env, int capacity = 16) 
        : env_(env), pushed_(env->PushLocalFrame(capacity) == 0) {}
    ~JniLocalFrame() { if (pushed_) env_->PopLocalFrame(nullptr); }
    // 禁止拷贝,允许移动
};

这解决了我开头提到的局部引用溢出问题。高频调用场景里,每个方法入口 PushLocalFrame,出口自动 PopLocalFrame,局部引用上限从 512 变成每个帧的容量,压力小很多。


第二个是 JniStringJniByteArray,封装字符串和字节数组的临界区访问:


class JniByteArray {
    JNIEnv* env_;
    jbyteArray array_;
    jbyte* ptr_;
    jboolean is_copy_;
public:
    JniByteArray(JNIEnv* env, jbyteArray array) 
        : env_(env), array_(array), ptr_(env->GetByteArrayElements(array, &is_copy_)) {}
    ~JniByteArray() { 
        if (ptr_) env_->ReleaseByteArrayElements(array_, ptr_, 0); 
    }
    jbyte* data() const { return ptr_; }
    jsize length() const { return env_->GetArrayLength(array_); }
};

GetByteArrayElements 可能返回数组拷贝也可能返回直接指针,is_copy_ 标记这个信息。ReleaseByteArrayElements 的第三个参数 0 表示拷贝数据回 Java 数组,如果 C++ 侧只读不改,可以优化成 JNI_ABORT 省一次拷贝。这个封装把决策暴露给调用方,但确保释放总是发生。


第三个是模板化的方法调用,减少 CallVoidMethodCallIntMethod 这类重复。利用 C++11 的变参模板:


template<typename Ret, typename... Args>
Ret call_method(JNIEnv* env, jobject obj, jmethodID mid, Args... args) {
    if constexpr (std::is_same_v<<Ret, void>) {
        env->CallVoidMethod(obj, mid, JniArgConverter<<Args>::convert(args)...);
    } else if constexpr (std::is_same_v<<Ret, jint>) {
        return env->CallIntMethod(obj, mid, JniArgConverter<<Args>::convert(args)...);
    }
    // ... 其他返回类型
}

JniArgConverter 做类型转换,比如 std::stringjstringstd::vector<uint8_t>jbyteArray。这个设计借鉴了 Djinni 的生成代码思路,但用手写模板实现,灵活性更高。


这套封装的代价是编译时间。模板加 if constexpr,C++17 起步,老项目可能升级编译器有困难。另外异常安全:如果 CallIntMethod 过程中 Java 层抛异常,C++ 侧不会自动感知,需要每次调用后检查 env->ExceptionCheck()。我加了宏包装,但代码里到处是 JNI_CHECK_EXCEPTION 还是显得啰嗦。


Kotlin 侧的封装:隐藏 Native 方法


C++ 侧有封装,Kotlin 侧也值得做一层。直接暴露 external fun 给业务层,签名里全是 jlongjbyteArray,类型安全差,而且 Native 库的加载初始化逻辑散落在各处。


我的做法是用 Kotlin 的 object 单例包装,内部持有 long 类型的 Native handle,对外暴露类型安全的 API。比如:


class NativeEngine private constructor(private val handle: Long) {
    companion object {
        init {
            System.loadLibrary("myengine")
        }
        
        fun create(config: EngineConfig): NativeEngine {
            val handle = nativeCreate(config.toJson())
            return NativeEngine(handle).also {
                // 注册 Cleaner,确保 Native 资源释放
                cleaner.register(it, CleanAction(handle))
            }
        }
        
        private class CleanAction(private val handle: Long) : Runnable {
            override fun run() = nativeDestroy(handle)
        }
    }
    
    fun process(input: ByteArray): Result {
        if (input.isEmpty()) throw IllegalArgumentException("empty input")
        return nativeProcess(handle, input).let { Result.parse(it) }
    }
    
    private external fun nativeCreate(configJson: String): Long
    private external fun nativeDestroy(handle: Long)
    private external fun nativeProcess(handle: Long, input: ByteArray): String
}

几个细节。EngineConfig 是 Kotlin 的数据类,序列化成 JSON 传给 Native 侧,避免在 JNI 里逐个字段传递。Cleaner 是 Java 9 引入的,比 finalize() 可靠,确保 Native handle 总是释放,但释放时机不保证立即执行,所以 NativeEngine 也提供显式的 close() 方法给确定性释放场景。


Result.parse(it) 这里,Native 侧返回 JSON 字符串,Kotlin 侧用 kotlinx.serialization 解析。这比在 JNI 里构造 Kotlin 对象简单得多,也避免了 C++ 侧调用 Kotlin 构造函数的性能开销。代价是多了序列化步骤,但我的场景里数据量不大,这开销可忽略。


这个模式的坑在于 System.loadLibrary 的时机。如果放在 init 块里,类首次访问时加载,但多线程并发首次访问可能触发竞态,虽然 loadLibrary 内部有同步,但加载失败后的重试逻辑不好处理。我后来改成 Application 初始化时显式加载,单例的 init 块只做空检查。


线程模型:AttachCurrentThread 的隐藏成本


JNI 封装里最容易被忽视的是线程附着。C++ 的回调线程、线程池里的工作线程,要调用 JNI 必须先 AttachCurrentThread,用完 DetachCurrentThread。这操作有显著开销:每次 attach 都会在 JVM 里创建一个新的 Java Thread 对象,attach/detach 频繁时 GC 压力增大。


Android 的 JavaVM 提供了 AttachCurrentThreadAsDaemon 变体,创建的线程是守护线程,不阻止 JVM 退出。但 daemon 线程 attach 后,如果不再 detach,线程终止时 JVM 会自动清理,这比手动 detach 省事,但清理时机不可控。


我的项目里有个 C++ 的音频渲染线程,固定线程,高频回调 Kotlin 层更新播放进度。最初的实现每次回调都 attach/detach, profiling 发现这占了 5% 的 CPU。优化方案:线程启动时 attach 一次,保存 JNIEnv__PLACEHOLDER_ITALIC_0__ 只在当前线程有效,不能跨线程传递。另外线程 terminate 前必须 detach,否则 JVM 退出时可能崩溃。


封装成 JniThreadScope


class JniThreadScope {
    JavaVM* vm_;
    JNIEnv* env_;
    bool attached_;
public:
    explicit JniThreadScope(JavaVM* vm) : vm_(vm) {
        attached_ = (vm->GetEnv(reinterpret_cast<void**>(&env_), JNI_VERSION_1_6) == JNI_EDETACHED);
        if (attached_) {
            JavaVMAttachArgs args = {JNI_VERSION_1_6, nullptr, nullptr};
            vm->AttachCurrentThread(&env_, &args);
        }
    }
    ~JniThreadScope() {
        if (attached_) vm_->DetachCurrentThread();
    }
    JNIEnv* env() const { return env_; }
};

线程函数入口构造 JniThreadScope,出口自动 detach。但注意:如果 C++ 代码抛异常导致栈展开,析构函数会执行,这没问题;但如果线程被 pthread_cancel 终止,析构函数可能不执行,造成泄漏。Android 的 bionic libc 对 pthread_cancel 支持有限,实际遇到的不多,但设计上要知道这个边界。


现代替代方案:NativeActivity 和 AAudio 的启示


聊工具不能只聊 JNI。Google 在推的一些新 API,实际上在减少 JNI 的必要性。NativeActivity 让纯 C++ 应用不写 Java/Kotlin 成为可能,但局限很大:没有系统 UI 组件,所有界面自己用 OpenGL/Vulkan 画,适合游戏,不适合普通应用。


AAudio 音频 API 的设计更有意思。它提供了 C++ 的原生接口,但关键回调仍然发生在 C++ 线程,如果要把音频数据传回 Java/Kotlin 分析,还是绕不开 JNI。不过 AAudio 的 AAudioStreamBuilder_setDataCallback 是纯 C 回调,没有 Java 对象参与,这避免了 JNI 局部引用的问题。


Jetpack 的 Media3 库走另一条路:Native 层只暴露编解码能力,播放控制、UI 状态全在 Kotlin 层,通过 Binder IPC 通信。这本质上是用 AIDL 替代 JNI,但 AIDL 的生成代码底层还是 JNI 机制。对于性能不敏感的调用,AIDL 的异步语义比同步 JNI 更不容易阻塞,但序列化开销更大。


我个人觉得,除非是纯游戏或者全自绘 UI,否则完全避开 JNI 不现实。重点是把 JNI 交互限制在必要的边界上,减少调用频次,每次调用批量传递数据。


工具选择的实际建议


回到工具选择。我现在的项目里,Djinni 用于新设计的跨语言接口,IDL 定义清晰,团队协同时接口变更有版本控制。SWIG 用于接入一个第三方 C++ 库,库的头文件稳定,SWIG 一次性生成后很少改动。手写 JNI 的 RAII 封装用于高频路径和特殊类型场景,作为前两者的补充。


具体选择时,几个判断维度。接口稳定性:如果 C++ 侧接口频繁变更,Djinni 的 IDL 维护成本会累积,不如手写灵活。已有代码规模:大规模遗留 C++ 代码,SWIG 的"几乎不改代码"有优势,但要接受生成代码的体积。团队 C++ 能力:Djinni 和手写封装都需要团队能读 C++,如果主要是 Kotlin 开发者,SWIG 的"黑盒"特性反而降低门槛。


价格因素:三个方案都是免费开源。但隐性成本不同:Djinni 需要维护 IDL 和生成流程,SWIG 需要处理生成代码的异常和调试,手写封装需要持续的代码审查确保 JNI 规范合规。我算过一笔账:一个中等复杂度的模块,Djinni 的初期搭建(含 Kotlin 适配脚本)约 2 人日,后续每次接口变更 0.5 人日;手写 JNI 初期 1 人日,但后续每次变更 1 人日且容易出 bug。长期维护的项目,Djinni 更划算。


一个未解决的难题:Kotlin 协程与 Native 回调


最后提一个我还在踩坑的场景:Kotlin 协程与 C++ 异步回调的整合。理想状态是 C++ 侧发起异步操作,Kotlin 侧用 suspend fun 等待结果,避免回调地狱。


实际实现有几种方案。一种是 C++ 回调里 resume 一个 Continuation,但这要求 C++ 侧持有 Kotlin 的 Continuation 对象,全局引用管理复杂,而且 resume 的线程必须是 Kotlin 协程的调度线程,C++ 回调线程通常不满足。


另一种方案是 C++ 侧用 future/promise 模式,Kotlin 侧用 suspendCancellableCoroutine 桥接。但 CancellableContinuation 的取消传播到 C++ 侧,需要额外的取消令牌机制,代码耦合度高。


我目前的妥协方案是:C++ 侧保持回调接口,Kotlin 侧用 CallbackFlow 包装成 Flow。这不是真正的 suspend fun,但背压和取消语义相对清晰。Google 的 CameraX 库有类似的 Native 回调桥接实现,可以参考其源码,但那是 Java 的 ListenableFuture,Kotlin 协程的适配要额外一层。


这个场景没有现成工具完美解决,可能等 Project Panama 或者 FFM(Foreign Function & Memory API)成熟后,Java 侧的 Native 调用会有更现代的抽象。但 Android 的 JVM 不是 OpenJDK 主线,新特性落地慢,短期内 JNI 仍是主流。


写这篇文章时,我回头看三年前写的第一个 JNI 模块,满屏的 env-> 调用和手动引用管理,现在基本看不懂了。工具演进很快,但 JNI 的底层机制没变——理解局部引用表、全局引用、线程附着这些基础,才能判断封装工具在做什么、为什么有时失效。封装是手段,不是目的。

远程工作对 Android 团队的影响,协作效率真的降了吗 2026-06-13
MediaStore 的日期查询索引,为什么有时候会慢 2026-06-13

评论区