包體積優(yōu)化中,資源優(yōu)化一般都是首要且容易有成效的優(yōu)化方向。資源優(yōu)化是通過(guò)優(yōu)化APK中的資源項(xiàng)來(lái)優(yōu)化包體積,本文我們會(huì)介紹得物App在資源優(yōu)化上做的一些實(shí)踐。
1
插件優(yōu)化
插件優(yōu)化資源在得物App最新版本上收益12MB。插件優(yōu)化的日志在包體積平臺(tái)有具體的展示,也是為了提供一個(gè)資源問(wèn)題追溯的能力。
1.1 插件環(huán)境配置
插件首先會(huì)初始化環(huán)境配置,如果機(jī)器上未安裝運(yùn)行環(huán)境則會(huì)去oss下載對(duì)應(yīng)的可執(zhí)行文件。
1.2 圖片壓縮
在開(kāi)發(fā)階段,開(kāi)發(fā)同學(xué)首先會(huì)通過(guò)TinyPNG等工具主動(dòng)對(duì)圖片進(jìn)行壓縮,而對(duì)于三方庫(kù)和一些業(yè)務(wù)遺漏處理的圖片則會(huì)在打包的時(shí)候通過(guò)gradle插件進(jìn)行壓縮。
圖片壓縮插件使用 cwebp 對(duì)圖片進(jìn)行webp轉(zhuǎn)換,使用 guetzli 對(duì)JPEG進(jìn)行壓縮,使用pngquant對(duì)PNG 進(jìn)行壓縮,使用 gifsicle 對(duì)gif進(jìn)行壓縮。在實(shí)施對(duì)過(guò)程中,對(duì)于 res 目錄下的文件優(yōu)先使用 webp 處理,對(duì)assets 目錄下的文件則進(jìn)行同格式壓縮。下面先介紹下資源壓縮插件的工作模式和原理。
1.2.1 Res圖片壓縮
第一步,找到并遍歷 ap_ 文件
這里對(duì) ap_ 文件進(jìn)行一下簡(jiǎn)單介紹,ap_ 文件是由 AAPT2 生成的,AAPT2(Android 資源打包工具)是一種構(gòu)建工具,Android Studio 和 Android Gradle 插件使用它來(lái)編譯和打包應(yīng)用的資源。AAPT2 會(huì)解析資源、為資源編制索引,并將資源編譯為針對(duì) Android 平臺(tái)進(jìn)行過(guò)優(yōu)化的二進(jìn)制格式。
AAPT2這個(gè)工具在打包過(guò)程中主要做了下列工作: 把"assets"和"res/raw"目錄下的所有資源進(jìn)行打包(會(huì)根據(jù)不同的文件后綴選擇壓縮或不壓縮),而"res/"目錄下的其他資源進(jìn)行編譯或者其他處理(具體處理方式視文件后綴不同而不同,例如:".xml"會(huì)編譯成二進(jìn)制文件,".png"文件會(huì)進(jìn)行優(yōu)化等等)后才進(jìn)行打包; 會(huì)對(duì)除了assets資源之外所有的資源賦予一個(gè)資源ID常量,并且會(huì)生成一個(gè)資源索引表resources.arsc; 編譯AndroidManifest.xml成二進(jìn)制的XML文件; 把上面3個(gè)步驟中生成結(jié)果保存在一個(gè)*.ap_文件,并把各個(gè)資源ID常量定義在一個(gè) R.java R.txt中; |
第二步,解壓 ap_ 文件,找到 res/drawable 、res/mipmap 、res/raw 目錄下的圖片進(jìn)行壓縮
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 }圖片的壓縮收益最大,且實(shí)施簡(jiǎn)單,風(fēng)險(xiǎn)最低,是資源優(yōu)化的首選。
1.2.2Assets圖片壓縮
Assets 圖片壓縮的處理方式與 res 下差不多,區(qū)別僅僅在于掛載的 task 與 壓縮模式不同,Assets 下單資源由于是通過(guò) AssetsManager 按照名稱獲取的,且使用場(chǎng)景不可控,無(wú)法明確感知業(yè)務(wù)使用對(duì)格式是否有要求的前提下,同格式壓縮是相對(duì)穩(wěn)妥的方案。
val mergeAssets = project.tasks.getByName("merge${variantName}Assets") 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 資源去重
相較于壓縮,資源的去重需要對(duì)arsc文件格式有一點(diǎn)了解。為了便于理解,這里先對(duì)arsc二進(jìn)制文件進(jìn)行一點(diǎn)簡(jiǎn)單的介紹。 resource.arsc文件是Apk打包過(guò)程中的產(chǎn)生的一個(gè)資源索引文件,它是一個(gè)二進(jìn)制文件,源碼ResourceTypes.h 定義了其數(shù)據(jù)結(jié)構(gòu)。通過(guò)學(xué)習(xí)resource.arsc文件結(jié)構(gòu),可以幫助我們深入了解apk包體積優(yōu)化中使用到的 重復(fù)資源刪除、資源文件名混淆 技術(shù)。
將apk使用AS 打開(kāi)也能看到resource.arsc中存儲(chǔ)的信息
說(shuō)回到資源去重,去重打原理很簡(jiǎn)單,找到資源文件目錄下相同的文件,然后刪除掉重復(fù)的文件,最后到 arsc 中修改記錄,將刪除的文件索引名稱進(jìn)行替換。
由于刪除重復(fù)資源在 arsc 中只是對(duì)常量池中路徑替換,并沒(méi)有刪除 arsc 中的記錄,也沒(méi)有修改PackageChunk 中的常量池內(nèi)容,也就是對(duì)應(yīng)上圖中的 Name 字段,故而重復(fù)資源的刪除安全性比較高。
下面介紹下具體實(shí)施方案:
第一步遍歷ap文件,通過(guò) crc32 算法找出相同文件。之所以選擇 crc32 是因?yàn)?gralde 的 entry file 自帶 crc32 值,不需要進(jìn)行額外計(jì)算,但是 crc32 是有沖突風(fēng)險(xiǎn)的,故而又對(duì) crc32 的重復(fù)結(jié)果進(jìn)行 md5 二次校驗(yàn)。
第二步則是對(duì)原始重復(fù)文件的刪除
第三步修改 ResourceTableChunk 常量池內(nèi)容,進(jìn)行資源重定向
// 查詢重復(fù)資源 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() // 所有的重復(fù)資源都指定到這個(gè)第一個(gè)文件上 val coreResources = zips[0] for (index in 1 until zips.size) { // 重復(fù)的資源 val repeatZipFile = zips[index] result?.add("${repeatZipFile.name} => ${coreResources.name} reduce[${byteToSize(repeatZipFile.size)}]") // 刪除解壓的路徑的重復(fù)文件 File(unZipDir, repeatZipFile.name).delete() // 將這些重復(fù)的資源都重定向到同一個(gè)文件上 resouce .chunks .filterIsInstance () .forEach { chunk -> val stringPoolChunk = chunk.stringPool val index = stringPoolChunk.indexOf(repeatZipFile.name) if (index != -1) { // 進(jìn)行剔除重復(fù)資源 stringPoolChunk.setString(index, coreResources.name) } } } } } resouce }
1.4資源混淆
資源混淆則是在資源去重打基礎(chǔ)上更進(jìn)一步,與代碼混淆的思路一致,用長(zhǎng)路徑替換短路徑,一來(lái)減小文件名大小,二來(lái)降低arsc中常量池中二進(jìn)制文件大小。
長(zhǎng)路徑替換短路徑修改 ResourceTableChunk 即可,與重復(fù)資源處理如出一轍。
同時(shí)我們發(fā)現(xiàn) PackageChunk 中常量池中字段還是原來(lái)的內(nèi)容,但是并不影響apk的運(yùn)行。因?yàn)橥ㄟ^(guò)getDrawable(R.drawable.xxx)方式加載的資源在編譯后對(duì)應(yīng)的是getDrawable(0x7f08xxxx)這種16進(jìn)制的內(nèi)容,其實(shí)就是與 arsc 中的 ID 對(duì)應(yīng),用不上 Name 字段。而通過(guò)getResources().getIdentifier()方式調(diào)用的我們通過(guò)白名單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) // 移動(dòng)文件 File("$unZipDir${File.separator}$v").renameTo(File("$unZipDir${File.separator}$newPath")) continue } // 判斷是否有相同的 val newPath = if (mappings[v] == null) { val newPath = createProcessPath(v, builder) // 創(chuàng)建路徑 val parent = File("$unZipDir${File.separator}$newPath").parentFile if (!parent.exists()) { parent.mkdirs() } // 移動(dòng)文件 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 () .flatMap { it.chunks.values } .filterIsInstance () .forEach { for (index in 0 until it.stringCount) { it.getStrings()?.forEachIndexed { index, s -> str2[s]?.let { result -> it.setString(index, result) } } } } // 將 mapping 映射成 指定格式文件,供給反混淆服務(wù)使用 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 }
白名單配置必不可少,保證反射調(diào)用資源不參與混淆
createProcessPath 用于將長(zhǎng)路徑修改為短路徑
修改 PackageChunk 中的常量池,用于極致的包體裁剪,未壓縮前減小包體300kb,arsc壓縮后降低包體70kb
生成資源混淆mapping文件,提供給包體積服務(wù)進(jìn)行資源名稱還原使用
資源混淆的落地過(guò)程必須要謹(jǐn)慎,對(duì)存量代碼,在得物app中我們先通過(guò)字節(jié)碼掃描找出所有反射調(diào)用資源的地方,配置keep文件。對(duì)于后續(xù)業(yè)務(wù)開(kāi)發(fā)中新增的反射調(diào)用則通過(guò)測(cè)試流程及早發(fā)現(xiàn)問(wèn)題。
1.5 ARSC壓縮
Arsc 壓縮降低的體積非常可觀,壓縮后的arsc 700kb,未壓縮的約 7MB。實(shí)施起來(lái)通過(guò) 7zip對(duì) arsc文件壓縮即可。
但是 Target Sdk 在30以上 arsc 壓縮被禁了。壓縮 resources.arsc 雖然能帶來(lái)包體上的收益,但也有弊端,它將帶來(lái)內(nèi)存和運(yùn)行速度上的劣勢(shì)。不壓縮的resources.arsc系統(tǒng)可以使用mmap來(lái)節(jié)約內(nèi)存的使用(一個(gè)app的資源至少被3個(gè)進(jìn)程所持有:自己, launcher, system),而壓縮的resources.arsc會(huì)存在于每個(gè)進(jìn)程中。
2
資源下發(fā)
Apk 中的存量大資源在打包后包體積平臺(tái)檢測(cè)出來(lái),針對(duì)問(wèn)題資源排期處理。動(dòng)態(tài)下發(fā)和無(wú)用刪除則是處理存量資源的常用手段,同時(shí)通過(guò) CI 前置管控新增資源過(guò)大的情況。
資源下發(fā)的主體主要是 so 文件和圖片,對(duì)下發(fā)的資源的管控則需可以通過(guò)平臺(tái)化管理。堵不如疏,能下發(fā)的資源就下發(fā)是包體優(yōu)化的一大利器。
下發(fā)的資源通過(guò)動(dòng)態(tài)資源管理平臺(tái)進(jìn)行處理
3
無(wú)用資源刪除
無(wú)用資源的檢測(cè)結(jié)合bytex的 resCheck 編譯期 與 matrix-apk-canary smail 掃描的結(jié)果,將業(yè)務(wù)可以處理的部分在平臺(tái)上展示,版本迭代過(guò)程中邊迭代邊治理,能夠有效防止無(wú)用資源的持續(xù)惡化。
4
總結(jié)
本文主要介紹了得物APP資源優(yōu)化做了的一些動(dòng)作,其中對(duì)資源優(yōu)化插件的工作模式進(jìn)行了重點(diǎn)介紹。當(dāng)然,對(duì)于資源依舊有不少手段可以完善,比如提供高效簡(jiǎn)單的 9 圖下發(fā)方案,包體積平臺(tái)增加圖片相似度檢測(cè)能力、把一些次級(jí)的資源通過(guò)插件包下發(fā)都是之后可以嘗試的地方。
審核編輯:劉清
-
處理器
+關(guān)注
關(guān)注
68文章
19382瀏覽量
230464 -
二進(jìn)制
+關(guān)注
關(guān)注
2文章
795瀏覽量
41701 -
RAW
+關(guān)注
關(guān)注
0文章
21瀏覽量
3818 -
png
+關(guān)注
關(guān)注
0文章
14瀏覽量
4439
原文標(biāo)題:得物Android包體積資源優(yōu)化實(shí)踐
文章出處:【微信號(hào):OSC開(kāi)源社區(qū),微信公眾號(hào):OSC開(kāi)源社區(qū)】歡迎添加關(guān)注!文章轉(zhuǎn)載請(qǐng)注明出處。
發(fā)布評(píng)論請(qǐng)先 登錄
相關(guān)推薦
評(píng)論