本文繼續分析booster的實現原理。更多相關文章見booster-分析
booster-task-compression
這個元件主要做了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
包含下列步驟:
- 下載依賴
- 合併
Manifest
檔案(MergeApkManifestsTask
) - 合併
res
資源(MergeResourcesTask
) - 合併
assets
資源(MergeAssetsTask
) - 處理資源,生成
_.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
圖片壓縮的大致實現是:
- 對於
minSdkVersion > 17
的應用,在資源編譯過程中使用cwebp
命令將圖片轉為webp
格式。 - 對於
minSdkVersion < 17
的應用,在資源編譯過程中使用pngquant
命令對圖片進行壓縮。
對於這兩個工具的詳細了資料可以參考下面文章:
webp使用指南 : developers.google.com/speed/webp/…
pngquant使用實踐 : juejin.im/entry/587f1…
具體實現
圖片資源的壓縮分為兩步:
assets
下的圖片資源壓縮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