ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

extractNativeLibs属性全解析:从原理到APK优化实践

extractNativeLibs属性全解析:从原理到APK优化实践 1. 先把这个开关的底细摸清楚extractNativeLibs 到底是什么提到 APK 体积优化和启动速度优化绕不开android:extractNativeLibs这个 Manifest 属性。过去一年多我一直在做安装包治理每次和团队对齐优化方案时总有人把这个属性和“是不是把 so 压缩一下”混为一谈甚至出现过把extractNativeLibs改成false之后低端机直接崩溃的线上事故。所以我觉得很有必要把这里面的原理、坑位和正确打法梳理一遍给后面再做包体优化的同学留一份可参考的实操记录。先说结论extractNativeLibsfalse的意思是告诉系统“你不要在安装 APK 的时候把 APK 里lib/arm64-v8a/下的.so文件解压提取到/data/data/包名/目录里”。系统在运行你的 App 时直接尝试从 APK 内部加载原生库省掉一次物理拷贝。反过来如果没设置这个属性或者明确写了extractNativeLibstrue那系统在安装阶段会把.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.0API 23之前系统没有能力直接从 APK 内部加载原生库所有 App 安装时都必须把.so提取到磁盘。这个逻辑在旧版本 Android 上可以说是强制性的你自己改 Manifest 都没有用因为系统底层还没有对应的支持。提取模式的问题在老机器上尤其明显/data/data分区通常是独立且容量紧张的区域很多年前的 16GB 存储手机装两三个大型游戏基本就告急了。同时安装 App 时系统会对 APK 里的每一个.so依次做解压、写入、校验这一个过程消耗的时间在低端机上可以达到秒级甚至十几秒安装时卡在“正在安装”进度条的用户体验非常糟糕。更要命的是解压出来的文件散落在私有目录如果应用卸载时清理不干净长期下来还会产生碎片。从内存和启动性能角度看提取模式意味着 App 启动时系统需要先定位并加载磁盘上的.so文件。对于使用大量动态库的应用来说首启阶段要频繁触发 IO冷启动耗时直接被拉高。内存上也有额外开销加载器需要为这些文件建立映射和缓存占用的匿名内存页肉眼可见地增加。2.2 不提取模式省掉的不只是拷贝那一瞬间extractNativeLibsfalse之所以能带来性能提升是因为它改变了原生库的存储和加载方式。假设我们的 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 体积不少文章说“设置 extractNativeLibsfalse 可以减少 APK 体积”这句话其实不严谨。准确地说如果配合useLegacyPackaging false并且使用 App Bundle 或 ABI 拆分Google Play 在分发时可以只给你目标设备的 ABI 对应的.so这样用户下载的包自然变小不需要在 APK 里塞下所有架构的库。换言之包体优化主要靠 ABI 拆分和压缩资源而extractNativeLibs解决的是安装后占用的空间和运行时的加载效率。我在实践中的理解是包体优化做的是“下载体积”这个开关做的是“安装占用体积 运行时空载开销 安装耗时”。两者经常被一起提起是因为一个完整的优化方案本来就要求同时处理这两点。搞清楚这个区别你在向上汇报的时候才不会说错结论。3. 为什么有人死活不关兼容性和第三方依赖的暗礁3.1 Android 6.0 以下你关不关都一样首先明确一个版本边界extractNativeLibsfalse真正生效是从 Android 6.0API 23开始的。在 API 23 以下无论你 Manifest 里怎么写系统都会忽略这个属性强制提取.so到私有目录。所以如果你的minSdk低于 23需要有心理准备低版本设备上依然会走提取模式但因为 APK 内的.so是以未压缩方式存放的提取过程不会额外解压安装时间不会因为设置为 false 而恶化。这里有两个细节值得注意如果 APK 里的.so是压缩存放的而你设置了extractNativeLibsfalse在 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 等框架对extractNativeLibsfalse的支持程度不同接入时需要确认版本和配置。安全加固、风控 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中保持extractNativeLibstrue但把体积最大的几个 so 单独用STORED方式打包其他 so 照旧提取。这样既可以保留部分兼容性又能在主要体积和加载性能上获得收益。不过这个做法需要定制打包脚本后面我会讲到一个可行的方案。4. 实操把 extractNativeLibs 改成 false 的完整步骤4.1 在 AndroidManifest.xml 中直接设置最直接的方式是在application节点上增加属性application android:extractNativeLibsfalse ... /application注意这个属性是application级别的不是manifest级别不是activity级别别放错位置。改完之后直接构建 APK用下面的命令验证属性是否写入成功aapt dump xmltree yourapp.apk --file AndroidManifest.xml输出里会有一段类似A: android:extractNativeLibs(0x010104ea) (type 0x12) 0x00x0 表示 false0xffffffff 或 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等价于extractNativeLibstrueuseLegacyPackaging false等价于extractNativeLibsfalse。如果你在 Manifest 里写死了extractNativeLibs又以 DSL 设置了useLegacyPackaging实际打包结果以 DSL 为准但建议不要两边同时显式配置避免排查问题时混淆。这里补充一个 AS 4.x / AGP 7.x 环境下的细节如果你的项目同时启用了android:extractNativeLibsfalse但packagingOptions里的jniLibs处理有自定义逻辑比如 exclude 某些 ABI请保证 ABI 过滤后的lib/目录依然包含所有运行时需要的库否则安装后运行时会因为缺少某个.so而直接崩溃。4.3 用 Android Gradle Plugin 的 DSL 做 ABI 拆分配合为了最大化收益通常会把extractNativeLibsfalse和 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配合extractNativeLibsfalse安装后应用在设备的私有目录里不会再生成一整套 lib 文件夹副本磁盘占用能降到最低。需要注意universalApk false会放弃“一个包兼容所有设备”的场景如果你的应用要通过网页分发或企业内部分发建议保留一个 universal APK。4.4 so 的打包方式压缩还是不压缩这是最容易出问题的地方。默认情况下Gradle 打包 so 时会选择压缩DEFLATE方式存储以减小 APK 体积。但是要让系统从 APK 内部直接加载 soso 必须是以未压缩STORED方式存储的并且地址要与内存页边界对齐。如果我们只设置extractNativeLibsfalse而 so 还是DEFLATE存储那么在 Android 6.0 以上的设备上系统会尝试直接加载 APK 内的 so但压缩过的 so 无法被 mmap 执行运行时就会抛出类似下面的异常java.lang.UnsatisfiedLinkError: dlopen failed: library lib/arm64-v8a/libxxx.so not found你可能会想谁会主动把 so 压缩呢答案是默认的构建流程。这里有一个容易混淆的点以前extractNativeLibstrue时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 版本过低或者系统没有遵循这个属性这就需要回到兼容性分析那一步。另一个验证方式是通过dumpsysadb 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。前者才能配合extractNativeLibsfalse使用。实际检查时我会把常用 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要么针对低版本设备走extractNativeLibstrue的定制包。5.2 设置 false 后 APK 体积反而变大这是另一个常见现象原来 so 是压缩的改了 false 后 so 变未压缩APK 体积自然变大。很多人会因此放弃这个方案但实际上这是正常且正确的。真正的收益不体现在下载体积而体现在安装体积和运行性能。如果你既要 APK 下载体积小又要安装时少占用空间怎么办我的经验是把主力 so 用未压缩存储跟随 extractNativeLibsfalse把不常用的、只在特定业务模块里动态加载的 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 第三方加固和 extractNativeLibsfalse 的冲突遇到过最坑的一个案例是接入了某加固平台后加固平台重新打包 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 traceopenat -p pid看日志里是打开了base.apk里的路径还是/data/data/.../lib下的独立文件。这个手段在排查“为什么我改了配置但 so 还是被提取”的时候特别管用能直接看到系统到底走了哪条加载路径。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进