booster分析-App资源压缩

3,277 阅读4分钟

本文继续分析booster的实现原理。更多相关文章见booster-分析

booster-task-compression这个组件主要做了3件事:

  1. 删除冗余的图片资源
  2. 压缩图片资源
  3. 重新压缩resourceXX.ap_文件中的资源

在分析它们的实现之前,我们先来了解一下Android的资源编译过程:

回顾 App 资源编译步骤

对于资源编译有哪些步骤我并没有找到比较详细官方文档,不过我们可以通过查看com.android.tools.build:gradle的源码来了解这个过程。构建一个app包所涉及的到GradleTask(比如assembleRelease)的源码大概位于ApplicationTaskMamager.java文件中:

ApplicationTaskManager.java

@Override
public void createTasksForVariantScope(final TaskFactory tasks, final VariantScope variantScope) {

    BaseVariantData variantData = variantScope.getVariantData();
    ...
    // Create all current streams (dependencies mostly at this point)
    createDependencyStreams(tasks, variantScope);

    ...
    // Add a task to process the manifest(s)
    recorder.record(
            ExecutionType.APP_TASK_MANAGER_CREATE_MERGE_MANIFEST_TASK,
            project.getPath(),
            variantScope.getFullVariantName(),
            () -> createMergeApkManifestsTask(tasks, variantScope));

    ...

    // Add a task to merge the resource folders
    recorder.record(
            ExecutionType.APP_TASK_MANAGER_CREATE_MERGE_RESOURCES_TASK,
            project.getPath(),
            variantScope.getFullVariantName(),
            (Recorder.VoidBlock) () -> createMergeResourcesTask(tasks, variantScope, true));

    // Add a task to merge the asset folders
    recorder.record(
            ExecutionType.APP_TASK_MANAGER_CREATE_MERGE_ASSETS_TASK,
            project.getPath(),
            variantScope.getFullVariantName(),
            () -> createMergeAssetsTask(tasks, variantScope, null));

    
    recorder.record(
            ExecutionType.APP_TASK_MANAGER_CREATE_PROCESS_RES_TASK,
            project.getPath(),
            variantScope.getFullVariantName(),
            () -> {
                // Add a task to process the Android Resources and generate source files
                createApkProcessResTask(tasks, variantScope);

                // Add a task to process the java resources
                createProcessJavaResTask(tasks, variantScope);
            });
    ...
}

上面我只截取了ApplicationTaskMamager.createTasksForVariantScope()部分代码,createTasksForVariantScope()就是用来创建很多Task来构建一个可运行的App的。通过这个方法我们可以看到构建一个App包含下列步骤:

  1. 下载依赖
  2. 合并Manifest文件(MergeApkManifestsTask)
  3. 合并res资源(MergeResourcesTask)
  4. 合并assets资源(MergeAssetsTask)
  5. 处理资源,生成_.ap文件(ApkProcessTesTask)

上面我省略了很多步骤没有列出来。

booster资源压缩的实现原理就是创建了一些Task插入在上面的步骤之间来完成自定义的操作

冗余资源的删除

这个操作会在app构建完成MergeResourcesTask之后进行:

//移除冗余资源的 task, 执行位于资源合并之后
val klassRemoveRedundantFlatImages = if (aapt2) RemoveRedundantFlatImages::class else RemoveRedundantImages::class

val reduceRedundancy = variant.project.tasks.create("remove${variant.name.capitalize()}RedundantResources", klassRemoveRedundantFlatImages.java) {
    it.outputs.upToDateWhen { false }
    it.variant = variant
    it.results = results
    it.sources = { variant.scope.mergedRes.search(pngFilter) }
}.dependsOn(variant.mergeResourcesTask)

即会根据当前不同的AAPT版本创建不同的冗余图片移除任务(操作的图片格式为png, 但不包括.9.png)。

AAPT 的冗余资源的移除

如果对资源编译采用的是AAPT,则执行的任务为RemoveRedundantImages:

open class RemoveRedundantImages: DefaultTask() {

    lateinit var variant: BaseVariant

    lateinit var results: CompressionResults

    lateinit var sources: () -> Collection<File>

    @TaskAction
    open fun run() {
        TODO("Reducing redundant resources without aapt2 enabled has not supported yet")
    }
}

可以看到RemoveRedundantImages并没有做什么具体的操作。实际上gradle会在AAPT资源合并操作之前移除冗余的资源,具体规则是:

默认情况下,Gradle会合并同名的资源,如可能位于不同资源文件夹中的同名可绘制对象。这一行为不受shrinkResources属性控制,也无法停用,因为当多个资源与代码查询的名称匹配时,有必要利用这一行为来避免错误。只有在两个或更多个文件具有完全相同的资源名称、类型和限定符时,才会进行资源合并。Gradle会在重复项中选择它认为最合适的文件(根据下述优先顺序),并且只将这一个资源传递给AAPT,以便在APK文件中分发。

Gradle会在以下位置查找重复资源:

  • 与主源集关联的主资源,通常位于 src/main/res/。
  • 变体叠加,来自编译类型和编译特性。
  • 库项目依赖项。

Gradle会按以下级联优先顺序合并重复资源 : 依赖项 → 主资源 → 编译特性 → 编译类型

更具体的合并规则可查看: 合并重复资源

当然gradle的资源合并操作是必须的

AAPT2 的冗余资源的移除

Android Gradle Plugin 3.0.0及更高版本默认会启用AAPT2。相较于AAPT,AAPT2会利用增量编译加快app打包过程中资源的编译。对于AAPT2更加详细的介绍可以参考 : developer.android.com/studio/comm…

app编译使用的是AAPT2时,booster RemoveRedundantFlatImages的处理:

internal open class RemoveRedundantFlatImages : RemoveRedundantImages() {
    @TaskAction
    override fun run() {
        val resources = sources().parallelStream().map {
            it to it.metadata
        }.collect(Collectors.toSet())

        resources.groupBy({
            it.second.resourceName.substringBeforeLast('/')   // 同文件夹下的文件
        }, {
            it.first to it.second
        }).forEach { entry ->
            entry.value.groupBy({
                it.second.resourceName.substringAfterLast('/')
            }, {
                it.first to it.second
            }).map { group ->
                group.value.sortedByDescending {
                    it.second.config.screenType.density // 按密度降序排序
                }.takeLast(group.value.size - 1)  //同名文件,取密度最大的
            }.flatten().parallelStream().forEach {
                try {
                    if (it.first.delete()) {  // 删除冗余的文件
                        val original = File(it.second.sourcePath)
                        results.add(CompressionResult(it.first, original.length(), 0, original))
                    } else {
                        logger.error("Cannot delete file `${it.first}`")
                    }
                } catch (e: IOException) {
                    logger.error("Cannot delete file `${it.first}`", e)
                }
            }
        }
    }
}

RemoveRedundantFlatImages所做的操作是: 在资源合并后,对于同名的png图片,它会取density最高的图片,然后把其他的图片删除

比如你有下面3张启动图:

  • mipmap-hdpi -> ic_launcher.png
  • mipmap-xhdpi -> ic_launcher.png
  • mipmap-xxxhdpi -> ic_launcher.png

booster处理后就会剩下mipmap-xxxhdpi -> ic_launcher.png这一张图片打包到apk中。

图片资源的压缩

booster图片压缩的大致实现是:

  1. 对于minSdkVersion > 17的应用,在资源编译过程中使用cwebp命令将图片转为webp格式。
  2. 对于minSdkVersion < 17的应用,在资源编译过程中使用pngquant命令对图片进行压缩。

对于这两个工具的详细了资料可以参考下面文章:

webp使用指南 : developers.google.com/speed/webp/…

pngquant使用实践 : juejin.cn/post/684490…

具体实现

图片资源的压缩分为两步:

  1. assets下的图片资源压缩
  2. res下的图片资源压缩

这里直接压缩assets下图片资源是存在一些问题的:如果工程中引入了flutter,flutter中对图片资源是明文引用的,booster将图片转为webp格式的话会造成flutter中图片失效。因此这点要注意。

这里就不去跟源码的详细步骤了,因为涉及的点很多。其实主要实现就是创建一个Task, 将图片文件转为webp

res的资源压缩为例, 会执行到下面的代码:

nternal open class CwebpCompressImages : CompressImages() {

    open fun compress(filter: (File) -> Boolean) {
        sources().parallelStream().filter(filter).map { input ->
            val output = File(input.absolutePath.substringBeforeLast('.') + ".webp")
            ActionData(input, output, listOf(cmdline.executable!!.absolutePath, "-mt", "-quiet", "-q", "80", "-o", output.absolutePath, input.absolutePath))
        }.forEach {
            val s0 = it.input.length()
            val rc = project.exec { spec ->
                spec.isIgnoreExitValue = true
                spec.commandLine = it.cmdline
            }
            when (rc.exitValue) {

            }
        }
    }
}

cmdline.executable!!.absolutePath就是代表cwbp命令的位置。

重新压缩resourceXX.ap_文件中的资源

这个操作的入口代码是:

class CompressionVariantProcessor : VariantProcessor {
    override fun process(variant: BaseVariant) {

        variant.processResTask.doLast {
            variant.compressProcessedRes(results)   //重新压缩.ap_文件
            variant.generateReport(results)  //生成报告文件
        }  

        ...
    }
}

compressProcessedRes()的具体实现是:

private fun BaseVariant.compressProcessedRes(results: CompressionResults) {
    val files = scope.processedRes.search {
        it.name.startsWith("resources") && it.extension == "ap_"
    }
    files.parallelStream().forEach { ap_ ->
        val s0 = ap_.length()
        ap_.repack {
            !NO_COMPRESS.contains(it.name.substringAfterLast('.')) 
        }
        val s1 = ap_.length()
        results.add(CompressionResult(ap_, s0, s1, ap_))
    }
}

即找到所有的resourcesXX.ap_文件,然后对他们进行重新压缩打包。ap_.repack方法其实是把里面的每个文件都重新压了一遍(已经压过的就不再压了):

private fun File.repack(shouldCompress: (ZipEntry) -> Boolean) {
    //创建一个新的 .ap_ 文件
    val dest = File.createTempFile(SdkConstants.FN_RES_BASE + SdkConstants.RES_QUALIFIER_SEP, SdkConstants.DOT_RES)

    ZipOutputStream(dest.outputStream()).use { output ->
        ZipFile(this).use { zip ->
            zip.entries().asSequence().forEach { origin ->
                // .ap_ 中的文件再压缩一遍
                val target = ZipEntry(origin.name).apply {
                    size = origin.size
                    crc = origin.crc
                    comment = origin.comment
                    extra = origin.extra
                    //如果已经压缩过就不再压缩了
                    method = if (shouldCompress(origin)) ZipEntry.DEFLATED else origin.method
                }

                output.putNextEntry(target)

                zip.getInputStream(origin).use {
                    it.copyTo(output)
                }
                ..
            }
        }
    }

    //覆盖掉老的.ap_文件
    if (this.delete()) {
        if (!dest.renameTo(this)) {
            dest.copyTo(this, true)
        }
    }
}

resourcesXX.ap_文件的压缩报告如下:

46.49% xxx/processDebugResources/out/resources-debug.ap_  153,769 330,766 xxx/out/resources-debug.ap_

压缩前:391KB , 压缩后:177KB; 即压缩了46.49%

压缩总结

我新建了一个Android工程,在使用booster压缩前打出的apk大小为2.8MB, 压缩后打出的apk大小为2.6MB

实际上booster-task-compression这个组件对于减小apk的大小还是有很显著的效果的。不过是否是适用于项目则需要根据项目具体情况来考虑。

更多文章见 : AdvancedAdnroid