包体积优化|裁剪resources.arsc和资源压缩自动化

一、介绍

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_文件进行资源去除和压缩处理。

四、解决方案

总体流程:

  1. 找到"process${variantName}Resource"Task;

  2. 在这个"process${variantName}Resource"Task后新增我们的Task;

  3. 获取资源打包任务的输出目录中的所有文件,遍历出后缀带"ap_"的文件;

  4. 对于每一个.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 中。

重点流程如下:

  1. 写入映射关系,它会在映射文件中写入一条映射关系,表示这个重复的资源文件对应到了哪一个保留下来的资源文件;

  2. 删除重复的资源文件

  3. 更新资源表,遍历资源表中的所有 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 压缩图片

压缩图片主要分为两种:

  1. 使用pngquant压缩png类型的图,使用guetzli来压缩jpg类型的图;
  2. 使用cwebp将png和jpg类型的图片转为webp格式,再进行压缩;

主要流程如下:

  1. 检查文件类型: 首先,它会检查输入的文件是否是图片;

  2. 处理 JPG 图片: 如果图片文件是 JPG 格式,它会执行以下操作:

    • 创建一个临时文件路径。
    • 使用 guetzli 命令来压缩图片,并将结果保存到临时文件中。
    • 获取压缩后的图片大小。
    • 如果压缩后的大小小于原始大小,它会删除原始文件,并将临时文件重命名为原始文件的名字
    • 如果压缩后的大小不小于原始大小,它会删除临时文件,并返回 0。
  3. 处理PNG图: 使用 pngquant 命令来压缩图片;

  4. 处理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...

参考资料:

  1. Android App包瘦身优化实践
  2. 得物 Android 包体积优化实践:高效应用,优化用户体验
  3. RESOURCE.ARSC生成和结构
  4. 抖音 Android 包体积优化探索:资源二进制格式的极致精简
  5. pngquantguetzlicwebp
相关推荐
周全全2 分钟前
MySQL报错解决:The user specified as a definer (‘root‘@‘%‘) does not exist
android·数据库·mysql
理想不理想v26 分钟前
vue经典前端面试题
前端·javascript·vue.js
不收藏找不到我27 分钟前
浏览器交互事件汇总
前端·交互
- 羊羊不超越 -37 分钟前
App渠道来源追踪方案全面分析(iOS/Android/鸿蒙)
android·ios·harmonyos
YBN娜41 分钟前
Vue实现登录功能
前端·javascript·vue.js
阳光开朗大男孩 = ̄ω ̄=41 分钟前
CSS——选择器、PxCook软件、盒子模型
前端·javascript·css
minDuck1 小时前
ruoyi-vue集成tianai-captcha验证码
java·前端·vue.js
小政爱学习!1 小时前
封装axios、环境变量、api解耦、解决跨域、全局组件注入
开发语言·前端·javascript
魏大帅。1 小时前
Axios 的 responseType 属性详解及 Blob 与 ArrayBuffer 解析
前端·javascript·ajax