聊聊 Kotlin 代理的“缺陷”與應對
theme: simplicity-green highlight: androidstudio
Kotlin 代理是面試中經常被問到的問題,比如介紹一下代理的實現原理以及在使用中的一些注意事項等,本文將帶你梳理這些問題,讓你從更高維度上認識“代理”
Kotlin 有很多讓人津津樂道的語法,“代理”就是經常被提及的一個。Kotlin 在語言級別通過 by 關鍵字支援了代理模式的實現。代理模式是最常用的設計模式之一,它是使用“組合”替代“繼承”的最佳實踐。下面取自 Wiki 中關於代理模式的例子:
```kotlin class Rectangle(val width: Int, val height: Int) { fun area() = width * height }
class Window(val bounds: Rectangle) {
// Delegation
fun area() = bounds.area()
}
這是一個代理模式的典型場景:`Window` 將 `area()` 的具體實現委託給了 `Retangle` 型別物件 `bounds`,`Rectangle` 與 `Window` 是**代理**與**接收**的關係。如果我們使用 Kotlin 的 `by` 關鍵字實現同樣邏輯,程式碼變成下面這樣:
kotlin
interface ClosedShape {
fun area(): Int
}
class Rectangle(val width: Int, val height: Int) : ClosedShape { override fun area() = width * height }
class Window(private val bounds: ClosedShape) : ClosedShape by bounds ```
Kotlin 的 by
關鍵字只能基於介面進行代理,所以我們需要抽象出 Window
和 Rectangle
的共同介面 ClosedShape
,通過 by
關鍵字, Window
將 area()
委託給 bounds
來實現, Window
內部中省掉了直接呼叫 bounds
的程式碼。這個例子比較簡單,優勢體現的不明顯,試想隨著介面方法的增多,by 可以幫我們減少大量的模板程式碼。
雖然 by
關鍵字為我們帶來了方便,但是它的一些機制也受到不少開發者詬病,甚至連 Kotlin 首席設計師 Andrey Breslav 都曾公開表示不喜歡這個功能。Kotlin 介面代理被詬病的問題主要有兩個:
- 代理中無法訪問 this
- 代理無法執行時替換
缺陷1:代理中無法訪問 "this"
代理與繼承的一個重要區別在於,繼承關係中父類可以通過 this
訪問執行時的真正例項;而代理關係中代理無法通過 this
直接訪問接收方物件(例子中的 Window
),但有時我們確實需要獲取接收方的狀態參與計算,在 Java 中的常見做法是接收方在建立代理時注入自身例項。而 Kotlin 的 by
關鍵字需要在接收方例項化之前建立好代理,因此無法為代理注入 this
物件。
上面的例子中,假設 width
和 height
是 Window
維護的狀態而非 Rectangle
,我們在 Rectangle
的 area()
中依賴它們來進行計算,此時該如何解決呢?一個可行的做法是在 Window
的 init
中注入向 Rectangle
注入所需的狀態。這裡需要注意兩點,
- 第一,直接注入 width 和 height 是不行的,假設 Window 的 size 會變化,所以 Rectangle 需要在計算 area 時始終獲取最新的數值,
- 第二,注入 Window 例項作為 “this”,通過 this 獲取最新的 widht 和 height?這也是不妥的,Rectangle 依賴 Window 型別,會降低 Rectangle 的可複用性。
兼顧上述兩點後,更合理的做法是為 Rectangle
定義一個可以獲取 width/height
的函式型別,然後由 Wiindow
注入這個回撥,程式碼如下:
```kotlin interface ClosedShape { fun area(): Int }
class Rectangle : ClosedShape {
lateinit var size: () -> Pair
class Window(private val bounds: Rectangle) : ClosedShape by bounds { private var width: Int = TODO() private var height: Int = TODO()
init {
bounds.size = { width to height }
}
}
``
也許有人會提議為
area()增加引數,動態傳入
width和
height,但是這增加了
Window` 的呼叫方的負擔,違背面向物件中封裝性的設計原則。
缺陷2:無法執行時替換代理
不少人希望代理模式中的代理能夠根據需要動態替換,實現類似策略模式的效果。但這在目前 Kotlin 代理中是無法實現的。不少 Kotlin 的初學者曾經誤認為通過 var
替換代理例項,比如下面程式碼中,我們將 Window
的引數 bounds
的宣告從 val
改為 var
kotlin
class Window(private var bounds: ClosedShape) : ClosedShape by bounds
但是經編譯後的程式碼實際是下面這樣,代理儲存在 bounds
之外的另一個 final
成員 `$$delegate_0
中。
```java public final class Window implements ClosedShape { private ClosedShape bounds; // $FF: synthetic field private final ClosedShape $$delegate_0;
public Window(@NotNull ClosedShape bounds) { Intrinsics.checkNotNullParameter(bounds, "bounds"); super(); this.$$delegate_0 = bounds; this.bounds = bounds; }
public int area() { return this.$$delegate_0.area(); } } ```
即使我們在執行時為 bounds
賦值新的物件,代理中的例項也不會發生變化。
假設有這樣的場景, Window
的形狀在執行時會發生變化,相應地我們需要計算 area
的代理由 Rectangle
變為 Oval
,此時該如何解決呢? 一個不難想到的思路是:增加代理的“代理”,實現代理例項的可替換:
```kotlin class Proxy(var target: ClosedShape) : ClosedShape { override fun area() = target.area() }
class Rectangle : ClosedShape {
lateinit var size: () -> Pair
class Oval : ClosedShape {
lateinit var size: () -> Pair
class Window(private val bounds: Proxy) : ClosedShape by bounds { private var width: Int = TODO() private var height: Int = TODO()
private val rectangle by lazy {
Rectangle().apply { size = { width to height } }
}
private val oval by lazy {
Oval().apply { size = { width to height } }
}
fun changeShape(mode: Shape) {
when (mode) {
Rectangle -> bounds.target = rectangle
Oval -> bounds.target = oval
}
}
} ```
上面程式碼中,我們定義了一個 Proxy
作為 Window
的代理,而真正被呼叫到的物件是 Proxy
的 target
,它可以在執行時根據需要做出變化。
但這也帶來一個問題,如果介面中的方法很多,Proxy
中會出現大量的 target 的轉發程式碼,增加我們的工作量。此時我們可以使用動態代理對其優化:
```kotlin
class Proxy(var target: ClosedShape?) {
fun create() : ClosedShape {
return newProxyInstance(
ClosedShape::class.java.getClassLoader(), arrayOf<Class<*>>(ClosedShape::class.java), object : InvocationHandler {
override fun invoke(proxy: Any?, method: Method, args: Array
class Window(private val bounds: Proxy) : ClosedShape by bounds.create() {
//...省略
}
``
上面程式碼中,
Proxy的
create()` 返回一個動態代理物件,幫節省了原本需要手動實現的轉發程式碼。
對比其他解決方案
通過上面分析我們知道,使用 by
關鍵字建立的代理需要在接收方(例子中的 Window
)例項化之前確定,並且在編譯後儲存在一個不可見的 final
成員上,這使得接收方缺少對代理的直接控制的能力,比如無法在 Window
內建立代理,也無法在執行時替換代理。而對比 Kotlin 之外的其他同類解決方案中,你會發現接收方的控制力明顯要強得多:
- Lombook (Kotlin 出現前常用的語法糖工具)提供了 @Delegate 註解,它可以幫助我們將接收方的成員宣告為代理,無需再通過建構函式傳入,接收方可以在自行建立代理的同時方便地做一些注入工作;
- Guava(Google 提供的 JDK 增強庫)也提供了實現代理模式的 ForwardingObject,它允許我們在接收方內部通過重寫
protected abstract Object delegate();
返回最新的代理物件,實現代理的可替換。
因此,我們可以簡單下一個結論:Kotlin 代理之所以被人詬病,其根本原因在於相對於其他同類方案,接收方缺少對代理的直接控制的能力。目前有不少開發者提了相關 Issue,也許可以期待 Kotlin 在未來的版本中出現更合理的解決方案。在此之前,我們只能通過本文介紹一些 Workaround 進行應對。需要注意本文講的代理僅僅指介面代理,相比之下,屬性代理的設計合理得多,不存在上述這些問題。
- Android Studio Electric Eel 起支援手機投屏
- Compose 為什麼可以跨平臺?
- 一看就懂!圖解 Kotlin SharedFlow 快取系統
- 深入淺出 Compose Compiler(2) 編譯器前端檢查
- 深入淺出 Compose Compiler(1) Kotlin Compiler & KCP
- Jetpack MVVM七宗罪之三:在 onViewCreated 中載入資料
- 為什麼說 Compose 的宣告式程式碼最簡潔 ?Compose/React/Flutter/SwiftUI 語法對比
- Compose 型別穩定性註解:@Stable & @Immutable
- Fragment 這些 API 已廢棄,你還在使用嗎?
- 告別KAPT!使用 KSP 為 Kotlin 編譯提速
- 探索 Jetpack Compose 核心:深入 SlotTable 系統
- 盤點 Material Design 3 帶來的新變化
- Compose 動畫邊學邊做 - 夏日彩虹
- Google I/O :Android Jetpack 最新變化(二) Performance
- Google I/O :Android Jetpack 最新變化(一) Architecture
- Google I/O :Android Jetpack 最新變化(四)Compose
- Google I/O :Android Jetpack 最新變化(三)UI
- 一文看懂 Jetpack Compose 快照系統
- 聊聊 Kotlin 代理的“缺陷”與應對
- AAB 扶正!APK 再見!