APK体积优化之重复资源

之前对公司的项目做了一些列APK体积的优化,其中涉及到一个小点,相同资源因命名方式的不同,在构建时都打入到APK中,导致体积增大。

一、背景

目前大部分中大型App的研发是由各个业务团队共同协作完成,这种组件化的开发模式就会存一个常见的问题, 不同的业务团队在使用各资源文件(如:drawable、layout等)时,为了避免引发资源覆盖的问题,每
个业务团队都会为自己的资源文件名添加前缀。这样就会导致有些资源文件内容其实相同,但因为名称的不同而重复存在,最终在构建时都加入到APK包中,因而导致包体积增大。为了解决这类“重复资源”的问题,
但又不想增加各团队在协同相同资源开发的成本,因而采用在App构建时自动删除重复的资源文件,只保留其中一份, 而 其它相同资源都指向这个文件,这样对开发人员变得相对透明,不必关心资源是否重复,
在构建时也避免了APK体积不必要的增大。从收益的角度来看,减少apk大小因业务而定,但构建时间会有所增加。

二、原理

在介绍完背景后,接下来介绍一下插件所涉及到的内容以及主要逻辑。如果简单点说整个流程:就是通过侵入APK 的构建流程,修改中间产物达到想要的目的,而且不影响最终APK的稳定性。

在APK构建的过程中通过Package Task把dex、resources、assets、so等文件合并成一个APK。而在APK产生之 前,其实有个中间产物——*.ap_文件,这也是与资源最早相关的内容,它是processResource
Task的产物。 可以 在terminal中手动输入命令: ./gradlew process${variant}Resources ,或者使用Android Studio
中提供的Gradle面板,找到相应的模块点击process${variant}Resources需要执行的命令,当命令执行完以后, 会在build/intermediates/processed_res生成*.ap_产物:

这里的resources-chinaDebug.ap_ 文件其实就是个普通的 ZIP 文件,可以通过unzip命令解压出来。

$ unzip -lv resources-chinaDebug.ap_

从中看出resources-chinaDebug.ap_ 里面包含内容是:

  • AndroidManifest.xml

  • res文件夹

  • resources.arsc

Hook的原则是尽可能早的介入到构建流程中,因为如果能在最开始task处删除重复资源,这样就可以减少后续task 的执行时间。那么processResource Task
就是我们需要Hook的task,通过操作processResource Task的 产物*.ap_文件,删除res文件夹内重复资源,然后修改resources.arsc映射表,就可以达到想要的目的。

起始入口代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
 variant.processResTask?.doLast {
//在此去除重复的资源
variant.removeRepeatResources(it.logger, result)
}
private fun BaseVariant.removeRepeatResources(logger: Logger, results: RemoveRepeateResourceResult) {
//找出 resources.ap_ 文件
val files = processedRes.search {
it.name.startsWith(SdkConstants.FN_RES_BASE) && it.extension == SdkConstants.EXT_RES
}
files.parallelStream().forEach { ap_ ->
doRemoveRepeatResources(ap_, logger, results)
}
}

在确定插件Hook的task后,第二步需要对重复资源的定义,这里采用比较保守的方案:资源包中每个zipEntry.crc 和资源目录相同,只有同时满足这两个条件才会被定义为重复资源。

PS:起初判断资源是否相同,第一反应想到的就是文件的MD5值是否相同,而这里本身就是从ZIP文件解压出来,那完全可以通过获取未压缩数据条目的CRC-32进行校验,这样也可避免计算文件MD5值的耗时。

明确了Hook的Task,以及对重复资源的定义。接下来就是对文件的解压缩以及IO操作,这些都没什么可讲的。可能比较 不
好理解的点是如何操作resources.arsc文件,但没关系可通过android-chunk-util,这是一个用java编写的 resources.arsc文件解析工具,通过该工具可以帮助理解resources.arsc文件的结构,
同时通过该工具也可以更 改resources.arsc文件内容。至于resources.arsc文件的结构,这里由于篇幅的原因这就不做介绍, 感兴趣的同
学可查看:安卓resources.arsc解析教程

经过重复资源优化处理后,resources.arsc文件的结构如下图所示,每个资源代表一个chunk,假如以下3个chunk中的 资源相同,则处理后它们会指向相同的路径。

主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
//移除不同命名的重复资源
fun doRemoveRepeatResources(apFile: File, logger: Logger, results: CopyOnWriteArrayList<DuplicatedOrUnusedEntry>) {
val entryName = "resources.arsc"
var arscFile = File(apFile.parent, entryName)
if (arscFile.exists()) {
arscFile.delete()
}
try {
//1、解压reources.arsc文件
...
//2、找出所有的资源文件
val findResources = apFile.findResources()
...
//3、根据找到资源集合,保留resource.ap_重复相同资源不同命名的第一个zipEntry,其他重复的zipEntry删除掉
//在通过android-chunk-utils修改resources.arsc,把这些重复的资源重定向到同一个文件上
FileInputStream(arscFile).use {
ResourceFile.fromInputStream(it).apply {
val chunks = this.chunks
val removeDuplicatedEntrie = ArrayList<DuplicatedOrUnusedEntry>()//删除的资源名数据
val replaceDuplicatedEntryMap = HashMap<String, String>()//被删除后替换的资源名
findResources.filter { map -> map.value.size > 1 }.forEach { mapEntry ->
mapEntry.value.apply {
val retained = get(0)
val removedEntryNames = ArrayList<String>()
forEach { entry ->
if (retained != entry) {
//
removedEntryNames.add(entry.name)
replaceDuplicatedEntryMap[entry.name] = retained.name
removeDuplicatedEntrie.add(entry)
}
}
//移除apFile中重复的资源
apFile.removeZipEntry { zipEntry ->
!removedEntryNames.contains(zipEntry.name)
}
chunks.filterIsInstance<ResourceTableChunk>().map { chunk ->
chunk as ResourceTableChunk
}.forEach { chunk ->
val stringPool = chunk.stringPool
for (index in 0 until stringPool.stringCount) {
val key = stringPool.getString(index)
if (replaceDuplicatedEntryMap.containsKey(key)) {
//修改resources.arsc,把这些重复的资源重定向到同一个文件上
stringPool.setString(index, retained.name)

}
}
}
}

}
results.addAll(removeDuplicatedEntrie)
arscFile.delete()
//把ResourceFile中的数据写入到arscFile中
FileOutputStream(arscFile).use { fpt ->
BufferedOutputStream(fpt).use { bf ->
bf.write(this.toByteArray())
}
}
//把resource.ap_压缩包中的resources.arsc删除掉
apFile.removeZipEntry { zipEntry ->
entryName != zipEntry.name
}
//把修改后的 resources.arsc文件,放入到resource.ap_压缩包中
apFile.addZipEntry(arscFile, entryName, extractResult.entryMethod)

}
}
if (!arscFile.delete()) {
logger.error("fail to delete the file $arscFile")
}

} catch (e: Exception) {
logger.error("doRemoveRepeatResources erro", e)
}
}
  1. 从*.ap_文件中解压出res文件夹和resources.arsc文件;
  2. 收集资源放入到map中,map中key对象由zipEntry.crc和资源目录组成;
  3. 通过2中的map再结合*.ap_文件,重新得到没有重复资源的*.ap_文件;
  4. 同样在此通过2中map和android-chunk-utils库操作resources.arsc中ResourceTableChunk,把重复的资源重定向到一个文件上。得到一个新的resources.arsc表;
  5. 把3中新得到的*.ap_文件内的resources.arsc文件删除掉;
  6. 再把4中新的resources.arsc放入到*.ap_中,最终生成一个没有重复资源且resources.arsc做了重定向的*.ap_文件。

为了能够更加直观的展示插件的运行过程,做了个小动画动态的把每个步骤串行起来。如下所示,从开头到结束,其实就是从 原始的resource.ap_文件得到一个全新的resource.ap_文件。

四、最后

从收益角度来说,重复资源优化可能对APK体积的贡献并不那么突出。但如果你的APP本身已经做了一轮体积优化,而又恰好 采用组件化的形式开发(目前大部分app都是这种模式),那么重复资源的优化还是很有必要的。而且从技术的角度带来增
长是不错的,它涉及到的内容从插件的开发、APK的构建流程、资源的加载以及映射表的结构等。

五、参考资料