您好,登錄后才能下訂單哦!
這篇文章主要講解了“攜程機票Android Jetpack與Kotlin Coroutines的方法教程”,文中的講解內容簡單清晰,易于學習與理解,下面請大家跟著小編的思路慢慢深入,一起來研究和學習“攜程機票Android Jetpack與Kotlin Coroutines的方法教程”吧!
一、前言
1.1 技術背景與選型
自 2017年 Google IO 大會以來,經過三年的發展,Kotlin 已成為 Android 平臺無爭議的首選開發語言。但是相比語言本身,Kotlin 1.2 版本后進入 stable 狀態的協程(coroutines)的行業采用率仍然較低。
協程的優勢主要有:
更簡單的異步并發實現方式(近似于同步寫法)
更便捷的任務管理
更便捷的生產者-消費者模式實現
更高效的 cold stream 實現(即 Flow,根據官方數據,Flow 在部分 benchmarks 場景下效率是 RxJava 的兩倍,詳見參考鏈接 1)。
Google Android 團隊同時也在大力推廣 Jetpack 組件庫,其中 AAC 架構組件帶來了全新的應用架構實現方式,可以更便捷的實現 MVVM 這一非常適用于復雜業務場景的設計模式。
1.2 業務背景
今年接到一個大需求,產品方向上希望嘗試一種交通類業務融合的平臺化搜索首頁新體驗。于是各業務研發團隊經過幾輪技術評估,決定聯合啟動開發這個新項目。借此機會,機票 App 團隊決定基于 Android Jetpack AAC 組件庫和 Kotlin Coroutines 技術方案進行重構實現。
機票首頁的業務邏輯可以歸納抽象為以下兩種場景:
多個不同 View,依賴同一個數據源的變化。
多個不同 View,當用戶操作時,都會觸發同一數據源的變更。
針對這兩個場景,基于 ViewModel、LiveData 實現的 MVVM 模式非常契合,可以做到業務邏輯清晰且代碼耦合度低。ViewModel 表示一個業務模塊相關數據狀態的總集,同時向 View 暴露諸多數據狀態需要響應 View 的操作時調用的接口。而從屬于 ViewModel 下的 LiveData 則表示各個數據狀態本身,并提供給 View 訂閱。
在代碼實現中,我們在多個 View 中可以使用相同的 ViewModelStoreOwner(一般是 Fragment 或 Activity)獲取到同一個 ViewModel 對象,只要多個 View 訂閱同一個 ViewModel 中相同的 LiveData,并在數據狀態需要響應 UI操作而更新的時候調用 ViewModel 中的同一個函數,即可清晰簡潔的應對這兩種場景。
同時復盤當前機票首頁的代碼歷史債:
代碼冗長,沒有合理的封裝、拆分以及架構模式,單文件代碼行數高。
復雜的異步操作導致回調代碼層層嵌套。
不恰當的線程池配置。
重復多余的 null 檢查與可能暗藏的 null 安全問題。
過多的 UI 層級嵌套,代碼冗雜且性能不高。
仍在使用一些 Google 官方淘汰的舊技術,沒有及時跟進新技術。
通過合理的封裝、拆分以及使用 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 ——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 小節:
我們剛才編寫的示例代碼之間的關系已經一目了然,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 最終還是出現了兩個頗為棘手的問題。
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的方法教程這一問題有了更深刻的體會,具體使用情況還需要大家實踐驗證。這里是億速云,小編將為大家推送更多相關知識點的文章,歡迎關注!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。