包体积优化:资源文件优化

1,264 阅读25分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

在前面《包体积优化:包体积基础知识》章节中我们知道了 APK 主要由资源文件、dex 文件和 so 文件组成。这三类文件,都可以基于精简、压缩和动态化这三条方法论来进行体积的优化。那么在本章中,我们就先来看一看资源文件是如何在这三条方法论的指导下进行体积优化的。

精简

提到资源文件的精简离不开两类方向:第一类是对资源进行精简,这也是我们最容易想到的方案;第二类就是对字符串的精简。 下面就一起看看这两类方向能衍生出哪些优化方案吧!

资源精简

资源精简主要是删减没有被用到或者冗余的资源,本章中主要介绍下面这 3 种方案:

  1. 通过 Android 提供的工具检查和删除没被使用到的资源;
  1. 通过多 dpi 资源去重删减不必要的资源;
  1. 通过检测重复图片进行图片资源去重。

无用资源检测和删除

我们先看第一种方案,我们可以通过 Lint 或者开启 shrinkResources 来扫描项目中没有被使用到的资源,并进行删除。

  • 我们直接在 Android Studio 的 Analyze 菜单中点击 Run Inspection by Name,并在对话框里输入 unused resource 就能执行无用资源扫描。扫描出结果后,我们手动删除即可。这种方案比较简单,不需要进行太多的讲解。
  • 第二种精简无用资源的方式是在 gradle 配置中开启 shrinkResources。此外,我们还需要注意在 proguard 文件中关闭 dontshrink 字段,否则扫描出的无用资源并不会被优化。
android {
    ...
    buildTypes {
        release {
            //开启无用资源精简
            shrinkResources true
            minifyEnabled true
            proguardFiles
                getDefaultProguardFile('proguard-android.txt'),'proguard-rules.pro'
            
        }
        debug {
            ……
        }
    }
}

shrinkResources 主要有 safe 和 strict 两种模式,默认模式为 safe。 在Android 中除了可以根据资源 ID 直接获取资源,还可以通过 Resources.getIdentifier() 接口来动态拼接 ID 获取资源。在 safe 安全模式下,将所有具有匹配名称格式的资源标记为可能已使用,所以这些资源都无法被优化。比如下面这种方式,就会导致 img_xxx 后缀的图片都被标记为已使用。

String name = String.format("img_%1d", index + 1);
res = getResources().getIdentifier(name, "drawable", getPackageName());

在 strict 严格模式下会忽略 getIdentifier 接口规则,一个资源只有真正在代码中通过 ID 获取使用,才会被标记已使用。

我们可以在 res/raw/keep.xml 文件中配置 shrinkResources 的模式。在项目中,我们应该避免使用 Resources.getIdentifier() 接口来获取资源,并且尽量将 shrinkResources 设置为 strict 模式。如果我们的项目确实需要使用这个接口,并且 strict 模式导致有些资源文件被误优化了, 我们可以在 keep.xml 中配置要保留的文件。

<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
    tools:shrinkMode="strict"
        tools:keep="@drawable/ic_get_by_identifier"/>

当我们开启 shrinkResources 后,会在 apk 构建过程中执行 ShrinkResourcesTransform 这个 gradle task 进行无用资源的扫描和优化。原理如下:

  1. 通过 ASM(字节码操作)遍历所有的 class 文件,并分析文件中是否用到 R 文件中的 id ,以此来检测资源文件是否被代码所使用
  1. 遍历 Mainfest 配置文件,res 目录下的非文件资源,以此来检测资源文件是否被 Mainfest 配置文件使用
  1. 根据 shrinkMode 判断是否要处理 getIdentifier 引用的资源;
  1. 最后,将无用资源替换为一个同名的空资源,这里需要注意的是,这里并不会删除没被使用的资源,这样做的原因是避免因为资源文件被删除后导致程序出现一些 crash。

我们在这里只需要大致了解 ShrinkResourcesTransform 这个 task 所做的事情即可,如果你感兴趣,也可以深入分析这个 task 的源码。

dpi 资源去重

在了解多 dpi 去重的优化之前,我们先了解一下什么是 dpi 。

dpi 是 Dots Per Inch 的缩写,表示的是屏幕上每一个物理点有几个像素。Android 通过提供多套 dpi 级别来适应不同分辨率的屏幕。为什么需要用 dpi 来适配不同的屏幕呢?比如有一款屏幕为 5 英寸的高端手机,是 2K 分辨率的屏幕,还有很大的 15 英寸平板,屏幕也是 2K 分辨率。一张 100 x 100 分辨率图片在这台手机上显示正常,但是如果这张图片到了平板上去了,按照同样分辨率来显示,图片看起来就会非常大,也可能会变模糊。所以只有按照这台平板的屏幕像素密度,进行缩放之后这张图片看起来才是正常的。

我们在代码中用来设置字体大小的 dp 单位,之所以能自适应不同的屏幕,就是因为 dp 会根据屏幕的 dpi 级别,换算成不同的分辨率,换算公式是 px = dp * (dpi / 160) 。Android 中提供的 dpi 级别如下:

dpi 密度分辨率转换系数说明
ldpi0.75适用于低密度屏幕,分辨率一般是 240x320
mdpi1适用于中密度屏幕,这是基准密度,分辨率一般是320*480
hdpi1.5适用于高密度,分辨率一般是 480*800
xhdpi2适用于超高密度屏幕,分辨率至少是 960*720
xxhdpi3适用于超超高密度屏幕,一般是 1280×720

在应用开发中,为了适应不同 dpi 的屏幕,我们通常会放入多套不同尺寸的资源在不同的 dpi 资源文件下。

当我们了解 dpi 后,就可以利用这个特性来精简我们的包体积了。在国内的市场中,我们只需要采用市占率最高一套 dpi,通常是 xxhdpi 来存放资源即可。

这时你可能会问,如果只是用 xxhdpi,那么低 dpi 的手机需要使用图片资源时怎么办呢?实际上,我们可以在代码中,根据当前屏幕的 dpi ,对图片资源分辨率进行缩放。即使我们不对图片做缩放,一个高分辨率的图片资源,在低分辨率的屏幕上使用问题也不大,只要我们不直接按照图片的分辨率显示图片就行了。我们在项目中,也要约定不要直接使用 px 这种单位,而是尽量使用 dp 单位。至于国外的市场,可以通过 AAB 直接下发不同 dpi 的资源,所以能用最优方案解决多 dpi 资源的问题。

图片去重

图片去重很好理解,就是删除项目中重复的图片,在中小型项目中,我们通过人工检测资源文件就可以很容易的地实现这一优化。但是随着我们的项目越来越大,项目中的图片会越来越多,特别对于多仓多库架构的大型 App 来说,图片资源分散在各个仓库中,这个时候通过人工检测重复图片就做不到了,所以需要通过自动化的工具来扫描 App 进行检测和优化,我们通常有两种方案。

第一种是通过自定义的 gradle 脚本,扫描 res 资源目录中的图片,然后通过 md5 判断图片是否重复,删除重复的图片,并扫描代码中使用到该图片的代码,通过字节码修改进行替换。第二种就是扫描 res 资源目录中的图片,然后通过 md5 判断图片是否重复,删除重复的图片并记录地址,同时替换 resources.arsc 文件中该图片的索引地址。

上面两种方案中,第一种方案比较耗时,因为要遍历整个项目的代码,目前业界主要使用的是第二种方案,所以这里就介绍一下第二种方案。

在 Android 代码中,我们访问 res 下的图片资源都是通过 id 来访问的,然后资源管理器再根据 id 去 resources.arsc 文件中查找对应图片资源的地址。所以当我们扫描到重复图片后,直接去 resources.arsc 文件中将重复图片的索引替换成同一张图片就可以,不需要通过字节码插桩直接修改源码。

如上图,这一方案原理比较简单,实现起来却比较复杂。这是因为该方案需要修改 resources.arsc 文件,所以我们需要对这个文件结构非常的熟悉,那么第一步我们就来熟悉一下 resources.arsc 文件。

resouces.arsc 文件剖析

我们在 AndroidStudio 中看到的 resources.arsc 文件如下,文件结构和数据都是清晰明了的,数据都是由 ID、Name 和 value组成,但在这里看到的都是 AS 帮我们解析好并以直观的方式展现给我们了。

实际上, resources.arsc 文件是一个二进制文件,和之前修改 so 文件一样,我们想要修改文件里的数据,就需要知道这个文件结构中数据段的组成,并能准确查找到相应区域中的数据。resources.arsc 文件主要分为了 6 个数据段,我们从上到下来看。

  1. RES_TABLE_TYPE(头部信息),用来记录头大小、文件大小、资源包的数量(一个 apk 通常只有一个资源包)。
  1. RES_STRING_POOL_TYPE(字符串常量池),主要存储非文件资源的值以及文件资源的路径。 将字符串全部放在一个区域,可以去重,从而减少体积。
  1. RES_TABLE_PACKAGE_TYPE(资源头信息)。
  1. 资源类型字符串池和资源项名称字符串池,数据结构和字符串常是一样的。为了和字符串常量池区分,这里特别说明下:
<string name="tip">hello world</string> 
hello world 为字符串资源,存储在字符串常量池中;
string 为资源类型,存储在资源类型字符串常量池中;
tip 为资源项名称,存储在资源项名称字符串常量池中。
  1. RES_TABLE_TYPE_SPEC_TYPE(类型规范数据块),用来存储资源项的配置信息,系统根据不同设备的配置就可以加载不同的资源项。
  1. RES_TABLE_TYPE_TYPE(资源类型项数据块),用来存储资源项的名称、类型、值和配置等等。

下面这张图是网上普遍流传的 resources.arsc 文件结构图,可以帮助我们理解这个文件的结构。

我们想要实现图片去重的方案,就需要 遍历 res 资源下的图片,寻找重复图片,如果是重复图片则删除,并修改 resources.arsc 文件中 字符串 常量池里这张图的索引地址。

那么如何修改 resources.arsc 文件里面的数据?我们还是以前面章节中提到的 so 文件的修改作为引子,相似知识迁移是帮助我们快速了解新知识的手段之一。修改 so 文件时,我们需要将每一块数据段转换成对应的数据结构,这样就能方便地定位到数据的地址并进行修改了,resources.arsc 文件也一样,需要将每个数据段转换成对应的数据结构,然后定位到数据具体的地址进行修改。

上面提到的六个数据段,每一段都被称为 Chunk 而非 so 文件的 Segment ,每一块 Chunk 都有对应的数据结构可以供我们使用,这些数据结构的定义都在 ResourceTypes.h 文件中,只有了解这些数据结构定义我们才能完成对 resources.arsc 文件的修改 ,所以下面举几个例子帮助大家熟悉这些数据结构。

  • 以 RES_TABLE_TYPE 头部信息这个 chunk 举例,它的数据结构如下:
// ResTable_header 就是 RES_TABLE_TYPE 的数据结构
struct ResTable_header
{
    //头结构
    struct ResChunk_header header;

    // 资源包的数量
    uint32_t packageCount;
};

/* ResChunk_header 是一个通用的数据结构,用来描述当前 chunk 的头部信息
其他的 chunk 也用这个数据结构,并用 type 来区分类型,大小为 8 个字节*/
struct ResChunk_header
{
    // 当前 chunk 的类型
    uint16_t type;

    // 当前 chunk 头部的大小
    uint16_t headerSize;

    // 当前这块 chunk 的大小
    uint32_t size;
}; 

enum {
   RES_NULL_TYPE               = 0x0000,
   RES_STRING_POOL_TYPE        = 0x0001, // 字符串常量池
   RES_TABLE_TYPE              = 0x0002, // 头部
   RES_TABLE_PACKAGE_TYPE      = 0x0200, // 资源头
   RES_TABLE_TYPE_TYPE         = 0x0201, //类型规范数据块
   RES_TABLE_TYPE_SPEC_TYPE    = 0x0202, //资源类型项数据块
   RES_TABLE_LIBRARY_TYPE      = 0x0203
};

所以如果我们想要解析 RES_TABLE_TYPE 这个 chunk 的数据,实现如下:

/* 1. 我们通过 kotlin 来解析这个数据段,
所以先用 kotlin 定义一个 ResChunk_header 数据结构*/
class ResChunk_header(var type: uint16_t, //当前这个chunk的类型
                      var headerSize: uint16_t, //当前这个chunk的头部大小
                      var size: uint32_t) {
    //当前这个chunk的大小
    enum class ChunkType(var type: Int) {
        RES_NULL_TYPE(0x0000),
        RES_STRING_POOL_TYPE(0x0001),
        RES_TABLE_TYPE(0x0002),
        RES_XML_TYPE(0x0003),
        RES_XML_FIRST_CHUNK_TYPE(0x0100),
        RES_XML_START_NAMESPACE_TYPE(0x0100),
        RES_XML_END_NAMESPACE_TYPE(0x0101),
        RES_XML_START_ELEMENT_TYPE(0x0102),
        RES_XML_END_ELEMENT_TYPE(0x0103),
        RES_XML_CDATA_TYPE(0x0104),
        RES_XML_LAST_CHUNK_TYPE(0x017f),
        RES_XML_RESOURCE_MAP_TYPE(0x0180),
        RES_TABLE_PACKAGE_TYPE(0x0200),
        RES_TABLE_TYPE_TYPE(0x0201),
        RES_TABLE_TYPE_SPEC_TYPE(0x0202)

    }

    override fun toString(): String {
        return "type = ${getType(type)}, typeHexValue = ${type.getHexValue()}, headerSize = ${headerSize.getValue()}, headerHexValue = ${headerSize.getHexValue()}, size = ${size.getValue()}, sizeHexValue = ${size.getHexValue()}"
    }

    fun getType(type: uint16_t): String {
        when (type.getValue().toInt()) {
            ResChunk_header.ChunkType.RES_NULL_TYPE.type -> return "RES_NULL_TYPE"
            ResChunk_header.ChunkType.RES_STRING_POOL_TYPE.type -> return "RES_STRING_POOL_TYPE"
            ResChunk_header.ChunkType.RES_TABLE_TYPE.type -> return "RES_TABLE_TYPE"
            ResChunk_header.ChunkType.RES_XML_TYPE.type -> return "RES_XML_TYPE"
            ResChunk_header.ChunkType.RES_XML_FIRST_CHUNK_TYPE.type -> return "RES_XML_FIRST_CHUNK_TYPE"
            ResChunk_header.ChunkType.RES_XML_START_NAMESPACE_TYPE.type -> return "RES_XML_START_NAMESPACE_TYPE"
            ResChunk_header.ChunkType.RES_XML_END_NAMESPACE_TYPE.type -> return "RES_XML_END_NAMESPACE_TYPE"
            ResChunk_header.ChunkType.RES_XML_START_ELEMENT_TYPE.type -> return "RES_XML_START_ELEMENT_TYPE"
            ResChunk_header.ChunkType.RES_XML_END_ELEMENT_TYPE.type -> return "RES_XML_END_ELEMENT_TYPE"
            ResChunk_header.ChunkType.RES_XML_CDATA_TYPE.type -> return "RES_XML_CDATA_TYPE"
            ResChunk_header.ChunkType.RES_XML_LAST_CHUNK_TYPE.type -> return "RES_XML_LAST_CHUNK_TYPE"
            ResChunk_header.ChunkType.RES_XML_RESOURCE_MAP_TYPE.type -> return "RES_XML_RESOURCE_MAP_TYPE"
            ResChunk_header.ChunkType.RES_TABLE_PACKAGE_TYPE.type -> return "RES_TABLE_PACKAGE_TYPE"
            ResChunk_header.ChunkType.RES_TABLE_TYPE_TYPE.type -> return "RES_TABLE_TYPE_TYPE"
            ResChunk_header.ChunkType.RES_TABLE_TYPE_SPEC_TYPE.type -> return "RES_TABLE_TYPE_SPEC_TYPE"
        }

        return ""
    }
}
// 再定义好 ResTable_header 的数据结构
class ResTable_header(var header: ResChunk_header, var packageCount: uint32_t) {
    override fun toString(): String {

        return header.toString() + ", packagerCount = ${packageCount.getValue()}, packagerCountHexValue = ${packageCount.getHexValue()}"
    }
}

fun readTable() {
     // 1. 读取 arsc 文件,并转换成字节流
    val stream = File("resources.arsc").inputStream()
    val os = ByteArrayOutputStream()
    val bytes = ByteArray(1024)var len = stream.read(bytes)
    while (len != -1) {
        os.write(bytes, 0, len)len = stream.read(bytes)}
    val streamByte = os.toByteArray()
       
    //2. 读取文件前八个字节的数据,并转换成 ResTable_header 数据结构 
    val resTable_header = ResTable_header(getResChunk_header(streamByte, 0), read_uint32_t(streamByte, 8))  
    
    println(resTable_header)
}   

//根据偏移地址,分别读取 RES_TABLE_TYPE 、头大小和文件大小的值    
fun getResChunk_header(stream: ByteArray, index: Int): ResChunk_header {return ResChunk_header(read_uint16_t(stream, index), read_uint16_t(stream, index + 2), read_uint32_t(stream, index + 4))}   

上面是用 kotlin 来解析 header,所以需要重新定义一下数据结构,如果我们用 C++ 来解析,只需要把 ResourceTypes.h 中定义好的数据结构拷贝过来就能用了。

  • 我们再以看看 RES_STRING_POOL_TYPE 常量池的数据结构:
struct ResStringPool_header
{
    //常量池 chunk 头结构
    struct ResChunk_header header;
    // 字符串的数量
    uint32_t stringCount;

    // 字符串样式个数
    uint32_t styleCount;

    // 用来描述字符串的属性,如编码格式等
    enum {
        SORTED_FLAG = 1<<0,
        UTF8_FLAG = 1<<8
    };
    uint32_t flags;

    // 字符串数据块在当前块内的偏移,这里主要是读取字符串的时候需要从这里开始读取
    uint32_t stringsStart;

    // 字符串样式内容与常量池头部起始点之间的偏移距离
    uint32_t stylesStart;
};

我们再看看如何解析字符串常量池这个chunk 的数据,实现如下:

// 读取 字符串池的偏移地址,这里实际固定是 8 个字节
val offset = resTable_header.header.headerSize.getValue().toInt()
//读取字符串池
val resStringPool_header = resStringPool_header(streamByte, offset)

private fun resStringPool_header(streamByte: ByteArray, offset: Int): ResStringPool_header {
    //1. 解析 ResStringPool_header,偏移地址可以参考上面的图,比如字符串数偏移是 8,stype 偏移是 12
    val resStringPool_header = ResStringPool_header(getResChunk_header(streamByte, offset),
            read_uint32_t(streamByte, offset + 8),
            read_uint32_t(streamByte, offset + 12),
            read_uint32_t(streamByte, offset + 16),
            read_uint32_t(streamByte, offset + 20),
            read_uint32_t(streamByte, offset + 24))
   
    //2. 解析完成后会有两个偏移数组,一个是string偏移数组,另一个是style偏移数组,我们根据这个数据去拿里面记录的值数据
   
    // string偏移数组
    val stringCount = resStringPool_header.stringCount.getValue()
    val resString_offset_array = ResString_offset_array(Array(stringCount, {
        uint32_t(0)
    }))
    for (i in 0..stringCount - 1) {
        resString_offset_array.offset_array[i] = read_uint32_t(streamByte, resStringPool_header.header.headerSize.getValue() + offset + i * 4)
    }

    // style偏移数组
    val styleCount = resStringPool_header.styleCount.getValue()
    val resStyle_offset_array = ResStyle_offset_array(Array(styleCount, {
        uint32_t(0)
    }))
    for (i in 0..styleCount - 1) {
        resStyle_offset_array.offset_array[i] = read_uint32_t(streamByte, resStringPool_header.header.headerSize.getValue() + stringCount * 4 + offset + i * 4)
    }

    //根据 string偏移数组解析字符串池
    val stringStartOffset = offset + resStringPool_header.stringsStart.getValue()
    val stringArray: Array<String> = Array(resStringPool_header.stringCount.getValue(), {
        ""
    })

    for (i in 0..resStringPool_header.stringCount.getValue() - 1) {
        var size: Int
        if (i + 1 <= resStringPool_header.stringCount.getValue() - 1) {
            size = resString_offset_array.offset_array[i + 1].getValue() - resString_offset_array.offset_array[i].getValue()
        } else {
            size = resStringPool_header.header.size.getValue() - resString_offset_array.offset_array[i].getValue() - resStringPool_header.stringCount.getValue() * 4 - 28
        }
        val resultPair = readString2(streamByte, size, stringStartOffset + resString_offset_array.offset_array[i].getValue())
        stringArray[i] = resultPair.first
    }

    //根据 style 偏移数组解析资源字符串值
    val resString_string_array = ResString_string_array(stringArray)
    resString_string_array.stringArray.forEachIndexed { index, s ->
        println("$index : $s")
    }

    val styleStartOffset = offset + resStringPool_header.stylesStart.getValue()
    val styleStringArray: Array<ResStringPool_span> = Array(resStringPool_header.styleCount.getValue(), {
        ResStringPool_span(
                ResStringPool_ref(uint32_t(0)),
                uint32_t(0),
                uint32_t(0)
        )
    })

    for (i in 0..resStringPool_header.styleCount.getValue() - 1) {
        styleStringArray[i] = ResStringPool_span(
                ResStringPool_ref(read_uint32_t(streamByte, styleStartOffset + resStyle_offset_array.offset_array[i].getValue())),
                read_uint32_t(streamByte, styleStartOffset + resStyle_offset_array.offset_array[i].getValue() + 4),
                read_uint32_t(streamByte, styleStartOffset + resStyle_offset_array.offset_array[i].getValue() + 8)
        )
    }

    for (i in 0..resStringPool_header.styleCount.getValue() - 1) {
        println(styleStringArray[i])
    }

    resStringPool_header.stringOffsetArray = resString_offset_array
    resStringPool_header.styleOffsetArray = resStyle_offset_array
    resStringPool_header.stringStringArray = resString_string_array
    resStringPool_header.styleStringArray = ResStyle_string_array(styleStringArray)
    return resStringPool_header
}
  • 其他的数据段也和上面的结构类似,就不一一列举了。这里推荐一些介绍 resources.arsc 文件结构的文章,如果你感兴趣,也可以去查阅。

Android resources.arsc的解析

一文读懂resource.arsc文件结构

解析 resources.arsc 文件的开源库也非常多,这里也推荐一些,大家可以自己去看看源码实现,我上面举例用的代码实现,也是从开源库 resourcesAnalyzer 中截取的。

resourcesAnalyzer:通过 kotlin 解析resources.arsc的开源库

android-chunk-utils:通过 java 解析resources.arsc的开源库

了解了 resources.arsc 文件 ,我们就可以去思考图片去重的优化方案了。我们可以通过编写脚本去读 apk 包,解析 resources.arsc 文件,以及对图片去重的方式来实施方案,但是这种方式需要我们对 Apk 包重新签名,步骤比较繁琐。所以这一本章中主要介绍如何通过 gradle 插件来完成这一方案

自定义 gradle 脚本

APK 编译打包的过程有很多阶段,我们可以将自定义的 gradle 脚本放在这些阶段中执行,比如字节码插桩,需要放在 class 文件生成 dex 文件的这一阶段。如果我们想要修改 resources.arsc 文件,也需要放在一个合适的阶段,如果这个阶段太早则相关文件还未生成出来,太晚则可能影响到其他脚本的执行。这里推荐放在 processXXXResources 这个 task 之后执行。

processResources 这个阶段会将 res 资源、assets 资源、resources.arsc 文件,打包成一个 .ap_ 格式的 zip 压缩包,所以我们可以将自定义的 gradle 脚本放在这个 task 之后进行,这个阶段就能读取到 resources.arsc 文件了。关于如何编写自定义 gradle 脚本,在前面字节码插桩时已经讲过了,这里就不再介绍了,代码的实现流程如下:

class AnnotationExecutorPlugin implements Plugin<Project> {
    @Override
    void apply(Project project) {
        project.afterEvaluate {
            //1. 找到 ProcessXXXResources 这个task
            def processResSet = project.tasks.findAll {
                boolean isProcessResourcesTask = false
                android.applicationVariants.all {
                    variant -> if (it.name == 'process' + variant.getName() + 'Resources') {
                        isProcessResourcesTask = true
                    }
                }
                return isProcessResourcesTask
            }
            if(!isProcessResourcesTask){
                return
            }
            //2. 将我们的自定义脚本放在 ProcessAndroidResources 这个task之后执行
            for (def processRes in processResSet){
                processRes.doLast {
                    File[] fileList = getResPackageOutputFolder().listFiles()
                    for (def i = 0; i < fileList.length; i++) {
                        //3. 找到 .ap_ 文件
                        if (fileList[i].isFile() && fileList[i].path.endsWith(".ap_")) {
                            File packageOutputFile = fileList[i];
                            //4. 解压 .ap_ 文件
                            int prefixIndex = packageOutputFile.path.lastIndexOf(".")
                            String unzipPath = packageOutputFile.path.substring(0, prefixIndex) + File.separator
                            unZip(packageOutputFile, unzipPath)
                            //5. 解析  resources.arsc 文件,并进行图片去重操作
                            imageOptimize(unzipPath)                          
                            //6. 将 解压后的文件重新打包成 .ap_ zip压缩包
                            zipFolder(unzipPath, packageOutputFile.path)

                        }
                    }


                }
            }
        }
    }
}

// 文件的解压和压缩实现如下
def unZip(File src, String savepath)throws IOException
{
    def count = -1;
    def index = -1;
    def flag = false;
    def file1 = null;
    def is = null;
    def fos = null;
    def bos = null;
 
    ZipFile zipFile = new ZipFile(src);
    Enumeration<?> entries = zipFile.entries();
 
    while(entries.hasMoreElements())
    {
        def buf = new byte[2048];
        ZipEntry entry = (ZipEntry)entries.nextElement();
        def filename = entry.getName();
 
        filename = savepath + filename;
        File file2=file(filename.substring(0, filename.lastIndexOf('/')));
 
        if(!file2.exists()){
            file2.mkdirs()
        }
 
        if(!filename.endsWith("/")){
 
            file1 = file(filename);
            file1.createNewFile();
            is = zipFile.getInputStream(entry);
            fos = new FileOutputStream(file1);
            bos = new BufferedOutputStream(fos, 2048);
 
            while((count = is.read(buf)) > -1)
            {
                bos.write(buf, 0, count );
            }
 
            bos.flush();
 
            fos.close();
            is.close();
 
        }
    }
 
    zipFile.close();
 
}
 
def zipFolder(String srcPath, String savePath)throws IOException
{
    def saveFile = file(savePath)
    saveFile.delete()
    saveFile.createNewFile()
    def outStream = new ZipOutputStream(new FileOutputStream(saveFile))
    def srcFile = file(srcPath)
    zipFile(srcFile.getAbsolutePath() + File.separator, "", outStream)
    outStream.finish()
    outStream.close()
}
 
def zipFile(String folderPath, String fileString, ZipOutputStream out)throws IOException
{
    File srcFile = file(folderPath + fileString)
    if(srcFile.isFile()){
        def zipEntry = new ZipEntry(fileString)
        def inputStream = new FileInputStream(srcFile)
        out.putNextEntry(zipEntry)
        def len
        def buf = new byte[2048]
        while((len = inputStream.read(buf)) != -1){
            out.write(buf, 0, len)
        }
        out.closeEntry()
    }
    else{
        def fileList = srcFile.list()
        if(fileList.length <= 0){
            def zipEntry = new ZipEntry(fileString + File.separator)
            out.putNextEntry(zipEntry)
            out.closeEntry()
        }
 
        for(def i = 0; i < fileList.length; i++){
            zipFile(folderPath, fileString.equals("") ?  fileList[i] : fileString + File.separator + fileList[i], out)
        }
    }
}

通过上面的自定义 gradle 脚本,我们了解了这个方案的主流程,下面接着看看 imageOptimize 方法中图片去重的实现细节。

resources.arsc 图片去重

我并不建议我们自己写代码去解析 resources.arsc 文件比较繁琐,还容易出错,我们可以用上面提到的 android-chunk-utils 开源工具,使用起来非常方便,下面就看看如何使用这个工具来实现方案的代码流程。

void imageOpitmize(String resourcePath) {
    // 1. 遍历 res 目录下的图片,根据 md5 寻找重复图片,并记录在 map 中,map的key为 md5,value为图片数据,文件遍历和md5计算比较简单,就不贴代码了。
    HashMap<String, ArrayList <DuplicatedEntry>> duplicatedResources = findDuplicatedResources(resourcePath);
    File arscFile = new File(resourcePath+ 'resources.arsc')

    if (arscFile.exists()) {
        FileInputStream arscStream = null;
        
        //ResourceFile是 android-chunk-utils里面定义的数据结构
        ResourceFile resourceFile = null;
        try {
            arscStream = new FileInputStream(arscFile);

            resourceFile = ResourceFile.fromInputStream(arscStream);
            //1. 调用ResourceFile的getChunks方法,就能将arsc流转换成Chunk对象树
            List<Chunk> chunks = resourceFile.getChunks();

            HashMap<String, String> toBeReplacedResourceMap = new HashMap<String, String>(1024);

            Iterator<Map.Entry<String, ArrayList<DuplicatedEntry>>> iterator = duplicatedResources.entrySet().iterator();
            //2. 遍历 duplicatedResources 中记录的重复图片,其实这一步也可以在 findDuplicatedResources 的时候就做了
            while (iterator.hasNext()) {
                Map.Entry<String, ArrayList<DuplicatedEntry>> duplicatedEntry = iterator.next();
                // 保留第一个资源,索引从1开始,其他资源删除掉
                for (def index = 1; index < duplicatedEntry.value.size(); ++index) {
                    // 删除图片,并将删除的图片信息保存在 toBeReplacedResourceMap 这个数据结构中
                    removeZipEntry(apFile, duplicatedEntry.value.get(index).name);
                    toBeReplacedResourceMap.put(duplicatedEntry.value.get(index).name, duplicatedEntry.value.get(0).name);
                }
            }

            //3. 更新 resources.arsc 中的数据
            for (def index = 0; index < chunks.size(); ++index) {
                Chunk chunk = chunks.get(index);
                if (chunk instanceof ResourceTableChunk) {
                    ResourceTableChunk resourceTableChunk = (ResourceTableChunk) chunk;
                    //3. 找到字符串常量池,也是直接调用getStringPool方法即可,StringPoolChunk 也是android-chunk-utils工具中定义好的数据结构,直接使用就行
                    StringPoolChunk  stringPoolChunk  = resourceTableChunk.getStringPool();
                    for (def i = 0; i < stringPoolChunk.stringCount; ++i) {
                        //遍历字符串常量池的值,如果值和toBeReplacedResourceMap中包含的值相等,则进行替换
                        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);
            }
        }
    }

} 

到这里,通过图片去重的方案就讲完了,除了通过 md5 判断相同图片进行去重,在字节还用到了通过机器学习等方式来扫描相似图片,再通过人工判断是否可以删除,不过这个方案比较复杂,就不在这儿介绍了。

字符串精简

项目中资源的字符串都是存储在 resources.arsc 文件中的。所以对于一个 APK 包来说, resources.arsc 文件的体积也是较大的。下面是一个简单的 Demo 安装包,resources.arsc 就占用了 700kb,比 res 文件还大。所以对字符串的精简,也是资源文件优化很重要的一个手段。

这里我会主要介绍资源文件名混淆这一优化方案。

文件名混淆

文件名混淆后,可以将长的文件名变成短的文件名,所以 resources.arsc 中记录的数据就会更少,resources.arsc 的文件体积就自然下降了。对于一个资源文件较多的应用来说,资源名文件混淆带来的包体积收益很可观。

res/anim/abc_fade_in.xml -> r/a/a.xml

Android 提供了对代码的混淆,但是却没有提供对资源文件名的混淆(需要注意,AGP4.2 及以上已经有这个能力了)。既然如此,那我们就自己干吧。很明显,想要实现资源文件名的混淆,也是需要操作和修改 resources.arsc 文件。流程其实和前面图片去重差不多,所以了解了图片去重的方案实现,文件名混淆实现起来就很容易了。

我们同样可以在上面自定义的gradle插件中进行操作,只需要遍历 res 目录下的资源文件,然后按照字符串为 a、b、c、d…… 不断累加的顺序给文件重名,同时在 resources.arsc 文件的字符串常量池中找到文件的索引地址,然后修改就行了,代码流程其实和上面图片去重差不了太多,就不详细去讲了。

针对 resources.arsc 能做的东西还很多,比如:

  • 彻底删除无用资源及其字符串。前面提到 shrinkResources 配置只会将无用的资源文件置空,但是并不会删除,所以我们可以进一步删除这些资源及其在 resources.arsc 文件中的相关的字符串;
  • 删减里面无用的字符串等等。

这些优化实施起来都比较繁琐,需要熟悉 resources.arsc 文件的格式,但是并不复杂,技术点都和操作编辑文件,并且 android-chunk-utils 等工具也可以很方便地帮助我们实现这些技术。个人觉得,操作 resources.arsc 文件这一技术的通用性并不强,所以这里就不再展开介绍更多的方案。

使用第三方 SDK

对于上述提到的 dpi 去重,图片去重,文件名精简等功能,如果自己去实现工作量大而且复杂,幸运的是,这些优化方案都非常成熟而普遍了,所以这些优化我们都可以使用开源的库去完成,并不需要我们再去重复造轮子了,我在上面小节中讲解的流程代码也是从开源库中截取的。不管是微信的 Andresguard 框架,还是字节的 AabResGuard 都非常强大,使用也非常简单,参考官方文档进行接入即可,大家可以自行查看。

实际上,Android 从 AGP(Android Gradle Plugin) 4.2 开始,支持了资源混淆的功能,可以通过 android.enableResourceOptimizations 开启;从 AGP 7.1 支持了无用资源的完全删除,可以通过android.experimental.enableNewResourceShrinker.preciseShrinking开启。就像 R8 整合了 desugar,proguard 等流程一样,随着 AGP 的迭代功能越来越强,我们上面提到的这些优化方案可能慢慢都会被官方的 AGP 使用,但是对于已经上线较久的项目,AGP 版本一般不会太高,即使我们的 AGP 版本比较高,但是能知道这些方案的优化原理和实现,对我们的技术成长帮助也是很大的。

压缩

讲完了资源文件的精简优化,开始轮到压缩优化了。资源文件的压缩相对来说比较简单,无非就是换用更好的压缩算法来压缩资源。

首先是图片压缩。项目中的图片资源最好都压缩一下,市面上有很多图片压缩技术,可大幅减少图片的体积,但是肉眼感受不到区别。比如 tinypng,可以实现在无损压缩的情况下,图片文件大小缩小到原来的 30%-50%。使用也很简单,直接将我们的图片拖到官网中,就能输出一张压缩过后的图片。不过缺点是压缩的图片数量如果太多了需要收费。

当然,如果我们的应用不需要兼容 Android 4.4 以下的版本(现在市面上应用应该很少再兼容 4.4 以下了),直接将图片转换成 webp 是最佳的,webp 的压缩比是优于tinypng的。我们直接在Androidstudio 中右键点击图片就能转换了。

其次是 asset 资源压缩。 我们放在 asset 中的资源比较多的都是音频、视频、html、jss 等等,这些文件建议都用 7z 压缩,7z的压缩率比 zip 要高很多,我们在代码中使用时,通过 7z 解压后就能使用了。7z 的库也是开源的,我们可以在官网下载源码,通过 ndk 编译集成到项目中。当然,你也可以在 GitHub 上面找找是否有直接可是使用的 sdk,数量也不少。

动态化

对于图片以及assets目录下的音频、html、js、数据等资源,都可以采用网络下载的方式来进行优化。我们可以评估这一类资源的重要性以及使用频率,以此来决定是否采用网络下载的方式。如果对文件使用频率不确定,我们也可以通过埋点的方式确定文件的使用次数,然后对那些低频的文件,全部采用网络拉取这一动态化方案。

除了网络拉取,res 下的资源文件也可以通过插件来实现动态化,方案的技术原理需要我们知道资源管理器是如何进行资源加载的,后面插件化章节中会进行详细的讲解,这里就不展开了。

小结

到这里资源文件的体积优化就讲完了,事实上,除了这一章讲的优化方案,针对资源文件的优化,我们依然有很多事情可以做,只不过都离不开精简、压缩和动态化这三板斧

另外,本章中的有些优化方案确实比较复杂,比如 resouces.arsc 文件相关的优化。虽然它们都是很成熟的技术,有很多稳定的开源框架供我们使用,但只有掌握了它们的原理,我们技术的边界才能不断的扩展。