so文件是什么
so文件,即Shared Object,是用C/C++(JNI)编译出来的Android平台上的动态链接库,类比.dll。本质上就是一段用C/C++写的、编译成机器码的本地代码库,二进制机器码。它可以被多个程序共享加载。
Android Java 层无法直接调用C/C++,必须通过JNI。
为什么Android要用so文件
Android App 平时写的是Java或Kotlin代码,但有些场景下:
- 对性能要求特别高(如图像处理、音视频解码、AI 推理等),没有JVM开销
- 需要跨平台复用已有的C/C++代码(如 OpenCV、FFmpeg、TensorFlow Lite)
- 需要调用底层系统接口(JNI 层)
- 减少APK体积,C/C++编译为机器码后更紧凑
- 防止反编译,保护代码算法
这时就会用到Native层代码(C/C++),然后通过NDK编译生成so文件,供Java层调用。
so文件存放路径
so文件必须放在特定路径,AGP(Android Gradle Plugin)才能识别到:
1 | app/src/main/jniLibs/ |
Android设备有多种ABI(CPU架构),不同CPU指令集不同,so文件也须根据不同架构单独编译,每个CPU架构文件夹下都放对应的so文件。
可以在app模块的build.gradle中指定:
1 | android { |
调用so方法
定义JNI方法
1 | public class NativeHelper { |
通常由提供so文件的一方提供封装了Java native方法的jar/aar。
调用
1 | String msg = NativeHelper.getMessage(); |
JNI
JNI调用流程
JNI完整调用流程是:
- 加载so
- 调用
System.load(/data/xxx/libopencv.so)(传入全路径)或System.loadLibrary(opencv)(只传入文件名) - 内部调用到
Runtime.loadLibrary/Runtime.load,这是Java层入口,后面开始进入Native - 类加载器去加载,
ClassLoader.findLibrary根据CPU架构去查找不同路径 - 内部调用到
Runtime.nativeLoad,这是一个Native方法,它会进入 Android Runtime(C++层,简称ART) - 进入ART后调用到Linux动态链接器的
dlopen方法,通过mmap将so文件映射到内存,递归地加载它所引用的so,解析符号表完成so文件的加载和链接
- 调用
- JNI方法绑定,将Java中的方法与C/C++中的方法绑定,有静态注册和动态注册两种
- 方法调用
- Java层调用Native方法
- 通过JNI桥接进入C/C++
- 执行Native逻辑并返回结果
静态注册和动态注册
静态注册是将C/C++中的函数名按Java_包名_类名_方法名的格式命名,这样JVM会拼接函数名,进而找到对应的C++函数。但缺点是函数名很长,Java方法改名C代码必须同步改(强耦合),且启动时需要查找符号性能略差,且函数暴露符号可能被反编译攻击。
当然还可以用RegisterNatives动态注册,直接绑定Java方法 → C函数指针,不再依赖符号表和函数名。
JNIEnv是Java和Native之间的桥梁,提供了一套函数表,用于操作Java对象,其本质是函数指针表,作用是创建对象、调用Java方法、操作数组和字符串。
Native子线程不能直接用JNIEnv,JNIEnv是线程私有的。
Java与Native的类型映射
有基本类型映射、引用类型映射、数组类型三种。
基本类型映射:
| Java | JNI |
|---|---|
| int | jint |
| long | jlong |
| boolean | jboolean |
| float | jfloat |
| double | jdouble |
引用类型映射
| Java | JNI |
|---|---|
| String | jstring |
| Object | jobject |
| Class | jclass |
| Throwable | jthrowable |
数组类型
| Java | JNI |
|---|---|
| int[] | jintArray |
| byte[] | jbyteArray |
| Object[] | jobjectArray |
C++获取到Java传来的参数后记得释放内存。
JNI的三种引用
JNI/NDK开发指南(十)——JNI局部引用、全局引用和弱全局引用
即局部引用、全局引用和弱全局引用。
在编写本地C++代码时,要注意从JVM中获取到的引用在使用时被GC回收的可能性。由于本地代码不能直接通过引用操作JVM内部的数据结构,要进行这些操作必须调用相应的JNI接口来间接操作所引用的数据结构。
局部引用
以下几种方式创建的是局部引用:
1 | jstring str_obj_local_ref = (*env)->NewLocalRef(env, str_obj); // 通过NewLocalRef函数创建 |
局部引用会阻止GC回收所引用的对象,只在当前C++方法中有效,不能跨函数使用,也不能跨线程使用。C++函数返回后局部引用所引用的Java对象会被JVM自动释放。因此不能在函数中将局部引用存储在静态变量中缓存起来供下次调用时使用。也可以调用DeleteLocalRef手动释放:
1 | (*env)->DeleteLocalRef(env, local_ref); |
全局引用
基于局部引用创建,会阻GC回收所引用的对象。可以跨方法、跨线程使用。JVM不会自动释放,必须调用手动释放:
1 | static jclass g_cls_string; |
弱全局引用
基于局部引用或全局引用创建,类似Java的弱引用,不会阻止GC回收所引用的对象,可以跨方法、跨线程使用。引用不会自动释放,在JVM认为应该回收它的时候(比如内存紧张的时候)进行回收而被释放或手动释放:
1 | static jclass g_cls_string; |
so如何实现减小包体积?
动态下发
国内app如果要实现so动态下发,则打包的时候不把so打包进apk(或只带兜底版本的so),而是在代码运行到需要so的时候再去云端请求对应的so文件。请求so的时候需要带上一些参数:本机CPU架构、要下发哪个模块的so以及对应版本(避免主包版本太老JNI接口不兼容)、是否灰度、是否强制回滚等。同时依赖的其他so也会一起下发。
下载到本地应用内部存储,不要放外部存储,防止被篡改。先将so下载到临时目录,并对so进行哈希校验完整性(如sha256)和数字签名校验是否被篡改,全部校验通过后再重命名为正式so。下次进程重启后生效。
然后代码中加载so,注意用System.load,因为System.loadLibrary是从系统库搜索位置去找,自己下发的私有目录要用System.load加载。
完整的动态下发流程如下:
- 业务代码判断是否需要native模块
- 获取本机CPU架构
- 请求服务端元数据(版本、下载地址、sha256、签名)
- 下载zip/so到内部存储或受保护的位置防止被篡改
- 解压
- 校验sha256
- 校验签名
- 校验是否命中允许版本/灰度策略
- System.load(绝对路径)
- 调用external/native方法
使用Native实现算法C++编译后更紧凑
相比于Java的字节码,C/C++编译后的机器码没有类结构和运行时信息,直接就是CPU指令,编译器也会做优化,在算法密集型场景下通常更紧凑。
ABI裁剪
Android设备x86、x86_64架构即便只存在模拟器中,真机基本是arm架构,因此只保留arm64-v8a(64位)和armeabi-v7a(32位)即可。
strip
strip可以去掉so文件中的调试信息和符号表,例如将函数名、变量名删去,不需要名字直接使用地址,这样可以进一步缩小体积。
strip后不能进行debug,崩溃日志也难以看懂。记得保留一份 unstripped so