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包里毫无意义。

裁剪的核心手段有两个:

  • 去除调试符号:编译时加上 -sstrip 命令,能把符号表砍掉一大半。
  • 函数级裁剪:用 -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库优化整体思路。你可以把它当作一个检查清单:

So库优化知识体系 So库优化 So库裁剪 ABI适配 动态加载 So库压缩 去除调试符号 函数级裁剪 代码模块化 arm64-v8a armeabi-v7a abiFilters配置 按需加载 异步加载 插件化架构 默认压缩 XZ/LZMA压缩 体积vs速度权衡 核心原则: 裁剪无用代码 → 适配主流ABI → 按需动态加载 → 合理选择压缩策略 最终目标:更小的APK体积 + 更快的加载速度 + 更低的运行时内存

避坑指南

最后,分享几个我踩过的坑:

  • 不要用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库。嗯,别等到那时候再动手。

专注资料整理