首页 > 代码库 > Android App瘦身实战

Android App瘦身实战

随着业务的快速迭代增长,不断引入新的业务逻辑代码、图片资源和第三方SDK等,很多app都面临一个一个结果,app越来越大,甚至很多无用的代码,包体积的增大带来了很多问题,诸如app启动更慢,代码维护越来越困难。公司业务发展到一定程度之后,重构,代码优化,app瘦身成为不得不做的一个任务。这里以xx外卖app为例给大家讲讲app瘦身过程中常用的几种方法(也都是网上老生常谈的)。

apk文件构成

我们可以用Zip工具打开APK,一个常见的APK结构如下:
技术分享

可以看到APK由以下主要部分组成:

文件/目录 描述
lib/ 存放库文件,存放so文件,可能会有armeabi、armeabi-v7a、arm64-v8a、x86、x86_64、mips,大多数情况下只需要支持armabi与x86的架构即可
res/ 存放资源文件,例如:drawable、layout等等
assets/ 应用程序的资源,应用程序可以使用AssetManager来检索该资源
classes(n).dex classes文件是Java Class,被DEX编译后可供Dalvik/ART虚拟机所理解的文件格式
resources.arsc 编译后的二进制资源文件
AndroidManifest.xml Android的清单文件,用于描述应用程序的名称、版本、所需权限、注册的四大组件

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

Zip格式优化

通过命令来查看APK文件时会得到以下信息。命令如下:

aapt l -v xxx.apk或unzip -l xxx.apk

技术分享

通过上图可以看到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在资源处理时对上述文件后缀类型的资源是不做压缩的,那是不是可以修改它们的压缩方式从而达到瘦身的效果呢?
答案是可以的,例如采用7Zip压缩等等。
为了大家更好的理解Android对资源的打包过程,我们下面来简单的分析一下。

aapt资源打包过程

首先来看一张Android打包过程图。
技术分享

通过上图可以看到Manifest、Resources、Assets的资源经过AAPT处理后生成R.java、Proguard Configuration、Compiled Resources。其中,Proguard Configuration是AAPT工具为Manifest中声明的四大组件以及布局文件中(XML layouts)使用的各种Views所生成的ProGuard配置。Compiled Resources是一个Zip格式的文件,这个文件包含了res、AndroidManifest.xml和resources.arsc的文件或文件夹,其实就是APK的“资源包”(res、AndroidManifest.xml和resources.arsc等资源)。
我们可以通过这个文件来修改不同后缀文件资源的压缩方式来达到瘦身效果的。

技术分享

在自己的项目中是通过在package${flavorName} Task对resources.arsc进行优化。下面是部分代码:

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);
        }
    }
}

classes.dex的优化

如何优化classes.dex的大小呢?大约有以下几种套路:

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

第一个方案对程序猿的素质要求比较高,项目经验也很重要,所以因人而异。

压缩代码

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

minifyEnabled true

例如,常见的一段build.gradle脚本。

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

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

在Gradle Plugin 2.2.0及以上版本ProGuard的配置文件会自动解压缩到rootProject.buildDir/<script type="math/tex" id="MathJax-Element-1">{rootProject.buildDir}/</script>{AndroidProject.FD_INTERMEDIATES}/proguard-files/目录下,proguardFiles会从这个目录来获取ProGuard配置。

每次执行完ProGuard之后,ProGuard都会在project.buildDir/outputs/mapping/<script type="math/tex" id="MathJax-Element-2">{project.buildDir}/outputs/mapping/</script>{flavorDir}/生成以下文件:

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

资源的优化

对于资源的优化也是最行之有效,最为直观的优化方案。通过对资源文件的优化,可以大大的减小apk体积大小。

图片优化

为了支持Android设备DPI的多样化([l|m|tv|h|x|xx|xxx]dpi)以及用户对高质量UI的期待,往往在App中使用了大量的图片以及不同的格式,例如:PNG、JPG 、WebP,那我们该怎么选择不同类型的图片格式呢?
Google I/O 2016大会上推荐使用WebP格式图片,可以大大减少体积,而显示又不失真。
技术分享

通过上图我们可以看出图片格式选择的方法:如果能用VectorDrawable来表示的话优先使用VectorDrawable,如果支持WebP则优先用WebP,而PNG主要用在展示透明或者简单的图片,而其它场景可以使用JPG格式。这样就达到了什么场景选什么图片更好。

矢量图片

使用矢量图片能够有效的减少App中图片所占用的大小,矢量图形在Android中表示为VectorDrawable对象。 使用VectorDrawable对象,100字节的文件可以生成屏幕大小的清晰图像,但系统渲染每个VectorDrawable对象需要大量的时间,较大的图像需要更长的时间才能出现在屏幕上。 因此只有在显示小图像时才考虑使用矢量图形。

WebP

如果App的minSdkVersion>=14(Android 4.0+)的话,可以选用WebP格式,因为WebP在同画质下体积更小。但是Android从4.0才开始WebP的原生支持,但是不支持包含透明度,直到Android 4.2.1+才支持显示含透明度的WebP。所以为了更好的使用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文件大小,同时保持图像质量。

开启资源压缩

Android的编译工具链中提供了一款资源压缩的工具,可以通过该工具来压缩资源,如果要启用资源压缩,可以在build.gradle文件中启用,例如:

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

Android构建工具是通过ResourceUsageAnalyzer来检查哪些资源是无用的,当检查到无用的资源时会把该资源替换成预定义的版本。关于资源工具压缩的详细介绍请查看Shrink Your Code and Resources

如果想知道哪些资源是无用的,可以通过资源压缩工具的输出日志文件${project.buildDir}/outputs/mapping/release/resources.txt来查看。例如:
技术分享

资源压缩工具只是把无用资源替换成预定义较小的版本,那我们如何删除这些无用资源呢?通常的做法是结合资源压缩工具的输出日志,找到这些资源并把它们进行删除。

resources.arsc的优化

关于resources.arsc的优化,主要从以下一个方面来优化:

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

资源混淆

这里推荐使用微信开源的资源混淆库AndResGuard,具体使用方法请查看安装包立减1M–微信Android资源混淆打包工具

无用资源优化

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

使用流程如下:

  1. 收集资源包(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;

2 通过android-chunk-utils把resources.arsc中对应的定义移除;
3. 删除资源包中对应的资源文件。

重复资源优化

产生重复资源的原因是不同的人,在开发的时候没有注意资源的可重用,对于人数比较少,规范到位是可以避免的,但是对于业务比较多,就会造成资源的重复。那么,针对这种问题,我们该怎么优化呢?
具体步骤如下:

  1. 通过资源包中的每个ZipEntry的CRC-32 checksum来筛选出重复的资源;
  2. 通过android-chunk-utils修改resources.arsc,把这些重复的资源都重定向到同一个文件上;
  3. 把其它重复的资源文件从资源包中删除。

工具类代码片段:

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);
            }
        }
    }
}

通过这种方式可以有效减少重复资源对包体大小的影响,同时这种操作方式对各业务团队透明。

<script type="text/javascript"> $(function () { $(‘pre.prettyprint code‘).each(function () { var lines = $(this).text().split(‘\n‘).length; var $numbering = $(‘
    ‘).addClass(‘pre-numbering‘).hide(); $(this).addClass(‘has-numbering‘).parent().append($numbering); for (i = 1; i <= lines; i++) { $numbering.append($(‘
  • ‘).text(i)); }; $numbering.fadeIn(1700); }); }); </script>

    Android App瘦身实战