【DevTips】谈谈 Kotlin 里构建对象

Builder 模式大家想必也都知道。但是应该在什么时候使用呢?如果满足以下条件,那么应该使用:

  • 需要构建的对象是 Immutable 的,也就是说,这个对象的实例属性应该是不能被更改的。
  • 需要构建的对象的可设置属性非常多,以致在 constructor 里塞下所有参数对调用方来说会非常麻烦。

那么 Java 里常见的 Builder 模式再简单不过了(以下例子做了缩短,同时用了 Kotlin 来表达):

class AppRequest {
    private var uid: String? = null
    private var appId: String? = null

    constructor(uid: String, appId: String) {
        this.uid = uid
        this.appId = appId
    }

    class Builder {
        private var uid: String? = null
        private var appId: String? = null

        fun setUid(uid: String?): Builder {
            uid ?: return this
            this.uid = uid

            return this
        }

        fun setName(appId: String?): Builder {
            appId ?: return this
            this.appId = appId

            return this
        }

        fun build(): AppRequest {
            requireNotNull(uid)
            requireNotNull(appId)
            return AppRequest(uid!!, appId!!)
        }
    }
}

写起来很繁琐,在 Kotlin 里可以用 data class 取代,同时如果可以,尽量多使用默认参数:

data class Request(
        val uid: String,
        val appId: String = DEFAULT_APP_ID
)

当然如果你还需要做好对 Java 的兼容,那么 @JvmOverloads 还是有必要的。

使用 data class,又或者在主构造函数里就定义属性的话,如果调用方是使用 Kotlin 的话,那么调用起来会比较舒服:

fun foo() {
    val request = Request(uid = “fake_uid”)
}

当然,如果你的这个对象本来的属性是 mutable 的,又或者不得不做成 mutable(比如我司的 ParcelablePlease 插件,帮助实现 Parcelable 接口的,就要求 field 必须至少是包可见,对 Kotlin 来说就是需要 public 了),那么我个人比较倾向添加这一种构造函数:

class Request {
    var uid: String? = null
    var appid: String = DEFAULT_APP_ID

    constructor()

    constructor(applier: Request.() -> Unit) {
        applier()
    }
}

当然代价就是:

  • 每次构建对象的时候都需要多创建一个 public interface Function1<in P1**,**out R> : Function<R>  对象。如果此目标对象需要频繁创建,那么此为一个不怎么好的方法。

一个 “环保” 的做法自然是,调用方直接使用:

fun foo() {
    val request = Request().apply {
        uid = “fake_uid”
    }
}

当然 Kotlin 这么做算是比较方便,如果确认了目标调用方还可能是使用 Java,那么还是最好做成 fluent 式风格。

除了上面提到,还可以添加一个 Top-Level Function,名字跟 class name 一样,只不过是大驼峰风格:

@Suppress(“FunctionName”)
@JvmSynthetic
inline fun Request(block: Request.() -> Unit): Request {
    return Request().apply(block)
}

方法名用大驼峰风格,这当然不符合不是 Java / Kotlin 里文档提到的代码规范。不过 Kotlin Coroutines 和 Jetpack Compose 本身也有用到这样的格式,比如 Job:

@Suppress(“FunctionName”)
public fun Job(parent: Job? = null): CompletableJob = JobImpl(parent)

比如 Container

@Composable
fun Container(
    modifier: Modifier = Modifier.None,
    padding: EdgeInsets = EdgeInsets(0.dp),
    alignment: Alignment = Alignment.Center,
    expanded: Boolean = false,
    constraints: DpConstraints = DpConstraints(),
    width: Dp? = null,
    height: Dp? = null,
    children: @Composable() () -> Unit
)

data class 就足够了吗?

前面说过 Data Class 是一个比较好的做法,但是也忽略了一些问题,就是二进制的兼容性。以下内容总结自 Jake 大神的文章,欢迎直接到原文阅读。

如果你经常对外输出 API,那么对一个公开的对象,他的属性不可能一成不变的。如果你在使用 data class,那么就要小心了,先来看看如果你把一个类定义为 data class,Kotlin 编译器会帮你做什么:

  • quals()/hashCode() pair;
  • toString() of the form “User(name=John, age=42)”;
  • componentN() functions  corresponding to the properties in their order of declaration;
  • copy() function.

二进制兼容的意思是,上层使用提供的 Public 方法/对象,在你底层修改代码后,上层是不需要重新编译的就能正常运行的。那么看看在这里有没可能会出现问题?

首先是 Deconstruct 就直接出问题了。对于这么一个类:

data class Person(val uid: String, val name: String = “no_name”)

调用方是这么写的:

fun foo() {
    val person = Person(uid = “fake_uid”, name = “Tony”)
    val (uid, name) = person
}

那么假如说需要在 uid 和 name 之间新增一个 long 类型的属性,会怎样?

data class Person(val uid: String, val birthday: Long = 0L, val name: String = “no_name”)

如果只是执行上面的例子,那么看起来还一切正常:

fun foo() {
    val person = Person(uid = “fake_uid”, name = “Tony”)
    val (uid, name: String) = person
}

但是你会发现,name 这个不再是 Person  里的 name 了,会变成 Long 类型了。因此如果你在下面会继续用到这个 name,那么就会出错了。当然,编译器报错那还好,怕的是下面有一个误写的 toString(),然后直接当字符串处理了,然后 Bug 就出来了...

就以上问题的解决方法是:

在 data class 主构造的末尾通过 append 的方式添加新的属性。

当然,如果你还使用 Copy 方法,那么你会发现,Kotlin 编译出来的 Java 等效代码里,也有一个 Copy 的方法,方法接受的参数包含了所有属性

public static Person copy$default(Person var0, String var1, long var2, String var4, int var5, Object var6)

那么对调用方来说,一旦调用了 Copy,那么你的底层对象在做属性变更的时候,这个 Copy 方法自然就不存在了,那么将会导致运行时发生 NoSuchMethodError 异常。

因此,Jake 提出可以这么写:

  • 不要用 data class,所有方法(包括 componentN() 来方便 Deconstruct)自己实现。
  • 如果你对象属性是 immutable 的,那么请添加 Builder 模式。Builder 模式要考虑 Java 和 Kotlin:比如使用 public 的 set 方法,但是使用 @JvmSynthetic 来对 Java 隐藏。
  • 添加一个跟此对象同名的方法(而且用大驼峰风格的命名),然后去掉主构造函数,这样可以做构造函数本身的兼容。
  • 使用以上的方式,同时提供一个 builder 的构造 block,以方面构造 immutable 的对象。

总结出来就是,抄自 Jake 的 Blog 并加上 componentN 的方法:

class Person private constructor(
        val name: String,
        val nickname: String?,
        val age: Int
) {
    @JvmSynthetic
    operator fun component1(): String {
        return name
    }

    @JvmSynthetic
    operator fun component2(): String? {
        return nickname
    }

    @JvmSynthetic
    operator fun component3(): Int {
        return age
    }

    override fun toString() = "Person(name=$name, nickname=$nickname, age=$age)"
    override fun equals(other: Any?) = other is Person
            && name == other.name
            && nickname == other.nickname
            && age == other.age

    override fun hashCode() = Objects.hash(name, nickname, age)

    class Builder {
        @set:JvmSynthetic // Hide 'void' setter from Java
        var name: String? = null

        @set:JvmSynthetic // Hide 'void' setter from Java
        var nickname: String? = null

        @set:JvmSynthetic // Hide 'void' setter from Java
        var age: Int = 0

        fun setName(name: String?): Builder = apply { this.name = name }
        fun setNickname(nickname: String?): Builder = apply { this.nickname = nickname }
        fun setAge(age: Int): Builder = apply { this.age = age }

        fun build() = Person(name!!, nickname, age)
    }
}

那么在 Kotlin 里,你可以这么写:

fun foo() {
    val person = Person {
        name = “Photon”
        age = 10
    }

    val (name, _, age) = person
}

在 Java 里,可以:

public void foo() {
    Person person = new Person.Builder()
            .setName(“John”)
            .setNickname(“nick”).build();
}
  • 同时,ComponentN 的方法也对 Java 隐藏了。