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