44、So库优化:So库裁剪、ABI适配、动态加载、So库压缩
聊到So库优化,我脑子里第一个蹦出来的场景是:几年前有个版本,APK体积突然飙到了80多MB。排查了半天,发现是某个第三方SDK把arm64-v8a、armeabi-v7a、x86、x86_64的So库全塞进去了。嗯,光这一个库就占了30MB。你说痛不痛?
So库的优化,说白了就是三件事:让它更小、让它更精、让它加载更快。我习惯把这四个维度串起来看——裁剪、适配、加载、压缩。咱们一个一个拆开聊。
So库裁剪:去掉你不需要的“脂肪”
So库为什么大?很多时候不是代码多,而是符号表、调试信息、未使用的函数全被打包进去了。我见过一个项目,So库里居然还留着printf的调用——这玩意儿在Release包里毫无意义。
裁剪的核心手段有两个:
- 去除调试符号:编译时加上
-s或strip命令,能把符号表砍掉一大半。 - 函数级裁剪:用
-ffunction-sections -fdata-sections配合--gc-sections,链接器会自动丢弃未引用的函数和数据。
重要:Release包一定要做strip。我见过一个团队,Debug和Release的So库大小差了40%。这40%全是没用的符号信息。
举个例子,你的CMakeLists.txt里可以这样配:
set(CMAKE_C_FLAGS_RELEASE "${CMAKE_C_FLAGS_RELEASE} -s -ffunction-sections -fdata-sections")
set(CMAKE_SHARED_LINKER_FLAGS_RELEASE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE} -Wl,--gc-sections")
另外,代码层面的裁剪也很关键。比如你只用了OpenCV的某个图像处理函数,就别把整个OpenCV的So库拖进来。用NDK的模块化编译,只编译你需要的源文件。我曾经把一个OpenCV的So库从12MB砍到了2.3MB——就是因为它只用了两个函数。
ABI适配:别做“全兼容”的傻事
很多开发者喜欢把所有的ABI都打进去,觉得这样兼容性最好。但你想过没有?x86架构的手机现在几乎绝迹了,你留着x86的So库干嘛?
我建议的做法是:
- 只保留arm64-v8a和armeabi-v7a。这是目前Android设备的主流。
- 如果App只支持64位,那就只留arm64-v8a。Google Play从2019年就开始要求64位支持了。
- 用
abiFilters在Gradle里明确指定:
android {
defaultConfig {
ndk {
abiFilters "arm64-v8a", "armeabi-v7a"
}
}
}
小技巧:如果你需要兼容旧设备,armeabi-v7a就够了。arm64-v8a的性能更好,但体积也更大。我一般会在构建脚本里做两套配置:Debug包全ABI方便测试,Release包只留主流ABI。
还有一个坑要注意:不同ABI的So库不能混用。比如你加载了arm64-v8a的libA.so,又加载了armeabi-v7a的libB.so,运行时大概率会崩溃。因为它们的指令集和调用约定不一样。我曾经排查过一个这样的Bug,花了整整两天才找到原因——嗯,从那以后我每次打包都会检查ABI一致性。
动态加载:按需加载,别一股脑全塞进内存
动态加载,说白了就是“用到的时候再加载”。比如你的App里有个图像滤镜功能,用户可能一个月才用一次。那你何必在启动时就把滤镜的So库加载进来?
Android里动态加载So库的方式很简单:
// 不放在静态代码块里,而是按需加载
public class FilterEngine {
private static boolean sLoaded = false;
public static void load() {
if (!sLoaded) {
System.loadLibrary("filter");
sLoaded = true;
}
}
public static native void applyFilter(Bitmap bitmap);
}
使用时:
// 用户点击滤镜按钮时才加载
FilterEngine.load();
FilterEngine.applyFilter(bitmap);
注意:动态加载虽然好,但别在UI线程里做。System.loadLibrary()涉及文件I/O和链接操作,耗时可能在几十到几百毫秒。我建议在子线程里预加载,或者用异步任务处理。
还有一种更高级的做法:从APK中解压So库到应用私有目录,再动态加载。这适用于一些特殊的场景,比如插件化架构。但要注意,从APK中解压So库会占用额外的存储空间,而且需要处理文件权限问题。我个人不太推荐这种做法,除非你确实需要绕过系统的So库加载机制。
So库压缩:用空间换时间,还是用时间换空间?
So库压缩,主要指的是在APK打包时对So库进行压缩。Android默认的打包工具会对So库做一定的压缩,但效果有限。你可以用更激进的压缩算法,比如XZ或LZMA。
在Gradle里配置:
android {
packagingOptions {
// 对So库使用更高级的压缩
doNotStrip "**/*.so"
}
buildTypes {
release {
// 启用资源压缩
isMinifyEnabled = true
// 对So库使用最佳压缩
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
}
}
}
但这里有个权衡:压缩率越高,安装时的解压时间越长。用户从应用商店下载APK时,下载体积变小了,但安装时间变长了。我做过一个测试:
| 压缩方式 | So库体积 | 安装时间 |
|---|---|---|
| 不压缩 | 12.0 MB | 0.8秒 |
| 默认压缩 | 8.5 MB | 1.2秒 |
| XZ压缩 | 5.2 MB | 3.5秒 |
你看,XZ压缩虽然体积小了56%,但安装时间多了4倍。如果你的App对首次启动速度很敏感,那就要慎重。我一般建议:对于大型So库(比如游戏引擎、AI模型库),用XZ压缩;对于小型So库,默认压缩就够了。
知识体系总览
下面这张图,是我梳理的So库优化整体思路。你可以把它当作一个检查清单:
避坑指南
最后,分享几个我踩过的坑:
- 不要用System.load()加载外部So库。System.loadLibrary()会从系统路径加载,而System.load()需要你传绝对路径。路径写错了,直接崩溃。我见过有人把So库放在SD卡里加载——权限问题、文件被删除问题,一堆坑。
- 注意So库的依赖关系。如果libA.so依赖libB.so,你必须先加载libB。否则链接时会报UnsatisfiedLinkError。我习惯在加载前用ldd(Linux)或readelf(Android)检查依赖链。
- Release包一定要做strip。这个我前面提过,但值得再说一遍。Debug包里的符号信息对用户毫无意义,只会增加体积。
我的习惯:每次发版前,我会用 readelf -S libxxx.so 检查So库的section信息。如果看到.debug_*相关的section,说明strip没做干净。另外,我会对比不同ABI的So库大小——如果arm64-v8a比armeabi-v7a大太多,说明可能有未优化的代码。
So库优化这件事,说难不难,说简单也不简单。关键是你要有意识地去关注它。很多团队直到APK体积超标、用户反馈安装失败时,才想起来回头优化So库。嗯,别等到那时候再动手。