本文原作者: 禹昂,携程机票移动端资深工程师,Kotlin 中文社区核心成员,图书《Kotlin 编程实践》译者。
原文发布于: 携程技术
https://mp.weixin.qq.com/s/y4Wx39-Kl8fuDVqtThKVDg
一、前言
1.1 技术背景与选型
自 2017 年 Google IO 大会以来,经过三年的发展,Kotlin 已成为 Android 平台无争议的首选开发语言。但是相比语言本身,Kotlin 1.2 版本后进入 stable 状态的协程 (coroutines) 的行业采用率仍然较低。
二. 热身准备
private lateinit var myViewModel: MyViewModel
......
myViewModel = ViewModelProvider(this)[MyViewModel::class.java]
myViewModel.liveData1.observer(this, Observe {
doSomething1(it)
})
myViewModel.liveData2.observer(this, Observe {
doSomething2(it)
})
......
由于 Kotlin 的 lambda 表达式与操作符重载,这段代码已经比对应的 Java 代码简洁多了,但是这段代码仍然不够 Kotlin style,我们稍微封装一下,定义两个新函数:
// 顶层函数版本
inline fun <reified T : ViewModel> getViewModel(owner: ViewModelStoreOwner, configLiveData: T.() -> Unit = {}): T =
ViewModelProvider(owner)[T::class.java].apply { configLiveData() }
// 扩展函数版本
inline fun <reified T : ViewModel> ViewModelStoreOwner.getSelfViewModel(configLiveData: T.() -> Unit = {}): T =
getViewModel(this, configLiveData)
为了不同的使用场景并且方便不同人的使用习惯,这里同时写了顶层函数版本与扩展函数版本,但是功能一模一样 (扩展函数版本直接调用了顶层函数版本)。现在如果我们要在 Fragment 中获取 ViewModel,看看会变成什么样 (这里使用扩展函数版本):
private lateinit var myViewModel: MyViewModel
......
myViewModel = getSelfViewModel {
liveData1.observe(this@MyFragment, Observer {
doSomething1(it)
})
liveData2.observe(this@MyFragment, Observer {
doSomething2(it)
})
......
}
这样封装的好处绝不仅仅在于让代码看起来 "DSL" 化。首先,内联的泛型实化函数让我们避免去编写 xxx::class.java 这样的样板式代码,而是只需要传一个泛型参数 (在这个例子中由于 lateinit 属性已经声明了类型,所以根据类型推导,我们连泛型参数都不必显式写出),这样看起来会优雅的多。其次,我们配合使用了带接受者的 lambda 表达式与作用域函数 apply 使我们在获取 ViewModel 内的 LiveData 对象的时候不再需要重复写多次 myViewModel. 这样的样板代码。
最后从代码结构来看,我们通常在获取到 ViewModel 对象后会直接订阅所有需要订阅的 LiveData,我们把所有的订阅逻辑都写到了 getSelfViewModel 函数的 lambda 表达式参数的作用域内,这样我们对订阅的代码可以更加一目了然。
这里只是个抛砖引玉,在我们决定要开始使用 Kotlin 来替换 Java 的时候,最好能先打牢 Kotlin 基础,这样我们才能发挥这门语言的最大潜力。从而避免使用 Kotlin 写出 Java 风格的代码。
三、正式实现
fun <T> liveData(
context: CoroutineContext = EmptyCoroutineContext,
timeoutInMs: Long = DEFAULT_TIMEOUT,
suspend LiveDataScope<T>.() -> block: Unit
): LiveData<T>
先看第三个参数 block,它是一个 suspend lambda 表达式,也就是说,它运行在协程中。第一个参数 context 通常用于指定这个协程执行的调度器,而 timeoutInMs 用于指定超时时间,当这个 LiveData 没有活跃的观察者的时候,时间如果超过超时时间,该协程就会被取消。由于第一和第二个参数都有默认值,所以大多数情况下,我们只需要传第三个参数。
liveData {} 函数在官方文档中并没有给出用例,所以并没有一个所谓标准的 "官方" 用法。我们观察了一下发现,block 块是一个带接收者的 lambda,而接收者类型是 LiveDataScope,且 LiveDataScope 有一个成员函数 emit,这就和 RxJava 的 create 操作符非常相似,更和 Flow 中的 flow {} 函数如出一辙。所以,如果要让我们的 LiveData 作为一个可持续发射数据的数据源,liveData {} 函数启动的这个协程需要不停的从外部取数据,这种场景正是协程中 Channel (参考链接 2) 的用武之地,我们用上述的技术编写一个简单的 ViewModel:
class CityViewModel : ViewModel() {
private val departCityTextChannel = Channel<String>(1)
val departCityTextLiveData = liveData {
for (result in departCityTextChannel)
emit(result)
}
// 外部的 UI 通过调用该方法来更新数据
fun updateCityUI() = viewModelScope.launch(Dispatchers.IO) {
val result = fetchData() // 拉取数据
departCityTextChannel.send(result)
}
}
首先我们声明并初始化了一个 Channel ——departCityTextChannel。然后我们使用 liveData {} 函数创建了 LiveData 对象,在 liveData {} 函数启动的协程内,我们通过无限循环不停的从 departCityTextChannel 中取数据,如果取不到,这个协程就会被挂起,直到有数据到来 (这比用 Java 线程加 BlockQueue 实现的类似的生产者消费者模式要高效很多)。for 循环对 Channel 有一等的支持。
如果 UI 要更新数据,会调用 updateCityUI() 函数,该函数内的所有操作 (通常都是耗时的) 在其启动的协程内异步进行。在这里我们通过 viewmodel-ktx 包提供的 viewModelScope 来启动协程,这个协程作用域的实现与 ViewModel 的实现相结合,可以通过 ViewModel 感知到外部 UI 组件的生命周期,从而帮助我们自动取消任务。
最后注意一点,我们在初始化 departCityTextChannel 时给工厂函数 Channel<String>(1) 传入的缓冲区 size 的大小是 1。这主要是为了我们可以避免生产者协程在等待消费者从 Channel 中取走数据时发生事实上的挂起,从而在一定程度上影响效率。当然如果有生产者生产的速度过快,而消费者消费的速度过慢而明显跟不上的时候,我们可以适当调大 size 的值。
我们的每个 LiveData 几乎都需要与其配合使用的 Channel,而且 liveData {} 函数做的事情也几乎都是一样的,即使用 for 循环从 Channel 拿到数据然后再使用 emit 函数发射出去。于是可以进行如下的封装:
inline val <T> Channel<T>.coroutineLiveData: LiveData<T>
get() = liveData {
for (entry in this@coroutineLiveData)
emit(entry)
}
ViewModel 内创建 departCityTextChannel 与 departCityTextLiveData 对象的代码就变成了这样:
class CityViewModel : ViewModel() {
private val departCityTextChannel = Channel<String>(1)
val departCityTextLiveData = departCityTextChannel.coroutineLiveData
...... 省略其他代码
我们封装了一个名为 coroutineLiveData 的内联扩展属性,它的 getter 已经将 LiveData 的创建逻辑封装好了,不过请注意,每次调用这个属性,实际上都返回了一个新的 LiveData 对象,所以正确的做法是在调用 coroutineLiveData 属性后,把它的结果保存下来,以此达到重复使用的目的,千万不要每次都使用 departCityTextChannel.coroutineLiveData 这样的方式来期望获取到同一个 LiveData 对象。当然,如果你觉得这样也许会有误导,也可以把 coroutineLiveData 属性改成扩展函数。
3.2 UI 代码订阅 LiveData
class CityView : LinearLayout {
constructor(context: Context) : super(context)
constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet)
constructor(context: Context, attributeSet: AttributeSet, defStyleAttr: Int) : super(context, attributeSet, defStyleAttr)
private val tvCity: TextView
// ...... 省略更多的 View 声明
init {
LayoutInflater.from(context).inflate(R.layout.flight_inquire_main_view, this).apply {
tvCIty = findViewById(R.id.tv_city)
// ...... 省略更多的 View 初始化
}
}
}
如果在 Fragment 或 Activity 中,获取 ViewModel 并订阅 LiveData 很容易,我们只需要把它们自身使用 this 传入即可。但是在 View 中获取不到 Fragment 对象,所以我们不得已必须要定义一个 initObserve 函数,通过将其暴露给 Fragment 调用来将 Fragment 自身的引用传入,于是 View 的代码就变成了如下这样:
class CityView : LinearLayout {
constructor(context: Context) : super(context)
constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet)
constructor(context: Context, attributeSet: AttributeSet, defStyleAttr: Int) : super(context, attributeSet, defStyleAttr)
private val tvCity: TextView
// ...... 省略更多的 View 声明
private lateinit var cityViewModel: CityViewModel
init {
LayoutInflater.from(context).inflate(R.layout.city_view, this).apply {
tvCIty = findViewById(R.id.tv_city)
// ...... 省略更多的 View 初始化
}
tvCity.setOnClickListener {
updateCityView()
}
}
fun <T> initObserver(owner: T) where T : ViewModelStoreOwner, T : LifecycleOwner {
cityViewModel = getViewModel(owner) {
cityLiveData.observe(owner, Observer {
tvCity.text = it
})
}
// ...... 省略其他 LiveData 订阅
}
private fun updateCityView() = cityVIewModel.updateCityView()
}
owner 实际上就是 Fragment,不过这里为了解耦,没有直接使用 Fragment,而是通过泛型,外加两个上界约束来确定 owner 的职责,一旦某天这个 View 要移植到 Activity 中,Activity 也可以将自身直接通过 initObserver 函数传入。在 Fragment 中,当我们通过 findViewById 拿到 View 对象之后就应该立即调用 initObserver 初始化订阅,代码就不赘述了。
我们用一张图来总结 3.1 小节与 3.2 小节:
我们刚才编写的示例代码之间的关系已经一目了然,MVVM 模式中的 V 与 VM 都已经有了,虽然 M 在图中没有体现,但获取数据的数据源,也就是 CityViewModel.updateCityUI() 函数中调用的 fetchData() 函数就属于 Model,它通常封装了数据库操作或网络服务拉取。
3.3 复杂场景
在开头的 1.2 小节中提到,我们有一些复杂的业务场景,比如多个独立的 View 依赖同一个数据源,或者多个 View 都可能触发同一个数据源的更新。那具体的实际情况举例就是,比如说现在有两个展示城市的 View,用户可以在其中任意一个更改城市,两个 View 中展示的城市信息都需要更新,这在实际情况中是非常典型的案例,将 1.2 小节中的场景 1 与场景 2 结合了起来。
基于以上的代码示例,也就是说除了上面的 CityView 我们还需要一个与它共享同一个数据源的 View,假如说存在一个 CityView2:
class CityView2 : LinearLayout {
// ...... 省略其他代码
private val tvCity: TextView
private lateinit var cityViewModel: CityViewModel
init {
LayoutInflater.from(context).inflate(R.layout.city_view2, this).apply {
tvCIty = findViewById(R.id.tv_city2)
}
tvCity.setOnClickListener {
updateCityView()
}
}
fun <T> initObserver(owner: T) where T : ViewModelStoreOwner, T : LifecycleOwner {
cityViewModel = getViewModel(owner) {
cityLiveData.observe(owner, Observer {
tvCity.text = it
})
}
}
private fun updateCityView() = cityVIewModel.updateCityView()
}
其他代码大同小异,无非是初始化 View、initObserver 函数、以及更新 UI 的函数。为了确保 CityView2 与 CityView 内的 cityViewModel 是同一个,只需确保 initObserver 函数传进来的 owner 是同一个对象就可以了。
这里我也画了一张图来描述这种关系:
四、新技术在生产环境遇到的挑战
任何一种被业界所公认且信赖的开源技术通常都经过了数百万乃至数千万级用户量的生产环境的检验。携程机票旧首页的 PV 量级在千万级别,考虑到 iOS 与 Android 双平台以及 AB 实验,新的 Android 机票平台化首页的 PV 量级也有百万级别。能否在百万级别的用户量下有优异的稳定性表现,是对本文提到的这几项技术的考验。
Kotlin 语言及其标准库本身已经迭代到 1.3.x 版本 (截止文章发稿前,最新版本为 1.4.10,而携程使用的则是 1.3.71),再加上好几年的国内外生产环境的检验,已经相对稳定。而本次使用的 ViewModel、LiveData 等 Jetpack 架构组件的版本为 2.2.0,经过线上数月的观测也非常稳定。但 Kotlin 协程框架 kotlinx.coroutines 最终还是出现了两个颇为棘手的问题。
五、结语
Kotlin 语言本身的优势以及所解决的问题很多都是 Java 开发者所面临的痛点。经过了数年的技术积累沉淀,1.3.x 版本 (1.3.x 的最后一个版本是 1.3.72) 的 Kotlin 已经相对稳定和成熟。
Kotlin 协程很强大,是一个雄心勃勃的项目,它为许多 Java 开发者带来了新的概念以及老问题的新解决方案。虽然它已经进入 release 阶段达一年半之久,但从我们的实践结果来看,其稳定性仍然还有提升的空间。随着 Kotlin 1.4 以及 kotlinx.coroutines 1.3.9 的推出,无论是 Kotlin 语言本身还是协程都已经进入了下一个阶段,相信在未来不久的时间里,它们的性能、稳定性、以及功能都会真正再上一个台阶。
Google 官方近些年与 Android 开发社区的关系日益密切,他们采纳了许多 Android 开发者提出的有效建议,并将其落地,Jetpack 就是成果之一。作为真正的官方出品,它的稳定性从实际表现来看的确经受住了考验。
Jetpack 不仅包含架构组件,还包含了一系列实用的库,比如声明式 UI 框架 (Compose)、SQLite 数据库操作框架 (Room)、依赖注入 (Hilt)、后台任务管理 (WorkManager) 等等,在未来的开发计划中逐渐尝试向更多的 Jetpack 相关技术迁移也会是一个重要的 Android 端技术改进方向。
参考链接
官方给出的响应式流 benchmarks
https://github.com/Kotlin/kotlinx.coroutines/blob/develop/benchmarks/src/jmh/kotlin/benchmarks/flow/scrabble/README.md
协程 Channel 官方文档 (中文翻译版,来自 Kotlin 中文站,译者即本文笔者)
https://www.kotlincn.net/docs/reference/coroutines/channels.html
中文社区关于协程安装报错 INSTALL_FAILED_DEXOPT 的讨论
https://discuss.kotliner.cn/t/topic/812
JetBrains 关于协程安装报 INSTALL_FAILED_DEXOPT 问题的回应 (Github Issues 1635)
https://github.com/Kotlin/kotlinx.coroutines/issues/1635
Google 关于协程安装报 INSTALL_FAILED_DEXOPT 问题的回应
https://issuetracker.google.com/issues/133167032
kotlinx.coroutines & Dispatchers.Main crash 的讨论 (Github Issues 1532)
https://github.com/Kotlin/kotlinx.coroutines/issues/1532
Dispatchers.Main crash 问题的 JetBrains 官方 BugFix commit (Pull requests 1572)
https://github.com/Kotlin/kotlinx.coroutines/pull/1572/commits
长按右侧二维码
查看更多开发者精彩分享
"开发者说·DTalk" 面向中国开发者们征集 Google 移动应用 (apps & games) 相关的产品/技术内容。欢迎大家前来分享您对移动应用的行业洞察或见解、移动开发过程中的心得或新发现、以及应用出海的实战经验总结和相关产品的使用反馈等。我们由衷地希望可以给这些出众的中国开发者们提供更好展现自己、充分发挥自己特长的平台。我们将通过大家的技术内容着重选出优秀案例进行谷歌开发技术专家 (GDE) 的推荐。
点击屏末 | 阅读原文 | 即刻报名参与 "开发者说·DTalk"