booster分析-App資源壓縮

susion發表於2019-09-20

本文繼續分析booster的實現原理。更多相關文章見booster-分析

booster-task-compression這個元件主要做了3件事:

  1. 刪除冗餘的圖片資源
  2. 壓縮圖片資源
  3. 重新壓縮resourceXX.ap_檔案中的資源

在分析它們的實現之前,我們先來了解一下Android的資源編譯過程:

回顧 App 資源編譯步驟

booster分析-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.im/entry/587f1…

具體實現

圖片資源的壓縮分為兩步:

  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

相關文章