【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 隐藏了。
Subscribe to JuniperPhoton's Blog
Get the latest posts delivered right to your inbox