Android App包瘦身优化实践

随着业务的快速迭代增长,美团App里不断引入新的业务逻辑代码、图片资源和第三方SDK,直接导致APK体积不断增长。包体积增长带来的问题越来越多,如CDN流量费用增加、用户安装成功率降低,甚至可能会影响用户的留存率。APK的瘦身已经是不得不考虑的事情。在尝试瘦身的过程中,我们借鉴了很多业界其他公司提供的方案,同时也针对自身特点,发现了一些新的技巧。本文将对其中的一些做详细介绍。

在开始讲瘦身技巧之前,先来讲一下APK的构成。

APK的构成


可以用Zip工具打开APK查看。比如,美团App 7.8.6的线上版本的格式是这样的:

apk_format.png

可以看到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).dexclasses文件是Java Class,被DEX编译后可供Dalvik/ART虚拟机所理解的文件格式
resources.arsc编译后的二进制资源文件
AndroidManifest.xmlAndroid的清单文件,格式为AXML,用于描述应用程序的名称、版本、所需权限、注册的四大组件

当然还会有一些其它的文件,例如上图中的org/、src/、push_version等文件或文件夹。这些资源是Java Resources,感兴趣的可以结合编译工作流中的流程图以及MergeJavaResourcesTransform的源码看看被打入APK包中的资源都有哪些,这里不做过多介绍。

在充分了解了APK各个组成部分以及它们的作用后,我们针对自身特点进行了分析和优化。下面将从Zip文件格式、classes.dex、资源文件、resources.arsc等方面来介绍下我们发现的部分优化技巧。

Zip格式优化


前面介绍了APK的文件格式以及主要组成部分,通过aapt l -v xxx.apk或unzip -l xxx.apk来查看APK文件时会得到以下信息,见下面截图:

apk_aapt_dump.png

通过上图可以看到APK中很多资源是以Stored来存储的,根据Zip的文件格式中对压缩方式的描述Compression_methods可以看出这些文件是没有压缩的,那为什么它们没有被压缩呢?从AAPT的源码中找到以下描述:

/* 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):

android_build_process.png 点击图片查看大图

通过上图可以看到Manifest、Resources、Assets的资源经过AAPT处理后生成R.java、Proguard Configuration、Compiled Resources。其中R.java大家都比较熟悉,这里就不过多介绍了。我们来重点看看Proguard Configuration、Compiled Resources都是做什么的呢?

  • Proguard Configuration是AAPT工具为Manifest中声明的四大组件以及布局文件中(XML layouts)使用的各种Views所生成的ProGuard配置,该文件通常存放在${project.buildDir}/${AndroidProject.FD_INTERMEDIATES}/proguard-rules/${flavorName}/${buildType}/aapt_rules.txt,下面是项目中该文件的截图,红框标记出来的就是对AndroidManifest.xml、XML Layouts中相关Class的ProGuard配置。
aapt_proguard_rules.png
  • Compiled Resources是一个Zip格式的文件,这个文件的路径通常为${project.buildDir}/${AndroidProject.FD_INTERMEDIATES}/res/resources-${flavorName}-${buildType}-stripped.ap_。 通过下面经过Zip解压后的截图,可以看出这个文件包含了res、AndroidManifest.xml和resources.arsc的文件或文件夹。结合Build Workflow中的描述,可以看出这个文件(resources-${flavorName}-${buildType}-stripped.ap_)会被apkbuilder打包到APK包中,它其实就是APK的“资源包”(res、AndroidManifest.xml和resources.arsc)。
ap_file_content.png

我们就是通过这个文件来修改不同后缀文件资源的压缩方式来达到瘦身效果的,而在后面“resources.arsc的优化”一节中也是操作的这个文件。

笔者在自己的项目中是通过在package${flavorName} Task(感兴趣的同学可以查看源码)之前进行这个操作的。

下面是部分代码片段:

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压缩等等。

本技巧的使用需要注意以下问题:

  • 如果音视频资源被压缩存放在APK中的话,在使用一些音频、视频API时尤其要注意,需要做好充分的测试。
  • resources.arsc文件最好不要压缩存储,如果压缩会影响一定的性能(尤其是冷启动时间)。
  • 如果想在Android 6.0上开启android:extractNativeLibs=”false”的话,.so 文件也不能被压缩,android:extractNativeLibs的使用姿势看这里:App Manifest --- application。

classes.dex的优化


如何优化classes.dex的大小呢?大体有如下套路:

  • 时刻保持良好的编程习惯和对包体积敏锐的嗅觉,去除重复或者不用的代码,慎用第三方库,选用体积小的第三方SDK等等。
  • 开启ProGuard来进行代码压缩,通过使用ProGuard来对代码进行混淆、优化、压缩等工作。

针对第一种套路,因各个公司的项目的差异,共性的东西较少,需要case by case的分析,这里不做过多的介绍。

压缩代码

可以通过开启ProGuard来实现代码压缩,可以在build.gradle文件相应的构建类型中添加minifyEnabled true。

请注意,代码压缩会拖慢构建速度,因此应该尽可能避免在调试构建中使用。不过一定要为用于测试的最终APK启用代码压缩,如果不能充分地自定义要保留的代码,可能会引入错误。

例如,下面这段来自build.gradle文件的代码用于为发布构建启用代码压缩:

android {
    buildTypes {
        release {
            minifyEnabled true
            proguardFiles getDefaultProguardFile(‘proguard-android.txt'),
                    'proguard-rules.pro'
        }
    }
    ...
}

除了minifyEnabled属性外,还有用于定义ProGuard规则的proguardFiles属性:

  • getDefaultProguardFile(‘proguard-android.txt')是从Android SDKtools/proguard/文件夹获取默认ProGuard设置。
  • proguard-rules.pro文件用于添加自定义ProGuard规则。默认情况下,该文件位于模块根目录(build.gradle文件旁)。

提示:要想做进一步的代码压缩,可尝试使用位于同一位置的proguard-android-optimize.txt文件。它包括相同的ProGuard规则,但还包括其他在字节码一级(方法内和方法间)执行分析的优化,以进一步减小APK大小和帮助提高其运行速度。

在Gradle Plugin 2.2.0及以上版本ProGuard的配置文件会自动解压缩到${rootProject.buildDir}/${AndroidProject.FD_INTERMEDIATES}/proguard-files/目录下,proguardFiles会从这个目录来获取ProGuard配置。

每次执行完ProGuard之后,ProGuard都会在${project.buildDir}/outputs/mapping/${flavorDir}/生成以下文件:

文件名描述
dump.txtAPK中所有类文件的内部结构
mapping.txt提供原始与混淆过的类、方法和字段名称之间的转换,可以通过proguard.obfuscate.MappingReader来解析
seeds.txt列出未进行混淆的类和成员
usage.txt列出从APK移除的代码

可以通过在usage.txt文件中看到哪些代码被删除了,如下图中所示android.support.multidex.MultiDex已经被删除了:

proguard_usage.png

R Field的优化

除了对项目代码优化和开启代码压缩之外,笔者在《美团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) {
        }
    }
}

其它优化手段

针对代码的瘦身还有很多优化的技巧,例如:

  • 减少ENUM的使用(详情可以参考:Remove Enumerations),每减少一个ENUM可以减少大约1.0到1.4 KB的大小;
  • 通过pmd cpd来检查重复的代码从而进行代码优化;
  • 移除掉所有无用或者功能重复的依赖库。

这些优化技巧就不展开介绍了。

资源的优化


图片优化

为了支持Android设备DPI的多样化([l|m|tv|h|x|xx|xxx]dpi)以及用户对高质量UI的期待,美团App中使用了大量的图片,在Android下支持很多格式的图片,例如:PNG、JPG 、WebP,那我们该怎么选择不同类型的图片格式呢? 在Google I/O 2016中提到了针对图片格式的选择,来看下图(图片来源于Image compression for Android developers):

android_image_format_choosen.png

通过上图可以看出一个大概图片格式选择的方法。如果能用VectorDrawable来表示的话优先使用VectorDrawable,如果支持WebP则优先用WebP,而PNG主要用在展示透明或者简单的图片,而其它场景可以使用JPG格式。针对每种图片格式也有各类的优化手段和优化工具。

使用矢量图片

可以使用矢量图形来创建独立于分辨率的图标和其他可伸缩图片。使用矢量图片能够有效的减少App中图片所占用的大小,矢量图形在Android中表示为VectorDrawable对象。 使用VectorDrawable对象,100字节的文件可以生成屏幕大小的清晰图像,但系统渲染每个VectorDrawable对象需要大量的时间,较大的图像需要更长的时间才能出现在屏幕上。 因此只有在显示小图像时才考虑使用矢量图形。有关使用VectorDrawable的更多信息,请参阅 Working with Drawables。

使用WebP

如果App的minSdkVersion高于14(Android 4.0+)的话,可以选用WebP格式,因为WebP在同画质下体积更小(WebP支持透明度,压缩比比JPEG更高但显示效果却不输于JPEG,官方评测quality参数等于75均衡最佳), 可以通过PNG到WebP转换工具来进行转换。当然Android从4.0才开始WebP的原生支持,但是不支持包含透明度,直到Android 4.2.1+才支持显示含透明度的WebP,在笔者使用中是判断当前App的minSdkVersion以及图片文件的类型(是否为透明)来选用是否适用WebP。见下面的代码片段:

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会使用内置的压缩算法来优化res/drawable/目录下的PNG图片,但也可能会导致本来已经优化过的图片体积变大,可以通过在build.gradle中设置cruncherEnabled来禁止AAPT来优化PNG图片。

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所替换:

shrink_resources_txt.png

资源压缩工具只是把无用资源替换成预定义较小的版本,那我们如何删除这些无用资源呢?通常的做法是结合资源压缩工具的输出日志,找到这些资源并把它们进行删除。但在笔者的项目中很多无用资源是被其它组件或第三方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的优化


针对resources.arsc,笔者尝试过的优化手段如下:

  • 开启资源混淆;
  • 对重复的资源进行优化;
  • 对被shrinkResources优化掉的资源进行处理。

下面将分别对这些优化手段进行展开介绍。

资源混淆

在笔者另一篇《美团Android资源混淆保护实践》文章中介绍了采用对资源混淆的方式来保护资源的安全,同时也提到了这种方式有显著的瘦身效果。笔者当时是采用修改AAPT的相关源码的方式,这种方式的痛点是每次升级Build Tools都要修改一次AAPT源码,维护性较差。目前笔者采用了微信开源的资源混淆库AndResGuard,具体的原理和使用帮助可以参考安装包立减1M--微信Android资源混淆打包工具。

无用资源优化

在上一节中介绍了可以通过shrinkResources true来开启资源压缩,资源压缩工具会把无用的资源替换成预定义的版本而不是移除,如果采用人工移除的方式会带来后期的维护成本,这里笔者采用了一种比较取巧的方式,在Android构建工具执行package${flavorName}Task之前通过修改Compiled Resources来实现自动去除无用资源。

具体流程如下:

  • 收集资源包(Compiled Resources的简称)中被替换的预定义版本的资源名称,通过查看资源包(Zip格式)中每个ZipEntry的CRC-32 checksum来寻找被替换的预定义资源,预定义资源的CRC-32定义在ResourceUsageAnalyzer,下面是它们的定义。
      // 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;
  • 通过android-chunk-utils把resources.arsc中对应的定义移除;
  • 删除资源包中对应的资源文件。

重复资源优化

目前美团App是由各个业务团队共同开发完成,为了方便各业务团队的独立开发,美团App进行了平台化改造。改造时存在很多资源文件(如:drawable、layout等)被不同的业务团队都拷贝到自己的Library下,同时为了避免引发资源覆盖的问题,每个业务团队都会为自己的资源文件名添加前缀。这样就导致了这些资源文件虽然内容相同,但因为名称的不同而不能被覆盖,最终都会被集成到APK包中,针对这种问题笔者采用了和前面“无用资源优化”一节中描述类似的策略。

具体步骤如下:

  • 通过资源包中的每个ZipEntry的CRC-32 checksum来筛选出重复的资源;
  • 通过android-chunk-utils修改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开发者人员自身的提出的基本要求,要时刻保持良好的编程习惯和对包体积敏锐的嗅觉。

参考文献

  • Android application package (APK)
  • Zip (file format))
  • Build Workflow
  • Android AAPT Source Code
  • Reduce APK Size
  • Shrink Your Code and Resources
  • Manage Your App's Memory
  • Vector Drawable
  • Javassist
  • ASM
  • pngcrush
  • pngquant
  • zopflipng
  • android-chunk-utils
  • 安装包立减1M--微信Android资源混淆打包工具
  • 减少 APK 的大小,Android 官方这样说
  • Google I/O 2016 笔记:APK 瘦身的正确姿势

作者简介

建帅,Android技术专家,2015年3月加入美团点评,目前就职于到店餐饮技术部信息与交易技术中心。

到店餐饮技术部交易与信息技术中心,负责美团点评美食用户端业务,服务于数以亿计用户,通过更好的榜单、真实的评价和完善的信息为用户提供更好的决策支持,致力于提升用户体验;同时承载所有餐饮商户端线上流量,为餐饮商户提供多种营销工具,提升餐饮商户营销效率,最终达到让国人“Eat Better、Live Better”的美好愿景!我们的团队包含且不限于Android、iOS、FE、Java、PHP等技术方向,已完备覆盖前后端技术栈。只要你来,就能点亮全栈开发技能树。

下一章:从Google白皮书看企业安全最佳实践

前不久Google发布了一份安全方面的白皮书Google Infrastructure Security Design Overview,直译的版本可以参考“网路冷眼”这版《Google基础设施安全设 ...