在 mergeRes 和 processRes 任务之间插入 WebP 压缩任务,如下图所示:
使用开源框架Cwebp,使用命令行对所有的图片进行遍历处理,然后将结果输出
Google 官方提供的下载地址:https://storage.googleapis.com/downloads.webmproject.org/releases/webp/index.html 下载的 cwebp 二进制可执行文件 64 位版本。
由于 WebP 格式在 14 <= minSdkVersion <= 17 不支持带 alpha 通道的图像,所以,针对 AAPT2, alpha 两个维度,将 task 分成了4 种:
应用是发布在 Google Play 上,应用的启动图标必须是 PNG 格式,否则 Google Play 不会接受。
以下代码均来自booster开源项目
1.自定义gradle插件BoosterPlugin.java
这里的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
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
可以看到清单文件确实存放在这个文件夹
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('@'))
}
}
}
}
}
所以最后会过滤出以下内容
然后是定义一个方法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的使用文档
继续看关键代码
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
图片的压缩就是通过过滤出这个目录下的所有png图片,这个过滤的逻辑在CwebpCompressionVariantProcessor中
关键就在于传入的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