$location") this.command.location.openStream().buffered().use { input -> FileUtils.copyInputStreamToFile(input, location) // 将 cwebp 拷贝一份到 build dir project.exec { // 然后给 cwebp 增加 executable 权限 it.commandLine = when { OS.isLinux() || OS.isMac() -> listOf("chmod", "+x", location.canonicalPath) OS.isWindows() -> listOf("cmd", "/c echo Y|cacls ${location.canonicalPath} /t /p everyone:f") else -> TODO("Unsupported OS ${OS.name}") } } } } } open class Command( val name:"> $location") this.command.location.openStream().buffered().use { input -> FileUtils.copyInputStreamToFile(input, location) // 将 cwebp 拷贝一份到 build dir project.exec { // 然后给 cwebp 增加 executable 权限 it.commandLine = when { OS.isLinux() || OS.isMac() -> listOf("chmod", "+x", location.canonicalPath) OS.isWindows() -> listOf("cmd", "/c echo Y|cacls ${location.canonicalPath} /t /p everyone:f") else -> TODO("Unsupported OS ${OS.name}") } } } } } open class Command( val name:"> $location") this.command.location.openStream().buffered().use { input -> FileUtils.copyInputStreamToFile(input, location) // 将 cwebp 拷贝一份到 build dir project.exec { // 然后给 cwebp 增加 executable 权限 it.commandLine = when { OS.isLinux() || OS.isMac() -> listOf("chmod", "+x", location.canonicalPath) OS.isWindows() -> listOf("cmd", "/c echo Y|cacls ${location.canonicalPath} /t /p everyone:f") else -> TODO("Unsupported OS ${OS.name}") } } } } } open class Command( val name:">
// 1)准备接口及相关实现

package work.dalvik.binder.myapplication

interface ISimpleInterface {
    val name: String
}

class SimplePlan: ISimpleInterface {
    override val name: String
        get() = "Plan"
}

class SimpleProject: ISimpleInterface {
    override val name: String
        get() = "Project"
}

class SimpleRobot: ISimpleInterface {
    override val name: String
        get() = "Robot"
}

// 2)在对应的文件里手动注册接口和实现
// 项目路径:app/src/main/resources/META-INF/services/work.dalvik.binder.myapplication.ISimpleInterface
// 打包后:/META-INF/services/work.dalvik.binder.myapplication.ISimpleInterface

work.dalvik.binder.myapplication.SimpleRobot
work.dalvik.binder.myapplication.SimpleProject
work.dalvik.binder.myapplication.SimplePlan

// 3)运行时发现服务

val services = ServiceLoader.load(ISimpleInterface::class.java)
for (service in services) {
    Log.d("cyrus", "${service.name} from ${service::class.java}")
}

// 11:23:39.785  5764-5764  cyrus D  Robot from class work.dalvik.binder.myapplication.SimpleRobot
// 11:23:39.785  5764-5764  cyrus D  Project from class work.dalvik.binder.myapplication.SimpleProject
// 11:23:39.786  5764-5764  cyrus D  Plan from class work.dalvik.binder.myapplication.SimplePlan

SPI

SPI 全称 Service Provider Interface,是 Java/Android 内置于标准库里的一个 服务发现 机制 ServiceLoader

// 1)加入依赖

dependencies {
    kapt 'com.google.auto.service:auto-service:1.0.1'
    implementation 'com.google.auto.service:auto-service-annotations:1.0.1'
}

// 2)加上注解 @AutoService

package work.dalvik.binder.myapplication

import com.google.auto.service.AutoService

interface ISimpleInterface {
    val name: String
}

@AutoService(ISimpleInterface::class)
class SimplePlan: ISimpleInterface {
    override val name: String
        get() = "Plan"
}

@AutoService(ISimpleInterface::class)
class SimpleProject: ISimpleInterface {
    override val name: String
        get() = "Project"
}

@AutoService(ISimpleInterface::class)
class SimpleRobot: ISimpleInterface {
    override val name: String
        get() = "Robot"
}

// 3)不再需要手动增加/删除 SPI 配置文件里的内容

// 4)运行时发现服务

val services = ServiceLoader.load(ISimpleInterface::class.java)
for (service in services) {
    Log.d("cyrus", "${service.name} from ${service::class.java}")
}

// 11:34:01.747  6141-6141  cyrus D  Plan from class work.dalvik.binder.myapplication.SimplePlan
// 11:34:01.748  6141-6141  cyrus D  Project from class work.dalvik.binder.myapplication.SimpleProject
// 11:34:01.749  6141-6141  cyrus D  Robot from class work.dalvik.binder.myapplication.SimpleRobot

Google AutoService

简化 SPI 的使用:使用注解 @AutoService 替换手动的 SPI 配置文件管理

buildscript {
    dependencies {
        classpath "com.didiglobal.booster:booster-gradle-plugin:$booster_version"

        // booster 是模块化的,每个功能 / 特性都是一个单独的依赖项,需要哪项特性把对应的依赖项加入进来即可
        // booster-gradle-plugin 通过 SPI / AutoService 发现并执行这些服务
        classpath "com.didiglobal.booster:booster-task-compression-cwebp:$booster_version"
        classpath "com.didiglobal.booster:booster-transform-shared-preferences:$booster_version"
        classpath "com.didiglobal.booster:booster-task-analyser:$booster_version"
        // ...
    }
}
apply plugin: 'com.didiglobal.booster'

引入 booster 框架

// <https://github.com/didi/booster/blob/master/booster-task-compression-cwebp/src/main/kotlin/com/didiglobal/booster/task/compression/cwebp/CwebpCompressionVariantProcessor.kt>
@AutoService(VariantProcessor::class)
class CwebpCompressionVariantProcessor : VariantProcessor {

    override fun process(variant: BaseVariant) {  // 处理各种变体(构建任务)
        val project = variant.project
        val results = CompressionResults()
        val ignores = project.findProperty(PROPERTY_IGNORES)?.toString()?.trim()?.split(',')?.map {
            Wildcard(it)
        }?.toSet() ?: emptySet()

        Cwebp.get(variant)?.newCompressionTaskCreator()?.createCompressionTask(variant, results, "resources", {
            variant.mergedRes.search(if (project.isAapt2Enabled) ::isFlatPngExceptRaw else ::isPngExceptRaw)
        }, ignores, variant.mergeResourcesTaskProvider)?.configure {
            it.doLast {
                results.generateReport(variant, Build.ARTIFACT)
            }
        }
    }
}

class SimpleCompressionTaskCreator(
    private val tool: CompressionTool,  /* Cwebp */
    private val compressor: (Boolean) -> KClass<out CompressImages<out CompressionOptions>>
) : CompressionTaskCreator {

    override fun createCompressionTask(
            variant: BaseVariant,
            results: CompressionResults,
            name: String,                       // resources
            supplier: () -> Collection<File>,   // variant.mergedRes.search(if (project.isAapt2Enabled) ::isFlatPngExceptRaw else ::isPngExceptRaw)
            ignores: Set<Wildcard>,             // emptySet()
            vararg deps: TaskProvider<out Task> // variant.mergeResourcesTaskProvider
    ): TaskProvider<out CompressImages<out CompressionOptions>> {

        val project = variant.project
        val aapt2 = project.isAapt2Enabled
        val install = getCommandInstaller(variant)

        // TaskContainer.register(String name, Class<T> type, Action<? super T> configurationAction)
        // 往 project 注册了一个 cwebp task
        // 名称是 compress[DemoRelease]ResourcesWith[CwebpCompressOpaqueFlatImages](显示在 Android Studio 的 Build 窗口里)
        // 实现类是 CwebpCompressOpaqueFlatImages
        return project.tasks.register(
            "compress${variant.name.capitalize()}${name.capitalize()}With${tool.command.name.substringBefore('.').capitalize()}", 
            getCompressionTaskClass(aapt2).java

        // 配置 cwebp task 的依赖图,确定它的执行次序
        ) { task ->
            task.group = BOOSTER
            task.description = "Compress image resources by ${tool.command.name} for ${variant.name}"
            task.dependsOn(variant.preBuildTaskProvider.get())    // 在所有的 pre-build tasks 之后执行
            task.tool = tool
            task.variant = variant
            task.results = results
            task.filter = if (ignores.isNotEmpty()) excludes(ignores) else MATCH_ALL_RESOURCES
            task.images = lazy(supplier)::value
        }.apply {
            dependsOn(install)                                      // 在安装 cwebp 可执行文件之后执行
            deps.forEach { dependsOn(it) }                          // 在所有 merge[XXX]Resources task 之后执行
            variant.processResTaskProvider?.dependsOn(this)         // 在所有 process[XXX]Resources task 前执行
            variant.bundleResourcesTaskProvider?.dependsOn(this)    // 在所有 bundle[XXX]Resources task 前执行
        }
    }

    // 注册一个 install cwebp task
    // 名称是 install[DemoRelease]Cwebp
    // 实现类是 CommandInstaller
    private fun getCommandInstaller(variant: BaseVariant): TaskProvider<out Task> {
        return variant.project.tasks.register(getInstallTaskName(variant.name)) {
            it.group = BOOSTER
            it.description = "Install ${tool.command.name} for ${variant.name}"
        }.apply {
            dependsOn(getCommandInstaller(variant.project))
            dependsOn(variant.mergeResourcesTaskProvider)    // 在所有 merge[XXX]Resources task 之后执行
        }
    }

    // cwebp 可执行文件每次构建只释放一次即可
    private fun getCommandInstaller(project: Project): TaskProvider<out Task> {
        val name = getInstallTaskName()
        return try {
            project.tasks.named(name)
        } catch (e: UnknownTaskException) {
            null
        } ?: project.tasks.register(name, CommandInstaller::class.java) {
            it.group = BOOSTER
            it.description = "Install ${tool.command.name}"
            it.command = tool.command
        }
    }

    private fun getInstallTaskName(variant: String = ""): String {
        @Suppress("DEPRECATION")
        return "install${variant.capitalize()}${tool.command.name.substringBefore('.').capitalize()}"
    }    
}

// 根据是否支持 aapt2、是否支持 alpha 等情况有四种核心实现,这里选取支持 aapt2 和 alpha 的 CwebpCompressOpaqueFlatImages 为例
class Cwebp internal constructor(val supportAlpha: Boolean) : CompressionTool(CommandService.get(CWEBP)) {
    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
            }
        }
    }
}

webp 格式化

注册 cwebp gradle task

VariantProcessor 是 booster API,本模块入口 CwebpCompressionVariantProcessor 通过 AutoService 注册为它的一个实现,然后 booster-gradle-plugin 在运行时通过 ServiceLoader 发现并执行 cwebp 模块

实现类是 CwebpCompressOpaqueFlatImagesCwebpCompressFlatImagesCwebpCompressOpaqueImages or CwebpCompressImages(根据 aapt2 和 alpha 的支持情况四选一)

依赖图:

cwebp_executable.png

install cwebp executable task

cwebp 可执行文件被打包进 aar,然后在 gradle task 期间被释放到 build dir 使用

// 此 task 在上一章节的 getCommandInstaller 方法里被注册进去
@CacheableTask
open class CommandInstaller : DefaultTask() {

    @get:Input
    lateinit var command: Command

    @get:OutputFile
    val location: File
        get() = project.buildDir.file("bin", command.name)

    @TaskAction
    fun install() {
        logger.info("Installing $command => $location")

        this.command.location.openStream().buffered().use { input ->
            FileUtils.copyInputStreamToFile(input, location)  // 将 cwebp 拷贝一份到 build dir
            project.exec {                                    // 然后给 cwebp 增加 executable 权限
                it.commandLine = when {
                    OS.isLinux() || OS.isMac() -> listOf("chmod", "+x", location.canonicalPath)
                    OS.isWindows() -> listOf("cmd", "/c echo Y|cacls ${location.canonicalPath} /t /p everyone:f")
                    else -> TODO("Unsupported OS ${OS.name}")
                }
            }
        }
    }
}

open class Command(
    val name: String,  // 可执行文件的名称:cwebp or cwebp.exe(windows)
    val location: URL  // 可执行文件的相对位置(如上图):ClassLoader.getResource("bin/linux/x64 | bin/macosx/[10.1x] | windows/x64")
) : Serializable {

    override fun equals(other: Any?) = when {
        this === other -> true
        other is Command -> name == other.name && location == other.location
        else -> false
    }

    @Throws(IOException::class)
    open fun execute(vararg args: String) {
        Runtime.getRuntime().exec(arrayOf(location.file.let(::File).canonicalPath) + args).let { p ->
            p.waitFor()
            if (p.exitValue() != 0) {
                throw IOException(p.stderr)
            }
        }
    }

    override fun hashCode(): Int {
        return arrayOf(name, location).contentHashCode()
    }

    override fun toString() = "$name:$location"

}
// gradle api
Project.objects.fileCollection().from(     // 创建一个空的文件集合,用以承载下面的文件
    Component.artifacts.get(               // Access to the variant's buildable artifacts for build customization.
        InternalArtifactType.MERGED_RES))  // 它是一个目录,包含了所有 resource merger 处理后的模块资源文件,且经过 aapt2 编译

// booster 为了兼容各个版本的 gradle:v3.6、v4.2、v7.3 等等,抽象出一个统一的接口:AGPInterface
// 针对不同的 gradle 版本,实现可能不一样,这里是针对 gradle 7.3 的实现
internal object V73 : AGPInterface {

    private val BaseVariant.component: ComponentImpl
        get() = BaseVariantImpl::class.java.getDeclaredField("component").apply {
            isAccessible = true
        }.get(this) as ComponentImpl

    override val BaseVariant.project: Project
        get() = component.variantDependencies.run {
            javaClass.getDeclaredField("project").apply {
                isAccessible = true
            }.get(this) as Project
        }
}

cwebp task

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 是经过 aapt2 compile 后的 png 格式图片资源

resources merger 将所有资源放至同一目录,并以资源限定符为前缀如 app\\build\\intermediates\\res\\merged\\release\\drawable-ldpi-v4_exo_icon_next.png.flatraw_ 开头的是 res/raw 目录下的资源文件,它们有资源 ID R.raw.[id] 但是不压缩,直接将原始数据包进 apk(Arbitrary files to save in their raw form),所以这里也要遵循规范排除这些 raw 资源

排除 nine patch 图片资源

cwebp -mt -quiet -q [quality] [*.png.flat] -o [*.webp]

cwebp 可以将 PNG, JPEG, TIFF, WebP or raw Y’CbCr 转换为 webp 格式(flat ?)

整体流程如下:

// 继承关系
CwebpCompressOpaqueFlatImages
  - CwebpCompressFlatImages
    - AbstractCwebpCompressImages
      - CompressImages<CompressionOptions>
        - org.gradle.api.DefaultTask

@CacheableTask
internal abstract class CwebpCompressOpaqueFlatImages {

    // 格式化后的 webp 文件的输出目录:
    // build/intermediates/compressed_res_cwebp/release/demo/compressDemoReleaseResourcesWithCwebpCompressOpaqueFlatImages
    @get:OutputDirectory
    val compressedRes: File
        get() = variant.project.buildDir.file(FD_INTERMEDIATES).file("compressed_${FD_RES}_cwebp", variant.dirName, this.name)

    @get:Internal
    val compressor: File
        get() = project.tasks.withType(CommandInstaller::class.java).find {
            it.command == tool.command
        }!!.location        

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

    override fun compress(filter: (File) -> Boolean /* always true for CwebpCompressOpaqueFlatImages */) {
        val cwebp = this.compressor.canonicalPath  // cwebp 可执行文件的位置
        val aapt2 = variant.buildTools.getPath(BuildToolInfo.PathId.AAPT2)
        val parser = SAXParserFactory.newInstance().newSAXParser()
        val icons = variant.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: (Pair<File, Aapt2Container.Metadata>) -> Boolean = { (input, metadata) ->
            if (!icons.contains(metadata.resourceName)) true else false.also {
                ignore(metadata.resourceName, input, File(metadata.sourcePath))
            }
        }

        // images() == variant.mergedRes.search(if (project.isAapt2Enabled) ::isFlatPngExceptRaw else ::isPngExceptRaw)
        // variant.mergedRes 是数据源
        // if (project.isAapt2Enabled) ::isFlatPngExceptRaw else ::isPngExceptRaw 是主要的过滤器
        images().parallelStream().map {
            it to it.metadata
        }.filter(this::includes).filter(isNotLauncherIcon).filter {
            filter(File(it.second.sourcePath))
        }.map {
            // 指定输出文件:文件名不变,后缀改为 webp,放到输出目录
            val output = compressedRes.file("${it.second.resourcePath.substringBeforeLast('.')}.webp")
            // 先用 cwebp 转格式,再用 aapt2 compile 编译为 flat 格式,因为这些 png 资源文件之前是 flat 格式的
            Aapt2ActionData(it.first, it.second, output,
                    listOf(cwebp, "-mt", "-quiet", "-q", options.quality.toString(), it.second.sourcePath, "-o", output.canonicalPath),
                    listOf(aapt2, "compile", "-o", it.first.parent, output.canonicalPath))
        }.forEach {
            it.output.parentFile.mkdirs()  // 为目标文件创建必须的目录
            val s0 = File(it.metadata.sourcePath).length()

            // cwebp 进行格式转换
            // Executes an external command.
            // The given action configures a {@link org.gradle.process.ExecSpec}, which is used to launch the process.
            // This method blocks until the process terminates, with its result being returned.
            // Params:
            //   action – The action for configuring the execution.
            // Returns:
            //   the result of the execution
            // ExecResult Project.exec(Action<? super ExecSpec> action);
            val rc = project.exec { spec ->
                spec.isIgnoreExitValue = true
                spec.commandLine = it.cmdline  // build/bin/cwebp -mt -quiet -q 80 [src] -o [output]
            }
            when (rc.exitValue) {
                0 -> {  // webp 格式转换成功
                    val s1 = it.output.length()
                    if (s1 > s0) {
                        results.add(CompressionResult(it.input, s0, s0, File(it.metadata.sourcePath)))
                        it.output.delete()
                    } else {

                        // aapt2 编译
                        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()
                }
            }
        }
    }

}

aapt2

aapt2 全称 Android Asset Packaging Tool,是一个专门处理资源文件的命令行工具,主要有以下功能:

  1. compile

输入原始的资源文件,输出编译后的中间产物(intermediate file):aapt2 compile path-to-input-files [options] -o output-directory/

  1. res/values 目录下的 xml 文件,比如字符串 strings.xml、颜色 colors.xml、数组 arrays.xml 等,被 aapt2 编译为 *.arsc.flat 中间产物,最后被链接为一个单独的 resources.arsc 打进 apk,也就是说项目源码里会存在 app/src/main/res/values 目录但 apk 里是没有 res/values 目录的
  2. 其余的 xml 文件被编译为紧凑的、二进制的 *.flat 中间产物,最后被链接打包进 apk
  3. png 图片被压缩为 *.png.flat 中间产物(不是 png 格式的图片了),size 会因为压缩变小很多
  4. 其余资源文件直接链接打包进 apk

.flat 文件是 aapt2 编译的产物,也叫做 aapt2 容器,由文件头(header)和资源项(entry)两大部分组成

Category Size in bytes Field Name Description
header 4 magic AAPT2 容器文件标识:AAPT 或者 0x54504141
4 version AAPT2 容器版本
4 entry_count 容器中包含的条目数量(一个 flat 文件可以包含多个资源项)
entry 4 entry_type 资源类型,目前仅支持两种:RES_TABLE(0x00000000) 和 RES_FILE(0x00000001)
8 entry_length 资源的长度
entry_length data 资源内容

RES_FILE 类型的结构如下:

Size in bytes Field Description
4 header_size header 的长度
8 data_size data 的长度
header_size header protobuf 序列化的 CompiledFile 结构,保存了文件名、文件路径、文件配置和文件类型等信息
x header_padding 0 - 3 个填充字节,用以 data 32 位对齐
data_size data 资源文件的内容:png、二进制的 XML 或者 protobuf 序列化的 XmlNode 结构
x data_padding 0 - 3 个填充字节,用以 data 32 位对齐