gitbook/朱涛 · Kotlin编程第一课/docs/479112.md
2022-09-03 22:05:03 +08:00

22 KiB
Raw Permalink Blame History

09 | 委托:你为何总是被低估?

你好我是朱涛。今天我们来学习Kotlin的委托特性。

Kotlin的委托主要有两个应用场景一个是委托类另一个是委托属性。对比第6讲我们学过的扩展来看的话Kotlin委托这个特性就没有那么“神奇”了。

因为扩展可以从类的外部为一个类“添加”成员方法和属性因此Kotlin扩展的应用场景也十分明确而Kotlin委托的应用场景就没那么清晰了。这也是很多人会“重视扩展”而“轻视委托”的原因。

然而,我要告诉你的是,Kotlin“委托”的重要性一点也不比“扩展”低。Kotlin委托在软件架构中可以发挥巨大的作用在掌握了Kotlin委托特性后你不仅可以改善应用的架构还可以大大提升开发效率。

另外如果你是Android工程师你会发现Jetpack Compose当中大量使用了Kotlin委托特性。可以说如果你不理解委托你就无法真正理解Jetpack Compose。

看到这里想必你也已经知道Kotlin委托的重要性了接下来就来开启我们的学习之旅吧

委托类

我们先从委托类开始,它的使用场景非常简单易懂:它常常用于实现类的“委托模式”。我们来看个简单例子:

interface DB {
    fun save()
}

class SqlDB() : DB {
    override fun save() { println("save to sql") }
}

class GreenDaoDB() : DB {
    override fun save() { println("save to GreenDao") }
}
//               参数  通过 by 将接口实现委托给 db 
//                ↓            ↓
class UniversalDB(db: DB) : DB by db

fun main() {
    UniversalDB(SqlDB()).save()
    UniversalDB(GreenDaoDB()).save()
}

/*
输出:
save to sql
save to GreenDao
*/

以上的代码当中我们定义了一个DB接口它的save()方法用于数据库存储SqlDB和GreenDaoDB都实现了这个接口。接着我们的UniversalDB也实现了这个接口同时通过by这个关键字将接口的实现委托给了它的参数db。

这种委托模式在我们的实际编程中十分常见UniversalDB相当于一个壳它虽然实现了DB这个接口但并不关心它怎么实现。具体是用SQL还是GreenDao传不同的委托对象进去它就会有不同的行为。

另外以上委托类的写法等价于以下Java代码我们可以再进一步来看下

class UniversalDB implements DB {
    DB db;
    public UniversalDB(DB db) { this.db = db; }
             //  手动重写接口,将 save 委托给 db.save()
    @Override//            ↓
    public void save() { db.save(); }
}

以上代码显示save()将执行流程委托给了传入的db对象。所以说Kotlin的委托类提供了语法层面的委托模式。通过这个by关键字就可以自动将接口里的方法委托给一个对象从而可以帮我们省略很多接口方法适配的模板代码。

委托类很好理解下面让我们重点来看看Kotlin的委托属性。

委托属性

正如我们前面所讲的,**Kotlin“委托类”委托的是接口方法而“委托属性”委托的则是属性的getter、setter。**在第1讲我们知道val定义的属性它只有get()方法而var定义的属性既有get()方法也有set()方法。

那么属性的getter、setter委托出去以后能有什么用呢我们可以从Kotlin官方提供的标准委托那里找到答案。

标准委托

Kotlin提供了好几种标准委托其中包括两个属性之间的直接委托、by lazy懒加载委托、Delegates.observable观察者委托以及by map映射委托。前面两个的使用频率比较高后面两个频率比较低。这里我们就主要来了解下前两种委托属性。

将属性A委托给属性B

从Kotlin 1.4 开始我们可以直接在语法层面将“属性A”委托给“属性B”就像下面这样

class Item {
    var count: Int = 0
    //              ①  ②
    //              ↓   ↓
    var total: Int by ::count
}

以上代码定义了两个变量count和total其中total的值与count完全一致因为我们把total这个属性的getter和setter都委托给了count。

注意代码中的两处注释是关键注释①代表total属性的getter、setter会被委托出去注释②::count代表total被委托给了count。这里的“::count”是属性的引用,它跟我们前面学过的函数引用是一样的概念。

total和count两者之间的委托关系一旦建立就代表了它们两者的getter和setter会完全绑定在一起如果要用代码来解释它们背后的逻辑它们之间的关系会是这样

// 近似逻辑实际上底层会生成一个Item$total$2类型的delegate来实现

class Item {
    var count: Int = 0

    var total: Int
        get() = count

        set(value: Int) {
            count = value
        }
}

也就是当total的get()方法被调用时它会直接返回count的值也就意味着会调用count的get()方法而当total的set()方法被调用时它会将value传递给count也就意味着会调用count的set()方法。

也许你会好奇Kotlin 1.4提供的这个特性有啥用为什么要分别定义count和total我们直接用count不好吗

这个特性,其实对我们软件版本之间的兼容很有帮助。假设Item是服务端接口的返回数据1.0版本的时候我们的Item当中只count这一个变量

// 1.0 版本
class Item {
    var count: Int = 0
}

而到了2.0版本的时候我们需要将count修改成total这时候问题就出现了如果我们直接将count修改成total我们的老用户就无法正常使用了。但如果我们借助委托就可以很方便地实现这种兼容。我们可以定义一个新的变量total然后将其委托给count这样的话2.0的用户访问total而1.0的用户访问原来的count由于它们是委托关系也不必担心数值不一致的问题。

好了,除了属性之间的直接委托以外,还有一种委托是我们经常会用到的,那就是懒加载委托。

懒加载委托

懒加载,顾名思义,就是对于一些需要消耗计算机资源的操作,我们希望它在被访问的时候才去触发,从而避免不必要的资源开销。前面第5讲学习单例的时候我们就用到了by lazy的懒加载。其实这也是软件设计里十分常见的模式我们来看一个例子

//            定义懒加载委托
//               ↓   ↓
val data: String by lazy {
    request()
}

fun request(): String {
    println("执行网络请求")
    return "网络数据"
}

fun main() {
    println("开始")
    println(data)
    println(data)
}

结果:
开始
执行网络请求
网络数据
网络数据

通过“by lazy{}我们就可以实现属性的懒加载了。这样通过上面的执行结果我们会发现main()函数的第一行代码由于没有用到data所以request()函数也不会被调用。到了第二行代码我们要用到data的时候request()才会被触发执行。到了第三行代码由于前面我们已经知道了data的值因此也不必重复计算直接返回结果即可。

并且,如果你去看懒加载委托的源代码,你会发现,它其实是一个高阶函数

public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)


public actual fun <T> lazy(mode: LazyThreadSafetyMode, initializer: () -> T): Lazy<T> =
    when (mode) {
        LazyThreadSafetyMode.SYNCHRONIZED -> SynchronizedLazyImpl(initializer)
        LazyThreadSafetyMode.PUBLICATION -> SafePublicationLazyImpl(initializer)
        LazyThreadSafetyMode.NONE -> UnsafeLazyImpl(initializer)
    }

可以看到lazy()函数可以接收一个LazyThreadSafetyMode类型的参数如果我们不传这个参数它就会直接使用SynchronizedLazyImpl的方式。而且通过它的名字我们也能猜出来它是为了多线程同步的。而剩下的SafePublicationLazyImpl、UnsafeLazyImpl则不是多线程安全的。

好了除了这两种标准委托以外Kotlin也还提供了Delegates.observable观察者委托by map映射委托,这两种委托比较简单,你可以点击这里给出的链接去了解它们的定义与用法。

自定义委托

在学完Kotlin的标准委托以后你也许会好奇**是否可以根据需求实现自己的属性委托呢?**答案当然是可以的。

不过为了自定义委托我们必须遵循Kotlin制定的规则。

class StringDelegate(private var s: String = "Hello") {
//     ①                           ②                              ③
//     ↓                            ↓                               ↓
    operator fun getValue(thisRef: Owner, property: KProperty<*>): String {
        return s
    }
//      ①                          ②                                     ③ 
//      ↓                           ↓                                      ↓
    operator fun setValue(thisRef: Owner, property: KProperty<*>, value: String) {
            s = value
    }
}

//      ②
//      ↓
class Owner {
//               ③
//               ↓     
    var text: String by StringDelegate()
}

以上代码一共有三套注释,我分别标注了①、②、③,其中注释①有两处,注释②有三处,注释③也有三处,相同注释标注出来的地方,它们之间存在密切的关联。

首先看到两处注释①对应的代码对于var修饰的属性我们必须要有getValue、setValue这两个方法同时这两个方法必须有 operator 关键字修饰。

其次看到三处注释②对应的代码我们的text属性是处于Owner这个类当中的因此getValue、setValue这两个方法中的thisRef的类型必须要是Owner或者是Owner的父类。也就是说我们将thisRef的类型改为 Any 也是可以的。一般来说这三处的类型是一致的当我们不确定委托属性会处于哪个类的时候就可以将thisRef的类型定义为“Any?”。

最后看到三处注释③对应的代码由于我们的text属性是String类型的为了实现对它的委托getValue的返回值类型以及setValue的参数类型都必须是 String类型或者是它的父类。大部分情况下,这三处的类型都应该是一致的。

不过上面这段代码看起来还挺吓人的,刚开始的时候你也许会不太适应。但没关系,你只需要把它当作一个固定格式就行了。你在自定义委托的时候只需要关心3个注释标注出来的地方即可。

而如果你觉得这样的写法实在很繁琐也可以借助Kotlin提供的ReadWriteProperty、ReadOnlyProperty这两个接口来自定义委托。

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

public interface ReadWriteProperty<in T, V> : ReadOnlyProperty<T, V> {
    public override operator fun getValue(thisRef: T, property: KProperty<*>): V

    public operator fun setValue(thisRef: T, property: KProperty<*>, value: V)
}

如果我们需要为val属性定义委托我们就去实现ReadOnlyProperty这个接口如果我们需要为var属性定义委托我们就去实现ReadWriteProperty这个接口。这样做的好处是通过实现接口的方式IntelliJ可以帮我们自动生成override的getValue、setValue方法。

以前面的代码为例我们的StringDelegate也可以通过实现ReadWriteProperty接口来编写

class StringDelegate(private var s: String = "Hello"): ReadWriteProperty<Owner, String> {
    override operator fun getValue(thisRef: Owner, property: KProperty<*>): String {
        return s
    }
    override operator fun setValue(thisRef: Owner, property: KProperty<*>, value: String) {
        s = value
    }
}

提供委托provideDelegate

接着前面的例子假设我们现在有一个这样的需求我们希望StringDelegate(s: String)传入的初始值s可以根据委托属性的名字的变化而变化。我们应该怎么做

实际上,要想在属性委托之前再做一些额外的判断工作,我们可以使用provideDelegate来实现。

看看下面的SmartDelegator你就会明白

class SmartDelegator {

    operator fun provideDelegate(
        thisRef: Owner,
        prop: KProperty<*>
    ): ReadWriteProperty<Owner, String> {

        return if (prop.name.contains("log")) {
            StringDelegate("log")
        } else {
            StringDelegate("normal")
        }
    }
}

class Owner {
    var normalText: String by SmartDelegator()
    var logText: String by SmartDelegator()
}

fun main() {
    val owner = Owner()
    println(owner.normalText)
    println(owner.logText)
}

结果:
normal
log

可以看到为了在委托属性的同时进行一些额外的逻辑判断我们使用创建了一个新的SmartDelegator通过它的成员方法provideDelegate嵌套了一层在这个方法当中我们进行了一些逻辑判断然后再把属性委托给StringDelegate。

如此一来通过provideDelegate这样的方式我们不仅可以嵌套Delegator还可以根据不同的逻辑派发不同的Delegator。

实战与思考

至此我们就算是完成了Kotlin委托的学习包括委托类、委托属性还有4种标准委托模式。除了这些之外我们还学习了如何自定义委托属性其中包括我们自己实现getValue、setValue两个方法还有通过实现ReadOnlyProperty、ReadWriteProperty这两个接口。而对于更复杂的委托逻辑我们还需要采用provideDelegate的方式来嵌套Delegator。

这里为了让你对Kotlin委托的应用场景有一个更清晰的认识我再带你一起来看看几个Android的代码案例。

案例1属性可见性封装

在软件设计当中我们会遇到这样的需求对于某个成员变量data我们希望类的外部可以访问它的值但不允许类的外部修改它的值。因此我们经常会写出类似这样的代码

class Model {
    var data: String = ""
        // ①
        private set

    private fun load() {
        // 网络请求
        data = "请求结果"
    }
}

请留意代码注释①处我们将data属性的set方法声明为private的这时候data属性的set方法只能从类的内部访问这就意味着类的外部无法修改data的值了但类的外部仍然可以访问data的值。

这样的代码模式很常见我们在Java/C当中也经常使用不过当我们的data类型从String变成集合以后问题就不一样了。

class Model {
    val data: MutableList<String> = mutableListOf()

    private fun load() {
        // 网络请求
        data.add("Hello")
    }
}

fun main() {
    val model = Model()
    // 类的外部仍然可以修改data
    model.data.add("World")
}

对于集合而言即使我们将其定义为只读变量val类的外部一旦获取到data的实例它仍然可以调用集合的add()方法修改它的值。这个问题在Java当中几乎没有优雅的解法。只要你暴露了集合的实例给外部外部就可以随意修改集合的值。这往往也是Bug的来源这样的Bug还非常难排查。

而在这个场景下,我们前面学习的“两个属性之间的委托”这个语法,就可以派上用场了。

class Model {
    val data: List<String> by ::_data
    private val _data: MutableList<String> = mutableListOf()

    fun load() {
        _data.add("Hello")
    }
}

在上面的代码中我们定义了两个变量一个变量是公开的“data”它的类型是List这是Kotlin当中不可修改的List它是没有add、remove等方法的。

接着我们通过委托语法将data的getter委托给了_data这个属性。而_data这个属性的类型是MutableList这是Kotlin当中的可变集合它是有add、remove方法的。由于它是private修饰的类的外部无法直接访问通过这种方式我们就成功地将修改权保留在了类的内部而类的外部访问是不可变的List因此类的外部只能访问数据。

案例2数据与View的绑定

在Android当中如果我们要对“数据”与“View”进行绑定我们可以用DataBinding不过DataBinding太重了也会影响编译速度。其实除了DataBinding以外我们还可以借助Kotlin的自定义委托属性来实现类似的功能。这种方式不一定完美但也是一个有趣的思路。

这里我们以TextView为例

operator fun TextView.provideDelegate(value: Any?, property: KProperty<*>) = object : ReadWriteProperty<Any?, String?> {
    override fun getValue(thisRef: Any?, property: KProperty<*>): String? = text
    override fun setValue(thisRef: Any?, property: KProperty<*>, value: String?) {
        text = value
    }
}

以上的代码我们为TextView定义了一个扩展函数TextView.provideDelegate而这个扩展函数的返回值类型是ReadWriteProperty。通过这样的方式我们的TextView就相当于支持了String属性的委托了。

它的使用方式也很简单:

val textView = findViewById<textView>(R.id.textView)

// ①
var message: String? by textView

// ②
textView.text = "Hello"
println(message)

// ③
message = "World"
println(textView.text)


结果:
Hello
World

在注释①处的代码我们通过委托的方式将message委托给了textView。这意味着message的getter和setter都将与TextView关联到一起。

在注释②处我们修改了textView的text属性由于我们的message也委托给了textView因此这时候println(message)的结果也会变成“Hello”。

在注释③处我们改为修改message的值由于message的setter也委托给了textView因此这时候println(textView.text)的结果会跟着变成“World”。

案例3ViewModel委托

在Android当中我们会经常用到ViewModel来存储界面数据。同时我们不会直接创建ViewModel的实例而对应的我们会使用委托的方式来实现。

// MainActivity.kt

private val mainViewModel: MainViewModel by viewModels()

这一行代码虽然看起来很简单但它背后隐藏了ViewModel复杂的实现原理。为了不偏离本节课的主题我们先抛开ViewModel的实现原理不谈。在这里我们专注于研究ViewModel的委托是如何实现的。

我们先来看看viewModels()是如何实现的:

public inline fun <reified VM : ViewModel> ComponentActivity.viewModels(
    noinline factoryProducer: (() -> Factory)? = null
): Lazy<VM> {
    val factoryPromise = factoryProducer ?: {
        defaultViewModelProviderFactory
    }

    return ViewModelLazy(VM::class, { viewModelStore }, factoryPromise)
}

public interface Lazy<out T> {

    public val value: T

    public fun isInitialized(): Boolean
}

原来viewModels()是Activity的一个扩展函数。也是因为这个原因我们才可以直接在Activity当中直接调用viewModels()这个方法。

另外我们注意到viewModels()这个方法的返回值类型是Lazy那么它是如何实现委托功能的呢

public inline operator fun <T> Lazy<T>.getValue(thisRef: Any?, property: KProperty<*>): T = value

实际上Lazy类在外部还定义了一个扩展函数getValue()这样我们的只读属性的委托就实现了。而Android官方这样的代码设计就再一次体现了职责划分、关注点分离的原则。Lazy类只包含核心的成员其他附属功能以扩展的形式在Lazy外部提供。

小结

最后,让我们来做一个总结吧。

  • 委托类,委托的是接口的方法,它在语法层面支持了“委托模式”。
  • 委托属性,委托的是属性的getter、setter。虽然它的核心理念很简单,但我们借助这个特性可以设计出非常复杂的代码。
  • 另外Kotlin官方还提供了几种标准的属性委托它们分别是两个属性之间的直接委托、by lazy懒加载委托、Delegates.observable观察者委托以及by map映射委托
  • 两个属性之间的直接委托它是Kotlin 1.4提供的新特性,它在属性版本更新、可变性封装上,有着很大的用处;
  • by lazy懒加载委托可以让我们灵活地使用懒加载它一共有三种线程同步模式默认情况下它就是线程安全的Android当中的viewModels()这个扩展函数在它的内部实现的懒加载委托从而实现了功能强大的ViewModel
  • 除了标准委托以外Kotlin可以让我们开发者自定义委托。自定义委托,我们需要遵循Kotlin提供的一套语法规范,只要符合这套语法规范,就没问题;
  • 在自定义委托的时候,如果我们有灵活的需求时,可以使用provideDelegate来动态调整委托逻辑。

看到这里相信你也发现了Kotlin当中看起来毫不起眼的委托实际上它的功能是极其强大的甚至可以说它比起扩展毫不逊色。其实只是因为Kotlin的委托语法要比扩展更难一些所以它的价值才更难被挖掘出来进而也就容易被开发者所低估。

希望这节课的内容可以对你有所启发也希望你可以将Kotlin强大的委托语法应用到自己的工作当中去。

思考题

这节课我们学习了Kotlin的委托语法也研究了几个委托语法的使用场景请问你还能想到哪些Kotlin委托的使用场景呢欢迎在评论区分享你的思路我们下节课再见。