APK 瘦身与混淆:让安装包更小、更安全、更易维护
Android 应用体积持续膨胀,早已不是什么新鲜事。功能模块层层叠加、图片资源不断扩张、第三方 SDK 越接越多、历史遗留代码无人清理、ABI 目录覆盖过全、资源在各模块中重复散落——随便哪一项,都足以让安装包体积步步攀升。不少人觉得“包大一点无所谓”,但实际影响远比想象中直接:下载等待时间变长、弱网环境失败率飙升、应用商店转化率随之下降。对于存储空间紧张的中低端设备,用户卸载的第一个应用往往就是体积最大的那个。而对研发团队而言,包体积失控,往往也意味着依赖边界模糊、资源管理混乱、构建配置形同虚设。

APK 瘦身绝不仅仅是上线前临时压缩几张图片就能解决的,它应该是一套持续治理的流程:先摸清体积到底花在了哪里,然后系统性地减少不必要的资源和代码,最后用混淆、裁剪和自动化检查守住成果。下面展开聊聊具体如何操作。
先看清体积构成
优化之前,别凭感觉猜测。Android Studio 自带的 APK Analyzer 可以直接打开 APK 或 AAB,一目了然地展示各个目录和文件的占比。常见体积来源无外乎以下几块:
res/:图片、布局、动画、raw 资源classes.dex:业务代码、三方库代码、Kotlin 运行时相关代码lib/:不同 ABI 下的 native so 文件assets/:离线包、字体、模型、配置、前端资源resources.arsc:资源索引表META-INF/:签名和元信息
一个实用的习惯是,每次版本发布前都导出一份体积报告,记录 APK、AAB、各 ABI 安装包、dex、res、lib 的大小变化。这样一来,如果某个版本突然多了几 MB,就能快速定位到是哪次依赖或资源引入导致的。
命令行也能快速查看到底哪些文件是体积大户:
unzip -l app-release.apk | sort -nr | head -40
这条命令能帮你迅速发现那些不该出现的大文件——比如误放进 assets/ 的调试数据、未压缩的长图、重复的字体,或者压根不该进入线上包的视频文件。
资源瘦身:先处理最容易膨胀的部分
很多应用包体积增长最快的其实不是代码,而是资源。图片、字体、动效、离线文件一旦缺少规范,很容易变成“谁都不敢删”的历史包袱。
清理无用资源
Gradle 提供了资源裁剪功能,能在构建过程中移除没有被代码引用的资源:
android {
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
注意 shrinkResources 依赖于代码压缩的结果,通常需要和 minifyEnabled 一起使用。它只能删除构建系统能判断为无用的资源,但像动态拼接资源名、反射访问资源、WebView 中引用本地资源这类场景,就得额外保留。
如果项目里存在动态资源访问,比如:
val id = resources.getIdentifier(iconName, "drawable", packageName)
构建工具很难知道这些资源是否真会被用到。此时可以通过 res/raw/keep.xml 来保留特定资源:
这类规则不要写得太宽泛,否则会把资源裁剪的收益抵消掉。
优化图片格式和尺寸
图片通常是资源瘦身的主战场。按用途选择格式是最基本的思路:
- 图标和简单插画优先考虑 VectorDrawable
- 普通位图优先考虑 WebP
- 需要透明通道时使用带透明的 WebP,或谨慎保留 PNG
- 照片类大图避免放入 APK,优先走远端下发或按需下载
把大图直接扔进 drawable-xxhdpi 并不等于适配。更稳妥的做法是按显示尺寸裁切资源,确保图片的真实像素和页面的实际需求匹配。一个只显示 48dp 的图标,没必要用几百 KB 的大图来承担。
Android Studio 支持批量转换 WebP,但转换后一定要人工检查:透明通道是否正常、渐变是否平滑、边缘有没有锯齿、深色模式下的显示效果如何。体积优化不能以视觉损坏为代价。
控制多密度资源
如果某些图片只在固定尺寸容器内显示,可以考虑只保留必要的密度,没必要在每个密度目录里都放一份超大图。对于矢量图标,使用 VectorDrawable 能直接避免多份位图资源的问题。
但别机械地删除密度资源。启动图、大尺寸运营图、复杂插画如果只保留低密度版本,高端设备上会明显模糊。资源瘦身的目标是匹配使用场景,而不是盲目减少文件数量。
处理语言资源
三方库可能带来大量本地化字符串。如果应用只支持少数语言,可以在 Gradle 中限制打包的语言:
android {
defaultConfig {
resourceConfigurations = listOf("zh", "en")
}
}
这个配置会影响最终打进包里的资源。使用前要确认产品的支持范围,避免误删实际需要的语言。
代码瘦身:减少无效依赖和未使用代码
代码体积主要体现在 dex 中。业务代码增长是不可避免的,但很多 dex 膨胀其实来自依赖失控。
审视三方库
引入一个 SDK 之前,至少要想清楚这些问题:
- 是否只用到了其中很小一部分能力?
- 有没有更轻量的替代方案?
- 会不会额外引入大量传递依赖?
- 是否包含 native so、资源、字体或离线文件?
- 会不会影响启动链路?
用 Gradle 查看依赖树非常方便:
./gradlew app:dependencies --configuration releaseRuntimeClasspath
如果某个库只是为了一个小工具方法,却拖进来一大坨依赖,就应该考虑替换了。对于传递依赖,可以按需排除:
implementation("com.example:sdk:1.0.0") {
exclude(group = "com.example.unused", module = "big-module")
}
排除之前一定要跑完整的回归测试。传递依赖有时候不是“看起来没用”就真的没用。
拆分可选能力
不是所有功能都必须内置在基础包里。大型离线资源、低频能力、特定地区功能,完全可以通过远端下载、动态特性模块或服务端配置控制来实现。
如果使用 Android App Bundle,Google Play 可以按设备配置分发资源和 native 库,让用户只下载自己设备需要的部分。对于非 Google Play 渠道,也可以按 ABI、屏幕密度或渠道策略生成不同的包。
控制 native so
lib/ 目录经常是体积大户,常见问题包括:
- 同时打入了 armeabi-v7a、arm64-v8a、x86、x86_64 四个 ABI
- 三方 SDK 带入未使用的 so 文件
- debug 符号或无关文件混进了 release 包
如果线上只支持 ARM 架构,可以限制 ABI:
android {
defaultConfig {
ndk {
abiFilters = listOf("armeabi-v7a", "arm64-v8a")
}
}
}
更推荐的做法是结合渠道和分发方式来做决策。如果项目需要模拟器测试或支持特殊设备,不要把 ABI 过滤写死到所有构建变体里。
R8 与混淆:压缩、优化和保护代码
Android release 构建通常使用 R8。它做的远不止“把类名改短”那么简单,还包括代码缩减、优化、混淆和 dex 生成。开启后,未使用的类、方法、字段会被移除,部分代码会被内联或简化,类名和成员名也会被压缩成短名称。
基础配置通常如下:
android {
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
开启混淆后,必须重点关注反射、序列化、JNI、路由、注解处理、WebView JSBridge 等场景。这些机制依赖类名、方法名或字段名,一旦被改名或删除,线上就会出问题。
Keep 规则要精确
很多项目的 proguard-rules.pro 会越写越宽,比如:
-keep class com.example.** { *; }
这条规则几乎等于告诉 R8:业务代码都别动。短期看是省事,长期看会让代码压缩和混淆的收益大幅下降。
更好的做法是只保留真正需要稳定名称的部分。比如 Gson 反射解析的模型,可以按注解保留字段:
-keepclassmembers class * {
@com.google.gson.annotations.SerializedName ;
}
如果某个类需要被 JNI 按名字查找,可以只保留对应类和 native 方法:
-keepclasseswithmembernames class * {
native ;
}
对于路由框架、数据库框架或三方 SDK,如果官方提供了规则,优先使用官方文档给出的配置,再结合项目实际收窄。
数据模型不要随便混淆字段名
网络 JSON、数据库字段、跨进程 Bundle、持久化缓存都可能依赖字段名称。建议对外部数据结构显式声明序列化名称:
data class UserProfile(
@SerializedName("user_id") val userId: String,
@SerializedName("nickname") val nickname: String
)
这样即使 Kotlin 属性名被混淆,JSON 字段仍然是稳定的。Room、Moshi、kotlinx.serialization 等框架也有各自的规则,核心原则是:不要让“代码字段名”隐式成为线上协议的一部分。
保留 mapping 文件
混淆之后,线上崩溃堆栈会变成短类名和短方法名。没有 mapping 文件,排查问题会非常痛苦。每次 release 构建都应该保存对应的 mapping 文件,并上传到崩溃平台。
mapping 文件通常位于:
app/build/outputs/mapping/release/mapping.txt
它和 APK 版本是强绑定的。不要只保留最新的一份,否则旧版本的崩溃将无法还原。
发布前验证:别让瘦身变事故
APK 瘦身和混淆都有破坏性风险。资源可能被误删,反射代码可能失效,SDK 初始化可能异常,WebView 本地资源可能找不到。因此 release 包必须经过充分验证,而不是只在 debug 包上跑一跑。
建议至少覆盖以下检查项:
- 使用 release 构建安装到真机
- 首次启动、登录、首页、核心业务流程完整走通
- 图片、图标、动效、深色模式显示正常
- 分享、支付、推送、地图、扫码等 SDK 功能可用
- WebView、路由、反射、序列化相关功能可用
- 崩溃平台能正确反混淆堆栈
- APK Analyzer 对比体积变化,确认没有异常新增
可以把体积检查加入 CI 流程,比如超过某个阈值就阻断合并或发出告警。阈值设置不要过于理想化,建议按模块和历史增长趋势来定。
一套落地流程
实际项目可以按这个顺序推进:
- 用 APK Analyzer 找出主要体积来源
- 清理明显无用的大资源和误入包的文件
- 转换合适的图片格式,压缩过大的位图
- 限制不需要的语言、ABI 和密度资源
- 审查三方依赖,移除或替换重依赖
- 开启 R8、资源裁剪和必要的优化配置
- 收窄 Keep 规则,避免全包保留
- 保存 mapping,并接入崩溃平台反混淆
- 用 release 包做核心流程回归
- 在 CI 中记录体积并设置增长提醒
这套流程不要求一次做完。更现实的做法是先处理收益最大的几项,再把体积变化纳入日常发布检查。
常见误区
只压缩图片,不治理依赖
图片优化通常见效快,但如果三方库和 native so 持续膨胀,包体积仍然会反弹。资源、代码、依赖、构建配置要一起看,不能只盯着一个方向。
Keep 规则越宽越安全
宽规则确实能减少混淆问题,但也会削弱 R8 的优化效果。正确的做法是让规则服务于具体场景:哪里需要稳定名称,就保留哪里。
debug 包正常就说明 release 包正常
debug 包通常不开启混淆和资源裁剪,很多问题只会出现在 release 包。所有和混淆、资源、签名、渠道相关的验证,都必须基于 release 构建进行。
体积越小越好
体积优化也有边界。过度压缩图片会损害视觉质量,过度拆分资源会增加运行时复杂度,过度延迟下载会影响离线体验。目标是让包体积合理,而不是为了数字漂亮牺牲产品体验。
小结
APK 瘦身与混淆本质上是一项工程治理工作。资源瘦身解决图片、语言、密度和无用文件的问题;代码瘦身解决依赖、dex 和 native so 的问题;R8 混淆在压缩代码的同时提升了逆向门槛;发布验证则确保这些优化不会破坏真实功能。
真正能长期稳定的做法,是把体积分析、规则审查、release 回归和 mapping 归档纳入日常流程。这样一来,安装包才不会在长期迭代中失控,团队也能在功能增长和体验成本之间保持清醒的平衡。
