KAE 将被正式移除,如何无缝迁移?

前言

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

如上图示,移除 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 无缝迁移的框架

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

相比其它方案,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 目前没有做支持

版本兼容

Kotlin AGP Gradle
最低支持版本 1.7.0 4.2.0 6.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

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

  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
  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  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(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(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

    @get:Input
    abstract val namespace: Property

    @get:OutputDirectory
    abstract val sourceOutputDir: DirectoryProperty    
}

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

并行 Task

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

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

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

    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() to mutableListOf()) { (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 ~

开源地址

github.com/kanyun-inc/…

作者:程序员江同学
链接:https://juejin.cn/post/7168256990484332580

你可能感兴趣的:(KAE 将被正式移除,如何无缝迁移?)