kotlin-android-extensions 外掛到底是怎麼實現的?
theme: smartblue highlight: a11y-dark
⚠️本文為稀土掘金技術社群首發簽約文章,14天內禁止轉載,14天后未獲授權禁止轉載,侵權必究!
前言
kotlin-android-extensions 外掛是 Kotlin 官方提供的一個編譯器外掛,用於替換 findViewById 模板程式碼,降低開發成本
雖然 kotlin-android-extensions 現在已經過時了,但比起其他替換 findViewById 的方案,比如第三方的 ButterKnife 與官方現在推薦的 ViewBinding
kotlin-android-extensions 還是有著一個明顯的優點的:極其簡潔的 API,KAE
方案比起其他方案寫起來更加簡便,這是怎麼實現的呢?我們一起來看下
原理淺析
當我們接入KAE
後就可以通過以下方式直接獲取 View
```kotlin import kotlinx.android.synthetic.main.activity_main.*
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
viewToShowText.text = "Hello"
}
} ```
而它的原理也很簡單,KAE
外掛將上面這段程式碼轉換成了如下程式碼
```kotlin public final class MainActivity extends AppCompatActivity { private HashMap _$_findViewCache;
protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); this.setContentView(1300023); TextView var10000 = (TextView)this._$_findCachedViewById(id.textView); var10000.setText((CharSequence)"Hello"); }
public View $_findCachedViewById(int var1) { if (this.$findViewCache == null) { this.$findViewCache = new HashMap(); } View var2 = (View)this.$findViewCache.get(var1); if (var2 == null) { var2 = this.findViewById(var1); this.$_findViewCache.put(var1, var2); } return var2; }
public void $_clearFindViewByIdCache() { if (this.$findViewCache != null) { this.$_findViewCache.clear(); }
}
}
```
可以看到,實際上 KAE
外掛會幫我們生成一個 _$_findCachedViewById()
函式,在這個函式中首先會嘗試從一個 HashMap 中獲取傳入的資源 id 引數所對應的控制元件例項快取,如果還沒有快取的話,就呼叫findViewById()
函式來查詢控制元件例項,並寫入 HashMap 快取當中。這樣當下次再獲取相同控制元件例項的話,就可以直接從 HashMap 快取中獲取了。
當然KAE
也幫我們生成了_$_clearFindViewByIdCache()
函式,不過在 Activity 中沒有呼叫,在 Fragment 的 onDestroyView 方法中會被呼叫到
總體結構
在瞭解了KAE
外掛的簡單原理後,我們一步一步來看一下它是怎麼實現的,首先來看一下總體結構
KAE
外掛可以分為 Gradle 外掛,編譯器外掛,IDE 外掛三部分,如下圖所示
我們今天只分析 Gradle 外掛與編譯器外掛的原始碼,它們的具體結構如下:
AndroidExtensionsSubpluginIndicator
是KAE
外掛的入口AndroidSubplugin
用於配置傳遞給編譯器外掛的引數AndroidCommandLineProcessor
用於接收編譯器外掛的引數AndroidComponentRegistrar
用於註冊如圖的各種Extension
原始碼分析
外掛入口
當我們檢視 kotlin-gradle-plugin 的原始碼,可以看到 kotlin-android-extensions.properties 檔案,這就是外掛的入口
implementation-class=org.jetbrains.kotlin.gradle.internal.AndroidExtensionsSubpluginIndicator
接下來我們看一下入口類做了什麼工作
```kotlin
class AndroidExtensionsSubpluginIndicator @Inject internal constructor(private val registry: ToolingModelBuilderRegistry) :
Plugin
private fun addAndroidExtensionsRuntime(project: Project) {
project.configurations.all { configuration ->
val name = configuration.name
if (name != "implementation") [email protected]
configuration.dependencies.add(
project.dependencies.create(
"org.jetbrains.kotlin:kotlin-android-extensions-runtime:$kotlinPluginVersion"
)
)
}
}
}
open class AndroidExtensionsExtension { open var isExperimental: Boolean = false
open var features: Set<String> = AndroidExtensionsFeature.values().mapTo(mutableSetOf()) { it.featureName }
open var defaultCacheImplementation: CacheImplementation = CacheImplementation.HASH_MAP
} ```
AndroidExtensionsSubpluginIndicator
中主要做了這麼幾件事
- 建立
androidExtensions
配置,可以看出其中可以配置是否開啟實驗特性,啟用的feature
(因為外掛中包含views
與parcelize
兩個功能),viewId
快取的具體實現(是hashMap
還是sparseArray
) - 自動新增
kotlin-android-extensions-runtime
依賴,這樣就不必在接入了外掛之後,再手動新增依賴了,這種寫法可以學習一下 - 配置
AndroidSubplugin
外掛,開始配置給編譯器外掛的傳參
配置編譯器外掛傳參
```kotlin class AndroidSubplugin : KotlinCompilerPluginSupportPlugin { // 1. 是否開啟編譯器外掛 override fun isApplicable(kotlinCompilation: KotlinCompilation<*>): Boolean { if (kotlinCompilation !is KotlinJvmAndroidCompilation) return false // ...
return true
}
// 2. 傳遞給編譯器外掛的引數
override fun applyToCompilation(
kotlinCompilation: KotlinCompilation<*>
): Provider<List<SubpluginOption>> {
//...
val pluginOptions = arrayListOf<SubpluginOption>()
pluginOptions += SubpluginOption("features",
AndroidExtensionsFeature.parseFeatures(androidExtensionsExtension.features).joinToString(",") { it.featureName })
fun addVariant(sourceSet: AndroidSourceSet) {
val optionValue = lazy {
sourceSet.name + ';' + sourceSet.res.srcDirs.joinToString(";") { it.absolutePath }
}
pluginOptions += CompositeSubpluginOption(
"variant", optionValue, listOf(
SubpluginOption("sourceSetName", sourceSet.name),
//use the INTERNAL option kind since the resources are tracked as sources (see below)
FilesSubpluginOption("resDirs", project.files(Callable { sourceSet.res.srcDirs }))
)
)
kotlinCompilation.compileKotlinTaskProvider.configure {
it.androidLayoutResourceFiles.from(
sourceSet.res.sourceDirectoryTrees.layoutDirectories
)
}
}
addVariant(mainSourceSet)
androidExtension.productFlavors.configureEach { flavor ->
androidExtension.sourceSets.findByName(flavor.name)?.let {
addVariant(it)
}
}
return project.provider { wrapPluginOptions(pluginOptions, "configuration") }
}
// 3. 定義編譯器外掛的唯一 id,需要與後面編譯器外掛中定義的 pluginId 保持一致
override fun getCompilerPluginId() = "org.jetbrains.kotlin.android"
// 4. 定義編譯器外掛的 `Maven` 座標資訊,便於編譯器下載它
override fun getPluginArtifact(): SubpluginArtifact =
JetBrainsSubpluginArtifact(artifactId = "kotlin-android-extensions")
} ```
主要也是重寫以上4個函式,各自的功能在文中都有註釋,其中主要需要注意applyToCompilation
方法,我們傳遞了features
,variant
等引數給編譯器外掛
variant
的主要作用是為不同 buildType
,productFlavor
目錄的 layout 檔案生成不同的包名
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.debug.activity_debug.*
import kotlinx.android.synthetic.demo.activity_demo.*
比如如上程式碼,activity_debug
檔案放在debug
目錄下,而activiyt_demo
檔案則放在demo
這個flavor
目錄下,這種情況下它們的包名是不同的
編譯器外掛接收引數
```kotlin class AndroidCommandLineProcessor : CommandLineProcessor { override val pluginId: String = ANDROID_COMPILER_PLUGIN_ID
override val pluginOptions: Collection<AbstractCliOption>
= listOf(VARIANT_OPTION, PACKAGE_OPTION, EXPERIMENTAL_OPTION, DEFAULT_CACHE_IMPL_OPTION, CONFIGURATION, FEATURES_OPTION)
override fun processOption(option: AbstractCliOption, value: String, configuration: CompilerConfiguration) {
when (option) {
VARIANT_OPTION -> configuration.appendList(AndroidConfigurationKeys.VARIANT, value)
PACKAGE_OPTION -> configuration.put(AndroidConfigurationKeys.PACKAGE, value)
EXPERIMENTAL_OPTION -> configuration.put(AndroidConfigurationKeys.EXPERIMENTAL, value)
DEFAULT_CACHE_IMPL_OPTION -> configuration.put(AndroidConfigurationKeys.DEFAULT_CACHE_IMPL, value)
else -> throw CliOptionProcessingException("Unknown option: ${option.optionName}")
}
}
} ```
這段程式碼很簡單,主要是解析variant
,包名,是否開啟試驗特性,快取實現方式這幾個引數
註冊各種Extension
接下來到了編譯器外掛的核心部分,通過註冊各種Extension
的方式修改編譯器的產物
```kotlin class AndroidComponentRegistrar : ComponentRegistrar { companion object { fun registerViewExtensions(configuration: CompilerConfiguration, isExperimental: Boolean, project: MockProject) {
ExpressionCodegenExtension.registerExtension(project,
CliAndroidExtensionsExpressionCodegenExtension(isExperimental, globalCacheImpl))
IrGenerationExtension.registerExtension(project,
CliAndroidIrExtension(isExperimental, globalCacheImpl))
StorageComponentContainerContributor.registerExtension(project,
AndroidExtensionPropertiesComponentContainerContributor())
ClassBuilderInterceptorExtension.registerExtension(project,
CliAndroidOnDestroyClassBuilderInterceptorExtension(globalCacheImpl))
PackageFragmentProviderExtension.registerExtension(project,
CliAndroidPackageFragmentProviderExtension(isExperimental))
}
}
override fun registerProjectComponents(project: MockProject, configuration: CompilerConfiguration) {
if (AndroidExtensionsFeature.VIEWS in features) {
registerViewExtensions(configuration, isExperimental, project)
}
}
} ```
可以看出,主要就是在開啟了AndroidExtensionsFeature.VIEWS
特性時,註冊了5個Extension
,接下來我們來看下這5個Extension
都做了什麼
IrGenerationExtension
IrGenerationExtension
是KAE
外掛的核心部分,在生成 IR 時回撥,我們可以在這個時候修改與新增 IR,KAE
外掛生成的_findCachedViewById
方法都是在這個時候生成的,具體實現如下:
```kotlin private class AndroidIrTransformer(val extension: AndroidIrExtension, val pluginContext: IrPluginContext) : IrElementTransformerVoidWithContext() {
override fun visitClassNew(declaration: IrClass): IrStatement {
if ((containerOptions.cache ?: extension.getGlobalCacheImpl(declaration)).hasCache) {
val cacheField = declaration.getCacheField()
declaration.declarations += cacheField // 新增_$_findViewCache屬性
declaration.declarations += declaration.getClearCacheFun() // 新增_$_clearFindViewByIdCache方法
declaration.declarations += declaration.getCachedFindViewByIdFun() // 新增_$_findCachedViewById方法
}
return super.visitClassNew(declaration)
}
override fun visitCall(expression: IrCall): IrExpression {
val result = if (expression.type.classifierOrNull?.isFragment == true) {
// this.get[Support]FragmentManager().findFragmentById(R$id.<name>)
createMethod(fragmentManager.child("findFragmentById"), createClass(fragment).defaultType.makeNullable()) {
addValueParameter("id", pluginContext.irBuiltIns.intType)
}.callWithRanges(expression).apply {
// ...
}
} else if (containerHasCache) {
// this._$_findCachedViewById(R$id.<name>)
receiverClass.owner.getCachedFindViewByIdFun().callWithRanges(expression).apply {
dispatchReceiver = receiver
putValueArgument(0, resourceId)
}
} else {
// this.findViewById(R$id.<name>)
irBuilder(currentScope!!.scope.scopeOwnerSymbol, expression).irFindViewById(receiver, resourceId, containerType)
}
return with(expression) { IrTypeOperatorCallImpl(startOffset, endOffset, type, IrTypeOperator.CAST, type, result) }
}
} ```
如上所示,主要做了兩件事:
1. 在visitClassNew
方法中給對應的類(比如 Activity 或者 Fragment )添加了_$_findViewCache
屬性,以及_$_clearFindViewByIdCache
與_$_findCachedViewById
方法
2. 在visitCall
方法中,將viewId
替換為相應的表示式,比如this._$_findCachedViewById(R$id.<name>)
或者this.findViewById(R$id.<name>)
可以看出,其實KAE
外掛的大部分功能都是通過IrGenerationExtension
實現的
ExpressionCodegenExtension
ExpressionCodegenExtension
的作用其實與IrGenerationExtension
基本一致,都是用來生成_$_clearFindViewByIdCache
等程式碼的
主要區別在於,IrGenerationExtension
在使用IR
後端時回撥,生成的是IR
。
而ExpressionCodegenExtension
在使用 JVM 非IR
後端時回撥,生成的是位元組碼
在 Kotlin 1.5 之後,JVM 後端已經預設開啟 IR
,可以認為這兩個 Extension
就是新老版本的兩種實現
StorageComponentContainerContributor
StorageComponentContainerContributor
的主要作用是檢查呼叫是否正確
kotlin
class AndroidExtensionPropertiesCallChecker : CallChecker {
override fun check(resolvedCall: ResolvedCall<*>, reportOn: PsiElement, context: CallCheckerContext) {
// ...
with(context.trace) {
checkUnresolvedWidgetType(reportOn, androidSyntheticProperty)
checkDeprecated(reportOn, containingPackage)
checkPartiallyDefinedResource(resolvedCall, androidSyntheticProperty, context)
}
}
}
如上,主要做了是否有無法解析的返回型別等檢查
ClassBuilderInterceptorExtension
ClassBuilderInterceptorExtension
的主要作用是在onDestroyView
方法中呼叫_$_clearFindViewByIdCache
方法,清除KAE
快取
kotlin
private class AndroidOnDestroyCollectorClassBuilder(
private val delegate: ClassBuilder,
private val hasCache: Boolean
) : DelegatingClassBuilder() {
override fun newMethod(
origin: JvmDeclarationOrigin,
access: Int,
name: String,
desc: String,
signature: String?,
exceptions: Array<out String>?
): MethodVisitor {
val mv = super.newMethod(origin, access, name, desc, signature, exceptions)
if (!hasCache || name != ON_DESTROY_METHOD_NAME || desc != "()V") return mv
hasOnDestroy = true
return object : MethodVisitor(Opcodes.API_VERSION, mv) {
override fun visitInsn(opcode: Int) {
if (opcode == Opcodes.RETURN) {
visitVarInsn(Opcodes.ALOAD, 0)
visitMethodInsn(Opcodes.INVOKEVIRTUAL, currentClassName, CLEAR_CACHE_METHOD_NAME, "()V", false)
}
super.visitInsn(opcode)
}
}
}
}
可以看出,只有在 Fragment 的onDestroyView
方法中添加了 clear 方法,這是因為 Fragment 的生命週期與其根 View 生命週期可能並不一致,而 Activity 的 onDestroy 中是沒有也沒必要新增的
PackageFragmentProviderExtension
PackageFragmentProviderExtension
的主要作用是註冊各種包名,以及該包名下的各種提示
kotlin
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.debug.activity_debug.*
import kotlinx.android.synthetic.demo.activity_demo.*
比如我們在 IDE 中引入上面的程式碼,就可以引入 xml 檔案中定義的各個 id 了,這就是通過這個Extension
實現的
總結
本文主要從原理淺析,總體架構,原始碼分析等角度分析了 kotlin-android-extensions 外掛到底是怎麼實現的
相比其它方案,KAE
使用起來可以說是非常簡潔優雅了,可以看出 Kotlin 編譯器外掛真的可以打造出極簡的 API,因此雖然KAE
已經過時了,但還是有必要學習一下的
如果本文對你有所幫助,歡迎點贊收藏~
- kotlin-android-extensions 外掛到底是怎麼實現的?
- 江同學的 2022 年終總結,請查收~
- kotlin-android-extensions 外掛將被正式移除,如何無縫遷移?
- 學習一下 nowinandroid 的構建指令碼
- Kotlin 預設可見性為 public,是不是一個好的設計?
- 2022年編譯加速的8個實用技巧
- 落地 Kotlin 程式碼規範,DeteKt 瞭解一下~
- Gradle 進階(二):如何優化 Task 的效能?
- 開發一個支援跨平臺的 Kotlin 編譯器外掛
- 開發你的第一個 Kotlin 編譯器外掛
- Kotlin 增量編譯是怎麼實現的?
- Gradle 都做了哪些快取?
- K2 編譯器是什麼?世界第二高峰又是哪座?
- Android 效能優化之 R 檔案優化詳解
- Kotlin 快速編譯背後的黑科技,瞭解一下~
- 別了 KAPT , 使用 KSP 快速實現 ButterKnife
- Android Apk 編譯打包流程,瞭解一下~
- 如何優雅地擴充套件 AGP 外掛
- ASM 插樁採集方法入參,出參及耗時資訊
- Transform 被廢棄,ASM 如何適配?