Android Apk瘦身方案2——gradle插件将png自动压缩为webp

实现思路

在 mergeRes 和 processRes 任务之间插入 WebP 压缩任务,如下图所示:
Android Apk瘦身方案2——gradle插件将png自动压缩为webp_第1张图片
使用开源框架Cwebp,使用命令行对所有的图片进行遍历处理,然后将结果输出
Google 官方提供的下载地址:https://storage.googleapis.com/downloads.webmproject.org/releases/webp/index.html 下载的 cwebp 二进制可执行文件 64 位版本。

由于 WebP 格式在 14 <= minSdkVersion <= 17 不支持带 alpha 通道的图像,所以,针对 AAPT2, alpha 两个维度,将 task 分成了4 种:
Android Apk瘦身方案2——gradle插件将png自动压缩为webp_第2张图片
应用是发布在 Google Play 上,应用的启动图标必须是 PNG 格式,否则 Google Play 不会接受。

代码实现

以下代码均来自booster开源项目

1.自定义gradle插件BoosterPlugin.java
Android Apk瘦身方案2——gradle插件将png自动压缩为webp_第3张图片
这里的variantProcessors方法代码如下:

  private val variantProcessors: Collection
        get() = BoosterServiceLoader.load(VariantProcessor::class.java, javaClass.classLoader).sortedBy {
            it.javaClass.getAnnotation(Priority::class.java)?.value ?: 0
        }

通过BoosterServiceLoader加载所有实现VariantProcessor接口的类,例如CwebpCompressionVariantProcessor就是用于webp图片压缩,PngquantCompressionVariantProcessor用于针对于不能使用cweb的情况进行图片压缩,还有很多其他的Processor。

查看CwebpCompressionVariantProcessor代码

@AutoService(VariantProcessor::class)
class CwebpCompressionVariantProcessor : VariantProcessor {

    override fun process(variant: BaseVariant) {
        val results = CompressionResults()
        val filter = if (variant.project.aapt2Enabled) ::isFlatPngExceptRaw else ::isPngExceptRaw
        Cwebp.get(variant)?.newCompressionTaskCreator()?.createCompressionTask(variant, results, "resources", {
            variant.scope.mergedRes.search(filter)
        }, variant.mergeResourcesTask)?.doLast {
            results.generateReport(variant, Build.ARTIFACT)
        }
    }

}

这里通过谷歌的AutoService来实现,这里使用实现VariantProcessor接口的方式,主要是为了解耦,因为有很多插件的功能,但是别人使用不一定都会用到,通过接口实现的方式,如果没使用到的功能,没有引入,则没有实现类

2.CwebpCompressionVariantProcessor#newCompressionTaskCreator

@AutoService(VariantProcessor::class)
class CwebpCompressionVariantProcessor : VariantProcessor {

    override fun process(variant: BaseVariant) {
        val results = CompressionResults()
        val filter = if (variant.project.aapt2Enabled) ::isFlatPngExceptRaw else ::isPngExceptRaw
        Cwebp.get(variant)?.newCompressionTaskCreator()?.createCompressionTask(variant, results, "resources", {
            variant.scope.mergedRes.search(filter)
        }, variant.mergeResourcesTask)?.doLast {
            results.generateReport(variant, Build.ARTIFACT)
        }
    }

}

3.Cwebp#newCompressionTaskCreator()

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

通过是否支持supportAlpha和aapt2决定不同的task
Android Apk瘦身方案2——gradle插件将png自动压缩为webp_第4张图片
minsdk只要大于17都是支持alpha的,且gradle 3.0以上都是使用aapt2了,所以这里创建CwebpCompressOpaqueFlatImages

4.SimpleCompressionTaskCreator#createCompressionTask

override fun createCompressionTask(variant: BaseVariant, results: CompressionResults, name: String, supplier: () -> Collection, vararg deps: Task): CompressImages {
        val aapt2 = variant.project.aapt2Enabled
        val install = getCommandInstaller(variant)
        val inputs = mutableListOf()

        return variant.project.tasks.create("compress${variant.name.capitalize()}${name.capitalize()}With${tool.command.name.substringBefore('.').capitalize()}", getCompressionTaskClass(aapt2).java) { task ->
            task.tool = tool
            task.variant = variant
            task.results = results
            task.supplier = {
                if (inputs.isEmpty()) {
                    inputs += supplier.invoke().filter { it.length() > 0 }.sortedBy { it }
                }
                inputs
            }
        }.apply {
            dependsOn(install, deps)
            variant.processResTask.dependsOn(this)
        }
    }

创建一个task ,名字为compressReleaseResourcesWithCwebp,这个task依赖task ‘:app:installCwebp’,installCwebp依赖mergeResourcesTask,而processResTask依赖compressReleaseResourcesWithCwebp,印证了前面说的wepb压缩task是在mergeRes之后和processRes之前,这样compressReleaseResourcesWithCwebp的task在打包过程中已经插入好,执行的顺序

5.AbstractCwebpCompressImages

abstract class AbstractCwebpCompressImages : CompressImages() {

    @TaskAction
    fun run() {
        this.options = CompressionOptions(project.getProperty(PROPERTY_OPTION_QUALITY, 80))
        compress(File::hasNotAlpha)
    }

    protected abstract fun compress(filter: (File) -> Boolean)

}

这里使用了@TaskAction,当Task执行时候都会调用action方法,然后就走到了run()方法中,这样就会走到compress方法

6.CwebpCompressFlatImages#compress

 override fun compress(filter: (File) -> Boolean) {
        val cwebp = this.compressor.canonicalPath
        val aapt2 = variant.scope.buildTools.getPath(BuildToolInfo.PathId.AAPT2)
        val parser = SAXParserFactory.newInstance().newSAXParser()
        val icons = variant.scope.mergedManifests.search {
            it.name == SdkConstants.ANDROID_MANIFEST_XML
        }.parallelStream().map { manifest ->
            LauncherIconHandler().let {
                parser.parse(manifest, it)
                it.icons
            }
        }.flatMap {
            it.parallelStream()
        }.collect(Collectors.toSet())

        // Google Play only accept APK with PNG format launcher icon
        // https://developer.android.com/topic/performance/reduce-apk-size#use-webp
        val isNotLauncherIcon: (File, Aapt2Container.Metadata) -> Boolean = { input, metadata ->
            if (!icons.contains(metadata.resourceName)) true else false.also {
                val s0 = input.length()
                results.add(CompressionResult(input, s0, s0, File(metadata.sourcePath)))
            }
        }

        supplier().parallelStream().map {
            it to it.metadata
        }.filter {
            isNotLauncherIcon(it.first, it.second)
        }.filter {
            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", options.quality.toString(), 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()
            val rc = project.exec { spec ->
                spec.isIgnoreExitValue = true
                spec.commandLine = it.cmdline
            }
            when (rc.exitValue) {
                0 -> {
                    val s1 = it.output.length()
                    if (s1 > s0) {
                        results.add(CompressionResult(it.input, s0, s0, File(it.metadata.sourcePath)))
                        it.output.delete()
                    } else {
                        val rcAapt2 = project.exec { spec ->
                            spec.isIgnoreExitValue = true
                            spec.commandLine = it.aapt2
                        }
                        if (0 == rcAapt2.exitValue) {
                            results.add(CompressionResult(it.input, s0, s1, File(it.metadata.sourcePath)))
                            it.input.delete()
                        } else {
                            logger.error("${CSI_RED}Command `${it.aapt2.joinToString(" ")}` exited with non-zero value ${rc.exitValue}$CSI_RESET")
                            results.add(CompressionResult(it.input, s0, s0, File(it.metadata.sourcePath)))
                            rcAapt2.assertNormalExitValue()
                        }
                    }
                }
                else -> {
                    logger.error("${CSI_RED}Command `${it.cmdline.joinToString(" ")}` exited with non-zero value ${rc.exitValue}$CSI_RESET")
                    results.add(CompressionResult(it.input, s0, s0, File(it.metadata.sourcePath)))
                    it.output.delete()
                }
            }
        }
    }

下面一步步代码进行分析

val cwebp = this.compressor.canonicalPath

获取我们存放cwebp库的路径,我电脑是/Users/xxxx/workcode/TestDemo/app/build/bin/cwebp

val aapt2 = variant.scope.buildTools.getPath(BuildToolInfo.PathId.AAPT2)

获取aapt2路径,例如/Users/xxxx/Library/Android/sdk/build-tools/29.0.2/aapt2

val icons = variant.scope.mergedManifests

获取合并后的Manifests存放目录:
/Users/xxxx/workcode/TestDemo/app/build/intermediates/bundle_manifest/debug/processDebugManifest/bundle-manifest
Android Apk瘦身方案2——gradle插件将png自动压缩为webp_第5张图片
可以看到清单文件确实存放在这个文件夹

val icons = variant.scope.mergedManifests.search {
            it.name == SdkConstants.ANDROID_MANIFEST_XML
        }.parallelStream().map { manifest ->
            LauncherIconHandler().let {
                parser.parse(manifest, it)
                it.icons
            }
        }.flatMap {
            it.parallelStream()
        }.collect(Collectors.toSet())

所以这句就是通过清单文件存放的路径,过滤出清单文件,然后解析里面的icon,LauncherIconHandler这个类就是进行这个操作的

internal class LauncherIconHandler : DefaultHandler() {

    private val _icons = mutableSetOf()

    val icons: Set
        get() = _icons

    override fun startElement(uri: String, localName: String, qName: String, attributes: Attributes) {
        when (qName) {
            "application" -> {
                attributes.getValue("android:icon")?.let {
                    _icons.add(it.substringAfter('@'))
                }
                attributes.getValue("android:roundIcon")?.let {
                    _icons.add(it.substringAfter('@'))
                }
            }
        }
    }

}

所以最后会过滤出以下内容
Android Apk瘦身方案2——gradle插件将png自动压缩为webp_第6张图片
然后是定义一个方法isNotLauncherIcon

val isNotLauncherIcon: (File, Aapt2Container.Metadata) -> Boolean = { input, metadata ->
            if (!icons.contains(metadata.resourceName)) true else false.also {
                val s0 = input.length()
                results.add(CompressionResult(input, s0, s0, File(metadata.sourcePath)))
            }
        }

将输入的图片文件,进行判断添加到results这个集合中

继续看代码

 val output = compressedRes.file("${it.second.resourcePath.substringBeforeLast('.')}.webp")
            Aapt2ActionData(it.first, it.second, output,
                    listOf(cwebp, "-mt", "-quiet", "-q", options.quality.toString(), it.second.sourcePath, "-o", output.absolutePath),
                    listOf(aapt2, "compile", "-o", it.first.parent, output.absolutePath))
 val output = compressedRes.file("${it.second.resourcePath.substringBeforeLast('.')}.webp")

建立输出webp文件的路径,这里是:

/Users/xxxx/workcode/TestDemo/app/build/intermediates/compressed_res_cwebp/debug/compressDebugResourcesWithCwebp/drawable-xxhdpi-v4/abc_ic_star_black_48dp.webp

然后拼接出执行cwebp和aapt2的路径,将资源转为webp然后aapt2进行处理,命令的来源可以参考webp和aapt2的使用文档
Android Apk瘦身方案2——gradle插件将png自动压缩为webp_第7张图片

继续看关键代码

it.output.parentFile.mkdirs()
            val s0 = File(it.metadata.sourcePath).length()
            val rc = project.exec { spec ->
                spec.isIgnoreExitValue = true
                spec.commandLine = it.cmdline
            }
            when (rc.exitValue) {
                0 -> {
                    val s1 = it.output.length()
                    if (s1 > s0) {
                        results.add(CompressionResult(it.input, s0, s0, File(it.metadata.sourcePath)))
                        it.output.delete()
                    } else {
                        val rcAapt2 = project.exec { spec ->
                            spec.isIgnoreExitValue = true
                            spec.commandLine = it.aapt2
                        }
                        if (0 == rcAapt2.exitValue) {
                            results.add(CompressionResult(it.input, s0, s1, File(it.metadata.sourcePath)))
                            it.input.delete()
                        } else {
                            logger.error("${CSI_RED}Command `${it.aapt2.joinToString(" ")}` exited with non-zero value ${rc.exitValue}$CSI_RESET")
                            results.add(CompressionResult(it.input, s0, s0, File(it.metadata.sourcePath)))
                            rcAapt2.assertNormalExitValue()
                        }
                    }
                }
                else -> {
                    logger.error("${CSI_RED}Command `${it.cmdline.joinToString(" ")}` exited with non-zero value ${rc.exitValue}$CSI_RESET")
                    results.add(CompressionResult(it.input, s0, s0, File(it.metadata.sourcePath)))
                    it.output.delete()
                }
            }
it.output.parentFile.mkdirs()

创建以下路径的父文件夹

/Users/xxxx/workcode/TestDemo/app/build/intermediates/compressed_res_cwebp/debug/compressDebugResourcesWithCwebp/drawable-xxhdpi-v4/abc_ic_star_black_48dp.webp

即父文件夹为

/Users/mayunlong/workcode/TestDemo/app/build/intermediates/compressed_res_cwebp/debug/compressDebugResourcesWithCwebp/drawable-xxhdpi-v4

然后通过project.exec执行cwebp的命令,即前面

Aapt2ActionData(it.first, it.second, output,
                    listOf(cwebp, "-mt", "-quiet", "-q", options.quality.toString(), it.second.sourcePath, "-o", output.absolutePath),
                    listOf(aapt2, "compile", "-o", it.first.parent, output.absolutePath))

里面listOf(cwebp, "-mt", "-quiet", "-q", options.quality.toString(), it.second.sourcePath, "-o", output.absolutePath)这句
这样,图片就通过命令变成了webp文件

接着判断执行结果

when (rc.exitValue)

当rc.exitValue为0时,说明执行代码成功

					val s1 = it.output.length()
                    if (s1 > s0) {
                        results.add(CompressionResult(it.input, s0, s0, File(it.metadata.sourcePath)))
                        it.output.delete()
                    } else {
                        val rcAapt2 = project.exec { spec ->
                            spec.isIgnoreExitValue = true
                            spec.commandLine = it.aapt2
                        }
                        if (0 == rcAapt2.exitValue) {
                            results.add(CompressionResult(it.input, s0, s1, File(it.metadata.sourcePath)))
                            it.input.delete()
                        } else {
                            logger.error("${CSI_RED}Command `${it.aapt2.joinToString(" ")}` exited with non-zero value ${rc.exitValue}$CSI_RESET")
                            results.add(CompressionResult(it.input, s0, s0, File(it.metadata.sourcePath)))
                            rcAapt2.assertNormalExitValue()
                        }
                    }

判断输出文件的大小时s1,如果压缩后反而比源文件s0还大,则对输出进行删除,否则,说明压缩后确实变小了,继续执行aapt2命令,将资源编译后输出到打包的资源文件夹,则对源文件进行删除。

这里还有一个需要强调的,mergeRes命令会将所有的资源生成.flat的中间产物,目录是/Users/xxxx/workcode/TestDemo/app/build/intermediates/res/merged/debug
Android Apk瘦身方案2——gradle插件将png自动压缩为webp_第8张图片
图片的压缩就是通过过滤出这个目录下的所有png图片,这个过滤的逻辑在CwebpCompressionVariantProcessor中
Android Apk瘦身方案2——gradle插件将png自动压缩为webp_第9张图片
关键就在于传入的filter,也就是isFlatPngExceptRaw

fun isFlatPngExceptRaw(file: File) : Boolean = isFlatPng(file) && !file.name.startsWith("raw_")
fun isFlatPng(file: File): Boolean = file.name.endsWith(".png.flat", true)
        && (file.name.length < 11 || !file.name.regionMatches(file.name.length - 11, ".9", 0, 2, true))

上面的代码就条件就是png.flat的过滤条件

还有这些.flat文件又是如何进行解析呢?代码在这里Aapt2ParserKt#parseResFileMetadata

private fun BinaryParser.parseResFileMetadata(): Metadata {
    val headerSize = readInt()
    val dataSize = readLong()

    return parse {
        ResourcesInternal.CompiledFile.parseFrom(readBytes(headerSize))
    }.let {
        Metadata(it.resourceName, it.sourcePath, Configuration().apply {
            size = it.config.serializedSize
            if (size <= 0) {
                return@apply
            }

            imsi.apply {
                mcc = it.config.mcc.toShort()
                mnc = it.config.mnc.toShort()
            }
            locale.apply {
                // TODO language = ...
                // TODO country = ...
            }
            screenType.apply {
                orientation = it.config.orientationValue.toByte()
                touchscreen = it.config.touchscreenValue.toByte()
                density = it.config.density.toShort()
            }
            input.apply {
                keyboard = it.config.keyboardValue.toByte()
                navigation = it.config.navigationValue.toByte()
                flags = 0 // TODO
            }
            screenSize.apply {
                width = it.config.screenWidth.toShort()
                height = it.config.screenHeight.toShort()
            }
            version.apply {
                sdk = it.config.sdkVersion.toShort()
                minor = 0
            }
            screenConfig.apply {
                layout = it.config.layoutDirectionValue.toByte()
                uiMode = it.config.uiModeTypeValue.toByte()
                smallestWidthDp = it.config.smallestScreenWidthDp.toShort()
            }
            screenSizeDp.apply {
                width = it.config.screenWidthDp.toShort()
                height = it.config.screenHeightDp.toShort()
            }
            // TODO localScript = ...
            it.config.localeBytes.takeIf { l ->
                l.size() > 0
            }?.let { l ->
                l.copyTo(localeVariant, 0, 0, l.size())
            }
            screenConfig2.apply {
                layout = it.config.screenRoundValue.toByte()
                colorMode = (it.config.hdrValue shl 2 and it.config.wideColorGamutValue).toByte()
            }
        })
    }
}

其实就是通过ResourcesInternal.CompiledFile.parseFrom方法,这个ResourcesInternal是通过proto文件进行生成的此方法,会将字节数组转化为ResourcesInternal.CompiledFile类
在这里插入图片描述
参考代码在这:https://android.googlesource.com/platform/frameworks/base/+/master/tools/aapt2/ResourcesInternal.proto

你可能感兴趣的:(性能优化,android,apk瘦身,Android性能优化)