1. 先把这个开关的底细摸清楚:extractNativeLibs 到底是什么
提到 APK 体积优化和启动速度优化,绕不开android:extractNativeLibs这个 Manifest 属性。过去一年多我一直在做安装包治理,每次和团队对齐优化方案时,总有人把这个属性和“是不是把 so 压缩一下”混为一谈,甚至出现过把extractNativeLibs改成false之后,低端机直接崩溃的线上事故。所以我觉得很有必要把这里面的原理、坑位和正确打法梳理一遍,给后面再做包体优化的同学留一份可参考的实操记录。
先说结论:extractNativeLibs="false"的意思是告诉系统“你不要在安装 APK 的时候,把 APK 里lib/arm64-v8a/下的.so文件解压提取到/data/data/<包名>/目录里”。系统在运行你的 App 时,直接尝试从 APK 内部加载原生库,省掉一次物理拷贝。反过来,如果没设置这个属性,或者明确写了extractNativeLibs="true",那系统在安装阶段会把.so文件从 APK 里解压出来,放到应用私有目录下,运行时通过dlopen去加载那个解压后的文件。
这一字之差,带来的差异远不止“少了个文件拷贝”。我见过最夸张的一个项目,32 个 ABI 相关的.so文件加起来有 280MB,安装到手机上以后,/data/data/<包名>/lib目录下又多占了 280MB 磁盘空间,安装耗时多了十几秒,首启反而变慢,因为冷启动时系统还要去检查这些文件是否存在。而把extractNativeLibs设为false之后,安装包小了只是表象,真正的好处是安装时间、磁盘占用、首次加载的 IO 都有明显改善。
但要注意,false不是魔法开关,它有一套严格的物理前提:.so在 APK 里必须按未压缩(STORED)方式存放,而且要做页对齐(zipalign -p处理过)。只要其中一个条件不满足,系统要么在安装时报错,要么运行时dlopen直接失败,表现就是java.lang.UnsatisfiedLinkError。这一块我会在后面的实操和排查部分展开讲。
2. 为什么“精简”值得做:两条加载路径的性能账
2.1 系统提取模式:旧方案的时间成本和空间成本
在 Android 6.0(API 23)之前,系统没有能力直接从 APK 内部加载原生库,所有 App 安装时都必须把.so提取到磁盘。这个逻辑在旧版本 Android 上可以说是强制性的,你自己改 Manifest 都没有用,因为系统底层还没有对应的支持。
提取模式的问题在老机器上尤其明显:/data/data分区通常是独立且容量紧张的区域,很多年前的 16GB 存储手机,装两三个大型游戏基本就告急了。同时,安装 App 时系统会对 APK 里的每一个.so依次做解压、写入、校验,这一个过程消耗的时间在低端机上可以达到秒级甚至十几秒,安装时卡在“正在安装”进度条的用户体验非常糟糕。更要命的是,解压出来的文件散落在私有目录,如果应用卸载时清理不干净,长期下来还会产生碎片。
从内存和启动性能角度看,提取模式意味着 App 启动时,系统需要先定位并加载磁盘上的.so文件。对于使用大量动态库的应用来说,首启阶段要频繁触发 IO,冷启动耗时直接被拉高。内存上也有额外开销,加载器需要为这些文件建立映射和缓存,占用的匿名内存页肉眼可见地增加。
2.2 不提取模式:省掉的不只是拷贝那一瞬间
extractNativeLibs="false"之所以能带来性能提升,是因为它改变了原生库的存储和加载方式。
假设我们的 APK 里有一个lib/arm64-v8a/libnative.so,如果它是以未压缩、页对齐的方式存放在 APK 内部的,那么系统在运行时不需要把它复制到私有目录,而是直接基于 APK 文件本身做内存映射(mmap)。这样有几个直接收益:
- 安装时省去了解压写入的过程,安装速度明显加快。
- App 占用的磁盘空间减小,尤其是 with 多 ABI 的包,省下的空间很可观。
- 运行时加载
.so时,内核可以直接从 page cache 读取已经映射好的内容,无需先读出压缩文件再解压到临时缓冲区,省了内存拷贝和 CPU 解压开销。 - 对于 64 位设备,页对齐的
.so支持以页为单位直接映射到内存,避免了因为加载地址不对齐导致的多余内存占用,这在 mulitple.so的场景下收益很明显。
一句话总结:这个开关不是让 APK 体积变小,而是让原生库不再被“二次搬运”,从 APK 直接加载,少一层 IO、少一份副本。包体变小的部分,本质上是省掉了为了提取而预留的那些空间开销,以及 apk 里对 so 做压缩后需要再解压的复杂流程。
2.3 那为什么给大家的感觉是这个开关能减小 APK 体积
不少文章说“设置 extractNativeLibs=false 可以减少 APK 体积”,这句话其实不严谨。准确地说,如果配合useLegacyPackaging = false并且使用 App Bundle 或 ABI 拆分,Google Play 在分发时可以只给你目标设备的 ABI 对应的.so,这样用户下载的包自然变小,不需要在 APK 里塞下所有架构的库。换言之,包体优化主要靠 ABI 拆分和压缩资源,而extractNativeLibs解决的是安装后占用的空间和运行时的加载效率。
我在实践中的理解是:包体优化做的是“下载体积”,这个开关做的是“安装占用体积 + 运行时空载开销 + 安装耗时”。两者经常被一起提起,是因为一个完整的优化方案本来就要求同时处理这两点。搞清楚这个区别,你在向上汇报的时候才不会说错结论。
3. 为什么有人死活不关:兼容性和第三方依赖的暗礁
3.1 Android 6.0 以下:你关不关都一样
首先明确一个版本边界:extractNativeLibs="false"真正生效是从 Android 6.0(API 23)开始的。在 API 23 以下,无论你 Manifest 里怎么写,系统都会忽略这个属性,强制提取.so到私有目录。所以如果你的minSdk低于 23,需要有心理准备:低版本设备上依然会走提取模式,但因为 APK 内的.so是以未压缩方式存放的,提取过程不会额外解压,安装时间不会因为设置为 false 而恶化。
这里有两个细节值得注意:
- 如果 APK 里的
.so是压缩存放的,而你设置了extractNativeLibs="false",在 Android 6.0 以上的设备上,系统可能会打印警告并仍然尝试从 APK 中加载,但真实运行情况不可控,很容易出现dlopen failed。 - 低版本设备上,如果
.so是压缩的,系统本来就要解压再复制,这个开关对它没有影响。
所以一个相对稳妥的策略是:minSdk >= 23时可以直接关掉提取,享受全部收益;minSdk < 23时,需要确认低版本系统的兼容性测试覆盖到位,尤其注意在 5.x 的国产 ROM 上跑一轮完整回归,因为部分 ROM 对extractNativeLibs的解析有自己的一套逻辑,测试机不够多的话很难发现。
3.2 哪些第三方库或框架强制依赖“提取后的路径”
这是最容易踩坑的地方。关闭提取后,.so不再存在于文件系统上的原路径,有些框架并不知道这一点,或内部逻辑写死了要从/data/data/<包名>/lib/目录去加载,一旦目录下找不到文件,直接抛出异常。
我整理了几类常见依赖方,你需要逐一确认自己的项目是否涉及:
- 热修复和插件化框架:很多这类框架自己去
System.load()指定路径,如果路径指向提取目录,关闭提取后就会失效。Tinker、Sophix 等框架对extractNativeLibs=false的支持程度不同,接入时需要确认版本和配置。 - 安全加固、风控 SDK、反调试 SDK:部分加固方案会重写或被调用系统的 native 库加载逻辑,对提取路径有强依赖。如果你的 App 接入了加固服务商,一定要去他们的文档里查
extractNativeLibs相关配置,别想当然地关掉。 - 手动
System.load某些 so 的业务代码:如果代码里写死了System.load("/data/data/.../lib/xxx.so"),那关闭提取后这条路径就不存在了,必须改成System.loadLibrary("xxx")或从ApplicationInfo.nativeLibraryDir读取。 - 一些古老的 C/C++ 代码里通过
dlopen拼接绝对路径加载兄弟 so 的情况:这是我之前在游戏 SDK 里遇到过的坑,处理的方案只能是修改 so 内的加载逻辑,或者在启动阶段手动保证 so 可用。
3.3 系统 WebView 和原生渲染库的特殊情况
处理多媒体、渲染、音视频类的原生库时要特别谨慎,这类库往往体积大、依赖关系复杂。比如某些视频播放器的核心库会内部dlopen自己的 satellite 库,如果这些库与主库不在同一目录,且目录被系统特殊处理,关闭提取后可能找不到依赖。
有一种替代思路:不全局关闭提取,而是在AndroidManifest.xml中保持extractNativeLibs="true",但把体积最大的几个 so 单独用STORED方式打包,其他 so 照旧提取。这样既可以保留部分兼容性,又能在主要体积和加载性能上获得收益。不过这个做法需要定制打包脚本,后面我会讲到一个可行的方案。
4. 实操:把 extractNativeLibs 改成 false 的完整步骤
4.1 在 AndroidManifest.xml 中直接设置
最直接的方式是在<application>节点上增加属性:
<application android:extractNativeLibs="false" ...> </application>注意这个属性是application级别的,不是manifest级别,不是activity级别,别放错位置。改完之后,直接构建 APK,用下面的命令验证属性是否写入成功:
aapt dump xmltree yourapp.apk --file AndroidManifest.xml输出里会有一段类似A: android:extractNativeLibs(0x010104ea) = (type 0x12) 0x0(0x0 表示 false,0xffffffff 或 0x1 表示 true)。这个方法对于手写 Manifest 的团队来说最直观。
4.2 通过 Gradle 配置:useLegacyPackaging
大部分现代项目使用 Android Gradle Plugin 构建,此时更推荐直接用 Gradle DSL,因为 AGP 会在处理 manifest 时自动生成对应的extractNativeLibs节点。
在模块的build.gradle中:
android { packagingOptions { // 设置为 false,表示不要使用传统提取方式 useLegacyPackaging false } }useLegacyPackaging和extractNativeLibs的关系是:useLegacyPackaging = true等价于extractNativeLibs="true";useLegacyPackaging = false等价于extractNativeLibs="false"。如果你在 Manifest 里写死了extractNativeLibs,又以 DSL 设置了useLegacyPackaging,实际打包结果以 DSL 为准,但建议不要两边同时显式配置,避免排查问题时混淆。
这里补充一个 AS 4.x / AGP 7.x 环境下的细节:如果你的项目同时启用了android:extractNativeLibs="false",但packagingOptions里的jniLibs处理有自定义逻辑(比如 exclude 某些 ABI),请保证 ABI 过滤后的lib/目录依然包含所有运行时需要的库,否则安装后运行时会因为缺少某个.so而直接崩溃。
4.3 用 Android Gradle Plugin 的 DSL 做 ABI 拆分配合
为了最大化收益,通常会把extractNativeLibs=false和 ABI 拆分一起使用。拆分方式有两种:
方式一,使用 App Bundle(.aab)发布,Google Play 在分发时自动按设备 ABI 抓取对应 so。这种情况下,useLegacyPackaging false依然是可靠的,但需要注意的是,本地测试时通过bundletool生成的 APK 默认会带上所有 ABI,验证时请使用--device-spec或手动生成指定 ABI 的 APK。
方式二,自己用 Gradle 的 splits 配置:
splits { abi { enable true reset() include 'armeabi-v7a', 'arm64-v8a', 'x86_64' universalApk false } }通过splits生成的每个 APK 只包含对应 ABI 的 so,配合extractNativeLibs=false,安装后应用在设备的私有目录里不会再生成一整套 lib 文件夹副本,磁盘占用能降到最低。需要注意,universalApk false会放弃“一个包兼容所有设备”的场景,如果你的应用要通过网页分发或企业内部分发,建议保留一个 universal APK。
4.4 so 的打包方式:压缩还是不压缩
这是最容易出问题的地方。默认情况下,Gradle 打包 so 时会选择压缩(DEFLATE)方式存储,以减小 APK 体积。但是,要让系统从 APK 内部直接加载 so,so 必须是以未压缩(STORED)方式存储的,并且地址要与内存页边界对齐。
如果我们只设置extractNativeLibs="false",而 so 还是DEFLATE存储,那么在 Android 6.0 以上的设备上,系统会尝试直接加载 APK 内的 so,但压缩过的 so 无法被 mmap 执行,运行时就会抛出类似下面的异常:
java.lang.UnsatisfiedLinkError: dlopen failed: library "lib/arm64-v8a/libxxx.so" not found你可能会想:谁会主动把 so 压缩呢?答案是默认的构建流程。这里有一个容易混淆的点:以前extractNativeLibs=true时,so 在 APK 里是压缩的,反正安装时会解压出来,运行时加载的是解压后的文件,所以压缩没有影响。当你改成false之后,so 的存储模式必须切换。AGP 的useLegacyPackaging false会自动处理这一层逻辑:它会让你 APK 内的 so 变为未压缩存储,同时完成 zipalign。所以不要人去手动改压缩策略,直接用 Gradle DSL 是最稳的。
如果想自定义打包脚本,比如我前面提到的“保留一部分 so 提取,另一部分不提取”的复杂方案,可以通过修改android块的jniLibs配置,把某些 abi 目录下的 so 排除掉,再单独用任务打进去,但这个方案复杂度高且较难维护,我自己实测下来还是会回到全量useLegacyPackaging false。
4.5 验证安装后是否真的没有提取
打包完之后,不能只看 APK 里的 Manifest,要装到真机上验证。步骤如下:
# 安装后看应用私有目录 adb shell run-as <packageName> ls -l /data/data/<packageName>/lib如果该目录下没有任何文件,说明提取确实被关闭了。如果目录下还有 so 文件,则有可能是设备 Android 版本过低,或者系统没有遵循这个属性,这就需要回到兼容性分析那一步。
另一个验证方式是通过dumpsys:
adb shell dumpsys package <packageName> | grep -A 20 "extractNativeLibs"不过这个命令的输出在不同厂商 ROM 上格式差异很大,我更多是把它作为一个辅助手段,最终以run-as查目录和实际跑功能为准。
4.6 APK Analyzer 的检查法
如果你用的是 Android Studio,直接打开 APK Analyzer,看lib/arm64-v8a/libxxx.so这一项,如果 Compressed Size 和 Download Size 数值相同,说明是 STORED;如果 Download Size 明显小于 File Size,说明是 DEFLATE。前者才能配合extractNativeLibs=false使用。
实际检查时,我会把常用 ABI 的 so 都看一遍,尤其是体积最大的几个。如果发现某个大的 so 是 DEFLATE 存储,通常说明构建配置有问题,需要回头检查useLegacyPackaging和android块里是否有覆盖逻辑。
5. 常见问题与排查技巧实录
5.1 修改之后,老设备上出现 UnsatisfiedLinkError
这个问题出现频率最高。案例现象是:minSdk 21,在 Android 5.1 手机上安装后,一打开页面就崩,报错信息里能看到dlopen failed: library "libcore.so" not found。
原因其实是我们前面说的版本边界:5.1 不支持从 APK 直接加载 so,系统仍会尝试提取,但因为 APK 里的 so 已经是未压缩存储,提取之后路径应该是存在的,为什么还是找不到?进一步排查发现,真正的问题出在 launcher 那个 Activity 里提前调用了某个 native 方法,而 so 的加载时机在 Application 的静态代码块里,静态代码块执行时nativeLibraryDir目录还没有就绪。
这种问题跟extractNativeLibs本身关系不大,跟 so 的加载时机、多线程初始化、类加载顺序有关。排查时建议打开System.loadLibrary的首个调用位置加日志,确认是在主线程还是子线程调用,以及是否在attachBaseContext之前调用。
如果确定是版本兼容性问题,我只能给出最朴素的建议:这类场景下不要全局关,要么升minSdk,要么针对低版本设备走extractNativeLibs=true的定制包。
5.2 设置 false 后 APK 体积反而变大
这是另一个常见现象:原来 so 是压缩的,改了 false 后 so 变未压缩,APK 体积自然变大。很多人会因此放弃这个方案,但实际上这是正常且正确的。真正的收益不体现在下载体积,而体现在安装体积和运行性能。
如果你既要 APK 下载体积小,又要安装时少占用空间,怎么办?我的经验是:把主力 so 用未压缩存储(跟随 extractNativeLibs=false),把不常用的、只在特定业务模块里动态加载的 so 放到单独的 asset 目录或者远程下载,用到时再加载。这里要注意,从 asset 加载的 so 需要先拷贝到应用私有目录或使用ReLinker这类库,让 so 之间能互相找到依赖。
5.3 关于 so 文件的对齐问题
前面反复提到页对齐(page alignment)。zipalign 工具的作用就是确保每个.so在 APK 中的偏移量是 4KB 或 16KB 的整数倍,这样才能被 mmap 高效加载。AGP 构建默认已经做了处理,但如果你使用了一些自定义打包插件,比如自己做 jar 合并、自研混淆压缩、资源混淆后又重新打包的 App,一定要在最后重新跑一次 zipalign,否则可能出现安装成功、运行时崩溃,且崩溃信息里带有dlopen failed: Segment .text is not aligned这类提示。
一个排查对齐问题的实用方法:
zipalign -c -v 4 yourapp.apk如果输出里出现lib/arm64-v8a/libxxx.so ... BAD,说明 APK 没有正确对齐,需要重新处理。注意的是,这条命令检查的是整个 APK 对齐状态,而不是单个 so 的对齐状态,但可以参考。
5.4 部分 ROM 不支持或表现异常
国产 ROM 是这一块的隐形坑。某些厂商的系统定制会在安装框架里修改 so 提取逻辑,导致你在原生 AOSP 上测试正常,在特定 ROM 上却出现 so 加载失败。遇到这种情况,优先排查 ROM 版本和系统更新日志,看是否有 known issue;另外可以在该 ROM 的设备上抓 logcat,印象比较深的一条提示是:
W/ziparchive: Unable to open '/data/app/.../base.apk': I/O error这往往和 APK 的压缩方式、ROM 对 APK 的访问权限有关,而不是真的文件损坏。遇到这种问题,除了等 ROM 修复,一个缓兵之计是向该品牌应用商店提交一个兼容性测试包申请,让他们测试通过后再灰度发布。
5.5 第三方加固和 extractNativeLibs=false 的冲突
遇到过最坑的一个案例是:接入了某加固平台后,加固平台重新打包 APK,把extractNativeLibs改成了 true,导致我们本地验证通过的优化在上线后实际不生效。排查的时候才发现是加固平台的默认策略在作怪。
解决办法是去加固平台的配置后台,寻找类似“原生库提取”或“so 库压缩选项”的开关,把加固后的extractNativeLibs保持为 false。有些平台需要在上传 APK 时额外传一份 manifest 配置,用于指定 application 节点的属性。这一步建议在项目接入加固的初期就确认清楚,否则上线后你连属性被谁改掉的都难找到。
5.6 如何测试多个 ABI 下的兼容性
即使minSdk >= 23,不同 ABI 的加载表现也可能不同。我在内部测试时,会准备 arm64-v8a、armeabi-v7a、x86_64 三台机器,跑同一套自动化用例,覆盖安装、启动、进入业务模块、退出等路径,并打印nativeLibraryDir路径和目录内容。如果某个 ABI 下的安装包因为abiFilters配置问题排除掉了某些库,就能在这一步尽早发现。
如果你们没有那么多真机,可以先用adb emulator的多 ABI 镜像做一些基础验证,但最终上架前至少要覆盖 arm64-v8a 真机。
6. 我的经验与进一步扩展建议
踩了这么多坑之后,我个人的偏好是:在全新项目里,直接把useLegacyPackaging设为 false,从一开始就按“不提取”模式设计 so 加载,避免后续为兼容旧逻辑做各种妥协。在存量项目里,要不要开这个开关,我会先做一轮 so 全量扫描,统计每个 so 的大小和加载方式,确认哪些是启动路径上必须要用的,哪些是业务模块动态加载的,再按优先级分批切换,而不是一条命令全局打开。
另外,这个优化往往不是一个孤立的动作,而是包体治理整体方案中的一环。我通常会把下面的几个事项放在一起考虑:
- ABI 过滤:确定线上用户的实际 ABI 占比,尽量只保留
arm64-v8a,降低维护成本。 - 动态功能模块:通过 App Bundle 动态交付或者 Google Play Feature Delivery 按需下载 so。
- 资源和 so 混淆:只对 so 做 strip、去除符号表,减小文件体积。
- 安装性能监控:把安装耗时、首启耗时纳入线上 APM 指标,衡量开关的收益。
最后再分享一个小技巧:验证是否生效时,除了看/data/data/<包名>/lib目录,还可以用strace跟踪dlopen的路径。在已 root 测试机上直接运行:
strace -f -e trace=openat -p <pid>看日志里是打开了base.apk里的路径,还是/data/data/.../lib下的独立文件。这个手段在排查“为什么我改了配置但 so 还是被提取”的时候特别管用,能直接看到系统到底走了哪条加载路径。