前语

kotlin-android-extensions 插件早在 2020 年就现已被宣布抛弃了,并且将在 Kotlin 1.8 中被正式移除:Discontinuing Kotlin synthetics for views

kotlin-android-extensions 插件将被正式移除,如何无缝迁移?

如上图示,移除 kotlin-android-extensions 的代码现已被 Merge 了,因而假如咱们需求升级到 Kotlin 1.8,则必需求移除 KAE

那么移除 kotlin-android-extensions 后,咱们该怎么搬迁呢?

搬迁计划

kotlin-android-extensions 插件将被正式移除,如何无缝迁移?

官方的搬迁计划如上所示,官方主张咱们老项目搬迁到 ViewBinding,新项目直接搬迁到 Jetpack Compose

关于新代码咱们当然能够这么做,可是关于很多存量代码,咱们该怎么搬迁?因为 KAE 简略易用的特性,它在项目中经常被很多运用,要搬迁如此多的存量代码,并不是一个简略的作业

存量代码搬迁计划

kotlin-android-extensions 插件将被正式移除,如何无缝迁移?

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 不会被解析
}

经过以上几步,搬迁就彻底啦~

支撑的类型

kotlin-android-extensions 插件将被正式移除,如何无缝迁移?

如上所示,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-android-extensions 插件将被正式移除,如何无缝迁移?

Kotlin 编译器插件,经过运用编译进程中供给的各种Hook时机,让咱们能够在编译进程中插入自己的逻辑,以达到修正编译产品的意图。比方咱们能够经过 IrGenerationExtension 来修正 IR 的生成,能够经过 ClassBuilderInterceptorExtension 修正字节码生成逻辑

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

kotlin-android-extensions 插件将被正式移除,如何无缝迁移?

kotlin-android-extensions 是怎么完成的

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

kotlin-android-extensions 插件将被正式移除,如何无缝迁移?

  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 的终究计划,结构如图所示

kotlin-android-extensions 插件将被正式移除,如何无缝迁移?

下面咱们就来介绍一下

kace-compiler 完成

kace-compiler 是一个 Kotlin 编译器插件,它的效果是给方针类型(Activity 或许 Fragment)主动增加接口与完成

kotlin-android-extensions 插件将被正式移除,如何无缝迁移?

如上所示,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

kotlin-android-extensions 插件将被正式移除,如何无缝迁移?

比方 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 ~

开源地址

github.com/kanyun-inc/…