之前在写 JNI 动态注册和静态注册的时候,涉及的都是如何编译 SO 以及如何在标准 Android 工程里使用。最近看到了一个第三方 SDK 的 SO 加载方式,觉得和常规做法不太一样,这里简单记录一下,顺便也研究下这种做法的利弊,以及它和 Android 16 16KB 页面大小适配之间的关系。
日常开发中,一般是把 SO 文件放在 src/main/jniLibs 目录下,按照不同 CPU 架构(arm64-v8a、armeabi-v7a 等)分别存放。打包的时候,Gradle 会自动把这些 SO 打进 APK 的 lib/ 目录里。运行时调用 System.loadLibrary("xxx"),系统会从 /data/app/包名/lib/ 这种路径下去加载。
这种方式的好处是简单省事,没什么额外的工作。不好的地方就是 SO 文件在 APK 里是明文存储的,用解压工具解压出来之后,据说是有工具可以解析(听说有IDA Pro 或者 readelf, 我暂时还没尝试),有可能逆向分析。另外,SO 打包进 APK后没法在运行时替换或者更新。
最近看到某个 扫码识别SDK 的做法不太一样。它没有把 SO 放到 jniLibs,而是放到了 assets 目录里。在应用初始化的时候,通过 AssetManager 把 SO 文件读出来,写入到应用自己的私有目录(比如 /data/data/包名/files/xxx),然后再调用 System.load(绝对路径) 去加载。
这种方式之所以可行,是因为 System.load(String) 本身支持加载任意路径的文件,不限于系统的库目录。
阅读了其中部分源码,感觉到这么做有这几个优点:
1、可以对 SO 做简单的加密。比如打包前先把 SO 的 ELF 头部(一般是前 52 个字节)做异或处理,这样 APK 里的 SO 文件就不再是合法的 ELF 格式,常见的逆向工具无法直接解析。再复制到私有目录的过程中再把头部还原,写入完整文件。这种方案虽然不是特别强的加密,但也能挡住一些初级的逆向尝试。
2、可以在运行时决定加载哪个 SO。比如可以根据当前设备的 CPU 架构、系统版本,甚至从服务端下载新的 SO 来替换本地文件。这对于需要动态更新底层算法的场景比较有用。
3、可以避开一些系统限制。比如某些定制 ROM 对 /data/app/.../lib 目录的访问有特殊限制(比如这个目录是不允许应用在运行时随意写入或修改的),通过私有目录加载可以绕开这类问题。
这么做应该也有缺点,首先是 APK 体积变大。SO 本身在 assets 里有一份,写入私有目录后又占一份空间,相当于占用翻倍。
其次是 首次启动慢。因为多了一步 IO 操作,把 SO 从 assets 复制到私有目录,会拖慢应用的冷启动速度,特别是 SO 文件比较大的时候。
另外还需要注意私有目录的可执行权限。Android 的 files 目录默认是可执行的,所以把 SO 放进去可以用 System.load 正常加载。不能放到 cache 目录, 否则加载失败。
另外,一个隐蔽的兼容性问题:16KB 页面大小。 这个点值得单独说下,因为在实际项目中踩过坑。Android 15 开始,系统支持 16KB 的页面大小(之前都是 4KB)。这就意味着所有 SO 的 LOAD 段对齐值必须不小于 16384,否则系统加载器会直接拒绝加载。如果 SO 是放在 jniLibs 里的,Android Studio 的 APK 分析器会扫描 lib/ 目录下的所有 SO,如果发现对齐值小于 16384,会直接报一个兼容性警告,意思是这个 SO 不适配 16KB 设备。但问题在于:APK 分析器只会扫描 lib/ 目录,不会去扫描 assets 目录。如果 SO 藏在了 assets 里,分析器根本发现不了它的存在,自然也就不会报任何提示。这就造成了一个很尴尬的局面:表面上 APK 分析器没有报任何 16KB 相关的警告了,让我觉得所有 SO 都没问题了,但实际运行在 16KB 设备上时,System.load 调用 dlopen 加载 SO 的过程中,会触发对齐校验,然后直接崩溃,报错信息类似这样:
bash
dlopen failed: program alignment (4096) cannot be smaller than system page size (16384)
这就是一个典型的"扫描工具没发现,运行时报错"的场景。动态加载并不会绕过系统底层的对齐校验,它只是把校验的时机从"安装时"挪到了"加载时",该报错还是会报错。
所以,不管采用哪种加载方式,要让 SO 在 16KB 设备上正常加载,还是需要重新编译 SO,即:使用 NDK r27 或更高版本,默认已经支持 16KB 对齐。 或者手动添加链接器标志:-Wl,-z,max-page-size=16384。 但如果是第三方SDK,只能联系厂商提供适配好的版本,或者到官网看是否有已适配好的最新版本。
ok。最后题外话,再稍微记录下那个扫码识别SDK,它采用 Java 层(API 封装) + JNI 层(原生接口) + C++ 核心算法库 的三层设计:
java 层:XXX 单例类,提供面向应用的 API(初始化、图像输入、结果回调、模型切换等),内部使用 HandlerThread 处理耗时操作,避免阻塞主线程。
JNI 层:XXXNative 类声明所有 native 方法,负责加载动态库、调用 C++ 函数,并将底层结果回调至 Java 层。
C++ 层:封装了扫码识别算法(条形码/二维码解码)、图像预处理、模型推理(可能基于深度学习),以及多端口(多摄像头)管理、超分辨率(SR)等增强功能。
数据流:
应用层(调用方)
↓
XXX(单例)
↓ (配置、图像、回调)
HandlerThread (后台线程)
↓
XXXNative (JNI)
↓
C++ 核心算法库
↓ (解码结果)
JNI 回调 Java 方法
↓
主线程 Handler 回调应用层
其中对so文件进行运行时解密的算法(下面代码仅为示例,实际偏移量因实现而异,这里仅作示意):
java
// 从 assets 读取 so 文件
InputStream in = assetManager.open(pre + name);
// ...
in.read(bytes, 0, 52);
// 将前 52 个字节进行异或 255 取反
for(int i = 0; i < 52; ++i) {
bytes[i] = (byte)(bytes[i] ^ 255);
}
fs.write(bytes, 0, 52); // 写入修复后的头部
// 继续写入剩余字节(未加密)
while((len = in.read(bytes)) != -1) {
fs.write(bytes, 0, len);
}
ok.