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,能及时追到是哪次依赖或资源引入导致的。
命令行也可以快速查看 APK 内容:
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 保留资源:
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@drawable/icon_level_*,@raw/startup_config" />
这类规则不要写得太宽,否则会抵消资源裁剪收益。
优化图片格式和尺寸
图片通常是资源瘦身的主战场。建议按用途选择格式:
- 图标和简单插画优先考虑 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。
- 三方 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 <fields>;
}
如果某个类需要被 JNI 按名字查找,可以只保留对应类和 native 方法:
-keepclasseswithmembernames class * {
native <methods>;
}
如果路由框架、数据库框架或三方 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 归档纳入日常流程。这样安装包不会在长期迭代中失控,团队也能在功能增长和体验成本之间保持清醒。