炉 II · 熔炼CH 08进度 08/26

委托:by 的展开,与 2.4 的显式后备字段

大多数人对 by 的认知停留在 by lazy,最多再加一个 by viewModels()。但 by 是 Kotlin 里最被低估的扩展点 —— 它让你把「一个属性怎么读、怎么写」这件事提取出来复用。Compose 的 by remember { mutableStateOf() }、Android KTX 的 by viewModels()、你天天写的 by lazy,全是同一个机制的实例。这一章拆开它的展开式,然后带你写两个真正能用在项目里的委托。最后用 2.4 刚转正的显式后备字段,删掉每个 ViewModel 里都有的那对样板属性。

属性委托类委托getValue/setValue2.4 显式后备字段

两种 by,别混淆

Kotlin 里有两个长得一样但完全不同的 by

// ① 类委托:把接口的实现「转包」给另一个对象
class LoggingList(private val inner: MutableList<String>)
    : MutableList<String> by inner { ... }

// ② 属性委托:把一个属性的读写「转包」给一个委托对象
val config: Config by lazy { loadConfig() }

先用五分钟讲完 ①,然后把整章留给 ②(因为 ② 才是你能大量复用的那个)。

类委托:装饰器模式的语法级支持

你想给 MutableList 加一层日志,但只想改 add。传统写法要实现接口的所有方法,一个个转发 —— 三十多个方法,全是样板。

class LoggingList<T>(private val inner: MutableList<T> = mutableListOf())
    : MutableList<T> by inner {          // 其余全部自动转发给 inner

    override fun add(element: T): Boolean {   // 只重写你关心的
        Log.d("list", "add $element")
        return inner.add(element)
    }
}

by inner 让编译器为接口的每一个方法生成一个转发函数。这是「组合优于继承」的语法级支持 —— 而 Java 里你要么手写三十个转发方法,要么继承一个 ArrayList(然后被继承的脆弱性咬)。

⚠ 炉渣 · 类委托的「自我问题」

委托生成的转发函数是直接调用 inner 的方法,而 inner 完全不知道你的包装类存在。所以:

class LoggingList<T>(private val inner: MutableList<T>) : MutableList<T> by inner {
    override fun add(element: T): Boolean { Log.d("list", "add"); return inner.add(element) }
}

LoggingList(mutableListOf()).addAll(listOf("a", "b"))
// addAll 被转发给 inner.addAll(),而 ArrayList.addAll 内部调的是它自己的 add ——
// 你的 override 完全没被调用,一条日志都不会打

这叫 self problem(自我问题),继承里的「模板方法」在委托里失效了。记住:类委托只转发接口方法,不参与被委托对象的内部调用。如果你需要拦截「所有路径」,委托做不到 —— 那说明你要的其实是继承,或者一个真正的代理。

属性委托:机制

现在是重点。by 后面的对象只要提供两个约定方法,就能接管一个属性:

// 你写的:
class VM {
    var name: String by MyDelegate()
}

// 编译器生成的(第 2 章的老朋友:反编译能看到):
class VM {
    private val name$delegate = MyDelegate()

    fun getName(): String =
        name$delegate.getValue(this, ::name)              // this = 宿主对象
                                                          // ::name = KProperty,带着属性的名字、类型等元信息
    fun setName(value: String) =
        name$delegate.setValue(this, ::name, value)
}

就这么简单。by 只是把 getter/setter 转发给另一个对象的语法糖,而那个对象能拿到两样宝贵的东西:宿主对象thisRef)和属性的元信息KProperty,包含属性名 —— 这是很多委托的关键)。

标准库给了两个现成的接口,直接实现它们即可:

fun interface ReadOnlyProperty<in T, out V> {
    operator fun getValue(thisRef: T, property: KProperty<*>): V
}

interface ReadWriteProperty<in T, V> {
    operator fun getValue(thisRef: T, property: KProperty<*>): V
    operator fun setValue(thisRef: T, property: KProperty<*>, value: V)
}

写两个真正有用的委托

by preference() —— 属性名就是 key

SharedPreferences 的样板代码,每个 Android 项目里都有一版:

class Settings(private val prefs: SharedPreferences) {
    var nightMode: Boolean by pref(false)
    var userName: String by pref("")
    var fontScale: Int by pref(14)
    // 注意:没有一个字符串 key —— key 就是属性名,编译器给的
}

// 委托实现(十几行,一次写完全项目复用)
class PrefDelegate<T>(
    private val default: T
) : ReadWriteProperty<Settings, T> {

    @Suppress("UNCHECKED_CAST")
    override fun getValue(thisRef: Settings, property: KProperty<*>): T =
        when (default) {
            is Boolean -> thisRef.prefs.getBoolean(property.name, default)
            is String  -> thisRef.prefs.getString(property.name, default)!!
            is Int     -> thisRef.prefs.getInt(property.name, default)
            else -> error("不支持的类型")
        } as T

    override fun setValue(thisRef: Settings, property: KProperty<*>, value: T) {
        thisRef.prefs.edit {
            when (value) {
                is Boolean -> putBoolean(property.name, value)
                is String  -> putString(property.name, value)
                is Int     -> putInt(property.name, value)
            }
        }
    }
}

fun <T> pref(default: T) = PrefDelegate(default)

关键在 property.name:key 不再是一个手写的字符串常量(可能拼错、可能和别处重复、重命名时会漏改),而是由编译器保证和属性名一致。这是 KProperty 带来的那点元信息的全部价值 —— 而它足够撬动一整套样板。

by autoCleared() —— 治好 Fragment 的 ViewBinding

还记得第 3 章那颗定时炸弹吗(lateinit var binding 在视图销毁后被访问)?委托能一劳永逸地治好它:

class AutoCleared<T : Any>(fragment: Fragment) : ReadWriteProperty<Fragment, T> {
    private var value: T? = null

    init {
        // 监听 Fragment 的视图生命周期(不是 Fragment 自己的)
        fragment.lifecycle.addObserver(object : DefaultLifecycleObserver {
            override fun onCreate(owner: LifecycleOwner) {
                fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewOwner ->
                    viewOwner?.lifecycle?.addObserver(object : DefaultLifecycleObserver {
                        override fun onDestroy(owner: LifecycleOwner) {
                            value = null       // ← 视图一死,立刻放手
                        }
                    })
                }
            }
        })
    }

    override fun getValue(thisRef: Fragment, property: KProperty<*>): T =
        value ?: error("${property.name} 只能在视图存活期间访问")   // 报错信息带属性名!

    override fun setValue(thisRef: Fragment, property: KProperty<*>, value: T) {
        this.value = value
    }
}

fun <T : Any> Fragment.autoCleared() = AutoCleared<T>(this)

// 用起来:
class MyFragment : Fragment() {
    private var binding: FragmentMyBinding by autoCleared()   // 泄漏与崩溃,一起治好
}

这就是委托的威力:把一个「每个 Fragment 都要正确做一遍、而且做错了不会立刻报错」的生命周期规则,收进一个类里,然后再也不用想它。

标准库里现成的委托

委托做什么什么时候用
by lazy { }第一次读时计算,之后缓存贵的对象、按需构造。默认加锁(第 3 章)
Delegates.observable(init) { p, old, new -> }值变了就回调属性变化要触发副作用(打日志、通知 UI)
Delegates.vetoable(init) { p, old, new -> Boolean }回调返回 false 就拒绝这次赋值属性级校验
Delegates.notNull<T>()像 lateinit,但支持基本类型lateinit var count: Int 编译不过时用它
by map(委托给 Map)属性读写转成 map 的 key 读写解析 JSON / 动态属性

Android / Compose 侧你天天用的:by viewModels()by activityViewModels()by navArgs()、以及 Compose 的 var text by remember { mutableStateOf("") } —— 那个 by 让你能像用普通变量一样读写 State,省掉了 .value。全是属性委托。

2.4:显式后备字段,砍掉 ViewModel 的样板

这个模式你写过几百遍:

class MyViewModel : ViewModel() {
    private val _state = MutableStateFlow<UiState>(Loading)   // 对内可写
    val state: StateFlow<UiState> get() = _state              // 对外只读

    private val _events = MutableSharedFlow<Event>()
    val events: SharedFlow<Event> get() = _events
    // 两个属性表达一个概念,写了一辈子
}

这是纯粹的样板:「对外暴露只读视图、对内保留可写句柄」是一个语言层面缺失的能力,只能靠一对属性凑出来。2.4 把它补上了:

✦ 新炉料 · 显式后备字段 2.4 转正
class MyViewModel : ViewModel() {
    val state: StateFlow<UiState> field = MutableStateFlow(Loading)

    fun load() {
        state.value = Loading        // ← 类内部:编译器智能转换成 MutableStateFlow,可写
    }
}

// 类外部看到的类型就是 StateFlow —— 只读,改不了
viewModel.state.value = X            // ✗ 编译错误

field 关键字声明了一个类型和属性本身不同的后备字段:属性对外是 StateFlow,字段对内是 MutableStateFlow。一个属性顶掉一对。

每一个 Android ViewModel 都会立刻受益 —— 这是 2.4 里对 Android 开发者最实惠的一条。但注意:它需要 Kotlin 2.4+,团队的 Kotlin 版本没跟上就用不了(这也是升级的理由之一)。

▸ 旧模具 · Java 有对应物吗

类委托:Java 没有,只能手写转发方法(或者用 Lombok 的 @Delegate,一个注解处理器 —— 又是「用工具补语言」的例子)。

属性委托:Java 完全没有对应物。因为 Java 里根本没有「属性」这个概念 —— 只有字段和方法。而委托机制的全部前提,是「属性的读写可以被替换成任意代码,而调用方无感知」。

这是 Kotlin 「属性优先于字段」这个设计决定的长期红利:一旦属性是一等公民,它就可以被委托、被观察、被拦截。Compose 的整个状态系统就建在这上面。

✚ 动手锻打

1. 把你项目里的 SharedPreferences 封装换成属性委托。你会删掉一大批字符串常量 key —— 每一个都是潜在的拼写错误。

2. 如果项目里有 Fragment + ViewBinding:实现上面那个 autoCleared(),替换掉所有 lateinit var binding。这一步同时修掉了「视图销毁后访问崩溃」和「binding 持有已销毁视图导致泄漏」两个问题。

3. 找一处「属性变化要触发副作用」的代码(手写的 setter 里塞了日志或通知),换成 Delegates.observable

4. 如果你的 Kotlin 已经是 2.4+:挑一个 ViewModel,把 _state / state 那一对改成显式后备字段。然后数一数全项目能删掉多少行 —— 这个数字通常很好看。

熔炼炉结束。类型系统的锋刃已经磨好了。下一炉「塑形」进入 Kotlin 真正的独门武器 —— 那些 Java 抄不走的东西,从 inline 开始。