ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android APK包体积优化实战:从88MB到51MB的全程复盘

Android APK包体积优化实战:从88MB到51MB的全程复盘 先交代一下背景。前阵子我们团队准备发新版本临近提测的时候负责投放的同事跑来问我APK又大了现在打出来的包都快90MB了渠道那边反馈下载转化率掉了能不能想想办法当时我打开Android Studio看了一眼最新构建产物心里其实挺有数的——这不是一天两天堆出来的问题是过去半年不断加功能、加依赖、加资源却没人对包体做任何治理的结果。于是花了大概两周时间把APK从88MB干到了51MB整体瘦了差不多42%。这篇文章就是把那两周做的事、踩的坑、以及沉淀下来的日常机制一次性整理清楚。文章会比较长但你可以直接按目录跳着看。我尽量按照先搞清楚现状再动手优化最后建立防止反弹的机制这个顺序来写所以即使你是第一次接触包体积优化跟着走也能完整落地。1. 包体积不是技术债是真金白银的成本很多开发同学对APK体积的第一反应是大点就大点现在Wi-Fi这么快流量也不贵。这个想法在圈内其实非常普遍但一旦你把包体积放到真实业务场景里去算账结论完全不一样。我先把这块说透因为如果认知不到位后面定优化目标、争取排期、跟产品对峙的时候你都站不住脚。1.1 一个APK的大小到底影响了什么第一是下载转化率。Google Play曾在官方博客公布过一组数据APK每增加6MB下载转化率会下降约1%。国内应用市场虽然没公布过类似曲线但我在两个渠道后台比对过自家应用的数据规律是接近的安装包越大用户在应用详情页点击安装的意愿越低特别是那些包体超过100MB的应用在非Wi-Fi场景下几乎是被直接劝退。第二是更新成功率。很多人只盯着从0到1的安装忽略了从1到1.1的升级。增量更新或者全量更新的成功率跟包体是负相关的。包越大下载到一半被系统杀掉、用户主动取消、存储空间不足导致安装失败的概率就越高。我们的版本更新率在过去一个季度一直往下掉后来把包体降下来之后更新成功率的回升是肉眼可见的。第三是CDN带宽和服务器成本。这个可能中小企业感受没那么深但用户量一旦上来每多1MB的包体乘以每日下载量再乘以每GB流量成本一年下来就是一笔不小的开销。对于出海产品这一项的开销会更大。第四是内部效率问题。一个90MB的APK从CI打包到上传分发平台再到测试下载安装整个链路都是时间成本。尤其是测试同学需要频繁切换版本验证Bug时包体越小、下载越快整个团队的研发节奏都会舒服不少。所以包体积优化本质上不是洁癖型工程改善它直接关系到投放效果、用户留存、运营成本是业务层面的硬指标。只有先把这个认知达成一致你后面去推动优化、争取资源时才能得到产品和管理层的配合。1.2 优化前必须先定基线我在动手之前做了一件事把最近10个线上版本的大小变化列了一张表看清楚包体是哪些版本涨上去的分别在哪个模块上涨的。这样做的目的有两个一是找到包体膨胀的时间节点方便追溯是哪次需求/哪次依赖升级引入的问题二是为后续优化设定一个可量化的目标比如在下一个版本里把APK体积压到60MB以内。这里有一个关键动作要让包体积优化可持续不能只靠一次大扫除还得从流程上定基线。我在后面第5章会专门展开讲日常保障机制这里先提一个最基础的建议——每次发布之前用脚本把新APK的各个组成部分dex、res、assets、so库的体积和上个版本做一次diff任何超过阈值比如单模块增重5%的变更都需要说明原因否则不允许合入发布分支。2. 先体检再动刀APK内部到底装的什么在没有工具辅助之前很多开发者对APK体积的判断是感觉级别的——觉得图片多就压图片觉得代码多就混淆代码。但真正的优化应该从一张体检表开始搞清楚APK的每个组成部分各自占了多少空间然后再决定从哪里下手。一个标准APK的构成其实很简单解压后无非就是这几块classes.dexJava/Kotlin字节码、resources.arsc资源索引表、res/编译后的资源文件、assets/原始资源、lib/native库按ABI分目录、META-INF/签名和清单信息。优化动作基本都是在跟这几块打交道。2.1 Android Studio自带的APK Analyzer怎么用首选工具是Android Studio自带的APK Analyzer它在老版本里叫APK Analyzer新版本里入口藏在Build菜单下Build Analyze APK...。选择APK文件之后它会展示一个非常直观的文件占比图你可以直接看到lib/占多少、res/占多少、classes.dex占多少还能继续点进去看每个子目录和单个文件的大小。有几个比较高频的查看维度原始文件大小 vs 下载大小APK Analyzer页面会同时展示APK的原始大小和预估下载大小即经过压缩后的传输大小。这两个指标都要关注因为部分渠道包推广时按下载大小计费而用户在应用市场看到的包体大小也是下载大小。按文件类型排序可以快速筛选出占用空间最大的文件通常排在最前面的都是图片、so库和比较大的资源文件。查看classes.dex的构成APK Analyzer能反查dex里的类列表虽然功能没那么强但至少能让你定位到这个50MB的dex里到底装了哪些包名下的类对排查超大依赖非常有用。2.2 命令行工具apkanalyzerAndroid Studio的图形界面工具适合单次人工分析但如果你要做常态化监控建议直接用命令行版本。Android SDK里内置了apkanalyzer工具路径在$ANDROID_HOME/cmdline-tools/latest/bin/apkanalyzer。我平时最常用的是这几条命令# 查看APK基本信息包括版本、SDK版本等 apkanalyzer apk summary app-release.apk # 按文件大小列出APK内容从大到小排序 apkanalyzer files list app-release.apk # 查看dex文件中的类摘要 apkanalyzer dex packages app-release.apk把apkanalyzer files list的输出接一个sort和管道就能快速生成一份按大小排序的APK内部文件清单。把这个清单用在CI里做包体变化监控比每次手动打开Android Studio点来点去高效得多。2.3 我们当时体检出的问题用APK Analyzer对88MB的旧包做了一次全量体检之后问题已经很清晰了。我贴一份脱敏后的数据给你参考你可以用它和你们自己的包体分布做对比组成部分体积MB占比问题定性res/34.238.9%大量PNG未压缩多套语言资源冗余lib/26.430.0%三套ABI重复打包含两个大型native库classes.dex14.816.8%存在大量无用代码与重复依赖assets/8.69.8%内置了一份旧版离线数据包可下架resources.arsc1.82.0%字符串和ID资源膨胀META-INF及其他2.22.5%正常签名信息这个表一眼就能看出来res和lib是最大的两块加起来快70%。所以后面我的优化顺序也很明确先啃资源再啃so库最后再对dex做精细化治理。很多团队一上来就纠结代码混淆和R8规则反而忽略了rc里那30多MB的图片——这条路其实是反的。3. 第一步先啃资源图片、语言、冗余文件资源是APK体积优化里见效最快、性价比最高的部分。几乎不需要动代码逻辑只要把资源配置理顺一个8MB到15MB的体积下降是很常见的。资源优化我拆成五个动作每一个都是可以直接落地的。3.1 开启shrinkResources和resConfigsAndroid Gradle Plugin里有两个开关建议每个项目都打开。第一个是shrinkResources它会对未被代码引用的资源做移除。第二个是resConfigs只保留你声明支持的语言和屏幕分辨率对应的资源。android { buildTypes { release { minifyEnabled true shrinkResources true resConfigs zh-rCN, zh-rTW, en } } }需要注意三点。一是shrinkResources必须配合minifyEnabled一起使用因为它依赖R8/ProGuard生成的资源引用信息来判断哪些资源没被引用到只开shrinkResources不开minifyEnabled是不起作用的。二是resConfigs会丢弃你未声明的语言资源如果后面往代码里新增了多语言支持但忘了在这里补充配置会出现运行时资源找不到的问题所以语言列表务必要跟产品实际支持的语言保持一致。三是如果用到了第三方SDK默认情况下各SDK自带的多语言资源也在裁剪范围内这可能导致SDK内部的某些文案在特定语言下回退到默认英文这个问题通常可接受但如果你的产品对多语言要求极高需要逐个SDK确认。3.2 用Lint找出正式没用的资源shrinkResources是在打包时静默移除未被引用的资源但有些资源是通过反射或者字符串拼接被引用的AGP的静态分析识别不到会被当成无用资源移除掉然后运行时崩溃。为了安全起见正式的流程里我建议先用Android Lint跑一遍扫描人工确认移除清单。在Android Studio里右键模块目录选择Analyze Inspect Code系统会列出lint报告其中包含了UnusedResources未使用的资源警告。也可以在命令行直接跑./gradlew :app:lintReleaselint报告会生成在app/build/reports/lint-results-release.html里面可以按资源类型浏览所有被标记为无用的资源文件标出引用位置。我们当时用lint扫出将近600个无用资源涉及各类drawable、layout、values文件加起来接近6MB这些都是历次改版迭代后遗留的尸体。不过lint对动态反射的引用识别能力有限。比如你项目里通过context.getResources().getIdentifier(ic_icon_ type, drawable, context.getPackageName())这种方式去取资源lint很可能误报。这种场景建议在lint配置里加上tools:ignoreUnusedResources或者调整Lint规则避免误删。3.3 图片压缩从PNG到WebP我们项目里的res目录有34MB其中占大头的基本都是启动页、banner、引导图这类大尺寸PNG和JPG。这里的优化手段按性价比从高到低排是转WebPAndroid 4.0API 14以上就原生支持WebP现在这个门槛对绝大多数应用来说已经不是问题。WebP在同等画质下比PNG小25%到34%。Android Studio里选中图片右键Convert to WebP直接批量处理整套资源。使用矢量图对于图标、插画这类单色系的图形资源直接用VectorDrawable矢量图体积可以小到忽略不计。但要注意矢量图首次渲染会有CPU计算开销列表页高频使用的复杂图标需要权衡。压缩工具预处理如果WebP之后仍需要保留PNG格式例如某些第三方SDK对资源格式有硬性要求建议在打包前用tinypng或pngquant批量压缩一遍通常能把图片体积再削掉一个量级。有一点必须提醒启动页、背景图这类大图资源不要直接塞在res/drawable里。正确做法是放到res/drawable-nodpi/或assets/避免因为不同分辨率的屏幕密度生成多个副本。很多APK的体积膨胀就是这么来的一张1080P的大图在drawable-xxhdpi、drawable-xhdpi、drawable-hdpi下各存了一份直接三倍起。3.4 Resource混淆与资源合并如果你的项目已经比较成熟、资源命名混乱可以引入资源混淆工具来进一步压缩resources.arsc和资源路径。业界目前用得最多的是腾讯的AndResGuard它核心做的事情是把资源路径从可读比如res/drawable-xxhdpi/splash_bg.png改成一个极短的字符串比如res/drawable-xxhdpi/a.png同时合并掉大量重复或冗余的资源项。在接入时有一个比较重要的坑AndResGuard会对resources.arsc里的资源名做混淆但如果你在代码里用getIdentifier()动态获取资源或者某些第三方SDK内部用固定路径访问资源混淆后就会找不到资源。接入前需要把所有动态引用资源的点全部改成静态引用或者将相关资源加入白名单。这个工作量说大不大说小不小建议作为资源优化的最后一步来做。另外如果你们的项目已经切换到Android App Bundle资源混淆做不做其实影响不大了因为在Google Play上使用App Bundle时系统本身已经会对不同设备的资源做按需下发这里不再展开。3.5 真实数据对比以我们的项目为例资源优化这一轮跑完res从34.2MB降到了19.6MB差不多砍掉15MB。其中resConfigs裁剪多语言资源贡献了约3.8MBlint清理无用资源贡献约5.9MB转WebP和压缩大图贡献约4.9MB资源混淆又挤掉了一部分水分。整个过程完全没改任何业务代码所以风险相对可控测试回流成本也比较低。4. 再啃硬骨头lib目录与so库精简化如果你的APK体检表里lib目录占比超过20%那不用怀疑so库是体积的大头。so库这一块的问题比较特殊它不像资源那样清理完就一劳永逸因为涉及到兼容性、运行时加载和第三方SDK的深水区处理起来要谨慎。4.1 先理解so库为什么这么大lib目录下的每个ABI文件夹armeabi-v7a、arm64-v8a、x86、x86_64里都装着一整套native库也就是说一套源代码被编译成多种CPU架构的机器码打包时全部塞进了APK。如果你的项目引用了某个体积较大的native库比如FFmpeg、OpenCV、游戏引擎运行时心里要有个概念每多支持一种ABI包体就会加上一套库的体积。4个ABI齐全的话光是支撑同一种能力包体就要乘以4。4.2 用abiFilters切割ABI在保证设备兼容性的前提下可以通过abiFilters指定只保留部分ABI。目前的Android设备市场x86和x86_64架构的设备基本绝迹了armeabiv7a之前的纯armeabi更是可以彻底放弃。对我们产品来说只需要关心arm64-v8a和armeabi-v7a。android { defaultConfig { ndk { abiFilters arm64-v8a, armeabi-v7a } } }顺带一提如果你们的应用minSdkVersion已经比较高了比如API 21以上机型和厂商新机型的覆盖率都在arm64-v8a上可以更激进一点只保留arm64-v8aAPK体积还能再往下走几MB。但这件事取决于你们的用户画像最好用平台给出的设备分布数据说话别拍脑袋。开abiFilters之后还有个连带动作要做检查build.gradle里是否还有其他途径把全量ABI打进来。有些第三方SDK会在自己的Gradle Transform里强行往APK里塞全量soabiFilters管不住它们。我们当时就踩过一个坑明明在abiFilters里只写了arm64-v8a和armeabi-v7a打出来的包在x86目录下还是躺着一个几MB的so排查半天才发现是某个bugly类SDK在manifest里注册了自己的so目录绕过了AGP的ABI过滤最后只能手动在打包脚本里加了一个剔除步骤。4.3 大so库的压缩与运行时解压有些so库属于低频使用场景比如扫码、图像处理、音视频编辑这些能力并不需要在应用启动时就全部加载。如果你把这类so库放在assets目录下或者以压缩形式放在lib目录下在用户第一次使用该功能时再解压到应用私有目录然后加载包体可以省出一大块代价是首次进入某个功能会有短暂的解压耗时。不过这种做法有一个前提你的应用进程需要在部分功能运行时动态加载so库这要求so库不能被系统的nativeLibraryDir机制管束。具体来说需要把so文件以libName.so的形式放在assets里运行时代码里手动把它复制到context.getDir(libs, Context.MODE_PRIVATE)目录然后用System.load(absPath)加载。这种方式跟Google Play的上传审核政策会有一点冲突如果完全走Google Play渠道需要仔细核对政策是否允许国内渠道一般没有这个问题。4.4 大型SDK选型要提前算包体账lib体积膨胀还有一个隐形推手为了某一两个功能引入的重型SDK。举个典型例子某个二维码SDK自带zxing相关的so库但实际上你只用了core模块的扫码能力某个视频处理库可能只调用了一个滤镜功能但它捆绑了整套FFmpeg。在做技术选型时强烈建议把SDK对包体的贡献和SDK对内存的贡献一起加入评估维度而不只是看功能和稳定性。我们当时在lib里发现有一个接近10MB的native库是某个老牌支付SDK带来的但实际上我们的支付场景根本用不到它那部分native能力。后来通过SDK的Gradle配置用一个exclude或者切换SDK模块的方式把它摘掉了包体立刻掉了8MB多。给一个方法论每次引入新SDK之前在Demo工程里打包一次专门对比引入前后的APK体积增量。数字摆出来由产品和技术一起决定要不要为这个功能接受这个体积代价。5. dex与assets的精细化治理抠出每一粒肉做完资源和lib之后包体已经有明显下降但离目标可能还有距离。这个时候要继续往下压就得对代码层、assets层动手了。这里的操作需要一定的分析能力但也是最能体现优化水平的地方。5.1 R8与ProGuard的正确配置在release构建中minifyEnabled true结合R8混淆和裁剪是代码瘦身的基础能力。很多项目虽然开了混淆但规则配置非常宽松导致大量代码没有被裁剪掉。我在看依赖体积问题时发现不少团队把-keep规则的粒度写得太大比如一刀切 keep 整个第三方包的所有类这相当于没有开启任何裁剪。R8的正确用法是只保留被反射调用的类、注解和接口只保留AndroidManifest里引用的四大组件不要为了省事直接-keep class com.某家大SDK.** { *; }而应该精确到某个被反射的类或者资源。另外升级到高版本AGP之后R8已经是默认的代码压缩器替代了ProGuard建议把老的ProGuard规则文件按R8的语法重新梳理一遍。R8对规则的处理比ProGuard更严格很多在ProGuard里能跑通的宽泛规则在R8里会直接报错或者导致裁剪失效。5.2 重复依赖与无意义依赖排查代码层的第二个体积黑洞是重复依赖。怎么发现用Gradle官方自带的dependency分析或者直接在AS里执行./gradlew :app:dependencies --configuration releaseRuntimeClasspath输出结果会比较长我一般会重点看这些信号同一个库出现了多个版本比如guava有12.0和19.0同时存在R8可能会同时保留两份同一个库通过不同坐标重复引用比如Google的gson和kotlinx.serialization同时存在且都没有进行互相排除显式引入了SDK中已传递依赖的库。我们在排查中发现了三个典型问题一个是两个图片库Glide和Fresco同时存在于项目里它们的基本能力高度重叠最终在统一到一个库之后光这一项就瘦了好几MB另一个是同一个网络库的旧版本和新版本同时存在被不同SDK引用导致dex里同时存在两套实现还有一个是某个内部工具库引用了整个Apache Commons包而我们只是用它里面的一个字符串转数组的工具方法。这种问题不是一次性能解决完的建议每两个迭代做一个依赖健康检查把无意义依赖尽早清理掉。5.3 assets目录里的隐藏大户assets目录是另一个容易被忽视的区域。因为开发者习惯把一些无法编译成资源的文件离线数据包、字体、WebView静态资源、预置JSON配置直接塞进assets里。资产不经过任何编译压缩所有放进来的文件基本就是最终的包体大小。我们当时的assets里有三样东西一个4.8MB的城市选择器离线数据包JSON格式、一套2.5MB的PDF模板、一份1.2MB的明文配置文件。这三样东西都是早期业务为了快速实现而做的本地内置但上线后实际使用频率极低。经过产品确认后将城市数据包和PDF模板切换成了运行时按需下载配置合并成600KB的轻量格式assets直接从8.6MB降到1.8MB。assets优化的核心原则是只有那些必须在第一时间离线可用的文件才值得放进APK其他能按需下载的、能删掉历史版本的一律不要打包。一个非常实用的验收标准是站在新用户的角度思考冷启动过程中真正需要读取的文件是哪些其余的就是候选的删除对象。5.4 动态功能模块何时值得上如果你的应用已经做到包体优化的极限但业务模块非常多、更新频率又不一致可以考虑Android App Bundle的动态功能模块Dynamic Feature Module。它允许按功能拆分模块用户只有在触发相关功能时才下载对应模块的代码和资源。这样可以大幅压缩首次安装包体。不过这个方案的成本也很明显需要重构项目模块结构、处理动态模块之间的依赖关系并且要求应用市场支持App Bundle国内渠道的历史兼容要做评估。对于中小团队我的建议是先把本章前面几个常规动作全部做完如果包体还是压不到目标线再评估动态模块方案。6. 后续不反弹的保障把包体积优化固化到日常包体积优化最尴尬的结局是这个版本压下去了下一个版本因为一次依赖升级、一个临时需求、一个拍脑袋的SDK接入又涨回去了。所以我在做完本轮优化之后把更多的精力放在了建立持续保障机制上确保这个问题不会再次失控。6.1 在CI里加一道包体监控门禁首先在CI流水线中加入打包后的体积diff检查。实现起来不算复杂可以在打包完成后用脚本读取新APK的文件构成和大小和历史基线做对比超过阈值就让任务失败。这个阈值可以分两层总量阈值比如整个APK相对上一次发布版本的增量不超过2MB分模块阈值比如某个so库、某个assets文件的大小增量相对上一次不超过500KB。这样做的意义是每次提交代码、每次依赖升级开发者在合入前就能看到我这行代码让包体涨了1.3MB而不是等到提测后才发现包体又失控了。6.2 每次发布前拉取一份包体报告CI门禁只解决合入前拦截但很多体积变化是在长周期的迭代中慢慢积累上去的单次diff可能看不出问题。所以除了门禁我建议在每次构建release包时自动生成一份完整的包体积报告并归档到固定位置。报告里至少包含这些内容整体APK体积和各部分模块占比文件大小Top 20清单dex中最占空间的Top 20类与上一个正式版本的体积对比明细。我实践下来的经验是这些报告不仅对研发有用对产品也有说服力。当你拿着上个版本包体是58MB这版本增长到61MB主要原因是XX需求引入了一张2MB的启动图和一个1.2MB的SDK这样的报告去找产品对齐时对方会更容易接受要么砍功能、要么接受体积上涨的权衡而不是等到用户安装率下跌后再来一起背锅。6.3 定期做体积治理checklist就算门禁和报告都做好了仍然会有一些漏网之鱼因为不是所有的体积变化都能通过单次diff暴露出来。比如一段时间内多个功能各加了几百KB加起来就是好几MB又比如图片资源在多次迭代中被反复复制粘贴改大小慢慢堆出一堆非常接近的变体。所以我会建议团队每两个季度做一次专门的体积治理checklist检查内容大致如下是否还有体积超过1MB的未压缩大图是否能清理掉lint报出的无用资源是否有重复的第三方依赖是否记得在所有release构建里开启shrinkResourcesassets里是否出现了新的可下载内容被内置进包的苗头新增SDK时是否都记录过体积增量。这份checklist也不需要很重放在团队文档里每次大版本规划时过一遍就行。真正目的是让包体积成为团队的技术文化里被持续盯住的一个指标而不只是某一次优化的临时KPI。7. 优化结果复盘与踩坑清单最后分享一下整个优化战役的最终数据和过程中印象深刻的几个教训希望能替你省掉一些弯路。第一版优化完成后APK从88MB降到51MB降低约42%下载传输大小从42MB降到26MB。具体拆解一下资源优化贡献了14.6MBso库精简和剔除贡献了11.2MB代码裁剪和依赖清理贡献了7.4MBassets瘦身贡献了6.8MB资源混淆和arsc优化又挤了2.9MB。从改动量和风险控制角度看资源和assets的优化是最划算的改动低、风险低、收益大。值得单独拎出来说的教训有几条不要迷信开启minify就万事大吉。很多项目的ProGuard/R8规则其实是反裁剪的规则写得太宽反而让R8失去作用。建议定期审视keep规则删掉不再需要的部分。第三方SDK是包体积的最不可控因素。每次集成SDK时要用空工程跑一遍体积对比否则它给你带来的增量远超过你的想象。尤其是在某个SDK里通过manifest引用了一堆so和资源的情况下处理起来最痛。永远留着前一个版本的APK做对照。阶段性的体积优化一定要保存好上一个版本的包文件和体积报告。否则当你压完一轮却说不清这25MB是从哪省出来的后续复盘和向上汇报时都会很被动。资源混淆放最后。它带来的收益在5%到8%之间但引入后可能因为白名单配置不到位导致运行时资源找不到。所以先把常规手段用尽再考虑它性价比才最高。根据我个人实际维护多个应用的经验包体积优化最难得不是某一招技能而是建立每次改动都要关心体积的潜意识。包体积不会自己变小它只会像一个没有盖子的垃圾桶被一点一点扔进越来越多的东西。希望通过这篇文章里的方法你的项目也能把这桶垃圾清空一次并且以后每次有新东西要扔进去之前都先想一想它值得占这几十KB甚至几MB吗
返回列表