得物 Android 包體積資源優(yōu)化實踐
包體積優(yōu)化中,資源優(yōu)化一般都是首要且容易有成效的優(yōu)化方向。資源優(yōu)化是通過優(yōu)化APK中的資源項來優(yōu)化包體積,本文我們會介紹得物App在資源優(yōu)化上做的一些實踐。
1、插件優(yōu)化
插件優(yōu)化資源在得物App最新版本上收益12MB。插件優(yōu)化的日志在包體積平臺有具體的展示,也是為了提供一個資源問題追溯的能力。
圖片
1.1 插件環(huán)境配置
插件首先會初始化環(huán)境配置,如果機器上未安裝運行環(huán)境則會去oss下載對應的可執(zhí)行文件。
圖片
1.2 圖片壓縮
在開發(fā)階段,開發(fā)同學首先會通過TinyPNG等工具主動對圖片進行壓縮,而對于三方庫和一些業(yè)務遺漏處理的圖片則會在打包的時候通過gradle插件進行壓縮。
圖片壓縮插件使用 cwebp 對圖片進行webp轉換,使用 guetzli 對JPEG進行壓縮,使用pngquant對PNG 進行壓縮,使用 gifsicle 對gif進行壓縮。在實施對過程中,對于 res 目錄下的文件優(yōu)先使用 webp 處理,對assets 目錄下的文件則進行同格式壓縮。下面先介紹下資源壓縮插件的工作模式和原理。
1.2.1 Res圖片壓縮
- 第一步,找到并遍歷 ap_ 文件
圖片
這里對 ap_ 文件進行一下簡單介紹,ap_ 文件是由 AAPT2 生成的,AAPT2(Android 資源打包工具)是一種構建工具,Android Studio 和 Android Gradle 插件使用它來編譯和打包應用的資源。AAPT2 會解析資源、為資源編制索引,并將資源編譯為針對 Android 平臺進行過優(yōu)化的二進制格式。
AAPT2這個工具在打包過程中主要做了下列工作:
把"assets"和"res/raw"目錄下的所有資源進行打包(會根據(jù)不同的文件后綴選擇壓縮或不壓縮),而"res/"目錄下的其他資源進行編譯或者其他處理(具體處理方式視文件后綴不同而不同,例如:".xml"會編譯成二進制文件,".png"文件會進行優(yōu)化等等)后才進行打包;
會對除了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
}
圖片的壓縮收益最大,且實施簡單,風險最低,是資源優(yōu)化的首選。
1.2.2 Assets圖片壓縮
Assets 圖片壓縮的處理方式與 res 下差不多,區(qū)別僅僅在于掛載的 task 與 壓縮模式不同,Assets 下單資源由于是通過 AssetsManager 按照名稱獲取的,且使用場景不可控,無法明確感知業(yè)務使用對格式是否有要求的前提下,同格式壓縮是相對穩(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 資源去重
相較于壓縮,資源的去重需要對arsc文件格式有一點了解。為了便于理解,這里先對arsc二進制文件進行一點簡單的介紹。
resource.arsc文件是Apk打包過程中的產(chǎn)生的一個資源索引文件,它是一個二進制文件,源碼ResourceTypes.h 定義了其數(shù)據(jù)結構。通過學習resource.arsc文件結構,可以幫助我們深入了解apk包體積優(yōu)化中使用到的 重復資源刪除、資源文件名混淆 技術。關于 ARSC 文件的具體細節(jié)感興趣的可以參考: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 中的常量池內(nèi)容,也就是對應上圖中的 Name 字段,故而重復資源的刪除安全性比較高。
下面介紹下具體實施方案:
- 第一步遍歷ap文件,通過 crc32 算法找出相同文件。之所以選擇 crc32 是因為 gralde 的 entry file 自帶 crc32 值,不需要進行額外計算,但是 crc32 是有沖突風險的,故而又對 crc32 的重復結果進行 md5 二次校驗。
- 第二步則是對原始重復文件的刪除
- 第三步修改 ResourceTableChunk 常量池內(nèi)容,進行資源重定向
// 查詢重復資源
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 即可,與重復資源處理如出一轍。
同時我們發(fā)現(xiàn) PackageChunk 中常量池中字段還是原來的內(nèi)容,但是并不影響apk的運行。因為通過getDrawable(R.drawable.xxx)方式加載的資源在編譯后對應的是getDrawable(0x7f08xxxx)這種16進制的內(nèi)容,其實就是與 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@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)
// 創(chuàng)建路徑
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中我們先通過字節(jié)碼掃描找出所有反射調用資源的地方,配置keep文件。對于后續(xù)業(yè)務開發(fā)中新增的反射調用則通過測試流程及早發(fā)現(xiàn)問題。
1.5 ARSC壓縮
Arsc 壓縮降低的體積非??捎^,壓縮后的arsc 700kb,未壓縮的約 7MB。實施起來通過 7zip對 arsc文件壓縮即可。
但是 Target Sdk 在30以上 arsc 壓縮被禁了。壓縮 resources.arsc 雖然能帶來包體上的收益,但也有弊端,它將帶來內(nèi)存和運行速度上的劣勢。不壓縮的resources.arsc系統(tǒng)可以使用mmap來節(jié)約內(nèi)存的使用(一個app的資源至少被3個進程所持有:自己, launcher, system),而壓縮的resources.arsc會存在于每個進程中。
2、資源下發(fā)
Apk 中的存量大資源在打包后包體積平臺檢測出來,針對問題資源排期處理。動態(tài)下發(fā)和無用刪除則是處理存量資源的常用手段,同時通過 CI 前置管控新增資源過大的情況。
資源下發(fā)的主體主要是 so 文件和圖片,對下發(fā)的資源的管控則需可以通過平臺化管理。堵不如疏,能下發(fā)的資源就下發(fā)是包體優(yōu)化的一大利器。
圖片
下發(fā)的資源通過動態(tài)資源管理平臺進行處理
圖片
3、無用資源刪除
無用資源的檢測結合bytex的 resCheck 編譯期 與 matrix-apk-canary smail 掃描的結果,將業(yè)務可以處理的部分在平臺上展示,版本迭代過程中邊迭代邊治理,能夠有效防止無用資源的持續(xù)惡化。
圖片
4、總結
本文主要介紹了得物APP資源優(yōu)化做了的一些動作,其中對資源優(yōu)化插件的工作模式進行了重點介紹。當然,對于資源依舊有不少手段可以完善,比如提供高效簡單的 9 圖下發(fā)方案,包體積平臺增加圖片相似度檢測能力、把一些次級的資源通過插件包下發(fā)都是之后可以嘗試的地方。