cover_image

Kace 使用与原理解析

猿辅导技术 猿辅导技术
2022年11月18日 06:42

前言

KAE 插件早在 2020 年就已经被宣布废弃了,并且将在 Kotlin 1.8 中被正式移除

图片

如上图示,移除 KAE 的代码已经被 Merge 了,因此如果我们需要升级到 Kotlin 1.8,则必须要移除 KAE

那么移除 KAE 后,我们该如何迁移呢?

迁移方案

图片

官方的迁移方案如上所示,官方建议我们老项目迁移到 ViewBinding,老项目直接迁移到 Jetpack Compose

对于新代码我们当然可以这么做,但是对于大量存量代码,我们该如何迁移?由于 KAE 简单易用的特性,它在项目中经常被大量使用,要迁移如此多的存量代码,并不是一个简单的工作

存量代码迁移方案

图片

KAE 存量代码主要有如图3种迁移方式

最简单也最直接的当然就是直接手动修改,这种方式的问题在于要迁移的代码数量庞大,迁移成本高。同时手动迁移容易出错,也不容易回测,测试不能覆盖到所有的页面,导致引入线上 bug

还二个方案,是把 KAE 直接从 Kotlin 源码中抽取出来单独维护,但是 KAE 中也大量依赖了 Kotlin 的源码,抽取成本较高。同时 KAE 中大量使用了 Kotlin 编译器插件的 API,而这部分 API 并没有稳定,当 K2 编译器正式发布的时候很可能还会有较大的改动,而这也带来较高的维护成本。

第三个方案就是本篇要重点介绍的 Kace

Kace 是什么?

Kace 即 kotlin-android-compatible-extensions,一个用于帮助从 kotlin-android-extensions 无缝迁移的框架

目前已经开源,开源地址可见:https://github.com/kanyun-inc/Kace

相比其它方案,Kace 主要有以下优点

  1. 接入方便,不需要手动修改旧代码,可以真正做到无缝迁移
  2. 与 KAE 表现一致(都支持 viewId 缓存,并在页面销毁时清除),不会引入预期外的 bug
  3. 统一迁移,回测方便,如果存在问题时,应该是批量存在的,避免手动修改可能引入线上 bug 的问题
  4. 通过生成源码的方式兼容 KAE,维护成本低

快速迁移

使用 Kace 完成迁移主要分为以下几步

1. 添加插件到 classpath

// 方式 1
// 传统方式,在根目录的 build.gradle.kts 中添加以下代码
buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("com.kanyun.kace:kace-gradle-plugin:1.0.0")
    }
}

// 方式 2
// 引用插件新方式,在 settings.gradle.kts 中添加以下代码
pluginManagement {
    repositories {
        mavenCentral()
    }
    plugins {
        id("com.kanyun.kace") version "1.0.0" apply false
    }
}

2. 应用插件

移除kotlin-android-extensions插件,并添加以下代码

plugins {
    id("com.kanyun.kace")
    id("kotlin-parcelize"// 可选,当使用了`@Parcelize`注解时需要添加
}

3. 配置插件(可选)

默认情况下 Kace 会解析模块内的每个 layout 并生成代码,用户也可以自定义需要解析的 layout

kace {
    whiteList = listOf() // 当 whiteList 不为空时,只有 whiteList 中的 layout 才会被解析
    blackList = listOf("activity_main.xml"// 当 blackList 不为空时,blackList 中的 layout 不会被解析
}

经过以上几步,迁移就完全啦~

支持的类型

图片

如上所示,Kace 目前支持了以上四种最常用的类型,其他 kotlin-android-extensions 支持的类型如 android.app.Fragment, android.app.Dialog, kotlinx.android.extensions.LayoutContainer 等,由于被废弃或者使用较少,Kace 目前没有做支持

版本兼容


KotlinAGPGradle
最低支持版本1.7.04.2.06.7.1

由于 Kace 的目标是帮助开发者更方便地迁移到 Kotlin 1.8,因此 Kotlin 最低支持版本比较高

原理解析:前置知识

编译器插件是什么?

Kotlin 的编译过程,简单来说就是将 Kotlin 源代码编译成目标产物的过程,具体步骤如下图所示:

图片

Kotlin 编译器插件,通过利用编译过程中提供的各种Hook时机,让我们可以在编译过程中插入自己的逻辑,以达到修改编译产物的目的。比如我们可以通过 IrGenerationExtension 来修改 IR 的生成,可以通过 ClassBuilderInterceptorExtension 修改字节码生成逻辑

Kotlin 编译器插件可以分为 Gradle 插件,编译器插件,IDE 插件三部分,如下图所示

图片

kotlin-android-extensions 是怎么实现的

我们知道,KAE 是一个 Kotlin 编译器插件,当然也可以分为 Gradle 插件,编译器插件,IDE 插件三部分。我们这里只分析 Gradle 插件与编译器插件的源码,它们的具体结构如下:

图片
  1. AndroidExtensionsSubpluginIndicatorKAE插件的入口
  2. AndroidSubplugin用于配置传递给编译器插件的参数
  3. AndroidCommandLineProcessor用于接收编译器插件的参数
  4. AndroidComponentRegistrar用于注册如图的各种Extension

关于更细节的分析可以参阅:kotlin-android-extensions 插件到底是怎么实现的?

总得来说,其实 KAE 主要做了两件事

  1. KAE 会将 viewId 转化为 findViewByIdCached 方法调用
  2. KAE 会在页面关闭时清除 viewId cache

那么我们要无缝迁移,就也要实现相同的效果

Kace 原理解析

第一次尝试

我们首先想到的是解析 layout 自动生成扩展属性,如下图所示

// 生成的代码
val AndroidExtensions.button1
    get() = findViewByIdCached<Button>(R.id.button1)

val AndroidExtensions.buttion2
    get() = findViewByIdCached(R.id.button1)

// 给 Activity 添加 AndroidExtensions 接口
class MainActivity : AppCompatActivity(), AndroidExtensions {
    private val androidExtensionImpl by lazy { AndroidExtensionsImpl() }
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        lifecycle.addObserver(androidExtensionImpl)
    }

    override fun <T : View?> findViewByIdCached(owner: AndroidExtensionsBase, id: Int): T {
        return androidExtensionImpl.findViewByIdCached(id)
    }
}

如上所示,主要做了这么几件事:

  1. 通过 gradle 插件,自动解析 layout 生成AndroidExtensions接口的扩展属性
  2. 给 Activity 添加 AndroidExtensions 接口
  3. 由于需要支持缓存,因此也需要添加一个全局的变量:androidExtensionImpl
  4. 由于需要在页面关闭时清除缓存,因此也需要添加lifecycle Observer
  5. 重写findViewByIdCached方法,将具体工作委托给AndroidExtensionsImpl

通过以上步骤,其实 KAE 的功能已经实现了,我们可以在 Activity 中通过button1button2等 viewId 获取对应的 View

但是这样还是太麻烦了,修改一个页面需要添加这么多代码,还能再优化吗?

第二次尝试

private inline val AndroidExtensions.button1
    get() = findViewByIdCached<Button>(this, R.id.button1)

val AndroidExtensions.buttion2
    get() = findViewByIdCached(this, R.id.button1)

class MainActivity : AppCompatActivity(), AndroidExtensions by AndroidExtensionsImpl() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    }
}
  1. 我们通过委托简化了代码调用,只需要添加一行AndroidExtensions by AndroidExtensionsImpl()就可以实现迁移
  2. 我们不需要在初始化的时候手动添加lifecycle observer,这是因为我们在调用findViewByIdCached方法时会将this传递过去,因此可以在第一次调用时初始化,自动添加lifecycle observer

可以看出,现在已经比较简洁了,只需要添加一行代码就可以实现迁移,但如果项目中有几百个页面使用了 KAE 的话,改起来还是有点痛苦的,目前还不能算是真正的无缝迁移

那么还能再优化吗?

第三次尝试

第3次尝试就是 Kace 的最终方案,结构如图所示

图片

下面我们就来介绍一下

kace-compiler 实现

kace-compiler 是一个 Kotlin 编译器插件,它的作用是给目标类型(Activity 或者 Fragment)自动添加接口与实现

图片

如上所示,kace-compiler 的作用就是通过KaceSyntheticResolveExtension扩展添加接口,以及KaceIrGenerationExtension扩展添加实现

处理后的代码如下所示:

class MainActivity : AppCompatActivity(), AndroidExtensions {
    private val $$androidExtensionImpl by lazy { AndroidExtensionsImpl() }

    override fun <T : View?> findViewByIdCached(owner: AndroidExtensionsBase, id: Int): T {
        return $$androidExtensionImpl.findViewByIdCached(id)
    }
}

你可能还记得,前面说过由于编译器插件 API 还没有稳定,因此将 KAE 抽取出来独立维护成本较高,那么我们这里为什么还使用了编译器插件呢?

这是因为我们这里使用的编译器插件是比较少的,生成的代码也很简单,将来维护起来并不复杂,但是可以大幅的降低迁移成本,实现真正的无缝迁移

kace-gradle-plugin 生成代码

kace-gradle-plugin 的主要作用就是解析 layout 然后生成代码,生成的代码如处所示

package kotlinx.android.synthetic.debug.activity_main

private inline val AndroidExtensionsBase.button1
    get() = findViewByIdCached<android.widget.Button>(this, R.id.button1)
internal inline val Activity.button1
    get() = (this as AndroidExtensionsBase).button1
internal inline val Fragment.button1
    get() = (this as AndroidExtensionsBase).button1
package kotlinx.android.synthetic.main.activity_main.view

internal inline val View.button1
    get() = findViewById<android.widget.Button>(R.id.button1)
  1. 给 Activity, Fragment, View 等类型添加扩展属性
  2. 给 View 添加的扩展属性目前不支持缓存,而是直接通过finidViewById实现
  3. 支持根据不同的variant,生成不同的package的代码,比如debug

Kace 性能优化

明确输入输出

前面介绍了 kace-gradle-plugin 的主要作用就是解析 layout 然后生成代码,但是对于一个比较大的模块,layout 可能有几百个,如果每次编译时都要运行这个 Task,会带来一定的性能损耗

理想情况下,在输入输出没有发生变化的情况下,应该跳过这个 Task

图片

比如 Gradle 中内置的 JavaCompilerTask,在源码与 jdk 版本没有发生变化的时候,会自动跳过(标记为 up-to-date)

Gradle 需要我们明确 Task 的输入与输出是什么,这样它才能决定是否可以自动跳过这个Task,如下所示:

abstract class KaceGenerateTask : DefaultTask() {

    @get:Internal
    val layoutDirs: ConfigurableFileCollection = project.files()

    @get:Incremental
    @get:InputFiles
    @get:PathSensitive(PathSensitivity.RELATIVE)
    internal open val androidLayoutResources: FileCollection = layoutDirs
        .asFileTree
        .matching { patternFilterable ->
            patternFilterable.include("**/*.xml")
        }

    @get:Input
    abstract val layoutVariantMap: MapProperty<String, String>

    @get:Input
    abstract val namespace: Property<String>

    @get:OutputDirectory
    abstract val sourceOutputDir: DirectoryProperty    
}

如上所示,通过注解的方式明确了 Task 的输入输出,在输入与输出都没有发生改变的时候,该 Task 会被标记为 up-to-date ,通过编译避免的方式提高编译性能

并行 Task

KaceGenerateTask的主要作用其实就是解析 layout 然后生成代码,每个 layout 都是相互独立的,在这种情况下就特别适合使用并行 Task

要实现并行 Task,首先要将 Task 转化为 Worker API

abstract class KaceGenerateAction : WorkAction<KaceGenerateAction.Parameters{
    interface Parameters : WorkParameters {
        val destDir: DirectoryProperty
        val layoutFile: RegularFileProperty
        val variantName: Property<String>
        val namespace: Property<String>
    }

    override fun execute() {
        val item = LayoutItem(
            parameters.destDir.get().asFile,
            parameters.layoutFile.get().asFile,
            parameters.variantName.get()
        )
        val namespace = parameters.namespace.get()
        val file = item.layoutFile
        val layoutNodeItems = parseXml(saxParser, file, logger)
        writeActivityFragmentExtension(layoutNodeItems, item, namespace)
        writeViewExtension(layoutNodeItems, item, namespace)
    }
}
  1. 第一步:首先我们需要定义一个接口来表示每个Action需要的参数,即KaceGenerateAction.Parameters
  2. 第二步:您需要将自定义Task中为每个单独文件执行工作的部分重构为单独的类,即KaceGenerateAction
  3. 第三步:您应该重构自定义Task类以将工作提交给 WorkerExecutor,而不是自己完成工作

接下来就是将KaceGenerateAction提交给WorkerExector

abstract class KaceGenerateTask : DefaultTask() {
    @get:Inject
    abstract val workerExecutor: WorkerExecutor

    @TaskAction
    fun action(inputChanges: InputChanges) {
        val workQueue = workerExecutor.noIsolation()
        // ...
        changedLayoutItemList.forEach { item ->
            workQueue.submit(KaceGenerateAction::class.java{ parameters ->
                parameters.destDir.set(destDir)
                parameters.layoutFile.set(item.layoutFile)
                parameters.variantName.set(item.variantName)
                parameters.namespace.set(namespace)
            }
        }
        workQueue.await() // 等待所有 Action 完成,计算耗时
        val duration = System.currentTimeMillis() - startTime
    }
}
  1. 您需要拥有WorkerExecutor服务才能提交Action。这里我们添加了一个抽象的workerExecutor并添加注解,Gradle 将在运行时注入服务
  2. 在提交Action之前,我们需要通过不同的隔离模式获取WorkQueue,这里使用的是线程隔离模式
  3. 提交Action时,指定Action实现,在这种情况下调用KaceGenerateAction并配置其参数

经过测试,在一个包括 500 个 layout 的模块中,在开启并行 Task 前全量编译耗时约 4 秒,而开启后全量编译耗时减少到 2 秒左右,可以有 100% 左右的提升

支持增量编译

还有一种常见的场景,当我们只修改了一个 layout 时,如果模块内的所有 layout 都需要重新解析并生成代码,也是非常浪费性能的

理想情况下,应该只需要重新解析与处理我们修改的 layout 就行了,Gradle 同样提供了 API 供我们实现增量编译

abstract class KaceGenerateTask : DefaultTask() {
    @get:Incremental
    @get:InputFiles
    @get:PathSensitive(PathSensitivity.RELATIVE)
    internal open val androidLayoutResources: FileCollection = layoutDirs
        .asFileTree
        .matching { patternFilterable ->
            patternFilterable.include("**/*.xml")
        }

    @TaskAction
    fun action(inputChanges: InputChanges) {
        val changeFiles = getChangedFiles(inputChanges, androidLayoutResources)
        // ...        
    }

    private fun getChangedFiles(
        inputChanges: InputChanges,
        layoutResources: FileCollection
    )
 = if (!inputChanges.isIncremental) {
        ChangedFiles.Unknown()
    } else {
        inputChanges.getFileChanges(layoutResources)
            .fold(mutableListOf<File>() to mutableListOf<File>()) { (modified, removed), item ->
                when (item.changeType) {
                    ChangeType.ADDED, ChangeType.MODIFIED -> modified.add(item.file)
                    ChangeType.REMOVED -> removed.add(item.file)
                    else -> Unit
                }
                modified to removed
            }.run {
                ChangedFiles.Known(first, second)
            }
    }
}

通过以下步骤,就可以实现增量编译

  1. androidLayoutResources使用@Incremental注解标识,表示支持增量处理的输入
  2. TaskAction方法添加inputChange参数
  3. 通过inputChanges方法获取输入中发生了更改的文件,如果发生了更改则重新处理,如果被删除了则同样删除目标目录中的文件,没有发生更改的文件则不处理

通过支持增量编译,当只修改或者添加一个 layout 时,增量编译耗时可以减少到 8ms 左右,大幅减少了编译耗时

总结

本文主要介绍了如何使用 Kace ,以及 Kace 到底是如何实现的,如果有任何问题,欢迎提出 Issue,如果对你有所帮助,欢迎点赞收藏 Star ~

开源地址

https://github.com/kanyun-inc/Kace

继续滑动看下一个
猿辅导技术
向上滑动看下一个