JNI 成本模型与边界设计:Kotlin 和 C++ 之间的桥为什么不能随便走
JNI 是 Kotlin/Java 和 C/C++ 的桥。桥能让两边通行,但过桥不是瞬移:要排队、检查、转换、到达另一边后还要适应那边的规则。
这一篇先不讲复杂语法,只讲一个核心问题:为什么 JNI 边界要设计,而不是每个功能都随手写一个 native 方法。
JNI 到底是什么
Android 上,Kotlin/Java 代码运行在 ART 运行时里。C/C++ 代码编译成 .so,由 CPU 直接执行。两边的对象模型、内存管理、异常机制都不一样。
JNI 就是它们之间的协议。
Kotlin/Java
有对象、GC、异常、线程模型
JNI
做参数转换、引用管理、线程附着
C/C++
有指针、手动资源管理、RAII、native 线程
官方 JNI Tips 文档也提醒:应尽量减少需要触碰 JNI 的线程数量,并把 JNI 接口集中在少量容易识别的文件里。
一次 JNI 调用发生了什么
假设 Kotlin 调用:
external fun nativeSeek(positionUs: Long)
native 侧:
extern "C" JNIEXPORT void JNICALL
Java_com_zerobug_player_NativeBridge_nativeSeek(JNIEnv* env, jobject thiz, jlong positionUs) {
}
一次调用至少涉及:
从 managed 世界进入 native 世界
准备 JNIEnv
转换参数类型
检查引用
执行 C++ 函数
返回 managed 世界
如果参数是 String、ByteArray、对象列表,成本会更高,因为可能涉及编码转换、数组拷贝、局部引用创建。
JNI 的成本不是“慢”,而是“边界复杂”
不要简单理解为 JNI 很慢。单次 JNI 调用通常不是灾难。真正的问题是高频、细粒度、分散调用。
错误设计:
每帧回调一次 getPosition()
每帧回调一次 getBufferPercent()
每帧回调一次 getDroppedFrames()
每个像素或每个 sample 都跨 JNI
这会导致:
调用次数过多
GC 和引用管理压力上升
线程边界复杂
日志和错误定位困难
更好的边界:命令和事件
把 JNI 设计成两条窄通道。
Kotlin -> Native:命令
Native -> Kotlin:事件
命令示例:
Open(uri)
AttachSurface(surface)
Play
Pause
Seek(positionUs)
Release
事件示例:
StateChanged
Progress
Buffering
Error
EndOfStream
这样 Kotlin 不需要知道 native 内部线程怎么跑,native 也不需要直接操作 UI。
粗粒度接口示例
class NativePlayerBridge {
external fun nativeCreate(): Long
external fun nativeSendCommand(
handle: Long,
commandType: Int,
argument: Long,
serial: Long,
)
external fun nativeRelease(handle: Long)
}
这里只有少量 JNI 方法。播放、暂停、seek 都走 nativeSendCommand,由 native 控制线程统一消费。
为什么不要频繁查询 native 状态
很多初学者会写:
val position = nativeGetPosition()
val duration = nativeGetDuration()
val isPlaying = nativeIsPlaying()
如果 UI 每 16ms 查一次,这就是持续跨边界。更稳的方式是 native 定期发送批量事件。
data class PlayerSnapshot(
val state: Int,
val positionUs: Long,
val bufferedUs: Long,
val driftUs: Long,
val serial: Long,
)
一次事件包含 UI 需要的全部信息。
C++ 侧命令入口
extern "C" JNIEXPORT void JNICALL
Java_com_zerobug_player_NativeBridge_nativeSendCommand(
JNIEnv*,
jobject,
jlong handle,
jint commandType,
jlong argument,
jlong serial
) {
auto* player = reinterpret_cast<PlayerController*>(handle);
if (player == nullptr) {
return;
}
player->postCommand(PlayerCommand{
static_cast<PlayerCommandType>(commandType),
argument,
serial,
});
}
这个 JNI 方法只做三件事。
检查 handle
转换参数
投递命令
它不做解码、不做文件读取、不阻塞 UI。
JNI 边界 KPI
设计 JNI 时可以给自己设几个指标。
每秒 JNI 调用次数
单次 JNI 平均耗时
每秒创建 local reference 数量
是否有 native 线程频繁 Attach/Detach
是否有 UI 线程长时间阻塞
播放器场景里,进度事件可以 250ms 一次,而不是每帧一次。错误和状态事件即时发送。
本章实验
设计两个版本。
版本 A:UI 每 16ms 调 nativeGetPosition
版本 B:native 每 250ms 批量回调 PlayerSnapshot
观察:
JNI 调用次数
UI 线程耗时
GC 次数
日志可读性
你会发现 B 版本更像工程系统,A 版本更像临时验证代码。
工程风险与观测
JNI 边界要重点观测三类风险。
并发风险:native 线程和 UI 线程同时改状态
超时风险:JNI 方法里做了文件读取、解码、等待锁
降级风险:native 初始化失败后 UI 没有 fallback 状态
建议为 bridge 增加轻量统计。
jni_command_count_per_second
jni_event_count_per_second
jni_max_call_ms
native_command_queue_size
如果某个页面每秒有几百次 JNI 调用,通常说明边界设计太碎。先不要优化 C++,先收敛接口。
本章结论
JNI 不是不能频繁用,而是不能没有边界地用。稳定 NDK 工程通常把 JNI 设计成少量命令入口和少量事件出口,把真正的状态和重活留在 native 控制线程里。