随着业务的快速迭代增长,美团 App 里不断引入新的业务逻辑代码、图片资源和第三方 SDK,直接导致 APK 体积不断增长。包体积增长带来的问题越来越多,如 CDN 流量费用增加、用户安装成功率降低,甚至可能会影响用户的留存率。APK 的瘦身已经是不得不考虑的事情。在尝试瘦身的过程中,我们借鉴了很多业界其他公司提供的方案,同时也针对自身特点,发现了一些新的技巧。本文将对其中的一些做详细介绍。
在开始讲瘦身技巧之前,先来讲一下 APK 的构成。
可以用 Zip 工具打开 APK 查看。比如,美团 App 7.8.6 的线上版本的格式是这样的:
可以看到 APK 由以下主要部分组成:
文件 / 目录 | 描述 |
---|---|
lib/ | 存放 so 文件,可能会有 armeabi、armeabi-v7a、arm64-v8a、x86、x86_64、mips,大多数情况下只需要支持 armabi 与 x86 的架构即可,如果非必需,可以考虑拿掉 x86 的部分 |
res/ | 存放编译后的资源文件,例如:drawable、layout 等等 |
assets/ | 应用程序的资源,应用程序可以使用 AssetManager 来检索该资源 |
META-INF/ | 该文件夹一般存放于已经签名的 APK 中,它包含了 APK 中所有文件的签名摘要等信息 |
classes(n).dex | classes 文件是 Java Class,被 DEX 编译后可供 Dalvik/ART 虚拟机所理解的文件格式 |
resources.arsc | 编译后的二进制资源文件 |
AndroidManifest.xml | Android 的清单文件,格式为 AXML,用于描述应用程序的名称、版本、所需权限、注册的四大组件 |
当然还会有一些其它的文件,例如上图中的
、
- org/
、
- src/
等文件或文件夹。这些资源是 Java Resources,感兴趣的可以结合编译工作流中的 流程图 以及 MergeJavaResourcesTransform 的源码 看看被打入 APK 包中的资源都有哪些,这里不做过多介绍。
- push_version
在充分了解了 APK 各个组成部分以及它们的作用后,我们针对自身特点进行了分析和优化。下面将从 Zip 文件格式、classes.dex、资源文件、resources.arsc 等方面来介绍下我们发现的部分优化技巧。
前面介绍了 APK 的文件格式以及主要组成部分,通过
或
- aapt l -v xxx.apk
来查看 APK 文件时会得到以下信息,见下面截图:
- unzip -l xxx.apk
通过上图可以看到 APK 中很多资源是以
来存储的,根据 Zip 的文件格式中对压缩方式的描述 Compression_methods 可以看出这些文件是没有压缩的,那为什么它们没有被压缩呢?从 AAPT 的源码中找到以下描述:
- Stored
- /* these formats are already compressed, or don't compress well */
- static const char * kNoCompressExt[] = {
- ".jpg",
- ".jpeg",
- ".png",
- ".gif",
- ".wav",
- ".mp2",
- ".mp3",
- ".ogg",
- ".aac",
- ".mpg",
- ".mpeg",
- ".mid",
- ".midi",
- ".smf",
- ".jet",
- ".rtttl",
- ".imy",
- ".xmf",
- ".mp4",
- ".m4a",
- ".m4v",
- ".3gp",
- ".3gpp",
- ".3g2",
- ".3gpp2",
- ".amr",
- ".awb",
- ".wma",
- ".wmv",
- ".webm",
- ".mkv"
- };
可以看出 AAPT 在资源处理时对这些文件后缀类型的资源是不做压缩的,那是不是可以修改它们的压缩方式从而达到瘦身的效果呢?
在介绍怎么做之前,先来大概介绍一下 App 的资源是怎么被打进 APK 包里的。Android 构建工具链使用 AAPT 工具来对资源进行处理,来看下图(图片来源于 Build Workflow ):
点击图片查看大图
通过上图可以看到
、
- Manifest
、
- Resources
的资源经过
- Assets
处理后生成
- AAPT
、
- R.java
、
- Proguard Configuration
。其中
- Compiled Resources
大家都比较熟悉,这里就不过多介绍了。我们来重点看看
- R.java
、
- Proguard Configuration
都是做什么的呢?
- Compiled Resources
是 AAPT 工具为
- Proguard Configuration
中声明的四大组件以及布局文件中(
- Manifest
)使用的各种 Views 所生成的 ProGuard 配置,该文件通常存放在
- XML layouts
,下面是项目中该文件的截图,红框标记出来的就是对
- ${project.buildDir}/${AndroidProject.FD_INTERMEDIATES}/proguard-rules/${flavorName}/${buildType}/aapt_rules.txt
、
- AndroidManifest.xml
中相关 Class 的 ProGuard 配置。
- XML Layouts
是一个 Zip 格式的文件,这个文件的路径通常为
- Compiled Resources
。 通过下面经过 Zip 解压后的截图,可以看出这个文件包含了
- ${project.buildDir}/${AndroidProject.FD_INTERMEDIATES}/res/resources-${flavorName}-${buildType}-stripped.ap_
、
- res
和
- AndroidManifest.xml
的文件或文件夹。结合 Build Workflow 中的描述,可以看出这个文件(
- resources.arsc
)会被
- resources-${flavorName}-${buildType}-stripped.ap_
打包到 APK 包中,它其实就是 APK 的 "资源包"(
- apkbuilder
、
- res
和
- AndroidManifest.xml
)。
- resources.arsc
我们就是通过这个文件来修改不同后缀文件资源的压缩方式来达到瘦身效果的,而在后面 "resources.arsc 的优化" 一节中也是操作的这个文件。
笔者在自己的项目中是通过在
Task(感兴趣的同学可以查看 源码 )之前进行这个操作的。
- package${flavorName}
下面是部分代码片段:
- appPlugin.variantManager.variantDataList.each { variantData ->
- variantData.outputs.each {
- def sourceApFile = it.packageAndroidArtifactTask.getResourceFile();
- def destApFile = new File("${sourceApFile.name}.temp", sourceApFile.parentFile);
- it.packageAndroidArtifactTask.doFirst {
- byte[] buf = new byte[1024 * 8];
- ZipInputStream zin = new ZipInputStream(new FileInputStream(sourceApFile));
- ZipOutputStream out = new ZipOutputStream(new FileOutputStream(destApFile));
- ZipEntry entry = zin.getNextEntry();
- while (entry != null) {
- String name = entry.getName();
- // Add ZIP entry to output stream.
- ZipEntry zipEntry = new ZipEntry(name);
- if (ZipEntry.STORED == entry.getMethod() && !okayToCompress(entry.getName())) {
- zipEntry.setMethod(ZipEntry.STORED)
- zipEntry.setSize(entry.getSize())
- zipEntry.setCompressedSize(entry.getCompressedSize())
- zipEntry.setCrc(entry.getCrc())
- } else {
- zipEntry.setMethod(ZipEntry.DEFLATED)
- ...
- }
- ...
- out.putNextEntry(zipEntry);
- out.closeEntry();
- entry = zin.getNextEntry();
- }
- // Close the streams
- zin.close();
- out.close();
- sourceApFile.delete();
- destApFile.renameTo(sourceApFile);
- }
- }
- }
当然也可以在其它构建步骤中采用更高压缩率的方式来达到瘦身效果,例如 采用 7Zip 压缩 等等。
本技巧的使用需要注意以下问题:
上开启
- Android 6.0
的话,.so 文件也不能被压缩,
- android:extractNativeLibs="false"
的使用姿势看这里:App Manifest --- application。
- android:extractNativeLibs
如何优化 classes.dex 的大小呢?大体有如下套路:
针对第一种套路,因各个公司的项目的差异,共性的东西较少,需要 case by case 的分析,这里不做过多的介绍。
可以通过开启 ProGuard 来实现代码压缩,可以在 build.gradle 文件相应的构建类型中添加
。
- minifyEnabled true
请注意,代码压缩会拖慢构建速度,因此应该尽可能避免在调试构建中使用。不过一定要为用于测试的最终 APK 启用代码压缩,如果不能充分地自定义要保留的代码,可能会引入错误。
例如,下面这段来自 build.gradle 文件的代码用于为发布构建启用代码压缩:
- android {
- buildTypes {
- release {
- minifyEnabled true
- proguardFiles getDefaultProguardFile('proguard-android.txt'),
- 'proguard-rules.pro'
- }
- }
- ...
- }
除了
属性外,还有用于定义 ProGuard 规则的 proguardFiles 属性:
- minifyEnabled
是从 Android SDK
- getDefaultProguardFile('proguard-android.txt')
文件夹获取默认 ProGuard 设置。
- tools/proguard/
文件用于添加自定义 ProGuard 规则。默认情况下,该文件位于模块根目录(build.gradle 文件旁)。
- proguard-rules.pro
提示:要想做进一步的代码压缩,可尝试使用位于同一位置的
文件。它包括相同的 ProGuard 规则,但还包括其他在字节码一级(方法内和方法间)执行分析的优化,以进一步减小 APK 大小和帮助提高其运行速度。
- proguard-android-optimize.txt
在 Gradle Plugin 2.2.0 及以上版本 ProGuard 的配置文件会自动解压缩到
目录下,
- ${rootProject.buildDir}/${AndroidProject.FD_INTERMEDIATES}/proguard-files/
会从这个目录来获取 ProGuard 配置。
- proguardFiles
每次执行完 ProGuard 之后,ProGuard 都会在
生成以下文件:
- ${project.buildDir}/outputs/mapping/${flavorDir}/
文件名 | 描述 |
---|---|
dump.txt | APK 中所有类文件的内部结构 |
mapping.txt | 提供原始与混淆过的类、方法和字段名称之间的转换,可以通过 来解析 |
seeds.txt | 列出未进行混淆的类和成员 |
usage.txt | 列出从 APK 移除的代码 |
可以通过在
文件中看到哪些代码被删除了,如下图中所示
- usage.txt
已经被删除了:
- android.support.multidex.MultiDex
除了对项目代码优化和开启代码压缩之外,笔者在 《美团 Android DEX 自动拆包及动态加载简介》 这篇文章中提到了通过内联 R Field 来解决 R Field 过多导致 MultiDex 65536 的问题,而这一步骤对代码瘦身能够起到明显的效果。下面是笔者通过字节码工具在构建流程中内联 R Field 的代码片段(字节码的修改可以使用 Javassist 或者 ASM ,该步骤笔者采用的是 Javassist)。
- ctBehaviors.each { CtBehavior ctBehavior ->
- if (!ctBehavior.isEmpty()) {
- try {
- ctBehavior.instrument(new ExprEditor() {
- @Override
- public void edit(FieldAccess f) {
- try {
- def fieldClassName = JavassistUtils.getClassNameFromCtClass(f.getCtClass())
- if (shouldInlineRField(className, fieldClassName) && f.isReader()) {
- def temp = fieldClassName.substring(fieldClassName.indexOf(ANDROID_RESOURCE_R_FLAG) + ANDROID_RESOURCE_R_FLAG.length())
- def fieldName = f.fieldName
- def key = "${temp}.${fieldName}"
- if (resourceSymbols.containsKey(key)) {
- Object obj = resourceSymbols.get(key)
- try {
- if (obj instanceof Integer) {
- int value = ((Integer) obj).intValue()
- f.replace("\$_=${value};")
- } else if (obj instanceof Integer[]) {
- def obj2 = ((Integer[]) obj)
- StringBuilder stringBuilder = new StringBuilder()
- for (int index = 0; index < obj2.length; ++index) {
- stringBuilder.append(obj2[index].intValue())
- if (index != obj2.length - 1) {
- stringBuilder.append(",")
- }
- }
- f.replace("\$_ = new int[]{${stringBuilder.toString()}};")
- } else {
- throw new GradleException("Unknown ResourceSymbols Type!")
- }
- } catch (NotFoundException e) {
- throw new GradleException(e.message)
- } catch (CannotCompileException e) {
- throw new GradleException(e.message)
- }
- } else {
- throw new GradleException("******** InlineRFieldTask unprocessed ${className}, ${fieldClassName}, ${f.fieldName}, ${key}")
- }
- }
- } catch (NotFoundException e) {
- }
- }
- })
- } catch (CannotCompileException e) {
- }
- }
- }
针对代码的瘦身还有很多优化的技巧,例如:
这些优化技巧就不展开介绍了。
为了支持 Android 设备 DPI 的多样化([l|m|tv|h|x|xx|xxx]dpi)以及用户对高质量 UI 的期待,美团 App 中使用了大量的图片,在 Android 下支持很多格式的图片,例如: PNG 、 JPG 、 WebP ,那我们该怎么选择不同类型的图片格式呢? 在
中提到了针对图片格式的选择,来看下图(图片来源于 Image compression for Android developers ):
- Google I/O 2016
通过上图可以看出一个大概图片格式选择的方法。如果能用
来表示的话优先使用 VectorDrawable,如果支持
- VectorDrawable
则优先用 WebP,而
- WebP
主要用在展示透明或者简单的图片,而其它场景可以使用
- PNG
格式。针对每种图片格式也有各类的优化手段和优化工具。
- JPG
可以使用 矢量图形 来创建独立于分辨率的图标和其他可伸缩图片。使用矢量图片能够有效的减少 App 中图片所占用的大小,矢量图形在 Android 中表示为 VectorDrawable 对象。 使用 VectorDrawable 对象,100 字节的文件可以生成屏幕大小的清晰图像,但系统渲染每个 VectorDrawable 对象需要大量的时间,较大的图像需要更长的时间才能出现在屏幕上。 因此只有在显示小图像时才考虑使用矢量图形。有关使用 VectorDrawable 的更多信息,请参阅 Working with Drawables 。
如果 App 的
高于 14(
- minSdkVersion
)的话,可以选用 WebP 格式,因为 WebP 在同画质下体积更小(WebP 支持透明度,压缩比比 JPEG 更高但显示效果却不输于 JPEG,官方评测 quality 参数等于 75 均衡最佳), 可以通过 PNG 到 WebP 转换工具 来进行转换。当然 Android 从 4.0 才开始 WebP 的原生支持,但是不支持包含透明度,直到
- Android 4.0+
才支持显示含透明度的 WebP,在笔者使用中是判断当前 App 的
- Android 4.2.1+
以及图片文件的类型(是否为透明)来选用是否适用 WebP。见下面的代码片段:
- minSdkVersion
- boolean isPNGWebpConvertSupported() {
- if (!isWebpConvertEnable()) {
- return false
- }
- // Android 4.0+
- return GradleUtils.getAndroidExtension(project).defaultConfig.minSdkVersion.apiLevel >= 14
- // 4.0
- }
- boolean isTransparencyPNGWebpConvertSupported() {
- if (!isWebpConvertEnable()) {
- return false
- }
- // Lossless, Transparency, Android 4.2.1+
- return GradleUtils.getAndroidExtension(project).defaultConfig.minSdkVersion.apiLevel >= 18
- // 4.3
- }
- def convert() {
- String resPath = "${project.buildDir}/${AndroidProject.FD_INTERMEDIATES}/res/merged/${variant.dirName}"
- def resDir = new File("${resPath}")
- resDir.eachDirMatch(~/drawable[a-z0-9-]*/) { dir ->
- FileTree tree = project.fileTree(dir: dir)
- tree.filter { File file ->
- return (isJPGWebpConvertSupported() && (file.name.endsWith(SdkConstants.DOT_JPG) || file.name.endsWith(SdkConstants.DOT_JPEG))) || (isPNGWebpConvertSupported() && file.name.endsWith(SdkConstants.DOT_PNG) && !file.name.endsWith(SdkConstants.DOT_9PNG))
- }.each { File file ->
- def shouldConvert = true
- if (file.name.endsWith(SdkConstants.DOT_PNG)) {
- if (!isTransparencyPNGWebpConvertSupported()) {
- shouldConvert = !Imaging.getImageInfo(file).isTransparent()
- }
- }
- if (shouldConvert) {
- WebpUtils.encode(project, webpFactorQuality, file.absolutePath, webp)
- }
- }
- }
- }
可以使用 pngcrush 、 pngquant 或 zopflipng 等压缩工具来减少 PNG 文件大小,而不会丢失图像质量。所有这些工具都可以减少 PNG 文件大小,同时保持图像质量。
pngcrush 工具特别有效:此工具在 PNG 过滤器和 zlib(Deflate)参数上迭代,使用过滤器和参数的每个组合来压缩图像。然后选择产生最小压缩输出的配置。
对于 JPEG 文件,你可以使用 packJPG 或 guetzli 等工具将 JPEG 文件压缩的更小,这些工具能够在保持图片质量不变的情况下,把图片文件压缩的更小。 guetzli 工具更是能够在图片质量不变的情况下,将文件大小降低 35%。
在 Android 构建流程中 AAPT 会使用内置的压缩算法来优化
目录下的 PNG 图片,但也可能会导致本来已经优化过的图片体积变大,可以通过在
- res/drawable/
中设置
- build.gradle
来禁止 AAPT 来优化 PNG 图片。
- cruncherEnabled
- aaptOptions {
- cruncherEnabled = false
- }
Android 的编译工具链中提供了一款资源压缩的工具,可以通过该工具来压缩资源,如果要启用资源压缩,可以在 build.gradle 文件中将
。例如:
- shrinkResources true
- android {
- ...
- buildTypes {
- release {
- shrinkResources true
- minifyEnabled true
- proguardFiles getDefaultProguardFile('proguard-android.txt'),
- 'proguard-rules.pro'
- }
- }
- }
需要注意的是目前资源压缩器目前不会移除 values / 文件夹中定义的资源(例如字符串、尺寸、样式和颜色),有关详情,请参阅 问题 70869 。
Android 构建工具是通过 ResourceUsageAnalyzer 来检查哪些资源是无用的,当检查到无用的资源时会把该资源替换成预定义的版本。详看下面代码片段(摘自
):
- com.android.build.gradle.tasks.ResourceUsageAnalyzer
- public class ResourceUsageAnalyzer {...
- /**
- * Whether we should create small/empty dummy files instead of actually
- * removing file resources. This is to work around crashes on some devices
- * where the device is traversing resources. See http://b.android.com/79325 for more.
- */
- public static final boolean REPLACE_DELETED_WITH_EMPTY = true;
- // A 1x1 pixel PNG of type BufferedImage.TYPE_BYTE_GRAY
- public static final byte[] TINY_PNG = new byte[] { (byte) - 119,
- (byte) 80,
- (byte) 78,
- (byte) 71,
- (byte) 13,
- (byte) 10,
- (byte) 26,
- (byte) 10,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 13,
- (byte) 73,
- (byte) 72,
- (byte) 68,
- (byte) 82,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 1,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 1,
- (byte) 8,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 58,
- (byte) 126,
- (byte) - 101,
- (byte) 85,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 10,
- (byte) 73,
- (byte) 68,
- (byte) 65,
- (byte) 84,
- (byte) 120,
- (byte) - 38,
- (byte) 99,
- (byte) 96,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 2,
- (byte) 0,
- (byte) 1,
- (byte) - 27,
- (byte) 39,
- (byte) - 34,
- (byte) - 4,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 73,
- (byte) 69,
- (byte) 78,
- (byte) 68,
- (byte) - 82,
- (byte) 66,
- (byte) 96,
- (byte) - 126
- };
- public static final long TINY_PNG_CRC = 0x88b2a3b0L;
- // A 3x3 pixel PNG of type BufferedImage.TYPE_INT_ARGB with 9-patch markers
- public static final byte[] TINY_9PNG = new byte[] { (byte) - 119,
- (byte) 80,
- (byte) 78,
- (byte) 71,
- (byte) 13,
- (byte) 10,
- (byte) 26,
- (byte) 10,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 13,
- (byte) 73,
- (byte) 72,
- (byte) 68,
- (byte) 82,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 3,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 3,
- (byte) 8,
- (byte) 6,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 86,
- (byte) 40,
- (byte) - 75,
- (byte) - 65,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 20,
- (byte) 73,
- (byte) 68,
- (byte) 65,
- (byte) 84,
- (byte) 120,
- (byte) - 38,
- (byte) 99,
- (byte) 96,
- (byte) - 128,
- (byte) - 128,
- (byte) - 1,
- (byte) 12,
- (byte) 48,
- (byte) 6,
- (byte) 8,
- (byte) - 96,
- (byte) 8,
- (byte) - 128,
- (byte) 8,
- (byte) 0,
- (byte) - 107,
- (byte) - 111,
- (byte) 7,
- (byte) - 7,
- (byte) - 64,
- (byte) - 82,
- (byte) 8,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 73,
- (byte) 69,
- (byte) 78,
- (byte) 68,
- (byte) - 82,
- (byte) 66,
- (byte) 96,
- (byte) - 126
- };
- public static final long TINY_9PNG_CRC = 0x1148f987L;
- // The XML document <x/> as binary-packed with AAPT
- public static final byte[] TINY_XML = new byte[] { (byte) 3,
- (byte) 0,
- (byte) 8,
- (byte) 0,
- (byte) 104,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 1,
- (byte) 0,
- (byte) 28,
- (byte) 0,
- (byte) 36,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 1,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 1,
- (byte) 0,
- (byte) 0,
- (byte) 32,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 1,
- (byte) 1,
- (byte) 120,
- (byte) 0,
- (byte) 2,
- (byte) 1,
- (byte) 16,
- (byte) 0,
- (byte) 36,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 1,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) - 1,
- (byte) - 1,
- (byte) - 1,
- (byte) - 1,
- (byte) - 1,
- (byte) - 1,
- (byte) - 1,
- (byte) - 1,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 20,
- (byte) 0,
- (byte) 20,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 3,
- (byte) 1,
- (byte) 16,
- (byte) 0,
- (byte) 24,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 1,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) - 1,
- (byte) - 1,
- (byte) - 1,
- (byte) - 1,
- (byte) - 1,
- (byte) - 1,
- (byte) - 1,
- (byte) - 1,
- (byte) 0,
- (byte) 0,
- (byte) 0,
- (byte) 0
- };
- public static final long TINY_XML_CRC = 0xd7e65643L;...
- }
上面截图中 3 个 byte 数组的定义就是资源压缩工具为无用资源提供的预定义版本,可以看出对
提供了
- .png
, 对
- TINY_PNG
提供了
- .9.png
以及对
- TINY_9PNG
提供了
- .xml
的预定义版本。
- TINY_XML
资源压缩工具的详细使用可以参考 Shrink Your Code and Resources 。资源压缩工具默认是采用安全压缩模式来运行,可以通过 开启严格压缩模式 来达到更好的瘦身效果。
如果想知道哪些资源是无用的,可以通过资源压缩工具的输出日志文件
来查看。如下图所示
- ${project.buildDir}/outputs/mapping/release/resources.txt
就是无用的,然后被预定义的版本
- res/layout/abc_activity_chooser_viewer.xml
所替换:
- TINY_XML
资源压缩工具只是把无用资源替换成预定义较小的版本,那我们如何删除这些无用资源呢?通常的做法是结合资源压缩工具的输出日志,找到这些资源并把它们进行删除。但在笔者的项目中很多无用资源是被其它组件或第三方 SDK 所引入的,如果采用这种优化方式会带来这些 SDK 后期维护成本的增加,针对这种情况笔者是通过采用在 resources.arsc 中做优化来解决的,详情看下面 "resources.arsc 的优化" 一节的介绍。
根据 App 自身支持的语言版本选用合适的语言资源,例如使用了 AppCompat,如果不做任何配置的话,最终 APK 包中会包含 AppCompat 中消息的所有已翻译语言字符串,无论应用的其余部分是否翻译为同一语言,可以通过
来配置使用哪些语言,从而让构建工具移除指定语言之外的所有资源。下图是具体的配置示例:
- resConfig
- android {
- ...
- defaultConfig {
- ...
- resConfigs "zh", "zh-rCN"
- }
- ...
- }
针对为不同 DPI 所提供的图片也可以采用相同的策略,需要针对自身的目标用户和目标设备做一定的选择,可以参考 Support Only Specific Densities 来操作。有关屏幕密度的详细信息,请参阅 Screen Sizes and Densities 。
对
文件也可以采用类似的策略,比如笔者的项目中只保留了
- .so
版本的
- armeabi
文件。
- .so
针对
,笔者尝试过的优化手段如下:
- resources.arsc
优化掉的资源进行处理。
- shrinkResources
下面将分别对这些优化手段进行展开介绍。
在笔者另一篇 《美团 Android 资源混淆保护实践》 文章中介绍了采用对资源混淆的方式来保护资源的安全,同时也提到了这种方式有显著的瘦身效果。笔者当时是采用修改 AAPT 的相关源码的方式,这种方式的痛点是每次升级
都要修改一次 AAPT 源码,维护性较差。目前笔者采用了微信开源的资源混淆库 AndResGuard ,具体的原理和使用帮助可以参考 安装包立减 1M-- 微信 Android 资源混淆打包工具 。
- Build Tools
在上一节中介绍了可以通过
来开启资源压缩,资源压缩工具会把无用的资源替换成预定义的版本而不是移除,如果采用人工移除的方式会带来后期的维护成本,这里笔者采用了一种比较取巧的方式,在 Android 构建工具执行
- shrinkResources true
Task 之前通过修改
- package${flavorName}
来实现自动去除无用资源。
- Compiled Resources
具体流程如下:
的简称)中被替换的预定义版本的资源名称,通过查看资源包(Zip 格式)中每个
- Compiled Resources
的
- ZipEntry
来寻找被替换的预定义资源,预定义资源的
- CRC-32 checksum
定义在 ResourceUsageAnalyzer,下面是它们的定义。
- CRC-32
- // A 1x1 pixel PNG of type BufferedImage.TYPE_BYTE_GRAY
- public static final long TINY_PNG_CRC = 0x88b2a3b0L;
- // A 3x3 pixel PNG of type BufferedImage.TYPE_INT_ARGB with 9-patch markers
- public static final long TINY_9PNG_CRC = 0x1148f987L;
- // The XML document <x/> as binary-packed with AAPT
- public static final long TINY_XML_CRC = 0xd7e65643L;
中对应的定义移除;
- resources.arsc
目前美团 App 是由各个业务团队共同开发完成,为了方便各业务团队的独立开发,美团 App 进行了平台化改造。改造时存在很多资源文件(如:drawable、layout 等)被不同的业务团队都拷贝到自己的 Library 下,同时为了避免引发资源覆盖的问题,每个业务团队都会为自己的资源文件名添加前缀。这样就导致了这些资源文件虽然内容相同,但因为名称的不同而不能被覆盖,最终都会被集成到 APK 包中,针对这种问题笔者采用了和前面 "无用资源优化" 一节中描述类似的策略。
具体步骤如下:
的
- ZipEntry
来筛选出重复的资源;
- CRC-32 checksum
,把这些重复的资源都
- resources.arsc
到同一个文件上;
- 重定向
代码片段:
- variantData.outputs.each {
- def apFile = it.packageAndroidArtifactTask.getResourceFile();
- it.packageAndroidArtifactTask.doFirst {
- def arscFile = new File(apFile.parentFile, "resources.arsc");
- JarUtil.extractZipEntry(apFile, "resources.arsc", arscFile);
- def HashMap < String,
- ArrayList < DuplicatedEntry >> duplicatedResources = findDuplicatedResources(apFile);
- removeZipEntry(apFile, "resources.arsc");
- if (arscFile.exists()) {
- FileInputStream arscStream = null;
- ResourceFile resourceFile = null;
- try {
- arscStream = new FileInputStream(arscFile);
- resourceFile = ResourceFile.fromInputStream(arscStream);
- List < Chunk > chunks = resourceFile.getChunks();
- HashMap < String,
- String > toBeReplacedResourceMap = new HashMap < String,
- String > (1024);
- // 处理arsc并删除重复资源
- Iterator < Map.Entry < String,
- ArrayList < DuplicatedEntry >>> iterator = duplicatedResources.entrySet().iterator();
- while (iterator.hasNext()) {
- Map.Entry < String,
- ArrayList < DuplicatedEntry >> duplicatedEntry = iterator.next();
- // 保留第一个资源,其他资源删除掉
- for (def index = 1; index < duplicatedEntry.value.size(); ++index) {
- removeZipEntry(apFile, duplicatedEntry.value.get(index).name);
- toBeReplacedResourceMap.put(duplicatedEntry.value.get(index).name, duplicatedEntry.value.get(0).name);
- }
- }
- for (def index = 0; index < chunks.size(); ++index) {
- Chunk chunk = chunks.get(index);
- if (chunk instanceof ResourceTableChunk) {
- ResourceTableChunk resourceTableChunk = (ResourceTableChunk) chunk;
- StringPoolChunk stringPoolChunk = resourceTableChunk.getStringPool();
- for (def i = 0; i < stringPoolChunk.stringCount; ++i) {
- def key = stringPoolChunk.getString(i);
- if (toBeReplacedResourceMap.containsKey(key)) {
- stringPoolChunk.setString(i, toBeReplacedResourceMap.get(key));
- }
- }
- }
- }
- } catch(IOException ignore) {} catch(FileNotFoundException ignore) {} finally {
- if (arscStream != null) {
- IOUtils.closeQuietly(arscStream);
- }
- arscFile.delete();
- arscFile << resourceFile.toByteArray();
- addZipEntry(apFile, arscFile);
- }
- }
- }
- }
通过这种方式可以有效减少重复资源对包体大小的影响,同时这种操作方式对各业务团队透明,也不会增加协调相同资源如何被不同业务团队复用的成本。
上述就是我们目前在 APK 瘦身方面的做的一些尝试和积累,可以根据自身情况取舍使用。当然我们还可以采取一些按需加载的策略来减少安装包的体积。最后提一点,砍掉不必要的功能才是安装包瘦身的超级大招。一个好的 App 的标准有很多方面,但提供尽可能小的安装包是其中一个重要的方面,这也是对我们 Android 开发者人员自身的提出的基本要求,要时刻保持良好的编程习惯和对包体积敏锐的嗅觉。
来源: http://www.tuicool.com/articles/r6bUjaV