介绍得物App在资源优化上做的一些实践

包体积优化中,资源优化一般都是首要且容易有成效的优化方向。资源优化是通过优化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图片压缩
第一步,找到并遍历 ap_ 文件
这里对 ap_ 文件进行一下简单介绍,ap_ 文件是由 aapt2 生成的,aapt2(android 资源打包工具)是一种构建工具,android studio 和 android gradle 插件使用它来编译和打包应用的资源。aapt2 会解析资源、为资源编制索引,并将资源编译为针对 android 平台进行过优化的二进制格式。
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 (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包体积优化中使用到的 重复资源删除、资源文件名混淆 技术。
将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()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() .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().getidentifier()方式调用的我们通过白名单keep住了,name 字段在这里也是可以移除的。
val resourcesfile = file(unzipdir, resources.arsc) val newresouce = fileinputstream(resourcesfile).use { inputstream -> val resouce = resourcefile.frominputstream(inputstream) resouce .chunks .filterisinstance() .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 str2[s]?.let { result -> it.setstring(index, result) } } } } // 将 mapping 映射成 指定格式文件,供给反混淆服务使用 val mmappingwriter: writer = bufferedwriter(filewriter(file, false)) val packagename = context.proguardresourcesextension.packagename val pathmappings = mutablemapof() val idmappings = mutablemapof() 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 图下发方案,包体积平台增加图片相似度检测能力、把一些次级的资源通过插件包下发都是之后可以尝试的地方。


口腔医疗服务的互联网化探索
大数据安全:数字化转型的“必答题”
千万不能栽在这个电路图上
过流保护PTC热敏电阻器选用指南
220V转12V直流稳压电压(电源电路图)
介绍得物App在资源优化上做的一些实践
酷比koobeeS12拍照体验 如何在同质化时代打造出差异化
诺基亚3310最新消息:诺基亚3310时代的经典,你会为这分情怀买单吗?
中国无人机出口优势明显,无人机竞争力明显提高
中国移动将停售新版iPhone15?
波士顿动力公司机器狗将售卖,商业化后能大卖吗?
三菱FX 5U PLC高速计数器的相关资料 赶紧收藏一波
荣耀Note9什么时候上市?华为荣耀Note9谍照曝光,大屏党福利马上就来,2798起价格很良心
华为P40最新渲染图曝光 采用双孔全面屏且后置矩阵相机
5G让新型基础设施建设提速,新能源产业链企业如何把握机会
曝苹果新款ARM架构Mac芯片最高搭载32核CPU
基于二维六方氮化硼无机液晶的磁光调制器
鸡血版显卡驱动:DirectX 12专版驱动
研究人员开发出一个水果采摘机器人
Rust UI框架:Slint UI简单入门