前言
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 并沒有穩(wěn)定,當 K2 編譯器正式發(fā)布的時候很可能還會有較大的改動,而這也帶來較高的維護成本。
第三個方案就是本篇要重點介紹的 Kace
Kace 是什么?
Kace 即 kotlin-android-compatible-extensions,一個用于幫助從 kotlin-android-extensions 無縫遷移的框架
目前已經開源,開源地址可見:github.com/kanyun-inc/…
相比其它方案,Kace 主要有以下優(yōu)點
- 接入方便,不需要手動修改舊代碼,可以真正做到無縫遷移
- 與 KAE 表現一致(都支持 viewId 緩存,并在頁面銷毀時清除),不會引入預期外的 bug
- 統(tǒng)一遷移,回測方便,如果存在問題時,應該是批量存在的,避免手動修改可能引入線上 bug 的問題
- 通過生成源碼的方式兼容 KAE,維護成本低
快速遷移
使用 Kace 完成遷移主要分為以下幾步
1. 添加插件到 classpath
// 方式 1
// 傳統(tǒng)方式,在根目錄的 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 的目標是幫助開發(fā)者更方便地遷移到 Kotlin 1.8,因此 Kotlin 最低支持版本比較高
原理解析:前置知識
編譯器插件是什么?
Kotlin 的編譯過程,簡單來說就是將 Kotlin 源代碼編譯成目標產物的過程,具體步驟如下圖所示:

Kotlin 編譯器插件,通過利用編譯過程中提供的各種Hook時機,讓我們可以在編譯過程中插入自己的邏輯,以達到修改編譯產物的目的。比如我們可以通過 IrGenerationExtension 來修改 IR 的生成,可以通過 ClassBuilderInterceptorExtension 修改字節(jié)碼生成邏輯
Kotlin 編譯器插件可以分為 Gradle 插件,編譯器插件,IDE 插件三部分,如下圖所示

kotlin-android-extensions 是怎么實現的
我們知道,KAE 是一個 Kotlin 編譯器插件,當然也可以分為 Gradle 插件,編譯器插件,IDE 插件三部分。我們這里只分析 Gradle 插件與編譯器插件的源碼,它們的具體結構如下:

-
AndroidExtensionsSubpluginIndicator是KAE插件的入口 -
AndroidSubplugin用于配置傳遞給編譯器插件的參數 -
AndroidCommandLineProcessor用于接收編譯器插件的參數 -
AndroidComponentRegistrar用于注冊如圖的各種Extension
關于更細節(jié)的分析可以參閱:kotlin-android-extensions 插件到底是怎么實現的?
總的來說,其實 KAE 主要做了兩件事
- KAE 會將 viewId 轉化為 findViewByIdCached 方法調用
- 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)
}
}
如上所示,主要做了這么幾件事:
- 通過 gradle 插件,自動解析 layout 生成
AndroidExtensions接口的擴展屬性 - 給 Activity 添加
AndroidExtensions接口 - 由于需要支持緩存,因此也需要添加一個全局的變量:
androidExtensionImpl - 由于需要在頁面關閉時清除緩存,因此也需要添加
lifecycle Observer - 重寫
findViewByIdCached方法,將具體工作委托給AndroidExtensionsImpl
通過以上步驟,其實 KAE 的功能已經實現了,我們可以在 Activity 中通過button1,button2等 viewId 獲取對應的 View
但是這樣還是太麻煩了,修改一個頁面需要添加這么多代碼,還能再優(yōu)化嗎?
第二次嘗試
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)
}
}
- 我們通過委托簡化了代碼調用,只需要添加一行
AndroidExtensions by AndroidExtensionsImpl()就可以實現遷移 - 我們不需要在初始化的時候手動添加
lifecycle observer,這是因為我們在調用findViewByIdCached方法時會將this傳遞過去,因此可以在第一次調用時初始化,自動添加lifecycle observer
可以看出,現在已經比較簡潔了,只需要添加一行代碼就可以實現遷移,但如果項目中有幾百個頁面使用了 KAE 的話,改起來還是有點痛苦的,目前還不能算是真正的無縫遷移
那么還能再優(yōu)化嗎?
第三次嘗試
第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 還沒有穩(wěn)定,因此將 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)
- 給 Activity, Fragment, View 等類型添加擴展屬性
- 給 View 添加的擴展屬性目前不支持緩存,而是直接通過
finidViewById實現 - 支持根據不同的
variant,生成不同的package的代碼,比如debug
Kace 性能優(yōu)化
明確輸入輸出
前面介紹了 kace-gradle-plugin 的主要作用就是解析 layout 然后生成代碼,但是對于一個比較大的模塊,layout 可能有幾百個,如果每次編譯時都要運行這個 Task,會帶來一定的性能損耗
理想情況下,在輸入輸出沒有發(fā)生變化的情況下,應該跳過這個 Task

比如 Gradle 中內置的 JavaCompilerTask,在源碼與 jdk 版本沒有發(fā)生變化的時候,會自動跳過(標記為 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 的輸入輸出,在輸入與輸出都沒有發(fā)生改變的時候,該 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)
}
}
- 第一步:首先我們需要定義一個接口來表示每個
Action需要的參數,即KaceGenerateAction.Parameters - 第二步:您需要將自定義
Task中為每個單獨文件執(zhí)行工作的部分重構為單獨的類,即KaceGenerateAction - 第三步:您應該重構自定義
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
}
}
- 您需要擁有
WorkerExecutor服務才能提交Action。這里我們添加了一個抽象的workerExecutor并添加注解,Gradle將在運行時注入服務 - 在提交
Action之前,我們需要通過不同的隔離模式獲取WorkQueue,這里使用的是線程隔離模式 - 提交
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)
}
}
}
通過以下步驟,就可以實現增量編譯
-
androidLayoutResources使用@Incremental注解標識,表示支持增量處理的輸入 - 給
TaskAction方法添加inputChange參數 - 通過
inputChanges方法獲取輸入中發(fā)生了更改的文件,如果發(fā)生了更改則重新處理,如果被刪除了則同樣刪除目標目錄中的文件,沒有發(fā)生更改的文件則不處理
通過支持增量編譯,當只修改或者添加一個 layout 時,增量編譯耗時可以減少到 8ms 左右,大幅減少了編譯耗時
總結
本文主要介紹了如何使用 Kace ,以及 Kace 到底是如何實現的,如果有任何問題,歡迎提出 Issue,如果對你有所幫助,歡迎點贊收藏 Star ~
開源地址
作者:程序員江同學
鏈接:https://juejin.cn/post/7168256990484332580