攜程機(jī)票Android Jetpack與Kotlin Coroutines實(shí)踐
一、前言
1.1 技術(shù)背景與選型
自 2017年 Google IO 大會(huì)以來(lái),經(jīng)過(guò)三年的發(fā)展,Kotlin 已成為 Android 平臺(tái)無(wú)爭(zhēng)議的首選開(kāi)發(fā)語(yǔ)言。但是相比語(yǔ)言本身,Kotlin 1.2 版本后進(jìn)入 stable 狀態(tài)的協(xié)程(coroutines)的行業(yè)采用率仍然較低。
協(xié)程的優(yōu)勢(shì)主要有:
- 更簡(jiǎn)單的異步并發(fā)實(shí)現(xiàn)方式(近似于同步寫(xiě)法)
- 更便捷的任務(wù)管理
- 更便捷的生產(chǎn)者-消費(fèi)者模式實(shí)現(xiàn)
- 更高效的 cold stream 實(shí)現(xiàn)(即 Flow,根據(jù)官方數(shù)據(jù),F(xiàn)low 在部分 benchmarks 場(chǎng)景下效率是 RxJava 的兩倍,詳見(jiàn)參考鏈接 1)。
Google Android 團(tuán)隊(duì)同時(shí)也在大力推廣 Jetpack 組件庫(kù),其中 AAC 架構(gòu)組件帶來(lái)了全新的應(yīng)用架構(gòu)實(shí)現(xiàn)方式,可以更便捷的實(shí)現(xiàn) MVVM 這一非常適用于復(fù)雜業(yè)務(wù)場(chǎng)景的設(shè)計(jì)模式。
1.2 業(yè)務(wù)背景
今年接到一個(gè)大需求,產(chǎn)品方向上希望嘗試一種交通類業(yè)務(wù)融合的平臺(tái)化搜索首頁(yè)新體驗(yàn)。于是各業(yè)務(wù)研發(fā)團(tuán)隊(duì)經(jīng)過(guò)幾輪技術(shù)評(píng)估,決定聯(lián)合啟動(dòng)開(kāi)發(fā)這個(gè)新項(xiàng)目。借此機(jī)會(huì),機(jī)票 App 團(tuán)隊(duì)決定基于 Android Jetpack AAC 組件庫(kù)和 Kotlin Coroutines 技術(shù)方案進(jìn)行重構(gòu)實(shí)現(xiàn)。
機(jī)票首頁(yè)的業(yè)務(wù)邏輯可以歸納抽象為以下兩種場(chǎng)景:
- 多個(gè)不同 View,依賴同一個(gè)數(shù)據(jù)源的變化。
- 多個(gè)不同 View,當(dāng)用戶操作時(shí),都會(huì)觸發(fā)同一數(shù)據(jù)源的變更。
針對(duì)這兩個(gè)場(chǎng)景,基于 ViewModel、LiveData 實(shí)現(xiàn)的 MVVM 模式非常契合,可以做到業(yè)務(wù)邏輯清晰且代碼耦合度低。ViewModel 表示一個(gè)業(yè)務(wù)模塊相關(guān)數(shù)據(jù)狀態(tài)的總集,同時(shí)向 View 暴露諸多數(shù)據(jù)狀態(tài)需要響應(yīng) View 的操作時(shí)調(diào)用的接口。而從屬于 ViewModel 下的 LiveData 則表示各個(gè)數(shù)據(jù)狀態(tài)本身,并提供給 View 訂閱。
在代碼實(shí)現(xiàn)中,我們?cè)诙鄠€(gè) View 中可以使用相同的 ViewModelStoreOwner(一般是 Fragment 或 Activity)獲取到同一個(gè) ViewModel 對(duì)象,只要多個(gè) View 訂閱同一個(gè) ViewModel 中相同的 LiveData,并在數(shù)據(jù)狀態(tài)需要響應(yīng) UI操作而更新的時(shí)候調(diào)用 ViewModel 中的同一個(gè)函數(shù),即可清晰簡(jiǎn)潔的應(yīng)對(duì)這兩種場(chǎng)景。
同時(shí)復(fù)盤(pán)當(dāng)前機(jī)票首頁(yè)的代碼歷史債:
- 代碼冗長(zhǎng),沒(méi)有合理的封裝、拆分以及架構(gòu)模式,單文件代碼行數(shù)高。
- 復(fù)雜的異步操作導(dǎo)致回調(diào)代碼層層嵌套。
- 不恰當(dāng)?shù)木€程池配置。
- 重復(fù)多余的 null 檢查與可能暗藏的 null 安全問(wèn)題。
- 過(guò)多的 UI 層級(jí)嵌套,代碼冗雜且性能不高。
- 仍在使用一些 Google 官方淘汰的舊技術(shù),沒(méi)有及時(shí)跟進(jìn)新技術(shù)。
通過(guò)合理的封裝、拆分以及使用 ViewModel 與 LiveData 可以方便的解決問(wèn)題 1;
Kotlin 自身的空安全特性解決了問(wèn)題 4;
問(wèn)題 5 與 6 主要通過(guò)合理的重構(gòu)以及使用 ConstraintLayout 等新技術(shù)來(lái)解決,但不在本文的討論范圍。
那么問(wèn)題 2 與 3 的解決,就需要 Kotlin 協(xié)程出場(chǎng)了。
二. 熱身準(zhǔn)備
2.1 拋磚引玉
在具體講解實(shí)現(xiàn)之前,先通過(guò)一個(gè)小例子拋磚引玉,來(lái)說(shuō)明一個(gè)小問(wèn)題。
如果我們?cè)谝粋€(gè) Fragment 中或 Activity 中要獲取一個(gè) ViewModel,然后訂閱它內(nèi)部的 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 表達(dá)式與操作符重載,這段代碼已經(jīng)比對(duì)應(yīng)的 Java 代碼簡(jiǎn)潔多了,但是這段代碼仍然不夠 Kotlin style,我們稍微封裝一下,定義兩個(gè)新函數(shù):
- // 頂層函數(shù)版本
- inline fun <reified T : ViewModel> getViewModel(owner: ViewModelStoreOwner, configLiveData: T.() -> Unit = {}): T =
- ViewModelProvider(owner)[T::class.java].apply { configLiveData() }
- // 擴(kuò)展函數(shù)版本
- inline fun <reified T : ViewModel> ViewModelStoreOwner.getSelfViewModel(configLiveData: T.() -> Unit = {}): T =
- getViewModel(this, configLiveData)
為了不同的使用場(chǎng)景并且方便不同人的使用習(xí)慣,這里同時(shí)寫(xiě)了頂層函數(shù)版本與擴(kuò)展函數(shù)版本,但是功能一模一樣(擴(kuò)展函數(shù)版本直接調(diào)用了頂層函數(shù)版本)?,F(xiàn)在如果我們要在 Fragment 中獲取 ViewModel,看看會(huì)變成什么樣(這里使用擴(kuò)展函數(shù)版本):
- private lateinit var myViewModel: MyViewModel
- ......
- myViewModel = getSelfViewModel {
- liveData1.observe(this@MyFragment, Observer {
- doSomething1(it)
- })
- liveData2.observe(this@MyFragment, Observer {
- doSomething2(it)
- })
- ......
- }
這樣封裝的好處絕不僅僅在于讓代碼看起來(lái)“DSL”化。首先,內(nèi)聯(lián)的泛型實(shí)化函數(shù)讓我們避免去編寫(xiě) xxx::class.java 這樣的樣板式代碼,而是只需要傳一個(gè)泛型參數(shù)(在這個(gè)例子中由于 lateinit 屬性已經(jīng)聲明了類型,所以根據(jù)類型推導(dǎo),我們連泛型參數(shù)都不必顯式寫(xiě)出),這樣看起來(lái)會(huì)優(yōu)雅的多。其次,我們配合使用了帶接受者的 lambda 表達(dá)式與作用域函數(shù) apply 使我們?cè)讷@取 ViewModel 內(nèi)的 LiveData 對(duì)象的時(shí)候不再需要重復(fù)寫(xiě)多次 myViewModel. 這樣的樣板代碼。
最后從代碼結(jié)構(gòu)來(lái)看,我們通常在獲取到 ViewModel 對(duì)象后會(huì)直接訂閱所有需要訂閱的 LiveData,我們把所有的訂閱邏輯都寫(xiě)到了 getSelfViewModel 函數(shù)的 lambda 表達(dá)式參數(shù)的作用域內(nèi),這樣我們對(duì)訂閱的代碼可以更加一目了然。
這里只是個(gè)拋磚引玉,在我們決定要開(kāi)始使用 Kotlin 來(lái)替換 Java 的時(shí)候,最好能先打牢 Kotlin 基礎(chǔ),這樣我們才能發(fā)揮這門(mén)語(yǔ)言的最大潛力。從而避免使用 Kotlin 寫(xiě)出 Java 風(fēng)格的代碼。
2.2 代碼角色劃分
如果把當(dāng)前的代碼按職責(zé)進(jìn)行劃分,大概有以下幾種:數(shù)據(jù)類(data class,類似于 Java Bean)、工具函數(shù)(例如格式化一個(gè)日期,將其轉(zhuǎn)換為可展示的字符串)、數(shù)據(jù)源(例如從網(wǎng)絡(luò)拉取數(shù)據(jù)或從本地?cái)?shù)據(jù)庫(kù)讀取數(shù)據(jù))、核心業(yè)務(wù)邏輯(在拿到原始數(shù)據(jù)后我們可能要對(duì)它根據(jù)業(yè)務(wù)需求進(jìn)行處理)、UI代碼(無(wú)須多言)、狀態(tài)信息(通常是一些用于表示狀態(tài)的可變對(duì)象等等或者數(shù)據(jù)的當(dāng)前狀態(tài))。
我們要將以上這幾種代碼劃分為三個(gè)角色,或者劃歸到三個(gè)范圍內(nèi),即:View、ViewModel、Model,也就是 MVVM 模式中三大角色。UI 代碼劃歸到 View;數(shù)據(jù)類、數(shù)據(jù)源劃規(guī)到 Model;而數(shù)據(jù)狀態(tài)或其他狀態(tài)信息劃歸到 ViewModel。而工具函數(shù)視情況而定,可以作為獨(dú)立組件也可以放到 Model 中。
三、正式實(shí)現(xiàn)
3.1 協(xié)程 Channel 與 LiveData 組合實(shí)現(xiàn)的基本模式
在 MVVM 模式中,VM 即 ViewModel 表示數(shù)據(jù)狀態(tài)。為了讓業(yè)務(wù)邏輯和代碼結(jié)構(gòu)更加合理。我們通常將一些彼此依賴對(duì)方狀態(tài)的數(shù)據(jù)(通常其表示的業(yè)務(wù)也是強(qiáng)相關(guān)的)拆分到同一個(gè) ViewModel 中。而 LiveData (通常位于 ViewModel 內(nèi)部)表示的是某些具體的數(shù)據(jù)狀態(tài)。例如在攜程機(jī)票首頁(yè)的業(yè)務(wù)中,出發(fā)城市的相關(guān)數(shù)據(jù)就可以用一個(gè) LiveData 來(lái)表示,到達(dá)城市則用另一個(gè) LiveData 來(lái)表示,而這兩個(gè) LiveData 都位于同一個(gè) ViewModel 中。
如果不使用 livedata-ktx 包,我們創(chuàng)建 LiveData 對(duì)象的方式主要是通過(guò)調(diào)用 MutableLiveData 類的構(gòu)造方法,我們通過(guò)直接使用 MutableLiveData 對(duì)象來(lái)進(jìn)行訂閱、數(shù)據(jù)更新等操作。MutableLiveData 與普通對(duì)象一樣,我們可以在任意一種異步框架下使用它。
但為了與 Kotlin 協(xié)程有更完美的配合,livedata-ktx 包提供給我們了另一種方式來(lái)創(chuàng)建 LiveData,即 liveData {} 函數(shù),該函數(shù)的函數(shù)簽名是這樣的:
- fun <T> liveData(
- context: CoroutineContext = EmptyCoroutineContext,
- timeoutInMs: Long = DEFAULT_TIMEOUT,
- @BuilderInference block: suspend LiveDataScope<T>.() -> Unit
- ): LiveData<T>
先看第三個(gè)參數(shù) block,它是一個(gè) suspend lambda 表達(dá)式,也就是說(shuō),它運(yùn)行在協(xié)程中。第一個(gè)參數(shù) context 通常用于指定這個(gè)協(xié)程執(zhí)行的調(diào)度器,而 timeoutInMs 用于指定超時(shí)時(shí)間,當(dāng)這個(gè) LiveData 沒(méi)有活躍的觀察者的時(shí)候,時(shí)間如果超過(guò)超時(shí)時(shí)間,該協(xié)程就會(huì)被取消。由于第一和第二個(gè)參數(shù)都有默認(rèn)值,所以大多數(shù)情況下,我們只需要傳第三個(gè)參數(shù)。
liveData {} 函數(shù)在官方文檔中并沒(méi)有給出用例,所以并沒(méi)有一個(gè)所謂標(biāo)準(zhǔn)的“官方”用法。我們觀察了一下發(fā)現(xiàn),block 塊是一個(gè)帶接收者的 lambda,而接收者類型是 LiveDataScope,且 LiveDataScope 有一個(gè)成員函數(shù) emit,這就和 RxJava 的 create 操作符非常相似,更和 Flow 中的 flow {} 函數(shù)如出一轍。所以,如果要讓我們的 LiveData 作為一個(gè)可持續(xù)發(fā)射數(shù)據(jù)的數(shù)據(jù)源,liveData {} 函數(shù)啟動(dòng)的這個(gè)協(xié)程需要不停的從外部取數(shù)據(jù),這種場(chǎng)景正是協(xié)程中 Channel (參考鏈接2)的用武之地,我們用上述的技術(shù)編寫(xiě)一個(gè)簡(jiǎn)單的 ViewModel:
- class CityViewModel : ViewModel() {
- private val departCityTextChannel = Channel<String>(1)
- val departCityTextLiveData = liveData {
- for (result in departCityTextChannel)
- emit(result)
- }
- // 外部的 UI 通過(guò)調(diào)用該方法來(lái)更新數(shù)據(jù)
- fun updateCityUI() = viewModelScope.launch(Dispatchers.IO) {
- val result = fetchData() // 拉取數(shù)據(jù)
- departCityTextChannel.send(result)
- }
- }
首先我們聲明并初始化了一個(gè) Channel ——departCityTextChannel。然后我們使用 liveData {} 函數(shù)創(chuàng)建了LiveData 對(duì)象,在 liveData {} 函數(shù)啟動(dòng)的協(xié)程內(nèi),我們通過(guò)無(wú)限循環(huán)不停的從 departCityTextChannel 中取數(shù)據(jù),如果取不到,這個(gè)協(xié)程就會(huì)被掛起,直到有數(shù)據(jù)到來(lái)(這比用 Java 線程加 BlockQueue 實(shí)現(xiàn)的類似的生產(chǎn)者消費(fèi)者模式要高效很多)。for 循環(huán)對(duì) Channel 有一等的支持。
如果 UI 要更新數(shù)據(jù),會(huì)調(diào)用 updateCityUI() 函數(shù),該函數(shù)內(nèi)的所有操作(通常都是耗時(shí)的)在其啟動(dòng)的協(xié)程內(nèi)異步進(jìn)行。在這里我們通過(guò) viewmodel-ktx 包提供的 viewModelScope 來(lái)啟動(dòng)協(xié)程,這個(gè)協(xié)程作用域的實(shí)現(xiàn)與 ViewModel 的實(shí)現(xiàn)相結(jié)合,可以通過(guò) ViewModel 感知到外部 UI 組件的生命周期,從而幫助我們自動(dòng)取消任務(wù)。
最后注意一點(diǎn),我們?cè)诔跏蓟?departCityTextChannel 時(shí)給工廠函數(shù) Channel(1)傳入的緩沖區(qū) size 的大小是 1。這主要是為了我們可以避免生產(chǎn)者協(xié)程在等待消費(fèi)者從 Channel中取走數(shù)據(jù)時(shí)發(fā)生事實(shí)上的掛起,從而在一定程度上影響效率。當(dāng)然如果有生產(chǎn)者生產(chǎn)的速度過(guò)快,而消費(fèi)者消費(fèi)的速度過(guò)慢而明顯跟不上的時(shí)候,我們可以適當(dāng)調(diào)大 size 的值。
我們的每個(gè) LiveData 幾乎都需要與其配合使用的 Channel,而且 liveData {} 函數(shù)做的事情也幾乎都是一樣的,即使用 for 循環(huán)從 Channel 拿到數(shù)據(jù)然后再使用 emit 函數(shù)發(fā)射出去。于是可以進(jìn)行如下的封裝:
- inline val <T> Channel<T>.coroutineLiveData: LiveData<T>
- get() = liveData {
- for (entry in this@coroutineLiveData)
- emit(entry)
- }
ViewModel 內(nèi)創(chuàng)建 departCityTextChannel 與 departCityTextLiveData 對(duì)象的代碼就變成了這樣:
- class CityViewModel : ViewModel() {
- private val departCityTextChannel = Channel<String>(1)
- val departCityTextLiveData = departCityTextChannel.coroutineLiveData
- ...... 省略其他代碼
我們封裝了一個(gè)名為 coroutineLiveData 的內(nèi)聯(lián)擴(kuò)展屬性,它的 getter 已經(jīng)將 LiveData 的創(chuàng)建邏輯封裝好了,不過(guò)請(qǐng)注意,每次調(diào)用這個(gè)屬性,實(shí)際上都返回了一個(gè)新的 LiveData 對(duì)象,所以正確的做法是在調(diào)用 coroutineLiveData 屬性后,把它的結(jié)果保存下來(lái),以此達(dá)到重復(fù)使用的目的,千萬(wàn)不要每次都使用 departCityTextChannel.coroutineLiveData 這樣的方式來(lái)期望獲取到同一個(gè) LiveData 對(duì)象。當(dāng)然,如果你覺(jué)得這樣也許會(huì)有誤導(dǎo),也可以把 coroutineLiveData 屬性改成擴(kuò)展函數(shù)。
3.2 UI 代碼訂閱 LiveData
雖然整個(gè)機(jī)票首頁(yè)的 UI 都位于一個(gè) Fragment 內(nèi),但業(yè)務(wù)之間不相關(guān)的 UI 我們可以分別單獨(dú)封裝成不同的 View。假如說(shuō)跟城市有關(guān)的 UI,我們可能就會(huì)像下面這樣做:
- 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 對(duì)象,所以我們不得已必須要定義一個(gè) initObserve 函數(shù),通過(guò)將其暴露給 Fragment 調(diào)用來(lái)將 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 實(shí)際上就是 Fragment,不過(guò)這里為了解耦,沒(méi)有直接使用 Fragment,而是通過(guò)泛型,外加兩個(gè)上界約束來(lái)確定 owner 的職責(zé),一旦某天這個(gè) View 要移植到 Activity 中,Activity 也可以將自身直接通過(guò) initObserver 函數(shù)傳入。在 Fragment 中,當(dāng)我們通過(guò) findViewById 拿到 View 對(duì)象之后就應(yīng)該立即調(diào)用 initObserver 初始化訂閱,代碼就不贅述了。
我們用一張圖來(lái)總結(jié) 3.1 小節(jié)與 3.2 小節(jié):
我們剛才編寫(xiě)的示例代碼之間的關(guān)系已經(jīng)一目了然,MVVM 模式中的 V 與 VM 都已經(jīng)有了,雖然 M 在圖中沒(méi)有體現(xiàn),但獲取數(shù)據(jù)的數(shù)據(jù)源,也就是 CityViewModel.updateCityUI() 函數(shù)中調(diào)用的 fetchData() 函數(shù)就屬于 Model,它通常封裝了數(shù)據(jù)庫(kù)操作或網(wǎng)絡(luò)服務(wù)拉取。
3.3 復(fù)雜場(chǎng)景
在開(kāi)頭的 1.2 小節(jié)中提到,我們有一些復(fù)雜的業(yè)務(wù)場(chǎng)景,比如多個(gè)獨(dú)立的 View 依賴同一個(gè)數(shù)據(jù)源,或者多個(gè) View 都可能觸發(fā)同一個(gè)數(shù)據(jù)源的更新。那具體的實(shí)際情況舉例就是,比如說(shuō)現(xiàn)在有兩個(gè)展示城市的 View,用戶可以在其中任意一個(gè)更改城市,兩個(gè) View 中展示的城市信息都需要更新,這在實(shí)際情況中是非常典型的案例,將 1.2 小節(jié)中的場(chǎng)景 1 與場(chǎng)景 2 結(jié)合了起來(lái)。
基于以上的代碼示例,也就是說(shuō)除了上面的 CityView 我們還需要一個(gè)與它共享同一個(gè)數(shù)據(jù)源的 View,假如說(shuō)存在一個(gè) 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()
- }
其他代碼大同小異,無(wú)非是初始化 View、initObserver 函數(shù)、以及更新 UI 的函數(shù)。為了確保 CityView2 與 CityView 內(nèi)的 cityViewModel 是同一個(gè),只需確保 initObserver 函數(shù)傳進(jìn)來(lái)的 owner 是同一個(gè)對(duì)象就可以了。
這里我也畫(huà)了一張圖來(lái)描述這種關(guān)系:
四、新技術(shù)在生產(chǎn)環(huán)境遇到的挑戰(zhàn)
任何一種被業(yè)界所公認(rèn)且信賴的開(kāi)源技術(shù)通常都經(jīng)過(guò)了數(shù)百萬(wàn)乃至數(shù)千萬(wàn)級(jí)用戶量的生產(chǎn)環(huán)境的檢驗(yàn)。攜程機(jī)票舊首頁(yè)的 PV 量級(jí)在千萬(wàn)級(jí)別,考慮到 iOS 與 Android 雙平臺(tái)以及 AB 實(shí)驗(yàn),新的 Android 機(jī)票平臺(tái)化首頁(yè)的 PV 量級(jí)也有百萬(wàn)級(jí)別。能否在百萬(wàn)級(jí)別的用戶量下有優(yōu)異的穩(wěn)定性表現(xiàn),是對(duì)本文提到的這幾項(xiàng)技術(shù)的考驗(yàn)。
Kotlin 語(yǔ)言及其標(biāo)準(zhǔn)庫(kù)本身已經(jīng)迭代到 1.3.x 版本(截止文章發(fā)稿前,最新版本為 1.4.10,而攜程使用的則是 1.3.71),再加上好幾年的國(guó)內(nèi)外生產(chǎn)環(huán)境的檢驗(yàn),已經(jīng)相對(duì)穩(wěn)定。而本次使用的 ViewModel、LiveData 等 Jetpack 架構(gòu)組件的版本為2.2.0,經(jīng)過(guò)線上數(shù)月的觀測(cè)也非常穩(wěn)定。但 Kotlin 協(xié)程框架 kotlinx.coroutines 最終還是出現(xiàn)了兩個(gè)頗為棘手的問(wèn)題。
4.1 集成協(xié)程的 APK 在部分國(guó)產(chǎn) Android 5.x 手機(jī)上報(bào)錯(cuò):INSTALL_FAILED_DEXOPT
問(wèn)題描述:Android app 工程在配置了大部分版本號(hào)為 1.3.x 的 kotlinx.coroutines 庫(kù)后,在部分國(guó)產(chǎn)的 Android 5.x 手機(jī)上安裝會(huì)報(bào)錯(cuò):INSTALL_FAILED_DEXOPT,導(dǎo)致無(wú)法安裝。
在攜程的編譯工具鏈條件下,只有 1.3.0 版本的 kotlinx.coroutines 庫(kù)可用,而其余 1.3.x 高版本在集成依賴后,會(huì)在 vivo X5Pro D(Android 5.0)這款機(jī)型上穩(wěn)定復(fù)現(xiàn)這個(gè)問(wèn)題。當(dāng)然,能穩(wěn)定復(fù)現(xiàn)這一問(wèn)題的手機(jī)品牌和型號(hào)不止這一個(gè)。
Kotlin 中文社區(qū)的論壇中也對(duì)此有所討論(參考鏈接 3)。這個(gè)帖子的博主也在 kotlinx.coroutines 庫(kù)的官方 Github 倉(cāng)庫(kù)的 issues 中向官方提問(wèn),但 JetBrains 官方回復(fù)說(shuō),這是 Google 工具鏈的問(wèn)題(參考鏈接 4)。之后這個(gè)問(wèn)題又提交給了 Google 方面,但 Google 方面表示,已經(jīng)了解此問(wèn)題,但由于涉及到的系統(tǒng)版本 Android 5.x 過(guò)于老舊,因此不予修復(fù)(參考鏈接 5)。
兩家官方的態(tài)度都已至此,我們只能抱希望由自己解決該問(wèn)題。我們能嘗試的方案包括:升級(jí) Android SDK Build-Tools 版本、升級(jí) Gradle 版本、升級(jí)至 Kotlin 1.4,并將 kotlinx.coroutines 升級(jí)至 1.3.9、使用 JDK 8 編譯 kotlinx.coroutines 的 Jar 包(官方使用的是 JDK 6)。以上嘗試全部無(wú)效。最終的方案是,只能暫時(shí)使用 1.3.0 版本的 kotlinx.coroutines 庫(kù),由于 1.3.1~1.3.8 版本中包含了大量對(duì) Flow 的完善以及 Bug 修復(fù),因此為了穩(wěn)定性考慮,業(yè)務(wù)代碼中只能暫時(shí)不使用Flow。
4.2 主線程調(diào)度器 Dispatchers.Main 獲取失敗導(dǎo)致 Crash
問(wèn)題描述:協(xié)程主線程調(diào)度器 Dispatchers.Main 在調(diào)用時(shí)會(huì)有小概率情況發(fā)生 crash,與機(jī)型、系統(tǒng)版本無(wú)關(guān)。
這個(gè)問(wèn)題經(jīng)由線上 crash 上報(bào)被我們發(fā)現(xiàn),共造成了 2000 余次的用戶 crash。
該問(wèn)題是 Dispatcher.Main 的實(shí)現(xiàn)上有缺陷導(dǎo)致的。在 kotlinx.coroutines 的官方 Github issues 頁(yè)中已經(jīng)有人提到了這個(gè)問(wèn)題(參考鏈接 6)。官方在 1.3.3 版本中使用 Class.forName 的方式替換了原先的 ServiceLoader 實(shí)現(xiàn),從而修復(fù)了該問(wèn)題(參考鏈接 7),因此如果要避免該問(wèn)題的出現(xiàn)最正確的解決方式是升級(jí) kotlinx.coroutines 庫(kù)的版本。
但是狗血的問(wèn)題發(fā)生了,由于 4.1 小節(jié)描述的問(wèn)題,除 1.3.0 版本以外,其他版本的 kotlinx.coroutines 庫(kù)均會(huì)發(fā)生 5.x 手機(jī)無(wú)法集成的問(wèn)題。這兩個(gè)問(wèn)題的同時(shí)出現(xiàn)近乎導(dǎo)致了我們的解決方案的“死鎖”,進(jìn)退兩難。
在發(fā)現(xiàn)線上問(wèn)題的最初,我們自定義了主線程調(diào)度器,從而代替官方的 Dispatchers.Main,并將業(yè)務(wù)代碼中的所有 Dispatcher.Main 替換為自定義的調(diào)度器,但這并沒(méi)有完全解決問(wèn)題。由于 ktx 版本的 Jetpack 架構(gòu)組件也依賴了 1.3.0 版本的 kotlinx.coroutines 庫(kù),所以即使我們不使用 Dispatchers.Main,ViewModel 和 LiveData 的內(nèi)部也會(huì)使用。無(wú)奈之下我們只得試圖復(fù)制使用到Dispatchers.Main 的 ViewModel 與 LiveData 的代碼,并將其中的 Dispatchers.Main 替換為自定義的主線程調(diào)度器。
但以上的方案均是臨時(shí)的,在不能升級(jí) kotlinx.coroutines 庫(kù)的情況下,最終我們決定 fork kotlinx.coroutines 的代碼。并將官方在 1.3.3 修復(fù)該問(wèn)題的 commit 通過(guò)類似 cherry-pick 的方式 merge 到 1.3.0 版本的代碼上,然后更改版本號(hào)并重新編譯 Jar 包,并將其放到公司內(nèi)部源上以供使用。
從長(zhǎng)遠(yuǎn)來(lái)看,隨著 5.x 手機(jī)的數(shù)量越來(lái)越少,最終攜程 app 的系統(tǒng)支持最低版本會(huì)提升到 Android 6.0,只有等到那時(shí)升級(jí) kotlinx.coroutines 版本才算最終相對(duì)完美的解決該問(wèn)題。
五、結(jié)語(yǔ)
Kotlin 語(yǔ)言本身的優(yōu)勢(shì)以及所解決的問(wèn)題很多都是 Java 開(kāi)發(fā)者所面臨的痛點(diǎn)。經(jīng)過(guò)了數(shù)年的技術(shù)積累沉淀,1.3.x 版本(1.3.x 的最后一個(gè)版本是 1.3.72)的 Kotlin 已經(jīng)相對(duì)穩(wěn)定和成熟。
Kotlin 協(xié)程很強(qiáng)大,是一個(gè)雄心勃勃的項(xiàng)目,它為許多 Java 開(kāi)發(fā)者帶來(lái)了新的概念以及老問(wèn)題的新解決方案。雖然它已經(jīng)進(jìn)入 release 階段達(dá)一年半之久,但從我們的實(shí)踐結(jié)果來(lái)看,其穩(wěn)定性仍然還有提升的空間。隨著 Kotlin 1.4 以及 kotlinx.coroutines 1.3.9 的推出,無(wú)論是 Kotlin 語(yǔ)言本身還是協(xié)程都已經(jīng)進(jìn)入了下一個(gè)階段,相信在未來(lái)不久的時(shí)間里,它們的性能、穩(wěn)定性、以及功能都會(huì)真正再上一個(gè)臺(tái)階。
Google 官方近些年與 Android 開(kāi)發(fā)社區(qū)的關(guān)系日益密切,他們采納了許多 Android 開(kāi)發(fā)者提出的有效建議,并將其落地,Jetpack 就是成果之一。作為真正的官方出品,它的穩(wěn)定性從實(shí)際表現(xiàn)來(lái)看的確經(jīng)受住了考驗(yàn)。
Jetpack 不僅包含架構(gòu)組件,還包含了一系列實(shí)用的庫(kù),比如聲明式 UI 框架(Compose)、SQLite 數(shù)據(jù)庫(kù)操作框架(Room)、依賴注入(Hilt)、后臺(tái)任務(wù)管理(WorkManager)等等,在未來(lái)的開(kāi)發(fā)計(jì)劃中逐漸嘗試向更多的 Jetpack 相關(guān)技術(shù)遷移也會(huì)是一個(gè)重要的 Android 端技術(shù)改進(jìn)方向。