一、介绍
resources.arsc是APK中的一个文件,主要包含apk相关的资源信息。这些资源包括了APP中使用的所有非代码元素,比如一些字符串String、图片、静态数组、color等,见下图。
它是怎么生成的?就是当你构建你的APP时,aapt会自动将APP的资源文件编译成二进制格式,并将它们存储在resources.arsc文件中。
resources.arsc主要作用是让APP能够快速且有效地访问和管理它的资源,由于这些资源已经被编译成了二进制格式,所以APP可以快速地从resources.arsc文件中加载资源。
二、痛点
一个app项目,一般是由多人联合开发,或者涉及到组件化或者多moudle,有可能存在重复图片资源,这些重复资源如果不处理,就会随着业务增长日渐膨胀。
另外,我们在引入图片资源时会先使用TinyPng进行压缩以减少包体积大小,但是涉及到多方开发时,难免会无法控制他人的行为,要想在根源上减少包体积,这就需要将压缩资源的动作交由程序自动化完成。
三、如何找到干预点
对于干预点的查找,就需要先简单了解一下APK的打包流程。
当我们执行assembleRelease命令时,会看到控制台会打印出多个Task,这里我们只需要关注processReleaseResources这个Task,因为它涉及到了res中资源的打包,
Android构建系统会使用AAPT将资源文件(例如,图片、XML布局文件等)编译成二进制格式,这个过程也会生成R.java文件,该文件为每个资源提供了一个唯一的ID,也就是将 res 中的资源文件等记录到 resources.arsc 文件中,
而 process${variantName}Resource
Task 会在 build/intermediates/processed_res/${variantName}/out
目录下生成 resources-${variantName}.ap_
压缩文件,其中包含了 AndroidManifest.xml、resources.arsc、drawable 等等文件。
我们就需要在打包流程中执行 processReleaseResources
Task后增加一个 Task,对 xx.ap_
文件进行资源去除和压缩处理。
四、解决方案
总体流程:
-
找到"
process${variantName}Resource
"Task; -
在这个"
process${variantName}Resource
"Task后新增我们的Task; -
获取资源打包任务的输出目录中的所有文件,遍历出后缀带"
ap_
"的文件; -
对于每一个
.ap_
文件,执行以下操作:- 解压缩该文件;
- 删除重复的资源文件;
- 压缩图片文件;
- 删除原始的.ap_文件,并将处理后的资源文件重新压缩为.ap_文件;
- 删除解压缩的资源文件夹;
kotlin
override fun apply(p0: Project) {
// 总配置
p0.extensions.create(CONFIG_NAME, Config::class.java)
// 重复资源配置
p0.extensions.create(REPEAT_RES_CONFIG_NAME, RepeatResConfig::class.java)
// 压缩图片配置
p0.extensions.create(COMPRESS_IMG_CONFIG_NAME, CompressImgConfig::class.java)
val hasAppPlugin = p0.plugins.hasPlugin(AppPlugin::class.java)
if (hasAppPlugin) {
p0.afterEvaluate {
FileUtil.setRootDir(p0.rootDir.path)
print("PluginTest Config " + p0.extensions.findByName(CONFIG_NAME))
val config: Config? = p0.extensions.findByName(CONFIG_NAME) as? Config
val repeatResConfig =
p0.extensions.findByName(REPEAT_RES_CONFIG_NAME) as? RepeatResConfig
val compressImgConfig =
p0.extensions.findByName(COMPRESS_IMG_CONFIG_NAME) as? CompressImgConfig
// 不开启插件
if (config?.enable == false) {
return@afterEvaluate
}
val byType = p0.extensions.getByType(AppExtension::class.java)
byType.applicationVariants.forEach {
val variantName = it.name.capitalize()
val processRes = p0.tasks.getByName("process${variantName}Resources")
processRes.doLast {
val resourcesTask =
it as LinkApplicationAndroidResourcesTask
val files = resourcesTask.resPackageOutputFolder.asFileTree.files
files.filter { file ->
file.name.endsWith(".ap_")
}.forEach { apFile ->
val mapping =
"${p0.buildDir}${File.separator}ResDeduplication${File.separator}mapping${File.separator}"
File(mapping).takeIf { fileMapping ->
!fileMapping.exists()
}?.apply {
mkdirs()
}
val originalLength = apFile.length()
val resCompressFile = File(mapping, REPEAT_RES_MAPPING)
val unZipPath = "${apFile.parent}${File.separator}resCompress"
ZipFile(apFile).unZipFile(unZipPath)
// 删除重复图片
deleteRepeatRes(
unZipPath,
resCompressFile,
apFile,
repeatResConfig?.whiteListName
)
// 压缩图片
compressImg(mapping, compressImgConfig, unZipPath)
apFile.delete()
ZipOutputStream(apFile.outputStream()).use { output ->
output.zip(unZipPath, File(unZipPath))
}
val lastLength = apFile.length()
print("优化结束缩减:${lastLength - originalLength}")
deleteDir(File(unZipPath))
}
}
}
}
}
}
以下会重点输出删除重复资源和压缩图片两个部分。
4.1 删除重复资源
资源ResourceFile中包含了文件的所有内容,chunks
就是ResourceFile的一个属性,属于一个列表,它这个列表中包含了资源文件中的所有chunk,在 Android 的资源文件中,不同类型的内容会被存储在不同类型的chunk中。例如,字符串会被存储在 StringPoolChunk
中,资源类会被存储在 ResourceTableChunk
中。
重点流程如下:
-
写入映射关系,它会在映射文件中写入一条映射关系,表示这个重复的资源文件对应到了哪一个保留下来的资源文件;
-
删除重复的资源文件;
-
更新资源表,遍历资源表中的所有 ResourceTableChunk 块,对于每一个Chunk,都执行以下的操作:
- 获取该Chunk的字符串池;
- 在字符串池中找到重复的资源文件的名字的索引;
- 如果找到了,那么它会将该索引处的字符串替换为保留下来的资源文件的名字;
kotlin
private fun deleteRepeatRes(
unZipPath: String,
mappingFile: File,
apFile: File,
ignoreName: MutableList<String>?
) {
val fileWriter = FileWriter(mappingFile)
val groupsResources = ZipFile(apFile).groupsResources()
val arscFile = File(unZipPath, RESOURCE_NAME)
val newResource = FileInputStream(arscFile).use { input ->
val fromInputStream = ResourceFile.fromInputStream(input)
groupsResources.asSequence().filter {
it.value.size > 1
}.filter { entry ->
val name = File(entry.value[0].name).name
ignoreName?.contains(name)?.let {
!it
} ?: true
}.forEach { zipMap ->
val zips = zipMap.value
val coreResources = zips[0]
for (index in 1 until zips.size) {
val repeatZipFile = zips[index]
fileWriter.synchronizedWriteString("${repeatZipFile.name} => ${coreResources.name}")
File(unZipPath, repeatZipFile.name).delete()
fromInputStream
.chunks
.asSequence()
.filter {
it is ResourceTableChunk
}
.map {
it as ResourceTableChunk
}
.forEach { chunk ->
val stringPoolChunk = chunk.stringPool
val index = stringPoolChunk.indexOf(repeatZipFile.name)
if (index != -1) {
stringPoolChunk.setString(index, coreResources.name)
}
}
}
}
fileWriter.close()
fromInputStream
}
arscFile.delete()
FileOutputStream(arscFile).use {
it.write(newResource.toByteArray())
}
}
4.2 压缩图片
压缩图片主要分为两种:
主要流程如下:
-
检查文件类型: 首先,它会检查输入的文件是否是图片;
-
处理 JPG 图片: 如果图片文件是 JPG 格式,它会执行以下操作:
- 创建一个临时文件路径。
- 使用 guetzli 命令来压缩图片,并将结果保存到临时文件中。
- 获取压缩后的图片大小。
- 如果压缩后的大小小于原始大小,它会删除原始文件,并将临时文件重命名为原始文件的名字
- 如果压缩后的大小不小于原始大小,它会删除临时文件,并返回 0。
-
处理PNG图: 使用 pngquant 命令来压缩图片;
-
处理WEBP图: 使用cwebp将png和jpg类型的图片转为webp格式;
kotlin
private suspend fun CoroutineScope.compressionImg(
mappingFile: File,
unZipDir: String,
config: CompressImgConfig,
webpsLsit: CopyOnWriteArrayList<WebpFileData>
) {
val mappginWriter = FileWriter(mappingFile)
launch {
val file = File("$unZipDir${File.separator}res")
file.listFiles()
.filter {
it.isDirectory && (it.name.startsWith("drawable") || it.name.startsWith("mipmap"))
}
.flatMap {
it.listFiles().toList()
}
.asSequence()
.filter {
config.whiteListName?.contains(it.name)?.let { !it } ?: true
}
.filter {
ImageUtil.isImage(it)
}
.forEach {
// 图片压缩
launch(Dispatchers.Default) {
when (config.optimizeType) {
OPTIMIZE_COMPRESS_PICTURE -> {
val originalPath =
it.absolutePath.replace("${unZipDir}${File.separator}", "")
val reduceSize = compressImg(it)
if (reduceSize > 0) {
mappginWriter.synchronizedWriteString("$originalPath => 减少[$reduceSize]")
} else {
mappginWriter.synchronizedWriteString("$originalPath => 压缩失败")
}
}
OPTIMIZE_WEBP_CONVERT -> {
val webp0K = ImageUtil.securityFormatWebp(it, config)
webp0K?.apply {
val originalPath = original.absolutePath.replace(
"${unZipDir}${File.separator}",
""
)
val webpFilePath = webpFile.absolutePath.replace(
"${unZipDir}${File.separator}",
""
)
mappginWriter.synchronizedWriteString("$originalPath => $webpFilePath => 减少[$reduceSize]")
webpsLsit.add(this)
}
}
else -> {
println("图片优化类型 optimizeType [${config.optimizeType}] 不存在,使用 ${OPTIMIZE_COMPRESS_PICTURE} 类型压缩图片!")
}
}
}
}
}.join()
mappginWriter.close()
}
五、收益
使用裁剪和压缩plugin之前:
使用之后:
before | after | |
---|---|---|
resources.arsc | 736.4kb | 162.2kb |
总包体积 | 16.8mb | 12.8mb |
源码地址: github.com/fuusy/ResCo...
参考资料: