滴滴Booster移動APP質量優化框架 學習之旅 二

 

推薦閱讀html

滴滴Booster移動App質量優化框架-學習之旅 一java

Android 模塊Api化演練android

不同視角的Glide剖析(一)git

 

續寫滴滴Booster移動APP質量優化框架學習之旅,上篇文章分析內置的transform:booster-transform-shrink booster-transform-shared-preferences,今天分析booster-task-compression以及定製task對資源索引文件resource.asrc進行優化(重複資源優化、無用資源優化)。github

 

booster-task-compressionweb

 

該task對針對資源的壓縮和刪除,減小了apk包的大小。原理主要對資源編譯打包過程進行了hook干涉,添加了三個Task:api

removeRedundantResources、compressAssets、compressResources,其先相關task依賴關係圖譜以下:微信

 

 

且在processResources任務 doTask階段進行compressProcessedRes。app

這四個動做做用依次以下:框架

①:removeRedundantResources   刪除多餘圖片,保留最大尺寸

②:compressResources                 對可編譯資源目錄res下的圖片進行壓縮

③:compressAssets                       對不可編譯資源asserts目錄下的圖片進行壓縮

④:compressProcessedRes          對resource*._ap進行壓縮

執行順序:① > ② > ④,③ > ④  動做②和③無相關,動做④在動做②和③都完成後才執行。

 

①:removeRedundantResources 

AndroidStudio3.0之後是默認開啓aapt2,Booster對沒有開啓aapt2的狀況沒有進行冗餘圖片的操做。在aapt2的模式下,mergeResource Task的產物是一些.flat文件,以下圖

 

removeRedundantResources 主要的邏輯代碼實現以下:

override fun run() {
//搜索知足AAPT2 container format :
//magic = 0x54504141 && version > 0 && count > 0 && type = RES_FILE val resources
= sources().parallelStream().map { it to it.metadata }.collect(Collectors.toSet()) resources.filter {
//過濾狀況 ResourcesInternal.CompileFile = null的狀況 it.second
!= null }.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 -> //組內根據density降序排序,再排除最高density的資源
group.value.sortedByDescending { it.second.config.density }.takeLast(group.value.size
- 1) }.flatten().parallelStream().forEach { try {
            //對除最高density的其餘density資源文件進行刪除
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) } } } } }

該task的邏輯爲搜索知足條件magic = 0x54504141 && version > 0 && count > 0 && type = RES_FILE的資源文件,根據資源名分組,組內根據config.density排序,排除掉最高的density的資源,其餘density的資源文件都刪除。

 

也許有些同窗對AAPT2 Container format不熟悉,見 APPT2源碼文檔 便知悉該Task的搜索過濾條件了,從文檔介紹,RES_FILE類型能夠是PNG file, binary XML, PNG file, binary XML, or aapt.pb.XmlNode,那是否是搜索過濾條件最好加上後綴爲png.flat,以避免形成誤刪。

 

集成該task,進行打包,console打印文件刪除失敗log,以下圖:

 

猜想跟文件流操做有關係,前面讀取.flat文件元數據信息(magic、count、type等),已在github提了issue39

 

②:compressResources

有損壓縮圖片資源,內置兩種壓縮方案: 

1.pngquant 有損壓縮(須要自行安裝 pngquant 命令行工具)

2.cwebp 有損壓縮(已內置)

在構建過程當中,Booster會根據配置智能選擇合適的壓縮器Compressor

 

 /**
         * Select the best compressor
         */
        fun get(project: Project): CompressionTool? {
            val pngquant = project.findProperty(PROPERTY_PNGQUANT)?.toString()
            val compressor = project.findProperty(PROPERTY_COMPRESSOR)?.toString()
            val binDir = project.buildDir.file(SdkConstants.FD_OUTPUT).absolutePath
            val minSdkVersion = project.getAndroid<BaseExtension>().defaultConfig.minSdkVersion.apiLevel

            project.logger.info("minSdkVersion: $minSdkVersion$")
            project.logger.info("$PROPERTY_COMPRESSOR: $compressor")
            project.logger.info("$PROPERTY_PNGQUANT: $pngquant")

            return when (compressor) {
                Cwebp.PROGRAM -> Cwebp(binDir)
                Pngquant.PROGRAM -> Pngquant(pngquant)
                else -> when {
                    minSdkVersion >= 18 -> Cwebp(binDir)
                    minSdkVersion in 15..17 -> Cwebp(binDir, true)
                    else -> Pngquant(pngquant).let {
                        if (it.isInstalled) it else null
                    }
                }
            }
        }

 

配置了可識別的Compressor,就使用該Compressor,不然根據minSdkVersion選擇合適的Compressor,Compressor再根據是否開啓aapt2建立合適的CompressImage任務

//Cweb
 override fun newCompressionTaskCreator() = SimpleCompressionTaskCreator(this) { aapt2 ->
        when (aapt2) {
            true -> when (opaque) {
                true -> CwebpCompressOpaqueFlatImages::class
                else -> CwebpCompressFlatImages::class
            }
            else -> when (opaque) {
                true -> CwebpCompressOpaqueImages::class
                else -> CwebpCompressImages::class
            }
        }
}

//pngquant
override fun newCompressionTaskCreator() = SimpleCompressionTaskCreator(this) { aapt2 ->
        if (aapt2) PngquantCompressFlatImages::class else PngquantCompressImages::class
    }

 

當開啓aapt2時,aapt2的compile階段的產物爲.flat文件,使用Cweb / pngquant 壓縮原圖片後,還須要經過aapt2編譯壓縮生成後的圖片,生成對應的.flat文件

//PngquantCompressFlatImages
override fun run() {
        ...

        sources().parallelStream().map {
            it to it.metadata
        }.filter {
            it.second != null
        }.map {
            val output = compressedRes.file("${it.second!!.resourcePath.substringBeforeLast('.')}$DOT_PNG")
            Aapt2ActionData(it.first, it.second!!, output, listOf(pngquant, "--strip", "--skip-if-larger", "-f", "-o", output.absolutePath, "-s", "1", it.second!!.sourcePath), listOf(aapt2, "compile", "-o", it.first.parent, output.absolutePath))
        }.forEach {
            it.output.parentFile.mkdirs()
            val s0 = File(it.metadata.sourcePath).length()
            val rc = project.exec { spec ->
                spec.isIgnoreExitValue = true
                spec.commandLine = it.cmdline
            }
            
           ...
        }
    }

//
CwebpCompressFlatImages
override fun compress(filter: (File) -> Boolean) {
  ...

sources().parallelStream().map {
it to it.metadata
}.filter {
      //在android15 - 17 還不支持透明的webp,過濾掉應用圖標
it.second != null && isNotLauncherIcon(it.first, it.second!!) && filter(File(it.second!!.sourcePath))
}.map {
val output = compressedRes.file("${it.second!!.resourcePath.substringBeforeLast('.')}.webp")
Aapt2ActionData(it.first, it.second!!, output,
listOf(cwebp, "-mt", "-quiet", "-q", "80", it.second!!.sourcePath, "-o", output.absolutePath),
listOf(aapt2, "compile", "-o", it.first.parent, output.absolutePath))
}.forEach {
it.output.parentFile.mkdirs()
val s0 = File(it.metadata.sourcePath).length()
     //cwep壓縮
val rc = project.exec { spec ->
spec.isIgnoreExitValue = true
spec.commandLine = it.cmdline
}
when (rc.exitValue) {
0 -> {
val s1 = it.output.length()
if (s1 > s0) {
            //cwebp壓縮後的產物文件大小比原圖片還大,則使用原圖

results.add(CompressionResult(it.input, s0, s0, File(it.metadata.sourcePath),Compression))
it.output.delete()
} else {
...
}
}
...
}
}
}

  

③:compressAssets  

 對不可編譯資源資源的壓縮,跟動做compressResources共用相同的compressor,選擇沒開啓的aapt2的CompressImage任務處理。

 

④:compressProcessedRes

processResources任務完成生成的的產物:resources*.ap_,路徑以下圖:

 

resources*.ap_是個壓縮文件,resources*.ap_中zip條目歸檔到apk中,經過 aapt l -v xxx.apk > log.txt  查看APK文件歸檔類信息 ,能夠看到APK中不少資源是以Stored來存儲的(),,根據Zip的文件格式中對壓縮方式的描述Compression_methods能夠看出這些文件是沒有壓縮的,那爲何它們沒有被壓縮呢?從AAPT的源碼分別從aapt和aapt2中找到如下描述:

 

/********************aapt Package.cpp****************************/
/*
these formats are already compressed, or don't compress well */ static const char* kNoCompressExt[] = { ".jpg", ".jpeg", ".png", ".gif", ".wav", ".mp2", ".mp3", ".ogg", ".aac", ".mpg", ".mpeg", ".mid", ".midi", ".smf", ".jet", ".rtttl", ".imy", ".xmf", ".mp4", ".m4a", ".m4v", ".3gp", ".3gpp", ".3g2", ".3gpp2", ".amr", ".awb", ".wma", ".wmv", ".webm", ".mkv" };

/********************aapt2 Link.cpp****************************/

// Populate some default no-compress extensions that are already compressed.
options.extensions_to_not_compress.insert(
{".jpg", ".jpeg", ".png", ".gif", ".wav", ".mp2", ".mp3", ".ogg",
".aac", ".mpg", ".mpeg", ".mid", ".midi", ".smf", ".jet", ".rtttl",
".imy", ".xmf", ".mp4", ".m4a", ".m4v", ".3gp", ".3gpp", ".3g2",
".3gpp2", ".amr", ".awb", ".wma", ".wmv", ".webm", ".mkv"});

     



從描述中都沒有找到webp格式,是否是webp圖片能夠以Deflate來存儲在apk?那麼看Booster作了什麼?

compressProcessedRes邏輯關鍵代碼以下:

 

//同aapt源碼的no-compress格式同出一轍
private val NO_COMPRESS = setOf(
"jpg", "jpeg", "png", "gif",
"wav", "mp2", "mp3", "ogg", "aac",
"mpg", "mpeg", "mid", "midi", "smf", "jet",
"rtttl", "imy", "xmf", "mp4", "m4a",
"m4v", "3gp", "3gpp", "3g2", "3gpp2",
"amr", "awb", "wma", "wmv", "webm", "mkv"
)

private
fun BaseVariant.compressProcessedRes(results: CompressionResults) {
//搜索resources-*.ap_文件 val files
= scope.processedRes.search { it.name.startsWith(SdkConstants.FN_RES_BASE) && it.extension == SdkConstants.EXT_RES } 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_)) } } private fun File.repack(shouldCompress: (ZipEntry) -> Boolean) { 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 -> val target = ZipEntry(origin.name).apply { size = origin.size crc = origin.crc comment = origin.comment extra = origin.extra
//不壓縮的格式,保持原有的ZipEntry.method,顯然webp格式的圖片的method method
= if (shouldCompress(origin)) ZipEntry.DEFLATED else origin.method } output.putNextEntry(target) zip.getInputStream(origin).use { it.copyTo(output) } output.closeEntry() } } } if (this.delete()) { if (!dest.renameTo(this)) { dest.copyTo(this, true) } } }

 

其邏輯爲搜索resources*.ap_,保存NO_COMPRESS格式的條目method,其餘格式的ZipEntry method修改成deflated,顯然resources*.ap_文件中webp圖片和resources.arsc資源索引文件的method改成了deflated,作了壓縮存儲,能夠aapt命令查看經Booster compression處理的apk zip條目信息進行驗證。

對於resources.arsc的壓縮是否對apk運行時性能有影響,大佬們也有這樣的討論,見 resources.arsc壓縮會影響性能嗎? 、Google I/O 2016 筆記:APK 瘦身的正確姿式,還沒有定論。

 

針對resources.arsc的優化,美團還提出以下手段:

1.開啓資源混淆

2.對重複的資源優化

3.對被shrinkResources優化掉的資源進行處理

資源混淆見微信開源的資源混淆庫AndResGuard

對重複的資源優化和對被shrinkResources優化掉的資源進行處理的原理見:美團博客 Android App包瘦身優化實踐

 

這裏根據美團講述的原理在Booster定製task實現對重複的資源優化和對無用資源優化討論,詳見工程module TaskCompression

 

1、對重複的資源優化

重複資源的篩選條件爲 資源的zipEntry.crc相等,最早出現的資源壓縮包產物ap_文件是在processResTask中,儘量早的刪除重複資源, 能夠減小後續task的執行時間,hook在processResTask以後,以下:

 

variant.processResTask?.doLast{
    variant.removeRepeatResources(it.logger,results)
}

 

這裏我按照同zipEntry.crc和同資源目錄(不一樣資源目錄可能有相同的crc資源,形成誤刪,不過可能性較小)去分類收集重複資源:

 

private fun File.findDuplicatedResources():Map<Key,ArrayList<DuplicatedOrUnusedEntry>>{
    var duplicatedResources = HashMap<Key,ArrayList<DuplicatedOrUnusedEntry>>(100)
    ZipFile(this).use { zip ->
        zip.entries().asSequence().forEach { entry ->
            val lastIndex : Int = entry.name.lastIndexOf('/')
            val key = Key(entry.crc.toString(),if(lastIndex == -1) "/" else entry.name.substring(0,lastIndex))
            if(!duplicatedResources.containsKey(key)){
                val list : ArrayList<DuplicatedOrUnusedEntry> = ArrayList(20)
                duplicatedResources[key] = list
            }

            val list = duplicatedResources[key]
            list?.add(DuplicatedOrUnusedEntry(entry.name,entry.size,entry.compressedSize,DuplicatedOrUnusedEntryType.duplicated))

        }
    }

    duplicatedResources.filter {
        it.value.size >= 2
    }.apply{
        duplicatedResources = this as HashMap<Key, ArrayList<DuplicatedOrUnusedEntry>>
    }

    return duplicatedResources
}

 

重複的資源優化的實現總體思路:

1.從ap_文件中解壓出resources.arsc條目,並收集該條目的ZipEntry.method,爲後續按照同ZipEntry.method 把改動後的resources.arsc添加到ap_文件中

2.收集重複資源

3.根據收集的重複資源,保留重複資源的第一個,從刪除ap_文件中刪除其餘重複資源的zipEntry

4.使用經過[android-chunk-utils]修改resources.arsc,把把這些重複的資源都重定向到沒有被刪除的第一個資源

5.按照同ZipEntry.method把改動後的resources.arsc添加到ap_文件中

源碼見:doRemoveRepeatResources方法

驗證: 分別在App/lib module顯示三張圖片,重複資源以下:

 

查看沒集成重複的資源優化的apk,如圖:

 

 

使用工具查看集成重複的資源優化的apk,如圖:

 

集成重複的資源優化打包,控制和輸出報告均可以看到以下輸出:

能夠知道刪除哪些重複資源,壓縮包減小了多少kb。

 

2、無用資源優化

經過shrinkResources true來開啓資源壓縮,資源壓縮工具會把無用的資源替換成預約義的版本而不是移除,那麼google出於什麼緣由這樣作了? ResourceUsageAnalyzer註釋是這樣說的的:

 

/**
     * Whether we should create small/empty dummy files instead of actually
     * removing file resources. This is to work around crashes on some devices
     * where the device is traversing resources. See http://b.android.com/79325 for more.
     */

 

 

註釋上說了適配解決某些設備crash問題,查看issue,發現發生crash的設備基本上都是三星手機,若是刪除無用資源,須要考慮該issue問題。

 

 

若是採用人工移除的方式會帶來後期的維護成本,在Android構建工具執行package${flavorName}Task以前經過修改Compiled Resources來實現自動去除無用資源。

具體流程以下: * 收集資源包(Compiled Resources的簡稱)中被替換的預約義版本的資源名稱,經過查看資源包 (Zip格式)中每一個ZipEntry的CRC-32 checksum來尋找被替換的預約義資源,預約義資源的CRC-32定義在ResourceUsageAnalyzer, 下面是它們的定義:

    // A 1x1 pixel PNG of type BufferedImage.TYPE_BYTE_GRAY
    public static final long TINY_PNG_CRC = 0x88b2a3b0L;

    // A 3x3 pixel PNG of type BufferedImage.TYPE_INT_ARGB with 9-patch markers
    public static final long TINY_9PNG_CRC = 0x1148f987L;

    // The XML document <x/> as binary-packed with AAPT
    public static final long TINY_XML_CRC = 0xd7e65643L;
    
    // The XML document <x/> as a proto packed with AAPT2
    public static final long TINY_PROTO_XML_CRC = 3204905971L;

 

從定義中沒有看到webp、jpg、jpeg相關的crc,那麼這些沒有定義crc-32的資源在ZipEntry中crc爲多少了,用預約義資源替換未使用的地方的實現以下:

private void replaceWithDummyEntry(JarOutputStream zos, ZipEntry entry, String name)throws IOException {
        // Create a new entry so that the compressed len is recomputed.
        byte[] bytes;
        long crc;
        if (name.endsWith(DOT_9PNG)) {
            bytes = TINY_9PNG;
            crc = TINY_9PNG_CRC;
        } else if (name.endsWith(DOT_PNG)) {
            bytes = TINY_PNG;
            crc = TINY_PNG_CRC;
        } else if (name.endsWith(DOT_XML)) {
            switch (format) {
                case BINARY:
                    bytes = TINY_BINARY_XML;
                    crc = TINY_BINARY_XML_CRC;
                    break;
                case PROTO:
                    bytes = TINY_PROTO_XML;
                    crc = TINY_PROTO_XML_CRC;
                    break;
                default:
                    throw new IllegalStateException("");
            }
        } else {
            //沒有預約資源格式,crc =0,數據爲空
            bytes = new byte[0];
            crc = 0L;
        }
        JarEntry outEntry = new JarEntry(name);
        if (entry.getTime() != -1L) {
            outEntry.setTime(entry.getTime());
        }
        if (entry.getMethod() == JarEntry.STORED) {
            outEntry.setMethod(JarEntry.STORED);
            outEntry.setSize(bytes.length);
            outEntry.setCrc(crc);
        }
        zos.putNextEntry(outEntry);
        zos.write(bytes);
        zos.closeEntry();

        ...
}

 

能夠得出篩選無使用資源的條件爲crc in以下集合中:

val unusedResourceCrcs  = longArrayOf(
    ResourceUsageAnalyzer.TINY_PNG_CRC,
    ResourceUsageAnalyzer.TINY_9PNG_CRC,
    ResourceUsageAnalyzer.TINY_BINARY_XML_CRC,
    ResourceUsageAnalyzer.TINY_PROTO_XML_CRC,
    0 //jpg、jpeg、webp等
)

 

打印packageAndroidTask的inputFiles,以下:

 

分別查看箭頭目錄下的文件,有*.ap_文件

 

而從上面兩圖中能夠了解到shrinkResources 影響到packageAndroidTask的inputFiles,沒有開啓shrinkResources, packageAndroidTask從processedResTask
產物中讀取ap_文件,開啓shrinkResources,從res_stripped目錄下讀取ap_文件, 根據其stripped名,也猜想出ap_文件中已經進行了預約資源替換未使用資源了,
能夠壓縮軟件查看未使用資源的zipEntry.crc 進行驗證,以下圖:

 

能夠看到沒有使用的webp、jpg資源的ZipEntry.crc爲0;若是集成了Booster內置的booster-task-compression, 會把png格式轉換成webp格式,沒使用的png最後的crc會變爲0。

 

刪除無用資源方案想到兩種:

方案一:刪除全部無用資源文件,以及刪除資源索引文件resources.arsc中global StringChunk有關無用資源的數據項。

缺點:刪除了global StringChunk中的數據項,改變了後續數據項的索引值,比如刪除List中的元素,後續的元素索引值減一同樣,牽一髮動全身,須要同步其餘chunk索引到global StringChunk數據項的索引值。不然會出現資源顯示混亂,甚至crash;同時須要考慮上述issue問題。對resources.arsc越大出現問題的機率越大

 

方案二:無用資源根據crc分類,再按照重複資源優化,沒有刪除global StringChunk數據項,沒有改變數據項的索引值,不須要改動其餘chunk,同時不會出現上述issue問題。

 

下面對方案二具體實現,方案一就不作討論了。

 

無用資源優化的代碼實現總體思路:

1.從ap_文件中解壓出resources.arsc條目,並收集該條目的ZipEntry.method,爲後續按照同ZipEntry.method 把改動後的resources.arsc添加到ap_文件中

2.收集無用資源

3.把收集的無用資源根據crc進行分類,在按照重複資源優化處理

 

源碼見:doRemoveUnusedResources1方法

 

集成無用資源優化打包,控制和輸出報告均可以看到以下輸出:

 

能夠知道刪除哪些無用資源,壓縮包減小了多少kb,無用資源優化減小的size並無多少。

 

以上重複資源優化和無用資源優化,沒有通過大量設備測試,僅供參考,源碼傳送門:Boosterstudy

 

 

 

參考閱讀:

滴滴Booster學習之旅 

Booster 官方文檔

美團-Android App包瘦身優化實踐

 

若是您對博主的更新內容持續感興趣,請關注公衆號!

相關文章
相關標籤/搜索