文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

携程机票Android Jetpack与Kotlin Coroutines的方法教程

2024-04-02 19:55

关注

这篇文章主要讲解了“携程机票Android Jetpack与Kotlin Coroutines的方法教程”,文中的讲解内容简单清晰,易于学习与理解,下面请大家跟着小编的思路慢慢深入,一起来研究和学习“携程机票Android Jetpack与Kotlin Coroutines的方法教程”吧!

一、前言

1.1 技术背景与选型

自 2017年 Google IO 大会以来,经过三年的发展,Kotlin 已成为 Android  平台无争议的首选开发语言。但是相比语言本身,Kotlin 1.2 版本后进入 stable 状态的协程(coroutines)的行业采用率仍然较低。

协程的优势主要有:

Google Android 团队同时也在大力推广 Jetpack 组件库,其中 AAC 架构组件带来了全新的应用架构实现方式,可以更便捷的实现 MVVM  这一非常适用于复杂业务场景的设计模式。

1.2 业务背景

今年接到一个大需求,产品方向上希望尝试一种交通类业务融合的平台化搜索首页新体验。于是各业务研发团队经过几轮技术评估,决定联合启动开发这个新项目。借此机会,机票  App 团队决定基于 Android Jetpack AAC 组件库和 Kotlin Coroutines 技术方案进行重构实现。

机票首页的业务逻辑可以归纳抽象为以下两种场景:

针对这两个场景,基于 ViewModel、LiveData 实现的 MVVM 模式非常契合,可以做到业务逻辑清晰且代码耦合度低。ViewModel  表示一个业务模块相关数据状态的总集,同时向 View 暴露诸多数据状态需要响应 View 的操作时调用的接口。而从属于 ViewModel 下的  LiveData 则表示各个数据状态本身,并提供给 View 订阅。

在代码实现中,我们在多个 View 中可以使用相同的 ViewModelStoreOwner(一般是 Fragment 或 Activity)获取到同一个  ViewModel 对象,只要多个 View 订阅同一个 ViewModel 中相同的 LiveData,并在数据状态需要响应 UI操作而更新的时候调用  ViewModel 中的同一个函数,即可清晰简洁的应对这两种场景。

同时复盘当前机票首页的代码历史债:

通过合理的封装、拆分以及使用 ViewModel 与 LiveData 可以方便的解决问题 1;

Kotlin 自身的空安全特性解决了问题 4;

问题 5 与 6 主要通过合理的重构以及使用 ConstraintLayout 等新技术来解决,但不在本文的讨论范围。

那么问题 2 与 3 的解决,就需要 Kotlin 协程出场了。

二. 热身准备

2.1 抛砖引玉

在具体讲解实现之前,先通过一个小例子抛砖引玉,来说明一个小问题。

如果我们在一个 Fragment 中或 Activity 中要获取一个 ViewModel,然后订阅它内部的 LiveData,如果直接使用官方的 API  通常是这样的:

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 风格的代码。

2.2 代码角色划分

如果把当前的代码按职责进行划分,大概有以下几种:数据类(data class,类似于 Java  Bean)、工具函数(例如格式化一个日期,将其转换为可展示的字符串)、数据源(例如从网络拉取数据或从本地数据库读取数据)、核心业务逻辑(在拿到原始数据后我们可能要对它根据业务需求进行处理)、UI代码(无须多言)、状态信息(通常是一些用于表示状态的可变对象等等或者数据的当前状态)。

我们要将以上这几种代码划分为三个角色,或者划归到三个范围内,即:View、ViewModel、Model,也就是 MVVM 模式中三大角色。UI  代码划归到 View;数据类、数据源划规到 Model;而数据状态或其他状态信息划归到 ViewModel。而工具函数视情况而定,可以作为独立组件也可以放到  Model 中。

三、正式实现

3.1 协程 Channel 与 LiveData 组合实现的基本模式

在 MVVM 模式中,VM 即 ViewModel  表示数据状态。为了让业务逻辑和代码结构更加合理。我们通常将一些彼此依赖对方状态的数据(通常其表示的业务也是强相关的)拆分到同一个 ViewModel 中。而  LiveData (通常位于 ViewModel 内部)表示的是某些具体的数据状态。例如在携程机票首页的业务中,出发城市的相关数据就可以用一个 LiveData  来表示,到达城市则用另一个 LiveData 来表示,而这两个 LiveData 都位于同一个 ViewModel 中。

如果不使用 livedata-ktx 包,我们创建 LiveData 对象的方式主要是通过调用 MutableLiveData  类的构造方法,我们通过直接使用 MutableLiveData 对象来进行订阅、数据更新等操作。MutableLiveData  与普通对象一样,我们可以在任意一种异步框架下使用它。

但为了与 Kotlin 协程有更完美的配合,livedata-ktx 包提供给我们了另一种方式来创建 LiveData,即 liveData {}  函数,该函数的函数签名是这样的:

fun <T> liveData(     context: CoroutineContext = EmptyCoroutineContext,     timeoutInMs: Long = DEFAULT_TIMEOUT,     @BuilderInference block: suspend LiveDataScope<T>.() -> 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 &mdash;&mdash;departCityTextChannel。然后我们使用 liveData {}  函数创建了LiveData 对象,在 liveData {} 函数启动的协程内,我们通过无限循环不停的从 departCityTextChannel  中取数据,如果取不到,这个协程就会被挂起,直到有数据到来(这比用 Java 线程加 BlockQueue 实现的类似的生产者消费者模式要高效很多)。for  循环对 Channel 有一等的支持。

如果 UI 要更新数据,会调用 updateCityUI() 函数,该函数内的所有操作(通常都是耗时的)在其启动的协程内异步进行。在这里我们通过  viewmodel-ktx 包提供的 viewModelScope 来启动协程,这个协程作用域的实现与 ViewModel 的实现相结合,可以通过  ViewModel 感知到外部 UI 组件的生命周期,从而帮助我们自动取消任务。

最后注意一点,我们在初始化 departCityTextChannel 时给工厂函数 Channel(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

虽然整个机票首页的 UI 都位于一个 Fragment 内,但业务之间不相关的 UI 我们可以分别单独封装成不同的 View。假如说跟城市有关的  UI,我们可能就会像下面这样做:

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 小节:

携程机票Android Jetpack与Kotlin Coroutines的方法教程

我们刚才编写的示例代码之间的关系已经一目了然,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 是同一个对象就可以了。

这里我也画了一张图来描述这种关系:

携程机票Android Jetpack与Kotlin Coroutines的方法教程

四、新技术在生产环境遇到的挑战

任何一种被业界所公认且信赖的开源技术通常都经过了数百万乃至数千万级用户量的生产环境的检验。携程机票旧首页的 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  最终还是出现了两个颇为棘手的问题。

4.1 集成协程的 APK 在部分国产 Android 5.x 手机上报错:INSTALL_FAILED_DEXOPT

问题描述:Android app 工程在配置了大部分版本号为 1.3.x 的 kotlinx.coroutines 库后,在部分国产的 Android  5.x 手机上安装会报错:INSTALL_FAILED_DEXOPT,导致无法安装。

在携程的编译工具链条件下,只有 1.3.0 版本的 kotlinx.coroutines 库可用,而其余 1.3.x 高版本在集成依赖后,会在 vivo  X5Pro D(Android 5.0)这款机型上稳定复现这个问题。当然,能稳定复现这一问题的手机品牌和型号不止这一个。

Kotlin 中文社区的论坛中也对此有所讨论(参考链接 3)。这个帖子的博主也在 kotlinx.coroutines 库的官方 Github 仓库的  issues 中向官方提问,但 JetBrains 官方回复说,这是 Google 工具链的问题(参考链接 4)。之后这个问题又提交给了 Google 方面,但  Google 方面表示,已经了解此问题,但由于涉及到的系统版本 Android 5.x 过于老旧,因此不予修复(参考链接 5)。

两家官方的态度都已至此,我们只能抱希望由自己解决该问题。我们能尝试的方案包括:升级 Android SDK Build-Tools 版本、升级  Gradle 版本、升级至 Kotlin 1.4,并将 kotlinx.coroutines 升级至 1.3.9、使用 JDK 8 编译  kotlinx.coroutines 的 Jar 包(官方使用的是 JDK 6)。以上尝试全部无效。最终的方案是,只能暂时使用 1.3.0 版本的  kotlinx.coroutines 库,由于 1.3.1~1.3.8 版本中包含了大量对 Flow 的完善以及 Bug  修复,因此为了稳定性考虑,业务代码中只能暂时不使用Flow。

4.2 主线程调度器 Dispatchers.Main 获取失败导致 Crash

问题描述:协程主线程调度器 Dispatchers.Main 在调用时会有小概率情况发生 crash,与机型、系统版本无关。

这个问题经由线上 crash 上报被我们发现,共造成了 2000 余次的用户 crash。

该问题是 Dispatcher.Main 的实现上有缺陷导致的。在 kotlinx.coroutines 的官方 Github issues  页中已经有人提到了这个问题(参考链接 6)。官方在 1.3.3 版本中使用 Class.forName 的方式替换了原先的 ServiceLoader  实现,从而修复了该问题(参考链接 7),因此如果要避免该问题的出现最正确的解决方式是升级 kotlinx.coroutines 库的版本。

但是狗血的问题发生了,由于 4.1 小节描述的问题,除 1.3.0 版本以外,其他版本的 kotlinx.coroutines 库均会发生 5.x  手机无法集成的问题。这两个问题的同时出现近乎导致了我们的解决方案的“死锁”,进退两难。

在发现线上问题的最初,我们自定义了主线程调度器,从而代替官方的 Dispatchers.Main,并将业务代码中的所有 Dispatcher.Main  替换为自定义的调度器,但这并没有完全解决问题。由于 ktx 版本的 Jetpack 架构组件也依赖了 1.3.0 版本的 kotlinx.coroutines  库,所以即使我们不使用 Dispatchers.Main,ViewModel 和 LiveData  的内部也会使用。无奈之下我们只得试图复制使用到Dispatchers.Main 的 ViewModel 与 LiveData 的代码,并将其中的  Dispatchers.Main 替换为自定义的主线程调度器。

但以上的方案均是临时的,在不能升级 kotlinx.coroutines 库的情况下,最终我们决定 fork kotlinx.coroutines  的代码。并将官方在 1.3.3 修复该问题的 commit 通过类似 cherry-pick 的方式 merge 到 1.3.0  版本的代码上,然后更改版本号并重新编译 Jar 包,并将其放到公司内部源上以供使用。

从长远来看,随着 5.x 手机的数量越来越少,最终携程 app 的系统支持最低版本会提升到 Android 6.0,只有等到那时升级  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 端技术改进方向。

感谢各位的阅读,以上就是“携程机票Android Jetpack与Kotlin Coroutines的方法教程”的内容了,经过本文的学习后,相信大家对携程机票Android Jetpack与Kotlin Coroutines的方法教程这一问题有了更深刻的体会,具体使用情况还需要大家实践验证。这里是编程网,小编将为大家推送更多相关知识点的文章,欢迎关注!

阅读原文内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-前端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯