Appearance
Kotlin 委托属性(Delegated Properties)
回到总览:00 基础知识(Foundations)相关模块:Koin 依赖注入 4.0(其旧版
by inject()就是属性委托;4.0 改用函数式koinInject(),可对比属性委托的适用边界)
一句话定义
委托属性用 by 把属性的 getter / setter 逻辑交给另一个对象实现,类本身不写存取代码、也没有 backing field。读写属性时,编译器插入对委托对象 getValue / setValue 的调用。
代码索引
| 主题 | Lab 说明 | 源码 |
|---|---|---|
| 委托属性运行验证 | Lab: Kotlin 委托属性运行验证 | DelegatedPropertiesDemo.kt |
1. 实现原理
1.1 by 编译后发生什么
val x by d 不是别名,而是编译器在每次访问 x 时插入一次转发:
- 读取
x→ 调用d.getValue(thisRef, property) - 写入
x(var)→ 调用d.setValue(thisRef, property, value)
其中 property 是 KProperty<*> 元数据(属性名、类型等)。属性本身没有 backing field,它只是一个「转发壳」。
kotlin
class Foo {
val x by lazy { "computed once" } // 读 x 时 -> lazy.getValue(this, ::x)
}对应 Lab 可验证:首次读 x 打印一次、后续读直接返回缓存(见 DelegatedPropertiesDemo.kt)。
1.2 内置委托的内部机制
标准库提供四个开箱即用的委托工厂,本质都是返回实现了 getValue / setValue 的对象:
| 委托 | 内部机制 | 行为 |
|---|---|---|
lazy | 持有 lambda + 状态(UNINITIALIZED / INITIALIZED)与缓存值;默认 LazyThreadSafetyMode.SYNCHRONIZED | 首次访问求值并缓存,之后直接返回 |
observable | 持值 + onChange 回调,设值后触发回调、不改写值 | 设值后通知 |
vetoable | 同 observable,但在设值前调用校验 lambda,返回 false 则保持旧值 | 设值前可拒绝 |
map | 把属性名当作 key,读写转发到背后的 Map / MutableMap | 用字典作后端存储 |
1.3 这几个例子分别想让你建立什么判断
lazy- 作用:把“什么时候初始化”收口成统一语义——首次真正读取时再创建。
- 意图:避免把昂贵初始化散落成手写
if (x == null)或重复 getter 逻辑。 - 特点:只算一次并缓存;默认线程安全;更像“每个属性一份懒值”,不是全局单例。
- 典型用例:昂贵对象、配置解析结果、首次进入页面才真正需要的 helper / repository。
observable- 作用:把“字段变化后我要收到通知”挂在属性本身。
- 意图:把日志、埋点、调试观察从业务逻辑里抽离。
- 特点:赋值已经成功后才回调;能拿到
property / old / new;不能拦截也不能改写最终值。 - 典型用例:状态变化日志、调试期追踪字段被谁改了、轻量级变更监听。
vetoable- 作用:把“赋值前先审批”嵌到属性写入路径里。
- 意图:阻止属性进入非法状态,而不是事后补救。
- 特点:返回
true才写入;返回false静默保持旧值,不抛异常。 - 典型用例:分数范围、重试次数、分页参数、状态机合法流转。
map- 作用:把“从字典里用 key 取值”的样板代码改成对象属性访问。
- 意图:让轻量配置 / JSON / 动态字段映射更紧凑、更好读。
- 特点:属性名与
Mapkey 绑定;适合轻量后端,不适合复杂强类型领域模型。 - 典型用例:配置表、轻量 JSON、arguments / extras、动态字段集合。
- 自定义委托
- 作用:把统一读写策略抽成一个可复用组件。
- 意图:避免在每个字段上重复写 trim、校验、归一化、持久化逻辑。
- 特点:外部看起来像普通属性,内部可在
getValue / setValue做改写、代理或持久化。 - 典型用例:统一
trim、大小写归一、脱敏、SharedPreferences / DataStore 代理。
1.4 自定义委托
任何对象只要提供 operator fun getValue(thisRef, property)(写属性再加 setValue),就能当委托;也可直接实现接口 ReadOnlyProperty / ReadWriteProperty,省去手写 operator 关键字:
kotlin
class UpperCaseDelegate : ReadWriteProperty<Any?, String> {
private var value = ""
override fun getValue(thisRef: Any?, property: KProperty<*>): String = value
override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
this.value = value.uppercase()
}
}
var name by UpperCaseDelegate() // name = "ada" 后读出来是 "ADA"2. 特点与优势
2.1 它解决什么
把「何时算、算几次、存哪、是否通知」这类存取逻辑从调用方抽离,集中到一个可复用的委托对象里。同一个 by 语法即可复用懒加载、变更监听、字段校验、外部存储等逻辑,调用方代码不变。
2.2 与其他实现方式 / 概念的区别
- vs 手写 getter / setter:手写要在每个类里重复「判断 + 缓存 + 通知」代码;委托把这些逻辑写一次、到处
by,优势是消除重复、逻辑集中。 - vs 扩展属性:扩展属性是给已有类加访问器,底层仍读写已有字段或计算;委托属性是本类属性的存取外包给另一对象,本类无 backing field。
- vs 双重检查锁(DCL)单例:
lazy默认线程安全且锁粒度更细,比手写 DCL 简洁;但lazy是「每个属性一份懒值」,DCL 是「每个类一份单例」,语义层级不同。 - vs 组合(持有另一对象并调用其方法) :委托是存取行为的编译期转发;组合是运行期持有关系。两者不冲突,可叠加。
- 适用边界(与 Koin 的回扣) :属性委托的「首次访问缓存」语义适合「生命周期内稳定一次」的取值(单例、懒加载);但在 Compose 重组模型里「随重组重算」的取值用
remember/derivedStateOf更直接。Koin 4.0 因此把by inject()改成函数式koinInject()——这不是委托属性「不好」,而是缓存语义与重组重算语义冲突。
3. 用法
3.1 内置委托
对应 Lab:delegated-properties README · DelegatedPropertiesDemo.kt
kotlin
// 懒加载重资源(替代 DCL)
class Reporter {
val html by lazy { buildExpensiveHtml() } // 用到才建,且只建一次
}
// 变更监听
var score by Delegates.observable(0) { _, old, new -> println("score: $old -> $new") }
// 设值前校验(返回 false 拒绝)
var level by Delegates.vetoable(0) { _, _, new -> new in 0..100 }
level = 150 // 被拒,level 仍为 0
// 配置 / JSON -> 对象字段(免逐个 get("key"))
val config = mapOf("timeout" to "30", "host" to "api")
val timeout: String by config // 读 timeout 即读 config["timeout"]3.2 自定义委托
对应 Lab:delegated-properties README · DelegatedPropertiesDemo.kt
实现 ReadOnlyProperty(只读)或 ReadWriteProperty(读写),见 §1.4 示例。常见用途:从外部存储(SharedPreferences / 数据库)读字段、统一日志、字段级校验。
3.3 运行验证
delegated-properties Lab 已实跑通过:验证 lazy 只算一次、observable 设值后通知、vetoable 拒绝赋值、自定义 UpperCaseDelegate 与 map 后端。
本页回答的问题(一句话答)
- 委托属性读写时实际调用什么、有 backing field 吗?
- 一句话答:读取调用
delegate.getValue(thisRef, property)、写入调用delegate.setValue(...);属性无 backing field,只是转发壳。
- 一句话答:读取调用
lazy解决的到底是什么问题?- 一句话答:它解决“昂贵对象不想一开始就建、但建完后又希望整个生命周期只复用一份”的问题。
observable和vetoable的根本区别是什么?- 一句话答:
observable是写入后通知,vetoable是写入前审批;前者只观察,后者可拒绝。
- 一句话答:
map委托适合拿来替代正式领域模型吗?- 一句话答:不适合;它更适合轻量配置和动态字段映射,复杂强类型对象仍应建明确模型。
lazy默认线程安全吗?与 DCL 单例区别?- 一句话答:默认
SYNCHRONIZED线程安全;lazy是「每属性一份懒值」,DCL 是「每类一份单例」。
- 一句话答:默认
vetoable拒绝赋值时抛异常还是静默保持旧值?- 一句话答:静默保持旧值,不抛异常;依赖赋值结果需自行读回确认。
- 为什么 Koin 4.0 弃用
by inject()?- 一句话答:Compose 重组要求「随重组重算」用函数调用更直接;
by的首次缓存语义在重组里反而是坑,故改koinInject()。
- 一句话答:Compose 重组要求「随重组重算」用函数调用更直接;
对应实验
| Lab | 说明 | 源码 |
|---|---|---|
| delegated-properties | 运行验证 lazy 只算一次、observable 设值后通知、vetoable 拒绝、自定义委托与 map 后端 | Lab: Kotlin 委托属性运行验证 · DelegatedPropertiesDemo.kt |
复习检查题
val x by delegate读取x时,字节码层面实际调用了什么?有 backing field 吗? 答:编译后属性无 backing field,读取被重写为delegate.getValue(thisRef, property);写入属性则是delegate.setValue(...)。属性只是对委托对象的转发壳。observable和vetoable的根本区别是什么? 答:observable在写入完成后通知,只能观察变化;vetoable在写入前校验,返回false时可静默拒绝本次赋值。lazy默认线程安全吗?它与手写双重检查锁(DCL)单例的核心区别是什么? 答:lazy默认LazyThreadSafetyMode.SYNCHRONIZED,线程安全且锁粒度细;区别在于lazy是「每个属性一份懒值」,DCL 是「每个类一份单例」,语义层级不同。map委托为什么适合轻量配置映射,却不适合复杂强类型模型? 答:因为它本质仍依赖字符串 key 与运行时取值,适合轻量、动态、字段少的场景;复杂模型需要更明确的类型约束、校验与演进能力。vetoable拒绝一次非法赋值时,会抛异常还是静默保持旧值? 答:静默保持旧值,不抛异常。调用方若依赖赋值结果,需读回确认或改用抛异常的显式校验。- 为什么 Koin 4.0 把
by inject()弃用、改成koinInject()? 答:Compose 重组模型要求「随重组重算」的取值用 Composable 函数更直接;by inject()的「首次访问缓存」语义在 Compose 里反而是坑,故改为函数式koinInject()。
速记
by= 把 get/set 外包:属性无 backing field,读写都转发给委托的getValue/setValue。- 四类内置:
lazy(只算一次)/observable(设值后通知)/vetoable(设值前可拒)/map(字典后端)。 - 自定义:实现
getValue(+setValue) 或ReadOnlyProperty/ReadWriteProperty。 - 工程判断:
lazy解决延迟初始化,observable解决事后通知,vetoable解决事前拦截,map解决轻量映射。 - 边界:随重组重算用
remember/derivedStateOf;by只适合「稳定一次」的取值。