先说一个我自己的感受。Kotlin写久了,最容易让人“回不去Java”的,其实不是协程,也不是扩展函数,而是委托属性和类型别名这一对看起来不起眼的语法糖。前者把属性的读写逻辑彻底抽象出来,后者把复杂的类型签名压缩成一句话。两者单独用都很顺手,放在一起用,能把很多模板代码消解得干干净净。
这篇文章我就围绕这两个特性,把我实际项目里用过的场景、踩过的坑、以及背后编译期的那些小九九,完整梳理一遍。不是官方文档的翻译,是一份基于真实使用经验的实操笔记。
1. 委托属性:属性不再是简单的字段
1.1 先理解“委托”到底委托了什么
很多朋友第一次看到val name by lazy { ... }时,会下意识觉得“这不就是懒加载工具方法嘛”。但委托属性的本质比工具方法要深一层:属性的getter和setter不再由字段读写决定,而是全部交给一个委托对象去实现。
在字节码层面,Kotlin编译器会为委托属性生成对应的getValue和setValue调用。也就是说,你写的是:
class User { val name: String by NameDelegate() }编译后,name的getter实际变成:
public final String getName() { return name$delegate.getValue(this, $$delegatedProperties[0]); }这才是关键。委托属性不是“帮你缓存一下结果”,而是把属性的访问协议完全解耦出来。你可以在不改变调用方任何代码的前提下,替换掉属性背后的存储、计算、校验、缓存、日志逻辑。
注意:这里的
getValue方法签名是带两个参数的,第一个是属性所在的对象,第二个是KProperty元数据。这两个参数不是摆设,后面做自定义委托时会用到。
这个机制很像一个“属性的插件化系统”。调用方永远只看到user.name,但背后可以是一张数据库表、一个SharedPreferences、一顶缓存,或者一段远程配置。
1.2 三张核心接口,看懂就能写自定义委托
Kotlin标准库定义了四套接口,对应可变和只读两种属性类型:
ReadOnlyProperty<in R, out T>:只读属性的委托,需要实现getValue(thisRef: R, property: KProperty<*>)。ReadWriteProperty<in R, T>:可变属性的委托,比上面多一个setValue方法。- 如果不实现接口,只是给类添加
operator fun getValue/setValue方法,编译器同样会认可。因为Kotlin的委托属性机制是操作符约定,不是严格的接口实现要求。
我第一次接触时也觉得奇怪,为什么非要搞接口和操作符两套都能用?后来写了一些库才发现,这给了极大的灵活性。接口适合规规矩矩的面向对象写法,操作符约定适合扩展已有类。比如你可以直接给MutableMap扩展getValue和setValue,这正好是by map委托能工作的原因。
class Person(val map: Map<String, Any?>) { val name: String by map val age: Int by map }这就是标准库提供的委托方式:Map本身就实现了对应的getValue,所以你什么都不用写,属性就会自动从Map里按key = 属性名取值。
1.3 编译期委托的底层细节
有一段字节码层面值得了解的细节:委托实例是存放在类里的一个私有字段,和属性本身对应的字段是完全独立的。
private final LNameDelegate; name$delegatename$delegate这个字段名是编译器自动生成的。如果你有一个属性叫name,委托字段就叫name$delegate;如果是扩展属性,命名规则会有不同,但语义一致。
另外,一个类里多个委托属性,不会为每个属性单独生成一个KProperty对象数组。编译器会生成一个静态的$delegatedProperties数组,里面放着属性对应的PropertyReference对象。这也是为什么getValue的第二个参数类型是KProperty<*>而非单个KProperty,因为它只是从数组里取一个元素传进来。
这些底层细节平时写业务代码不关心,但如果你要写一个通用框架,或者要排查“为什么我的自定义委托拿到的属性名不对”之类的问题,就得多留个心眼。
2. 懒加载与by lazy:三种模式的取舍
2.1 lazy不是一个函数,而是一套完整机制
by lazy背后是一个Lazy<T>接口,以及三个实现类:SynchronizedLazyImpl、PublicationLazyImpl、UnsafeLazyImpl。对应到lazy()函数,就是三个可选参数:
val a by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { ... } val b by lazy(LazyThreadSafetyMode.PUBLICATION) { ... } val c by lazy(LazyThreadSafetyMode.NONE) { ... }默认是SYNCHRONIZED,也就是锁内初始化。这也意味着你随便写一个by lazy就默认带上了多线程安全能力。而它是通过在初始化值的Lazy实例上加synchonized锁实现的。一旦初始化完成,后续的getter都直接返回缓存值,不再加锁。
这里有个心得体会:很多开发者在不需要线程安全的场景下全用默认lazy,导致每次首次访问都有一个没必要的锁开销。锁只发生在初始化那一刻,但线程安全模式的影响不止于此。比如在Android的UI主线程做首次懒加载,加锁虽然开销不大,但确实可以量化为几个纳秒到几十纳秒,对于高频调用路径来说没必要。真正的问题是错误地用NONE模式:如果两个线程同时触发初始化,初始化代码会执行两次,造成不可预期的副作用。
经验之谈:初始化逻辑有副作用(比如写文件、发埋点、改全局状态)时,不要用
NONE也无脑用SYNCHRONIZED。副作用代码不幂等的情况下,机制级别防不住语义问题,该加锁还是得上锁。
2.2 什么时候选PUBLICATION,什么时候选NONE
PUBLICATION模式的实现方式是CAS自旋。多个线程同时访问时会执行初始化,但最终只有一个线程的结果会被采纳,其余线程的结果会被丢弃。听起来很安全,但这里有个很大的坑:丢弃的初始化代码已经执行过了,不会回滚。所以有副作用的初始化逻辑,同样不适合用PUBLICATION。
那PUBLICATION适合什么场景?适合那些初始化结果不依赖外部状态、重复执行无害的场景。比如只读配置的加载、纯计算生成的值、从缓存里取的数据。
NONE模式更极端,完全不保证任何线程安全,但性能最好。适合只在单线程内访问的属性,比如object单例内部的懒加载成员,或者仅在某个特定线程上使用的属性。
我项目里有一个规则:
- 不确定是否会被多线程访问,用
SYNCHRONIZED,这是唯一不会出错的选择。 - 确定懒加载的值是幂等且无副作用的纯计算,且会频繁跨线程访问,用
PUBLICATION。 - 确定只在单线程使用,用
NONE。
2.3 lazy的一处易被忽视的坑:顶层属性的静态初始化锁
顶层by lazy有一个隐藏机制值得注意:顶层属性的Lazy实例和类内部属性的Lazy实例在字节码归属上不同。顶层属性会编译成对应文件类的静态字段,lazy初始化逻辑发生在静态getter里。如果你的顶层by lazy初始化逻辑很重,且被多个线程同时首次触发,SYNCHRONIZED模式会把这些线程全部暂时挂起。
一个朋友的经历是某个项目在启动阶段有一个顶层lazy属性负责加载大JSON配置,第一次访问时卡了800毫秒,于是UI线程直接卡顿。排查的时候发现其他线程也在并行访问这个属性,全部在等锁。这种场景要么先把配置预加载好,要么把顶层by lazy换成冷启动阶段明确初始化的普通属性。
3. 可观察属性:observable与vetoable
3.1 observable的真实使用场景
Delegates.observable可以在属性值变化时回调一个方法,但要注意一个细节:回调发生在值已经修改之后。
var score by Delegates.observable(0) { _, old, new -> println("$old -> $new") } score = 90这种“事后回调”的设计,让observable最适合做纯监听类需求:UI刷新、日志输出、事件通知。比如用户昵称修改后,需要同步刷新头像和昵称,用observable就很顺。你不用关心修改是否成功,只要值变了就触发。
和LiveData配合也是一个常规操作。我以前写过一个ViewModel基类,把一些状态字段定义成observable,在回调里直接更新LiveData。
var loading: Boolean by Delegates.observable(false) { _, _, new -> _loadingLiveData.value = new }这样Activity里的观察者不用感知ViewModel内部的状态变更逻辑,状态入口统一从LiveData走。
3.2 vetoable:不满足条件就拒绝赋值
vetoable和observable名字上对应,但语义相反:vetoable是在值修改之前触发回调,回调返回false就拒绝此次赋值,返回true才放行。
var age by Delegates.vetoable(0) { _, _, new -> new in 0..150 }真正有意思的是,vetoable经常被误用。我看到很多代码拿它做“同值过滤”:
var name by Delegates.vetoable("") { _, old, new -> old != new // 同值直接拒绝 }这样做虽然能达到效果,但语义是拧巴的——设计者本意是让你做业务校验,不是做优化。而且同值过滤有个边界问题:如果第一次赋值为空字符串,Java反射机制触发setter时传null,Kotlin侧的非空类型会抛异常,vetoable根本拦不住。所以非空校验还是得靠类型系统的可空性或者明确的require逻辑。
注意:
vetoable的回调里不要做耗时操作,它运行在赋值调用的线程,阻塞太久会让调用方卡住。
3.3 多个属性联动时,优先选择状态容器而非observable
我踩过一个很深刻的坑:一个页面有多个observable属性,它们互相联动。
var a by Delegates.observable(0) { _, _, _ -> b = a * 2 } var b by Delegates.observable(0) { _, _, _ -> c = b + 1 }表面上很灵活,实际运行后就会遇到赋值顺序问题:a变的时候触发b赋值,b赋值又触发c赋值,整个流程像瀑布一样。如果中间某次b赋值不满足vetoable条件,状态就变成“a已更新但是b被驳回”的中间态,UI上表现成两个控件不一致。
后来我改用状态容器统一管理,把联动逻辑放在唯一入口里:
data class UIState(val a: Int, val b: Int, val c: Int) var state by Delegates.observable(UIState(0, 0, 0)) { _, _, new -> // 统一刷UI }联动逻辑从“单个属性监听”变成“整体状态变更”,心智负担小很多。这也反映一个问题:委托属性的粒度适合处理“点状属性”,不适合处理“网状状态关系”。
4. by map:动态数据结构的优雅接入点
4.1 Map委托的机制和限制
前面提过,Map自带getValue和MutableMap的setValue,所以可以这样:
class Config(val map: MutableMap<String, Any?>) { var version: String by map var retryCount: Int by map }每次对version属性的读写,都会变成map["version"]的读写。这在解析JSON、处理动态配置时特别好用。如果一个字段在Map里不存在,默认getValue拿到的是null,如果属性是非空类型,就会在getter处抛NullPointerException。所以使用Map委托时,属性类型要定义为可空,或确保所有key一定会被填充。
MutableMap委托还有一个特点:它的setValue是通过map.put(key, value)实现的,如果某个属性没有对应的键,直接set会新增键值对。这让类变得有点像动态Bean,属性字段本身不需要声明在类里。不过这也是双刃剑:反射时看不到属性列表,IDE的自动补全也会失效。
4.2 用Map委托做数据类版本兼容
在接口返回的JSON字段经常增删的项目里,by map能减少很多样板代码。
假设后端返回:
{ "user_name": "张三", "user_age": 28 }传统方式是定义数据类,然后写@SerializedName注解。字段少还好,字段一多,且每个字段都有可能缺失时,就得为每个字段设默认值。
用Map委托的方式是:
class RemoteUser(val map: MutableMap<String, Any?>) { var userName: String by map var userAge: Int by map val extraData: MutableMap<String, Any?> get() = map }新增字段时,只需要在类里加一行var userAddress: String? by map,不需要改构造函数,不需要给序列化库加配置,更不用处理默认值。后端字段缺失时,取值是null,如果有业务判断就在getter上做。
心得:这种模式适合探索性开发和后端快速迭代阶段。接口稳定以后,还是建议迁回数据类。因为Map委托让类型检查和编译期检查变弱了,一旦项目复杂度上来,维护成本会超出省下的那点代码量。
4.3 一个容易忽略的坑:MutableMap无无参构造
直接写class Config : MutableMap<String, Any?> by HashMap()其实不常见,多数情况下你会把外部传入的Map包进来。但有的开发者会把Map委托用于data class的字段,比如:
data class User(val raw: Map<String, Any?>) { val name: String by raw }这里有个很隐蔽的问题:data class的equals和hashCode是基于原始字段raw生成的。如果你在别处修改了raw的某个键值,这个数据类的相等性判断会跟着变,这在业务上往往不是你想要的行为。而且toString会输出整个Map,日志里会变得很啰嗦。
4.4 Map委托配合read-only Map的谨慎之处
如果构造函数传进来的是Map而不是MutableMap,by map只能作为只读属性委托。Kotlin对此的限制是隐式的,编译并不会报错,而是你的setter调用会失败,因为Map接口本身没有setValue。这个运行时抛出的异常很容易让人困惑。
我在项目里坑过一次:某接口返回的Map被直接作为委托源,开发时没注意类型是Map还是MutableMap,运行到某个写入分支时才抛UnsupportedOperationException。排查了很久,后来干脆在入口处统一做一次toMutableMap(),彻底绕开可变性问题。
5. 自定义委托:从入门到实战
5.1 自定义一个带日志的委托
先拿一个最简单的入门场合练手。给属性加读写日志,用自定义委托是最干净的方案,因为调用方代码完全不用变。
class LoggedProperty<T>(private var value: T) { operator fun getValue(thisRef: Any?, property: KProperty<*>): T { println("${property.name} 被读取,当前值: $value") return value } operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { println("${property.name} 被赋值为 $value") this.value = value } } var userId: String by LoggedProperty("")这里的thisRef是属性所在对象。因为是顶层属性时,thisRef会是null,所以在日志里别直接打印thisRef.toString(),否则空指针等着你。
5.2 做一个SharedPreferences委托
在Android开发里,用自定义委托封装SharedPreferences,是把模板代码彻底消灭的一个经典例子。
class PrefDelegate<T>( private val prefs: SharedPreferences, private val key: String, private val default: T ) { operator fun getValue(thisRef: Any?, property: KProperty<*>): T { val value = prefs.all[key] return value as? T ?: default } operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { prefs.edit().apply { when (value) { is String -> putString(key, value) is Int -> putInt(key, value) is Boolean -> putBoolean(key, value) is Float -> putFloat(key, value) is Long -> putLong(key, value) else -> putString(key, value.toString()) } }.apply() } }使用的时候只需要:
object Prefs { private val prefs = context.getSharedPreferences("app", MODE_PRIVATE) var isFirstLaunch: Boolean by PrefDelegate(prefs, "first_launch", true) var userName: String by PrefDelegate(prefs, "user_name", "") }和原始写法相比,省掉的不仅有getSharedPreferences的重复调用,还有每次都要做的类型转换和key拼写错误。这个委托还有一个附加优势:key集中在类定义旁边,后续查找很容易。
不过这个写法有一个性能问题:getValue每次都会访问prefs.all,这是一个全量读取。在频繁调用的场景下应该改用prefs.getString(key, default)这类定向读取。我这里是为了代码简洁,实际项目建议按类型区分读取API。
5.3 生命周期相关的委托:onCleared自动执行
做Android时,经常需要在ViewModel销毁时做一些清理动作。最简单粗暴的办法是在onCleared()里手动调用清理方法。而自定义委托可以把清理动作绑定到属性上,让属性被访问后自动注册清理器。
思路是:委托内部持有一个viewModelScope,在getValue首次初始化时启动协程,并在ViewModel.onCleared()里取消或执行清理逻辑。这需要委托类持有对ViewModel的引用。
class AutoClearedProperty<T : Any?>( private val viewModel: ViewModel, private val initializer: () -> T ) { private var value: T? = null operator fun getValue(thisRef: Any?, property: KProperty<*>): T { if (value == null) { value = initializer() viewModel.viewModelScope.launch { // 观察属性对应的资源状态... } } @Suppress("UNCHECKED_CAST") return value as T } }这个委托的收益是:属性所在的ViewModel,只要引用关系正确,就不需要手写清理。缺点是委托类必须自己处理生命周期,一旦忘了在onCleared里释放资源,比手动清理更容易产生泄漏。所以这种委托更适合封装“有特定生命周期的资源”,而不是普通数据。
5.4 自定义委托的通用性设计原则
写自定义委托时,我建议每个人都要先问自己几个问题:
- 属性类型是否是泛型的?如果固定写死
Int或String,复用的价值会大打折扣。 - 是否需要访问属性名?很多委托(比如Map委托)天然依赖
property.name作为key,这时候KProperty参数就不能省略。 - 可变还是只读?只读委托实现一个
getValue就够,别画蛇添足加setValue。 - 委托实例是否需要共享状态?如果每个属性都新建一个委托实例(
by SomeDelegate()),状态天然隔离。如果用伴生对象共享同一个委托实例,那状态就是全局的,多属性之间会相互干扰。
5.5 委托属性的性能影响
虽然编译器把委托实例保存为字段,每次getter/setter只是类内部的一次间接调用,性能开销相比直接访问字段确实有,但关键是这个开销在普通业务代码中完全可以忽略。一次JIT内联之后,间接调用很可能也被优化掉了。
可如果属性访问特别频繁(比如游戏引擎每一帧读几十次),那委托属性的开销就被放大了。这种场景我建议直接用普通字段,或者手动缓存委托结果。比如:
private val cache by lazy { HeavyConfig() } val config: HeavyConfig get() = cache这里的getter其实是纯直传,不经过委托间接层,性能比by lazy在JIT层面的效果更好。有点反常,但实测下来确实这样,因为Kotlin标准库lazy的getValue带了一层property参数的传递,对JIT内联是个阻碍。
6. 类型别名:把复杂类型签名压缩成一句话
6.1 typealias能做什么,不能做什么
typealias的官方定义是“为现有类型提供替代名称”,它不会创建新类型。这意味着:
- 类型检查上,原类型和别名完全等同,可以互相赋值。
- 重载解析时,别名不会被视为独立类型。
- 打印
toString()时,运行时类型显示的是原类型,不是别名。
typealias UserId = Long fun getUser(id: UserId) {} val uid = 12345L getUser(uid) // 完全合法从功能上看,这更像是给类型做“宏替换”。所以别试图用它来做类型安全的新型——想做新型,得用inline class。这是一个常见误区,很多人以为typealias能区分不同类型,结果编译通过后运行时发现两边是同一个类型。
6.2 函数类型别名的实用场景
函数类型是那种最容易让代码变得又臭又长的。例如:
typealias OnResult<T> = (Result<T>) -> Unit typealias ErrorHandler = (Throwable, String) -> Unit fun request(onSuccess: OnResult<User>, onError: ErrorHandler) {}别小看这个重命名,它带来的价值不只是少打字,而是让函数签名变成可读的声明。(Result<T>) -> Unit哪怕看一眼知道含义,但如果参数多了,比如(String, Int, List<File>, Boolean) -> Unit,调用者就没法快速判断每个位置的语义。给它们起个名字,就是给文档加了块牌子。
6.3 泛型别名:约束和自由
typealias支持泛型参数,这点让它在复杂的泛型场景下很有用。
typealias StringMap<V> = MutableMap<String, V> typealias Callback<T> = (T) -> Unit val cache: StringMap<Any?> = mutableMapOf()但要注意,别名本身不能“创造”泛型约束。你不能写:
typealias NumberList<T : Number> = List<T>编译器会报错。typealias只能简单地把泛型参数同步给原类型,无法添加额外的限制条件。如果想加约束,只能通过定义一个泛型函数或泛型类来变通。
6.4 嵌套类型别名
嵌套类型是类型别名另一个大显身手的地方。常见的例子是范型链特别长的:
typealias NameToAgeMap = Map<String, Map<String, Int>> typealias QueryResult = List<Pair<String, Map<String, Any?>>>嵌套别名的可读性提升肉眼可见。不过这里也提醒一句:能起嵌套别名的前提是,这个类型确实会在多个地方出现。如果只在一个函数里用了一次,直接把这个长类型写出来反而更清楚,因为别名增加了一层间接跳转。
6.5 跨模块共享类型别名
在多模块项目里,每个模块的公共API都会暴露一些类型。类型别名可以放在一个公共模块里,统一管理跨模块的类型签名。我在项目里有一个专门存放类型别名的文件,里面集中定义了所有回调、监听器、错误处理器的签名。
好处是其他模块引用时,不需要各自定义一遍相同的函数类型,也不会出现两处定义不一致的尴尬。缺点也很明显:这是一个“类型上的公共依赖”,一旦别名定义的底层类型变化,所有引用它的模块都会被波及。所以在公共模块里定义别名时,千万别让别名绑定太具体的实现,尽量绑定接口或抽象类型。
6.6 导入别名:import ... as ...
除了typealias,Kotlin还支持导入别名:
import com.example.module.foo.utils.longFunctionName import com.example.module.bar.utils.longFunctionName as shortName这在两个类重名、或者在当前文件里要区分不同包的同名类时很管用。导入别名在文件级生效,不影响其他文件。使用场景虽然不算高频,但遇到java.util.Date和java.sql.Date同时出现时,它比全限定类名可读性好得多。
6.7 类型别名与内联类怎么选
很多Kotlin开发者在做“包装类型”时会纠结typealias和inline class。
- 用
typealias,本质上就是原类型,毫无运行时开销,但也毫无类型隔离。 - 用
inline class,可以定义新的类型,编译器在大多数场景直接内联成原始值,基本零开销,且能区分不同类型。
比如用户ID和订单ID,都底层是Long:
typealias UserId = Long typealias OrderId = Long然后你就发现,某天你顺手把OrderId传给了需要UserId的函数,编译器居然不报错,因为两者都是Long。这就是类型安全彻底失效的典型例子。
换成inline class:
@JvmInline value class UserId(val value: Long) @JvmInline value class OrderId(val value: Long)传错参数编译器立刻报错。这个差别在业务边界上价值巨大。所以我的选择口诀是:做纯可读性优化用typealias,做类型安全隔离用inline class。
7. 委托属性与类型别名的组合实践
7.1 组合一:带类型别名的Map委托
把委托属性和类型别名结合,能做出一些很好用的组合。比如:
typealias RawMap = MutableMap<String, Any?> class UserConfig(private val raw: RawMap) { var name: String? by raw var level: Int? by raw }这个组合的好处是:RawMap让函数签名简短,by raw让字段访问直接从Map取值,省去了手写的getter和setter。对于那些“结构动态变化、但同时又希望静态访问”的配置类,这个模式极其合适。
7.2 组合二:类型别名简化自定义委托的回调
自定义委托经常要接收各种回调。回调多了,委托类的构造函数会变得很长:
class PrefDelegate<T>( private val prefs: SharedPreferences, private val key: String, private val default: T, private val onChanged: (old: T?, new: T) -> Unit, private val onRead: (T) -> Unit )如果用类型别名收拢一下:
typealias ChangeListener<T> = (old: T?, new: T) -> Unit typealias ReadListener<T> = (value: T) -> Unit构造函数直接变得更清爽:
class PrefDelegate<T>( private val prefs: SharedPreferences, private val key: String, private val default: T, private val onChanged: ChangeListener<T> = { _, _ -> }, private val onRead: ReadListener<T> = {} )别小看这种做法,它还有一个潜在好处:如果后续你想给回调加参数,比如加一个KProperty参数,只需要改别名定义,所有使用处都能保持兼容。但是这里也要注意,过于细化的别名会让代码“看起来名字很多,实际结构很少”,阅读成本回升。所以别名只给“出现三次以上”的类型起名。
7.3 组合三:系统状态监听的精简写法
在Android开发里,一个常见的需求是监听系统设置变化或网络状态。如果用传统写法,接口回调要写很多,而用“类型别名 + 委托属性”能把监听暴露面收敛得很优雅。
比如网络状态:
typealias NetworkCallback = (isConnected: Boolean, networkType: String) -> Unit class NetworkStatus(initValue: Boolean) { var current by Delegates.observable(initValue) { _, old, new -> if (old != new) { listeners.forEach { it(new, detectType()) } } } private val listeners = mutableListOf<NetworkCallback>() fun observe(callback: NetworkCallback) { listeners.add(callback) } }这里NetworkCallback让监听方法的签名一目了然,observable帮我们自动派发变更事件。虽然代码不多,但它把回调注册、状态变更、通知派发集中在了一个类里。调用方只需要:
networkStatus.observe { isConnected, type -> updateUI(isConnected, type) }整体代码的可读性和扩展性比传统Listener加接口要好不少。
8. 常见陷阱与实践总结
8.1 委托属性陷阱清单
陷阱:自定义委托的getValue/setValue丢失thisRef。有些场景下你以为
thisRef是当前类的实例,但顶层属性时thisRef是null。不要在委托里无条件调用thisRef的方法或属性。陷阱:lazy捕获了外部变量导致状态过期。
lazy的初始化和调用通常都在同一实例中,但如果初始化逻辑捕获了外部可变变量,且外部变量在初始化之后又被修改,缓存值不会跟着变。很多人会把神器误解成响应式变量。陷阱:委托实例有状态,但多个属性共用了一个委托实例。比如在伴生对象里定义
val delegate = SomeDelegate(),然后多个属性都用by delegate。这会让这几个属性的状态串掉。Kotlin不禁止这种做法,但你要清楚自己在做什么。陷阱:Map委托的key与属性名强绑定。属性一旦用
@SerializedName("user_name")做过映射,Map委托下就不能再靠属性名取到值,你得手动在Map里维护key映射关系。陷阱:vetoable的拒绝不会撤销已经发生的副作用。
vetoable的回调发生在赋值之前,这没错。但如果回调内部做了某些逻辑(比如打日志、发埋点),即使返回false,这些逻辑已经执行了。
8.2 类型别名陷阱清单
陷阱:typealias没有类型安全。
typealias UserId = Long和typealias OrderId = Long在编译器看来完全相同,传错参数不报错。要做类型安全得用inline class。陷阱:typealias不能参与重载决策。如果两个函数签名只在别名的位置不同,实际上原类型相同,这样无法构成重载:
typealias A = String typealias B = String fun foo(x: A) {} fun foo(x: B) {} // 报错,平台声明冲突两个foo都被编译器还原成foo(String),属于签名相同。
陷阱:typealias在跨模块API变更时影响面大。如果公共模块里定义了一个别名,底层类型后来重构了,所有引用该别名的模块都需要重新编译。这个问题在二进制的兼容性上需要格外小心。
陷阱:别名的继承或实现能力为零。
typealias不能定义类成员、不能实现接口、不能新增方法。你只能把别名当作“原样替换”的记号,任何想在类型上附加行为的操作都得用扩展函数。
8.3 委托属性选型决策表
| 需求 | 推荐方案 | 原因 |
|---|---|---|
| 属性首次访问时才计算 | by lazy | 天然缓存,三种线程安全模式可选 |
| 属性变化时要通知外部 | Delegates.observable | 事后回调,适合做监听 |
| 属性赋值前要校验或过滤 | Delegates.vetoable | 回调返回false即可拒绝赋值 |
| 属性从Map里取值 | by map | Map自带的getValue/setValue协议 |
| 需要持久化存储字段 | 自定义SharedPreferences委托 | 把存储逻辑藏进委托里 |
| 需要AOP式日志或埋点 | 自定义日志委托 | 不改调用方代码,统一控制 |
| 复杂函数类型签名 | typealias | 短签名可读性强 |
| 需要区分两种同底层类型的ID | inline class | 类型安全隔离,编译期强制检查 |
8.4 踩过坑之后的一些体会
在我自己的项目里,把网络层回调从(Result<X>) -> Unit直接改成typealias ApiResponse<T> = (Result<T>) -> Unit后,整个模块的可读性提升非常明显。但真正让开发效率发生质变的却是委托属性——它把“属性的读写如何实现”这个问题从业务代码中抽离出去,让每个属性都像定义了一个接口,而实现的细节不再散落各处。
一点个人经验:写自定义委托时,建议把委托类的核心逻辑做成不可变的,委托实例本身不要存太多可变状态。否则很容易陷入“谁改了委托状态”的排查地狱。
另外,Kotlin官方文档里其实有一段很关键的话:委托属性是有协议支持的,不是语法糖级的语法糖。这意味着它的实现机制依赖操作符约定,如果你在Java代码里调用Kotlin的委托属性,你看到的只是普通方法调用,因为getValue和setValue被编译成了普通方法。
最后分享一个小技巧。如果项目里某个属性委托经常被用到,但你又不想每次都写完整的自定义委托类,可以把它做成顶层函数返回一个对象:
fun <T> prefsDelegate(prefs: SharedPreferences, key: String, default: T) = object : ReadWriteProperty<Any?, T> { override fun getValue(thisRef: Any?, property: KProperty<*>): T { /* ... */ } override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { /* ... */ } }然后使用:
var token: String? by prefsDelegate(prefs, "token", null)这样用起来比自定义一个具名类更省事,尤其是一个项目里不同模块只会用一两次的场景。不过当同一个委托模式被多个类复用时,还是建议写成具名类,因为它更容易单测和维护。
Kotlin这两个特性,如果只用它们的“语法糖”层面,那确实没太大感觉。但一旦你有意识地用委托属性去抽象属性访问,用类型别名去约束复杂签名,你会发现代码的结构会慢慢变得更干净,边界更清晰,这才是它们真正值钱的地方。