写点什么

得物 Android 包体积资源优化实践

作者:得物技术
  • 2023-07-20
    上海
  • 本文字数:8183 字

    阅读完需:约 27 分钟

得物 Android 包体积资源优化实践

包体积优化中,资源优化一般都是首要且容易有成效的优化方向。资源优化是通过优化 APK 中的资源项来优化包体积,本文我们会介绍得物 App 在资源优化上做的一些实践。

1. 插件优化

插件优化资源在得物 App 最新版本上收益 12MB。插件优化的日志在包体积平台有具体的展示,也是为了提供一个资源问题追溯的能力。


1.1 插件环境配置

插件首先会初始化环境配置,如果机器上未安装运行环境则会去 oss 下载对应的可执行文件。


1.2 图片压缩

在开发阶段,开发同学首先会通过 TinyPNG 等工具主动对图片进行压缩,而对于三方库和一些业务遗漏处理的图片则会在打包的时候通过 gradle 插件进行压缩。图片压缩插件使用 cwebp 对图片进行 webp 转换,使用 guetzli 对 JPEG 进行压缩,使用 pngquant 对 PNG 进行压缩,使用 gifsicle 对 gif 进行压缩。在实施对过程中,对于 res 目录下的文件优先使用 webp 处理,对 assets 目录下的文件则进行同格式压缩。下面先介绍下资源压缩插件的工作模式和原理。

1.2.1 Res 图片压缩

  • 第一步,找```language


到并遍历 ap_ 文件
![103.png](https://cdn.poizon.com/ctoo/072011/103.png)
###### AAPT2这个工具在打包过程中主要做了下列工作:把"assets"和"res/raw"目录下的所有资源进行打包(会根据不同的文件后缀选择压缩或不压缩),而"res/"目录下的其他资源进行编译或者其他处理(具体处理方式视文件后缀不同而不同,例如:".xml"会编译成二进制文件,".png"文件会进行优化等等)后才进行打包;会对除了assets资源之外所有的资源赋予一个资源ID常量,并且会生成一个资源索引表resources.arsc;编译AndroidManifest.xml成二进制的XML文件;把上面3个步骤中生成结果保存在一个*.ap_文件,并把各个资源ID常量定义在一个 R.java\ R.txt中;
- 第二步,解压 ap_ 文件,找到 res/drawable 、res/mipmap 、res/raw 目录下的图片进行压缩```fun compressImg(imgFile: File): Long { if (ImageUtil.isJPG(imgFile) || ImageUtil.isGIF(imgFile) || ImageUtil.isPNG(imgFile)) { val lastIndexOf = imgFile.path.lastIndexOf(".") if (lastIndexOf < 0) { println("compressImg ignore ${imgFile.path}") return 0 } val tempFilePath = "${imgFile.path.substring(0, lastIndexOf)}_temp${imgFile.path.substring(lastIndexOf)}"
if (ImageUtil.isJPG(imgFile)) { Tools.cmd("guetzli", "--quality 85 ${imgFile.path} $tempFilePath") } else if (ImageUtil.isGIF(imgFile)) { Tools.cmd("gifsicle", "-O3 --lossy=25 ${imgFile.path} -o $tempFilePath") } else if (ImageUtil.isPNG(imgFile)) { Tools.cmd( "pngquant", "--skip-if-larger --speed 1 --nofs --strip --force --quality=75 ${imgFile.path} --output $tempFilePath" ) } val oldSize = imgFile.length() val tempFile = File(tempFilePath) val newSize = tempFile.length() return if (newSize in 1 until oldSize) { val imgFileName: String = imgFile.path if (imgFile.exists()) { imgFile.delete() } tempFile.renameTo(File(imgFileName)) oldSize - newSize } else { if (tempFile.exists()) { tempFile.delete() } 0L } } return 0}
复制代码


图片的压缩收益最大,且实施简单,风险最低,是资源优化的首选。

1.2.2 Assets 图片压缩

Assets 图片压缩的处理方式与 res 下差不多,区别仅仅在于挂载的 task 与 压缩模式不同,Assets 下单资源由于是通过 AssetsManager 按照名称获取的,且使用场景不可控,无法明确感知业务使用对格式是否有要求的前提下,同格式压缩是相对稳妥的方案。


mergeAssets.doLast { task ->    (task as MergeSourceSetFolders).outputDir.asFileTree.files.filter {        val originalPath = it.absolutePath.replace(task.outputDir.get().toString() + "/", "")        val filter = context.compressAssetsExtension.whiteList.contains(originalPath)        if (filter) {            println("Assets compress ignore:$originalPath")        }        !filter    }.forEach { file ->        val originalPath = file.absolutePath.replace(task.outputDir.get().toString() + "/", "")        val reduceSize = CompressUtil.compressImg(file)        if (reduceSize > 0) {            assetsShrinkLength += reduceSize            assetsList.add("$originalPath => reduce[${byteToSize(reduceSize)}]")        }    }    println("assets optimized:${byteToSize(assetsShrinkLength)}")}
复制代码

1.3 资源去重

相较于压缩,资源的去重需要对 arsc 文件格式有一点了解。为了便于理解,这里先对 arsc 二进制文件进行一点简单的介绍。resource.arsc 文件是 Apk 打包过程中的产生的一个资源索引文件,它是一个二进制文件,源码 ResourceTypes.h 定义了其数据结构。通过学习 resource.arsc 文件结构,可以帮助我们深入了解 apk 包体积优化中使用到的 重复资源删除、资源文件名混淆 技术。关于 ARSC 文件的具体细节感兴趣的可以参考:https://huanle19891345.github.io/en/android/%E7%83%AD%E4%BF%AE%E5%A4%8D%E5%AD%97%E8%8A%82%E7%A0%81/tinker/%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/resource.arsc%E7%94%9F%E6%88%90%E5%92%8C%E7%BB%93%E6%9E%84/



将 apk 使用 AS 打开也能看到 resource.arsc 中存储的信息



说回到资源去重,去重打原理很简单,找到资源文件目录下相同的文件,然后删除掉重复的文件,最后到 arsc 中修改记录,将删除的文件索引名称进行替换。由于删除重复资源在 arsc 中只是对常量池中路径替换,并没有删除 arsc 中的记录,也没有修改 PackageChunk 中的常量池内容,也就是对应上图中的 Name 字段,故而重复资源的删除安全性比较高。下面介绍下具体实施方案:


  • 第一步遍历 ap 文件,通过 crc32 算法找出相同文件。之所以选择 crc32 是因为 gralde 的 entry file 自带 crc32 值,不需要进行额外计算,但是 crc32 是有冲突风险的,故而又对 crc32 的重复结果进行 md5 二次校验。

  • 第二步则是对原始重复文件的删除

  • 第三步修改 ResourceTableChunk 常量池内容,进行资源重定向


val groupResources = ZipFile(apFile).groupsResources()// 获取val resourcesFile = File(unZipDir, "resources.arsc")val md5Map = HashMap<String, HashSet<ZipEntry>>()val newResouce = FileInputStream(resourcesFile).use { stream ->    val resouce = ResourceFile.fromInputStream(stream)    groupResources.asSequence()        .filter { it.value.size > 1 }        .map { entry ->            entry.value.forEach { zipEntry ->                if (whiteList.isEmpty() || !whiteList.contains(zipEntry.name)) {                    val file = File(unZipDir, zipEntry.name)                    MD5Util.computeMD5(file).takeIf { it.isNotEmpty() }?.let {                        val set = md5Map.getOrDefault(it, HashSet())                        set.add(zipEntry)                        md5Map[it] = set                    }                }            }            md5Map.values        }        .filter { it.size > 1 }        .forEach { collection ->            // 删除多余资源            collection.forEach { it ->                val zips = it.toTypedArray()                // 所有的重复资源都指定到这个第一个文件上                val coreResources = zips[0]                for (index in 1 until zips.size) {                    // 重复的资源                    val repeatZipFile = zips[index]                    result?.add("${repeatZipFile.name} => ${coreResources.name}    reduce[${byteToSize(repeatZipFile.size)}]")                    // 删除解压的路径的重复文件                    File(unZipDir, repeatZipFile.name).delete()                    // 将这些重复的资源都重定向到同一个文件上                    resouce                        .chunks                        .filterIsInstance<ResourceTableChunk>()                        .forEach { chunk ->                            val stringPoolChunk = chunk.stringPool                            val index = stringPoolChunk.indexOf(repeatZipFile.name)                            if (index != -1) {                                // 进行剔除重复资源                                stringPoolChunk.setString(index, coreResources.name)                            }                        }                }            }        }
resouce}
复制代码

1.4 资源混淆

资源混淆则是在资源去重打基础上更进一步,与代码混淆的思路一致,用长路径替换短路径,一来减小文件名大小,二来降低 arsc 中常量池中二进制文件大小。长路径替换短路径修改 ResourceTableChunk 即可,与重复资源处理如出一辙。同时我们发现 PackageChunk 中常量池中字段还是原来的内容,但是并不影响 apk 的运行。因为通过 getDrawable(R.drawable.xxx)方式加载的资源在编译后对应的是 getDrawable(0x7f08xxxx)这种 16 进制的内容,其实就是与 arsc 中的 ID 对应,用不上 Name 字段。而通过 getResources().g


        val newResouce = FileInputStream(resourcesFile).use { inputStream ->            val resouce = ResourceFile.fromInputStream(inputStream)            resouce                .chunks                .filterIsInstance<ResourceTableChunk>()                .forEach { chunk ->                    val stringPoolChunk = chunk.stringPool                    // 获取所有的路径                    val strings = stringPoolChunk.getStrings() ?: return@forEach
for (index in 0 until stringPoolChunk.stringCount) { val v = strings[index]
if (v.startsWith("res")) { if (ignore(v, context.proguardResourcesExtension.whiteList)) { println("resProguard ignore $v ") // 把文件移到新的目录 val newPath = v.replaceFirst("res", whiteTempRes) val parent = File("$unZipDir${File.separator}$newPath").parentFile if (!parent.exists()) { parent.mkdirs() } keeps.add(newPath) // 移动文件 File("$unZipDir${File.separator}$v").renameTo(File("$unZipDir${File.separator}$newPath")) continue } // 判断是否有相同的 val newPath = if (mappings[v] == null) { val newPath = createProcessPath(v, builder) // 创建路径 val parent = File("$unZipDir${File.separator}$newPath").parentFile if (!parent.exists()) { parent.mkdirs() } // 移动文件 val isOk = File("$unZipDir${File.separator}$v").renameTo(File("$unZipDir${File.separator}$newPath")) if (isOk) { mappings[v] = newPath newPath } else { mappings[v] = v v } } else { mappings[v] } strings[index] = newPath!! } }
val str2 = mappings.map { val startIndex = it.key.lastIndexOf("/") + 1 var endIndex = it.key.lastIndexOf(".")
if (endIndex < 0) { endIndex = it.key.length } if (endIndex < startIndex) { it.key to it.value } else {// val vStartIndex = it.value.lastIndexOf("/") + 1// var vEndIndex = it.value.lastIndexOf(".")// if (vEndIndex < 0) {// vEndIndex = it.value.length// }// val result = it.value.substring(vStartIndex, vEndIndex) // 使用相同的字符串,以减小体积 it.key.substring(startIndex, endIndex) to "du" } }.toMap()
// 修改 arsc PackageChunk 字段 chunk.chunks.values.filterIsInstance<PackageChunk>() .flatMap { it.chunks.values } .filterIsInstance<StringPoolChunk>() .forEach { for (index in 0 until it.stringCount) { it.getStrings()?.forEachIndexed { index, s -> str2[s]?.let { result -> it.setString(index, result) } } } }
// 将 mapping 映射成 指定格式文件,供给反混淆服务使用 val mMappingWriter: Writer = BufferedWriter(FileWriter(file, false)) val packageName = context.proguardResourcesExtension.packageName val pathMappings = mutableMapOf<String, String>() val idMappings = mutableMapOf<String, String>() mappings.filter { (t, u) -> t != u }.forEach { (t, u) -> result?.add(" $t => $u") compress[t]?.let { compress[u] = it compress.remove(t) } val pathKey = t.substring(0, t.lastIndexOf("/")) pathMappings[pathKey] = u.substring(0, u.lastIndexOf("/")) val typename = t.split("/")[1].split("-")[0] val path1 = t.substring(t.lastIndexOf("/") + 1, t.indexOf(".")) val path2 = u.substring(u.lastIndexOf("/") + 1, u.indexOf(".")) val path = "$packageName.R.$typename.$path1" val pathV = "$packageName.R.$typename.$path2" if (idMappings[path].isNullOrEmpty()) { idMappings[path] = pathV } } generalFileResMapping(mMappingWriter, pathMappings) generalResIDMapping(mMappingWriter, idMappings) }
// 删除res下的文件 FileOperation.deleteDir(File("$unZipDir${File.separator}res")) // 将白名单的文件移回res keeps.forEach { val newPath = it.replaceFirst(whiteTempRes, "res") val parent = File("$unZipDir${File.separator}$newPath").parentFile if (!parent.exists()) { parent.mkdirs() } File("$unZipDir${File.separator}$it").renameTo(File("$unZipDir${File.separator}$newPath")) } // 收尾删除 res2 FileOperation.deleteDir(File("$unZipDir${File.separator}$whiteTempRes")) resouce }
复制代码


  • 白名单配置必不可少,保证反射调用资源不参与混淆

  • createProcessPath 用于将长路径修改为短路径

  • 修改 PackageChunk 中的常量池,用于极致的包体裁剪,未压缩前减小包体 300kb,arsc 压缩后降低包体 70kb



  • 生成资源混淆 mapping 文件,提供给包体积服务进行资源名称还原使用


资源混淆的落地过程必须要谨慎,对存量代码,在得物 app 中我们先通过字节码扫描找出所有反射调用资源的地方,配置 keep 文件。对于后续业务开发中新增的反射调用则通过测试流程及早发现问题。

1.5 ARSC 压缩

Arsc 压缩降低的体积非常可观,压缩后的 arsc 700kb,未压缩的约 7MB。实施起来通过 7zip 对 arsc 文件压缩即可。



但是 Target Sdk 在 30 以上 arsc 压缩被禁了。压缩 resources.arsc 虽然能带来包体上的收益,但也有弊端,它将带来内存和运行速度上的劣势。不压缩的 resources.arsc 系统可以使用 mmap 来节约内存的使用(一个 app 的资源至少被 3 个进程所持有:自己, launcher, system),而压缩的 resources.arsc 会存在于每个进程中。

2. 资源下发

Apk 中的存量大资源在打包后包体积平台检测出来,针对问题资源排期处理。动态下发和无用删除则是处理存量资源的常用手段,同时通过 CI 前置管控新增资源过大的情况。


资源下发的主体主要是 so 文件和图片,对下发的资源的管控则需可以通过平台化管理。堵不如疏,能下发的资源就下发是包体优化的一大利器。



下发的资源通过动态资源管理平台进行处理


3. 无用资源删除

无用资源的检测结合 bytex 的 resCheck 编译期 与 matrix-apk-canary smail 扫描的结果,将业务可以处理的部分在平台上展示,版本迭代过程中边迭代边治理,能够有效防止无用资源的持续恶化。


4. 总结

本文主要介绍了得物 APP 资源优化做了的一些动作,其中对资源优化插件的工作模式进行了重点介绍。当然,对于资源依旧有不少手段可以完善,比如提供高效简单的 9 图下发方案,包体积平台增加图片相似度检测能力、把一些次级的资源通过插件包下发都是之后可以尝试的地方。


::: hljs-center


*文/Jay


:::

本文属得物技术原创,更多精彩文章请看:得物技术官网

未经得物技术许可严禁转载,否则依法追究法律责任!

发布于: 刚刚阅读数: 6
用户头像

得物技术

关注

得物APP技术部 2019-11-13 加入

关注微信公众号「得物技术」

评论

发布
暂无评论
得物 Android 包体积资源优化实践_前端_得物技术_InfoQ写作社区