Kotlin 与 Native 控制桥:用命令队列写出稳定 JNI 边界
前面几章已经有了播放器核心链路:状态机、解复用、解码、同步、seek。最后还差一个边界:Kotlin UI 怎么安全地控制 native 播放器。
这个边界最容易被写坏。很多 native crash 的根因不是 C++ 算法错,而是 JNI 生命周期、线程和引用管理错。
先理解 JNI 是什么
Android App 的主要业务通常写在 Kotlin/Java 里,NDK 代码写在 C/C++ 里。JNI 是两边互相调用的桥。
Kotlin/Java 世界:对象由 ART 管理,有 GC
C/C++ 世界:对象由你自己管理,没有 GC
JNI:两边沟通的协议
桥不是免费的。跨 JNI 调用需要参数转换、引用检查、线程附着等工作。官方 JNI 文档也建议减少需要触碰 JNI 的线程数量,并把 JNI 接口集中在少量文件中。
错误边界:不要把每个 UI 动作都变成重型 JNI 调用
不建议这样设计。
nativePlay()
nativePause()
nativeSeek(position)
nativeSetVolume(volume)
nativeGetPosition()
nativeIsPlaying()
它会导致两个问题。
调用太碎:JNI 频繁跨边界
状态分散:Kotlin 和 native 都在猜播放器状态
更稳定的方式是命令队列。
Kotlin 只发命令
native 控制线程只消费命令
native 只回传事件
Kotlin 侧命令模型
enum class PlayerCommandType {
OPEN,
ATTACH_SURFACE,
DETACH_SURFACE,
PLAY,
PAUSE,
SEEK,
RELEASE,
}
data class PlayerCommand(
val type: PlayerCommandType,
val argument: Long = 0L,
val serial: Long,
)
播放器门面负责把 UI 动作转成命令。
class NativePlayerFacade(
private val bridge: NativePlayerBridge,
) {
private val serial = AtomicLong(0)
private var nativeHandle: Long = 0L
fun create() {
nativeHandle = bridge.nativeCreate()
}
fun play() {
send(PlayerCommand(PlayerCommandType.PLAY, serial = serial.incrementAndGet()))
}
fun seekTo(positionUs: Long) {
send(PlayerCommand(PlayerCommandType.SEEK, positionUs, serial.incrementAndGet()))
}
fun release() {
send(PlayerCommand(PlayerCommandType.RELEASE, serial = serial.incrementAndGet()))
nativeHandle = 0L
}
private fun send(command: PlayerCommand) {
if (nativeHandle == 0L) return
bridge.nativeSendCommand(
nativeHandle,
command.type.ordinal,
command.argument,
command.serial,
)
}
}
nativeHandle 是 native 对象地址。Kotlin 不理解它,只保存一个 Long 作为句柄。
Native 侧对象所有权
native 层创建播放器对象,并把地址返回给 Kotlin。
extern "C" JNIEXPORT jlong JNICALL
Java_com_zerobug_player_NativePlayerBridge_nativeCreate(JNIEnv*, jobject) {
auto* player = new PlayerController();
return reinterpret_cast<jlong>(player);
}
extern "C" JNIEXPORT void JNICALL
Java_com_zerobug_player_NativePlayerBridge_nativeSendCommand(
JNIEnv*,
jobject,
jlong handle,
jint type,
jlong argument,
jlong serial
) {
auto* player = reinterpret_cast<PlayerController*>(handle);
if (player == nullptr) return;
player->postCommand(static_cast<int>(type), argument, serial);
}
生产代码里要避免 double delete。常见做法是在 release 命令处理完成后由控制线程销毁资源,并让 Kotlin handle 置 0。
显式注册 JNI 方法
Android NDK samples 更推荐在 JNI_OnLoad 中使用 RegisterNatives,而不是依赖超长函数名自动匹配。原因是显式注册更容易集中管理 JNI 边界,也能减少暴露符号。
static JNINativeMethod kMethods[] = {
{"nativeCreate", "()J", reinterpret_cast<void*>(nativeCreate)},
{"nativeSendCommand", "(JIJJ)V", reinterpret_cast<void*>(nativeSendCommand)},
};
jint JNI_OnLoad(JavaVM* vm, void*) {
JNIEnv* env = nullptr;
if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
return JNI_ERR;
}
jclass clazz = env->FindClass("com/zerobug/player/NativePlayerBridge");
if (clazz == nullptr) {
return JNI_ERR;
}
if (env->RegisterNatives(clazz, kMethods, std::size(kMethods)) != JNI_OK) {
return JNI_ERR;
}
return JNI_VERSION_1_6;
}
JNI 签名很容易写错。(JIJJ)V 的含义是:
J:long
I:int
J:long
J:long
V:void
这就是为什么显式注册适合工程化管理。签名错了,加载阶段就能暴露问题。
回调事件:native 不要直接操作 UI
native 可以回传事件,但不要直接假设自己在主线程。
data class PlayerEvent(
val state: Int,
val positionUs: Long,
val bufferedUs: Long,
val driftUs: Long,
val serial: Long,
)
Kotlin 收到 native 事件后,再切到主线程更新 UI。
fun onNativeEvent(state: Int, positionUs: Long, bufferedUs: Long, driftUs: Long, serial: Long) {
mainScope.launch {
eventFlow.emit(PlayerEvent(state, positionUs, bufferedUs, driftUs, serial))
}
}
JNIEnv 不能跨线程保存
这是 JNI 新手非常容易踩的坑。官方文档明确指出,JNIEnv 是线程局部的,不能在线程之间共享。
正确做法是保存 JavaVM*,在需要回调的 native 线程里通过 GetEnv 或 AttachCurrentThread 获取当前线程的 JNIEnv*。
class JNIEnvScope {
public:
explicit JNIEnvScope(JavaVM* vm) : vm_(vm) {
if (vm_->GetEnv(reinterpret_cast<void**>(&env_), JNI_VERSION_1_6) == JNI_OK) {
return;
}
if (vm_->AttachCurrentThread(&env_, nullptr) == JNI_OK) {
attached_ = true;
}
}
~JNIEnvScope() {
if (attached_) {
vm_->DetachCurrentThread();
}
}
JNIEnv* get() const { return env_; }
private:
JavaVM* vm_ = nullptr;
JNIEnv* env_ = nullptr;
bool attached_ = false;
};
LocalRef 和 GlobalRef
JNI 引用也有生命周期。
LocalRef 只在当前 native 方法调用期间有效。
GlobalRef 可以跨方法、跨线程保存,但必须手动 DeleteGlobalRef。
播放器回调 listener 通常需要保存为 global ref。
class ListenerRef {
public:
ListenerRef(JNIEnv* env, jobject listener) : env_(env) {
ref_ = env_->NewGlobalRef(listener);
}
~ListenerRef() {
if (ref_ != nullptr) {
env_->DeleteGlobalRef(ref_);
}
}
jobject get() const { return ref_; }
private:
JNIEnv* env_ = nullptr;
jobject ref_ = nullptr;
};
生产代码里析构可能不在创建线程执行,因此更稳的做法是保存 JavaVM*,析构时重新获取当前线程的 JNIEnv*。
本章实验
实验一:连续发送命令。
play/pause/seek 重复 1000 次
native 不 crash
最终状态符合最后一条命令
实验二:旋转屏幕。
surfaceDestroyed -> detach surface
surfaceCreated -> attach new surface
播放器不使用旧 Surface
实验三:JNI 引用检查。
打开 Android Studio Memory Profiler 的 JNI heap view
反复进入退出播放页
GlobalRef 数量不应持续增长
工程风险与观测
Kotlin/native 桥要把生命周期事件当成一等公民。
native_handle_created
native_handle_released
command_queue_size
event_queue_size
surface_attach_count
surface_detach_count
如果 handle 创建次数大于释放次数,说明 native 对象泄漏。
如果 surface attach/detach 不成对,说明 UI 生命周期和 native 生命周期没有对齐。
如果命令队列持续增长,说明 native 控制线程处理不过来,要检查是否有超时任务。
桥接层还要保证幂等。
release 可以重复调用
detach surface 可以重复调用
旧 serial 的 seek 不能覆盖新 serial
native 初始化失败时 UI 有降级状态
本章结论
Kotlin 与 native 的稳定边界应该是“命令队列 + 事件回调”。Kotlin 不直接管理 native 内部状态,native 不直接操作 UI。JNI 只做窄桥,播放器主体在 native 控制线程里按状态机运行。