包体积优化中,资源优化一般都是首要且容易有成效的优化方向。资源优化是通过优化APK中的资源项来优化包体积,本文我们会介绍得物App在资源优化上做的一些实践。
插件优化资源在得物App最新版本上收益12MB。插件优化的日志在包体积平台有具体的展示,也是为了提供一个资源问题追溯的能力。
图片
插件首先会初始化环境配置,如果机器上未安装运行环境则会去oss下载对应的可执行文件。
图片
在开发阶段,开发同学首先会通过TinyPNG等工具主动对图片进行压缩,而对于三方库和一些业务遗漏处理的图片则会在打包的时候通过gradle插件进行压缩。
图片压缩插件使用 cwebp 对图片进行webp转换,使用 guetzli 对JPEG进行压缩,使用pngquant对PNG 进行压缩,使用 gifsicle 对gif进行压缩。在实施对过程中,对于 res 目录下的文件优先使用 webp 处理,对assets 目录下的文件则进行同格式压缩。下面先介绍下资源压缩插件的工作模式和原理。
第一步,找到并遍历 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<0){ println("compressImg ignore ${imgFile.path}")return0} val tempFilePath="${imgFile.path.substring(0, lastIndexOf)}_temp${imgFile.path.substring(lastIndexOf)}"if(ImageUtil.isJPG(imgFile)){ Tools.cmd("guetzli","--quality 85 ${imgFile.path} $tempFilePath")}elseif(ImageUtil.isGIF(imgFile)){ Tools.cmd("gifsicle","-O3 --lossy=25 ${imgFile.path} -o $tempFilePath")}elseif(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()returnif(newSizein1until oldSize){ val imgFileName: String=imgFile.pathif(imgFile.exists()){ imgFile.delete()} tempFile.renameTo(File(imgFileName))oldSize-newSize }else{if(tempFile.exists()){ tempFile.delete()}0L } }return0}
图片的压缩收益最大,且实施简单,风险最低,是资源优化的首选。
Assets 图片压缩的处理方式与 res 下差不多,区别仅仅在于挂载的 task 与 压缩模式不同,Assets 下单资源由于是通过 AssetsManager 按照名称获取的,且使用场景不可控,无法明确感知业务使用对格式是否有要求的前提下,同格式压缩是相对稳妥的方案。
val mergeAssets=project.tasks.getByName("merge${variantName}Assets")mergeAssets.doLast { task->(taskasMergeSourceSetFolders).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)}")}
相较于压缩,资源的去重需要对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)){ valfile=File(unZipDir,zipEntry.name)MD5Util.computeMD5(file).takeIf { it.isNotEmpty()}?.let { valset=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(indexin1until 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 valindex=stringPoolChunk.indexOf(repeatZipFile.name)if(index!=-1){// 进行剔除重复资源stringPoolChunk.setString(index,coreResources.name)} } } } } resouce }
资源混淆则是在资源去重打基础上更进一步,与代码混淆的思路一致,用长路径替换短路径,一来减小文件名大小,二来降低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<ResourceTableChunk>().forEach { chunk->val stringPoolChunk=chunk.stringPool// 获取所有的路径val strings=stringPoolChunk.getStrings()?:return@forEachfor(indexin0until 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").parentFileif(!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").parentFileif(!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("/")+1var endIndex=it.key.lastIndexOf(".")if(endIndex<0){ endIndex=it.key.length }if(endIndex<startIndex){ it.keytoit.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(indexin0until 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"))// 将白名单的文件移回reskeeps.forEach { val newPath=it.replaceFirst(whiteTempRes,"res")val parent=File("$unZipDir${File.separator}$newPath").parentFileif(!parent.exists()){ parent.mkdirs()}File("$unZipDir${File.separator}$it").renameTo(File("$unZipDir${File.separator}$newPath"))}// 收尾删除 res2FileOperation.deleteDir(File("$unZipDir${File.separator}$whiteTempRes"))resouce }
白名单配置必不可少,保证反射调用资源不参与混淆
createProcessPath 用于将长路径修改为短路径
修改 PackageChunk 中的常量池,用于极致的包体裁剪,未压缩前减小包体300kb,arsc压缩后降低包体70kb
图片
生成资源混淆mapping文件,提供给包体积服务进行资源名称还原使用
资源混淆的落地过程必须要谨慎,对存量代码,在得物app中我们先通过字节码扫描找出所有反射调用资源的地方,配置keep文件。对于后续业务开发中新增的反射调用则通过测试流程及早发现问题。
Arsc 压缩降低的体积非常可观,压缩后的arsc 700kb,未压缩的约 7MB。实施起来通过 7zip对 arsc文件压缩即可。
但是 Target Sdk 在30以上 arsc 压缩被禁了。压缩 resources.arsc 虽然能带来包体上的收益,但也有弊端,它将带来内存和运行速度上的劣势。不压缩的resources.arsc系统可以使用mmap来节约内存的使用(一个app的资源至少被3个进程所持有:自己, launcher, system),而压缩的resources.arsc会存在于每个进程中。
Apk 中的存量大资源在打包后包体积平台检测出来,针对问题资源排期处理。动态下发和无用删除则是处理存量资源的常用手段,同时通过 CI 前置管控新增资源过大的情况。
资源下发的主体主要是 so 文件和图片,对下发的资源的管控则需可以通过平台化管理。堵不如疏,能下发的资源就下发是包体优化的一大利器。
图片
下发的资源通过动态资源管理平台进行处理
图片
无用资源的检测结合bytex的 resCheck 编译期 与 matrix-apk-canary smail 扫描的结果,将业务可以处理的部分在平台上展示,版本迭代过程中边迭代边治理,能够有效防止无用资源的持续恶化。
图片
本文主要介绍了得物APP资源优化做了的一些动作,其中对资源优化插件的工作模式进行了重点介绍。当然,对于资源依旧有不少手段可以完善,比如提供高效简单的 9 图下发方案,包体积平台增加图片相似度检测能力、把一些次级的资源通过插件包下发都是之后可以尝试的地方。