APK 瘦身实战记录,从 80MB 压到 35MB
APK 瘦身实战记录,从 80MB 压到 35MB
起因:一个被渠道拒掉的包
去年 Q3 我们发版前,运营在群里丢过来一张截图,某应用商店后台提示"安装包超过 80MB,建议优化至 50MB 以下以获得推荐位加权"。当时我们的 release APK 是 82.3MB,用的是 Android App Bundle 分发,但国内渠道大多要直出 APK,AAB 的 dynamic delivery 根本用不上。
这个包体积是怎么膨胀起来的?我们的项目是一个内容型 App,原生 + React Native 混合架构,三年迭代下来积累了大量技术债。我花了两周时间把包压到 35MB 左右,过程中踩了不少坑,也推翻了一些网上流传甚广但实测无效的"优化技巧"。这篇文章把完整过程记下来,工具链、版本号、具体数据都保留,方便后来人对照参考。
第一步:先搞清楚 80MB 都去哪了
动手之前必须做体积分析,否则优化就是盲人摸象。Google 官方提供的工具是 bundletool 的 get-size total 命令,但这个命令是针对 AAB 的,对 APK 分析不够直观。我主要用了两个工具:
APK Analyzer(Android Studio 内置,我用的 Arctic Fox 2020.3.1 版本)。打开 Build → Analyze APK,直接拖入 APK 文件,能看到按目录和文件类型的体积分布。它的好处是可视化,能快速定位大头;缺点是只给原始大小,不显示压缩后贡献,有时候原始体积大的文件其实压缩率很高,对 APK 最终体积影响有限。
ClassyShark(https://github.com/google/android-classyshark,最后更新是 2021 年,但仍可用)。这个工具能看 dex 文件里的类和方法数,对分析代码膨胀很有用。我主要用它确认哪些第三方 SDK 偷偷塞了不该有的依赖。
分析结果很直观:我们的 82.3MB 里,lib/ 目录(.so 动态库)占了 34MB,assets/ 下的 React Native bundle 和字体文件占 21MB,res/ 资源占 18MB,classes.dex 占 7MB,META-INF 和其他杂项约 2MB。lib/ 和 assets/ 是两个绝对大头,合计超过 55MB,这是优化优先级最高的地方。
第二步:动态库裁剪,ABI 过滤的隐藏成本
lib/ 目录的 34MB 几乎全部来自 React Native 和音视频 SDK 的 .so 文件。Android 设备的 ABI 有 armeabi-v7a、arm64-v8a、x86、x86_64 四种,很多 SDK 为了兼容会打包全部四种。我们的 APK 里四种 ABI 的 .so 都齐全,每个 ABI 约 8-9MB。
最直接的优化是在 build.gradle 里做 ABI 过滤:
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a'
}去掉 x86 和 x86_64 能省约 17MB,因为市面上 99% 以上的真机是 ARM 架构。但这个操作有个坑:Android 模拟器默认是 x86 架构,过滤后模拟器无法运行。我们团队用真机调试为主,这个代价可以接受;如果你们的 CI 流程依赖模拟器跑自动化测试,需要额外配置,比如用 arm 模拟器镜像(速度极慢)或者单独打一个 x86 的 debug 包。
更隐蔽的问题是 arm64-v8a 和 armeabi-v7a 的取舍。arm64-v8a 的 .so 体积通常比 armeabi-v7a 大 30%-50%,因为指令集更宽。如果只保留 arm64-v8a,能再省约 6MB,但会牺牲部分老旧设备的兼容性和运行性能(64 位 so 跑在 32 位模式下的 fallback 问题)。Google Play 从 2019 年起要求 64 位支持,国内渠道没有硬性规定。我们最终保留了两个 ABI,因为用户画像里有相当比例的低端机。
这里推荐一个工具 android-ndk-size-analyzer(https://github.com/android/ndk-samples 里关联的工具,实际在 NDK r21 之后的 toolchain 目录下可以找到)。它能分析单个 .so 文件的体积构成,区分代码段、数据段、符号表等。我用它发现某音视频 SDK 的 .so 里带了完整的符号表(.symtab 和 .strtab),strip 之后单个 so 从 4.2MB 降到 2.8MB。
strip 操作可以在打包阶段自动化。Android Gradle Plugin 4.1 以上版本默认会 strip release 包的 .so,但有个已知问题:如果 so 是通过 jniLibs.srcDirs 直接引入的预编译库,而不是通过 CMake/ndk-build 编译的,AGP 可能不会自动 strip。需要在 build.gradle 里显式配置:
android {
packagingOptions {
jniLibs {
useLegacyPackaging false
}
}
}或者手动在构建脚本里跑 ${ANDROID_NDK}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip。我用的 NDK 版本是 r23b,llvm-strip 替代了旧的 arm-linux-androideabi-strip,参数兼容但路径有变化,这是容易踩坑的地方。
第三步:React Native 的 bundle 拆分与 Hermes 引擎
assets/ 目录的 21MB 里,React Native 的 bundle 文件占 12MB,字体和配置文件占 9MB。
React Native 0.60 版本开始官方支持 Hermes 引擎替代 JSC(JavaScriptCore)。Hermes 的核心设计目标之一就是降低包体积和提升启动速度。我们当时用的 RN 版本是 0.64.2,开启 Hermes 后 bundle 从 12MB 降到 3.8MB,压缩到 APK 里后贡献从约 4MB 降到 1.2MB。这个收益非常显著,是 RN 项目必做的优化。
开启方式是在 android/app/build.gradle 里:
project.ext.react = [
enableHermes: true
]但 Hermes 不是无痛切换。我们遇到两个问题:一是 Hermes 不支持完整的 ES6 特性,比如 Proxy,如果代码或依赖库用了 Proxy 会直接崩溃。我们用的某个状态管理库内部用了 Proxy,切换后闪退,最后换了一个实现。二是 Hermes 的调试体验比 JSC 差,Chrome DevTools 的断点有时候对不上行号,0.64.x 版本的 Hermes 还有内存泄漏的已知 issue(https://github.com/facebook/hermes/issues/564),在低端机上长时间运行后 JS 内存会涨。
字体文件的优化更有意思。我们用了 6 套自定义字体,原始 TTF 文件合计 18MB,打包压缩后仍有 9MB 左右。网上很多文章推荐用 FontSquirrel 或 transfonter 做子集化(只保留用到的字符),但中文字体子集化工具链远不如英文成熟。我试了两个方案:
字蛛(FontSpider)(https://github.com/aui/font-spider),基于 PhantomJS 扫描 HTML 中的文字,生成子集字体。但它是针对 Web 设计的,对 Android 的 assets 目录不友好,而且 PhantomJS 已经停止维护,跑在 CI 环境里依赖很重。我 fork 后改了一版支持扫描本地文本文件,但遇到复杂字体(比如可变字体)会子集化失败,最后弃用。
chinese-subset(https://github.com/Justineo/chinese-subset),基于 Node.js 和 fontkit,可以指定字符集生成子集。我用的版本是 1.2.0,支持 TTF 和 WOFF2。我们的 App 有固定的内容运营后台,文字内容可控,我导出了近三个月所有文章标题和正文的字符集合,约 3500 个不重复汉字,加上标点、英文、数字,子集化后单个中文字体从 4MB 降到 800KB-1.2MB。六套字体合计从 9MB 压到 3.5MB。
这个方案的局限很明显:如果内容运营用了生僻字或新增字符,会显示为系统默认字体(通常是思源黑体的 fallback)。我们在运营后台加了字符检测工具,发布前校验是否在子集范围内。另外,子集化过程会破坏 hinting 信息,小字号(12sp 以下)的渲染质量会下降,我们在设计规范里禁止了 12sp 以下的自定义字体使用。
第四步:资源图片的 WebP 转换与矢量图陷阱
res/ 目录的 18MB 里,图片资源是大头。Android Studio 从 4.0 开始内置了 PNG 转 WebP 的批量工具,右键目录选 Convert to WebP,可以配置有损/无损、质量系数。我把所有 PNG 和 JPEG 批量转成了有损 WebP,质量系数 75,视觉上设计师验收通过。转换后图片资源从 14MB 降到 4.2MB。
但 WebP 有个版本兼容问题。Android 4.0(API 14)开始支持有损 WebP,4.2.1(API 17)开始支持无损和透明通道 WebP。我们的 minSdk 是 21,完全不需要担心。如果 minSdk 是 16 或 17,透明 WebP 会解码失败,需要额外处理。
更隐蔽的坑是 9-patch 图。WebP 格式原生不支持 9-patch 的拉伸标记,Android 的 aapt2 在编译期会把 9-patch 的标记信息提取到二进制索引里,图片本身转成 WebP。这个机制在 AGP 4.1 以上版本工作正常,但 AGP 4.0.x 有个 bug(https://issuetracker.google.com/issues/159831203),9-patch 转 WebP 后拉伸区域信息丢失,导致图片拉伸异常。我们当时用的 AGP 是 4.0.2,踩了这个坑,升级到 4.1.3 解决。
矢量图(Vector Drawable)是另一个优化方向。理论上矢量图体积远小于位图,且无损缩放。但 Android 的 Vector Drawable 只支持 SVG 的子集,复杂渐变、滤镜、蒙版都不支持。我们有一批运营活动的装饰性插图,设计师给的是 SVG,手动简化后转成 Vector Drawable,体积确实小。但渲染性能是个问题:复杂路径在低端机上 draw 耗时明显,RecyclerView 快速滑动时掉帧。
我写了段简单的测量代码,用 Choreographer 统计帧时间,同一个列表用 Vector Drawable 和 WebP 分别跑,Vector Drawable 的 95 分位帧时间比 WebP 高 40%。最后折中方案是:图标和简单图形用 Vector Drawable,复杂插图仍用 WebP。这个决策不能只看包体积,要综合运行时性能。
第五步:代码裁剪与 R8 的误杀问题
classes.dex 的 7MB 看起来占比不高,但代码膨胀会影响运行时内存和启动速度,而且 dex 数量超过 1 个时会触发 multidex,对启动性能有负面影响。
我们的代码裁剪主要靠 R8(AGP 3.4 以上默认启用,替代 ProGuard)。R8 的 shrinkResources 选项可以移除未被引用的资源,但和代码裁剪联动时有个经典坑:
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}shrinkResources true 依赖 minifyEnabled true,它分析的是代码对资源的引用。如果代码里用反射加载资源,比如 getIdentifier("icon_" + id, "drawable", packageName),R8 会认为这些资源未被直接引用而删除,运行时崩溃。我们的运营活动系统有大量这种反射加载,需要在 proguard-rules.pro 里显式 keep:
-keepresources drawable/icon_*或者更粗暴地关掉 shrinkResources,但那样会损失体积优化。我最后写了个 Gradle 任务,在编译期扫描代码中的反射模式,自动生成 keep 规则,比手写维护可靠。
R8 的优化级别也有讲究。默认的 proguard-android-optimize.txt 会启用方法内联、类合并等激进优化,可能改变行为。我们遇到过一个 bug:某个 SDK 的初始化代码依赖类加载顺序,R8 的类合并后,静态初始化块的执行顺序变了,导致 NPE。这个 bug 在 R8 3.0.73(AGP 4.1 配套版本)里存在,升级到 R8 3.1.11(AGP 7.0 配套版本)后修复。但当时我们还没准备好升级 AGP,临时方案是在 proguard 规则里 -keep 那个类,禁用对它的优化。
第六步:资源混淆与 AndResGuard 的取舍
资源文件的路径名 res/drawable-xxhdpi/icon_foo.png 在 APK 里会占用一定体积,而且字符串常量池可以被反编译直接读取,有安全层面的考虑。美团开源的 AndResGuard(https://github.com/shwenzhang/AndResGuard)通过缩短资源路径名来节省体积,比如把 icon_foo.png 改成 a.png,drawable-xxhdpi 改成 b。
我集成的版本是 1.2.21,配置后资源相关字符串常量池从 1.8MB 降到 0.6MB,APK 整体减小约 1.1MB。收益不算巨大,但实现成本低,值得一做。
但 AndResGuard 有个限制:它通过修改 resources.arsc 和重命名资源文件来实现,对 Resources.getIdentifier() 的反射调用需要白名单处理,否则找不到资源。这和前面 R8 的 shrinkResources 问题叠加,配置复杂度上升。另外,Android 12(API 31)开始系统对 resources.arsc 的格式有更严格的校验,AndResGuard 1.2.21 生成的 arsc 在某些 Android 12 设备上解析失败,表现为启动崩溃。issue 列表里有人提了(https://github.com/shwenzhang/AndResGuard/issues/525),但项目维护不活跃,最后没有官方修复。我 fork 后自己改了 arsc 生成逻辑,对齐 Android 12 的格式要求,但这也让我对这个工具的长期维护性产生疑虑。如果项目今天从零开始,我可能会优先考虑其他方案,或者接受不做资源混淆的代价。
第七步:重复依赖与传递依赖的清理
体积分析时发现一个离谱的事实:我们的 APK 里同时存在 three 个版本的 OkHttp——4.9.0、4.2.2、3.12.0,分别来自不同的直接依赖,而它们又各自带了不同版本的 Okio。这是 Gradle 的传递依赖解析失败了吗?不是,是某些 SDK 把 OkHttp 打包成了 fat-aar,或者用了 implementation 但做了 shadow 改名,导致 Gradle 的依赖冲突 resolution 机制失效。
排查工具是 Gradle Dependencies Task 和 APK Analyzer 的类搜索。运行 ./gradlew app:dependencies --configuration releaseRuntimeClasspath 能看到依赖树,但 fat-aar 和 shadow 依赖不会出现在这里。只能在 APK Analyzer 里搜索 okhttp3 包名下的类,发现多份 OkHttpClient.class,再反推来源。
清理过程很繁琐。某直播 SDK 把 OkHttp 4.2.2 打包进了自己的 aar,我在 build.gradle 里用 exclude group: 'com.squareup.okhttp3' 排除我们自己的 OkHttp 依赖,统一用 SDK 内置的版本,但这样版本被锁定在 4.2.2,无法升级。另一个推送 SDK 用的是 3.12.0,那是 Android 5.0 兼容的旧版本,和 4.x 有 API 不兼容。最后推动两个 SDK 升级统一版本,花了两周跨团队沟通。
这里推荐一个辅助工具 Dependency Guard(https://github.com/dropbox/dependency-guard,Dropbox 开源,版本 1.1.0)。它在 CI 里检查依赖树的变化,如果有新增依赖或版本漂移就报错,防止体积问题回潮。配置简单,在 build.gradle 里:
plugins {
id 'com.dropbox.dependency-guard' version '1.1.0'
}
dependencyGuard {
configuration('releaseRuntimeClasspath')
}第一次运行生成基线文件,后续 CI 比对。它的局限是只能检测 Gradle 能看到的依赖,对 fat-aar 里的隐藏依赖无能为力,但配合 APK Analyzer 定期抽查,能覆盖大部分场景。
第八步:极致优化与收益递减
做完上述步骤后,APK 降到了 38MB 左右,距离 35MB 目标还差 3MB。这时候进入收益递减区间,每 1MB 的优化成本急剧上升。
我评估了几个方向:
ReDex(Facebook 开源,https://github.com/facebook/redex)。这是一个字节码优化工具,在 APK 构建完成后对 dex 文件做二次处理,包括内联、删除无用代码、字符串混淆等。Facebook 内部声称能省 10%-25% 的 dex 体积。我集成的是 2021 年的稳定版本,实际跑下来 dex 体积从 5.8MB 降到 5.2MB,APK 整体减小约 0.5MB。收益远低于预期,而且 ReDex 的构建流程侵入性强,需要额外 CI 步骤,调试符号和堆栈映射变得复杂。最后放弃,没有合入主线。
XAPK 或插件化分包。把部分资源或 so 文件拆成动态下发,能显著降低首次下载体积。但我们没有成熟的动态下发基础设施,且国内渠道对 XAPK 支持参差不齐,这个方向需要基础设施投入,不是短期能解决的。
更激进的图片压缩。我试了 cwebp 命令行工具的 -size 目标模式,指定输出文件大小让工具自动调整质量,以及 guetzli(Google 的感知优化 JPEG 编码器)。guetzli 的压缩率比标准 JPEG 高 20%-30%,但编码速度极慢,一张 1080p 图片要几分钟,CI 时间不可接受。cwebp 的 -size 模式在极低目标下会出现明显的 block artifact,设计师拒绝接受。
最后省下的 3MB 来自两个细节:一是发现 assets/ 里有一份 2.1MB 的 JSON 配置文件,是某 SDK 的默认配置,我们在初始化时已经用远程配置覆盖,但 SDK 在 aar 里硬编码了这份文件。用 APK Analyzer 确认未被代码引用后,在打包阶段用 packagingOptions { exclude 'assets/default_config.json' } 剔除。二是 React Native 的 Hermes 引擎在 0.64.x 版本默认带了调试符号的 so,自己 strip 后省了约 0.8MB。
最终数据与未解决的问题
两周优化后的最终数据:release APK 35.4MB,从 82.3MB 压缩了 57%。各目录贡献:lib/ 12MB(原 34MB),assets/ 4.5MB(原 21MB),res/ 5.8MB(原 18MB),classes.dex 5.2MB(原 7MB),其他 8MB(原 2.3MB,主要是新增的多语言资源和签名信息)。
有几个问题没有彻底解决,留作后续:
React Native 的 so 文件仍然偏大,Hermes + JSC 双引擎切换的残留代码清理不干净,因为 RN 的 Gradle 脚本在 0.64.x 版本里对引擎切换的支持有 bug,clean build 时旧引擎的 so 不会被删除。升级到 0.67+ 版本能解决,但 RN 大版本升级的成本另算。
国内渠道要求直出 APK,AAB 的 Play Feature Delivery 机制完全用不上。如果 Google 在国内推 AAB 分发,或者国内渠道支持类似机制,体积优化空间还能再打开。但目前没有迹象。
AndResGuard 的 Android 12 兼容问题让我对资源混淆的长期维护存疑。如果官方不再更新,可能需要迁移到 AGP 内置的资源优化,或者接受不做混淆。
工具清单与版本备忘
以下是我实际用到并验证过的工具,版本号是当时的环境,供参考:
这些工具不是每个都推荐无脑使用,具体取舍在正文里写了。APK 瘦身没有银弹,是持续性的工程投入,需要建立体积监控机制防止回潮。我们后来在 CI 里加了 APK 体积基线检查,单 PR 体积增长超过 500KB 就阻断合并,这个阈值是业务和技术妥协的结果。