JNI_OnLoad 与 RegisterNatives:让 native 入口在启动时就变得可检查
Kotlin 调 native,最常见的入门写法是超长函数名。
extern "C" JNIEXPORT void JNICALL
Java_com_zerobug_player_NativeBridge_nativePlay(JNIEnv*, jobject) {
}
这种方式能跑,但工程越大越难维护。更推荐的方式是:在 JNI_OnLoad 中使用 RegisterNatives 显式注册 native 方法。
Android NDK samples 也说明,它们更偏好通过 JNI_OnLoad 使用 RegisterNatives,并配合版本脚本控制符号导出。
JNI_OnLoad 是什么
Kotlin/Java 调用:
System.loadLibrary("player_core")
Android linker 会加载 libplayer_core.so。如果库里有 JNI_OnLoad,ART 会调用它。
jint JNI_OnLoad(JavaVM* vm, void*) {
return JNI_VERSION_1_6;
}
可以把 JNI_OnLoad 当成 native 库的入口登记处。适合做这些事:
保存 JavaVM*
查找 bridge class
注册 native 方法
初始化轻量全局配置
失败时阻止库继续加载
不适合做这些事:
打开大文件
创建播放器线程
初始化 codec
执行耗时网络请求
启动阶段越重,App 越容易卡。
RegisterNatives 解决什么问题
超长函数名依赖命名规则。包名、类名、方法名、签名一变,就可能匹配失败。
RegisterNatives 把 Kotlin 方法和 C++ 函数放进一张表里。
static JNINativeMethod kMethods[] = {
{"nativeCreate", "()J", reinterpret_cast<void*>(nativeCreate)},
{"nativeSendCommand", "(JIJJ)V", reinterpret_cast<void*>(nativeSendCommand)},
{"nativeRelease", "(J)V", reinterpret_cast<void*>(nativeRelease)},
};
每一项包含:
Kotlin 方法名
JNI 签名
C++ 函数指针
JNI 签名怎么读
签名 "(JIJJ)V" 看起来吓人,其实是规则编码。
括号内:参数
括号后:返回值
J:long
I:int
V:void
所以:
(JIJJ)V
表示:
long, int, long, long -> void
对应 Kotlin:
external fun nativeSendCommand(
handle: Long,
type: Int,
argument: Long,
serial: Long,
)
完整注册示例
static JavaVM* gVm = nullptr;
static jlong nativeCreate(JNIEnv*, jobject) {
auto* controller = new PlayerController();
return reinterpret_cast<jlong>(controller);
}
static void nativeRelease(JNIEnv*, jobject, jlong handle) {
auto* controller = reinterpret_cast<PlayerController*>(handle);
delete controller;
}
static JNINativeMethod kMethods[] = {
{"nativeCreate", "()J", reinterpret_cast<void*>(nativeCreate)},
{"nativeRelease", "(J)V", reinterpret_cast<void*>(nativeRelease)},
};
jint JNI_OnLoad(JavaVM* vm, void*) {
gVm = vm;
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/NativeBridge");
if (clazz == nullptr) {
return JNI_ERR;
}
int methodCount = sizeof(kMethods) / sizeof(kMethods[0]);
if (env->RegisterNatives(clazz, kMethods, methodCount) != JNI_OK) {
env->DeleteLocalRef(clazz);
return JNI_ERR;
}
env->DeleteLocalRef(clazz);
return JNI_VERSION_1_6;
}
如果类路径、方法名、签名不匹配,RegisterNatives 会失败,库加载阶段就能暴露问题。
和符号可见性的关系
使用超长函数名时,每个 JNI 函数都要暴露成动态符号。
使用 RegisterNatives 后,C++ 函数可以是 static,外部只需要看见 JNI_OnLoad。
版本脚本可以这样写:
{
global:
JNI_OnLoad;
local:
*;
};
这能减少导出符号数量,也让 native 库边界更清楚。
启动失败要有日志
不要让 JNI_OnLoad 静默失败。
#include <android/log.h>
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "PlayerJNI", __VA_ARGS__)
if (clazz == nullptr) {
LOGE("NativeBridge class not found");
return JNI_ERR;
}
日志要写“失败在哪个阶段”,而不是只写 init failed。
本章实验
先故意把 Kotlin 方法签名写错。
Kotlin: external fun nativeRelease(handle: Int)
C++: nativeRelease 签名 "(J)V"
你应该在加载阶段看到注册失败。
再修正签名,确认 System.loadLibrary 成功,nativeCreate 返回非 0 handle。
工程风险与观测
启动期 JNI 失败一定要可观测。否则用户只看到 App 闪退,开发者只能在 UnsatisfiedLinkError 里猜。
建议把 JNI_OnLoad 拆成几个阶段并打印日志。
stage=get_env
stage=find_class
stage=register_methods
stage=cache_vm
stage=done
每个阶段失败都返回 JNI_ERR,并输出类名、方法名或签名。
发布前审计:
RegisterNatives 表是否和 Kotlin external fun 一一对应
版本脚本是否只导出 JNI_OnLoad
JNI_OnLoad 是否没有耗时初始化
初始化失败是否有降级或错误页面
如果 native 不是核心功能,可以在 Kotlin 层做能力降级。如果 native 是播放器核心,加载失败应明确提示“不支持该设备或安装包损坏”,而不是让 UI 卡在空白页。
本章结论
JNI_OnLoad + RegisterNatives 把 JNI 入口从“靠名字猜”变成“启动时登记和校验”。它让错误更早暴露,也让符号可见性更容易治理。对稍微复杂一点的 NDK 模块,这是更稳的默认选择。