code小生 一個專注大前端領域的技術平臺 公衆號回覆 Android 加入安卓技術羣

作者:HiDhl

鏈接:https://juejin.im/post/5f153adff265da22fb287e6e

聲明:本文已獲 HiDhl 授權發表,轉發等請聯繫原作者授權

前言

在之前分享過一篇 Jetpack 綜合實戰應用 神奇寶貝(PokemonGo)  眼前一亮的 Jetpack + MVVM 極簡實戰 ,這個項目主要包了以下功能:

  1. 自定義 RemoteMediator 實現 network + db 的混合使用 ( RemoteMediator 是 Paging3 當中重要成員 )

  2. 使用 Data Mapper 分離數據源 和 UI

  3. Kotlin Flow 結合  Retrofit2 + Room 的混合使用

  4. Kotlin Flow 與 LiveData 的使用

  5. 使用 Coil 加載圖片

  6. 使用 ViewModel、LiveData、DataBinding 協同工作

  7. 使用 Motionlayout 做動畫

  8. App Startup 與 Hilt 的使用

我近期也在開發另外一個 Jetpack + MVVM 實戰應用,和神奇寶貝(PokemonGo) 有很多不同之處,神奇寶貝(PokemonGo) 主要偏向於 Paging3 的分頁處理,以及 Flow 在 MVVM 中的實戰。

而今天這篇文章主要來分析一下 神奇寶貝(PokemonGo) 項目,主要包含以下幾個方面的內容:

  • 在 Repositories 或者 DataSource 中直接使用 LiveData 這種做法對嗎?

  • Kotlin Flow 是什麼?

  • Kotlin Flow 解決了什麼問題?

  • Kotlin Flow 如何在 MVVM 中使用?

  • Kotlin Flow 如何與 Retrofit2 + Room 混合使用?

Google 推薦在 MVVM 中使用 Kotlin Flow

我相信如今幾乎所有的 Android 開發者至少都聽過 MVVM 架構,在 Google Android 團隊宣佈了 Jetpack 的視圖模型之後,它已經成爲了現代 Android 開發模式最流行的架構之一,如下圖所示:

在官宣 Jetpack 的視圖模型之後,同時 Google 在 Jetpack Guide 文章中的示例,也在 Repositories 或者 DataSource 中使用 LiveData, 以至於在很多開源的 MVVM 項目中也是直接使用 LiveData,但是在 Repositories 或者 DataSource 中直接使用 LiveData 這種做法對嗎?這是我一直以來的一個疑問?

直到我打開 Android 架構組件 頁面,看了在頁面上增加了最新的文章,這幾篇文章大概的內容是說如何在 MVVM 中使用 Flow 以及如何與 LiveData 一起使用,當我看完並通過實踐之後大概明白了,LiveData 是一個生命週期感知組件,它並不屬於 Repositories 或者 DataSource 層,下文會有詳細的分析。

在 Google 發佈的 Jetpack 的最新成員 Paging3,在其內部的源碼實現也是使用的 Flow,關於 Paging3 的使用可以參考以下鏈接:

  • Jetpack 成員 Paging3 實踐以及源碼分析(一)

  • Jetpack 新成員 Paging3 網絡實踐及原理分析(二)

  • 自定義 RemoteMediator 實現 network + db 的混合使用

不僅僅是 Jetpack 成員支持 Flow,在 Google 提供的 Demo 裏面也都在使用 Flow,也有很多開源的 MVVM 項目也在逐漸切換到 Flow,爲什麼 Google 會推薦使用它呢,使用 Flow 能帶來那些好處呢,爲我們解決了什麼問題?

Kotlin Flow 是什麼?Kotlin Flow 解決了什麼問題?

Flow 庫是在 Kotlin Coroutines 1.3.2 發佈之後新增的庫,也叫做異步流,類似 RxJava 的 ObservableFlowable 等等,所以很多人都用 Flow 與 RxJava 做對比。

Flow 相比於 RxJava 簡單的太多了,你還記得那些 RxJava 傻傻分不清楚的操作符嗎 ObservableFlowableSingleCompletableMaybe 等等。

那麼 Flow 爲我們解決了什麼問題,我主要從以下幾個方面思考:

  • LiveData 是一個生命週期感知組件,最好在 View 和 ViewModel 層中使用它,如果在 Repositories 或者 DataSource 中使用會有幾個問題

    • 它不支持線程切換,其次不支持背壓,也就是在一段時間內 發送 數據的速度 > 接受 數據的速度,LiveData 無法正確的處理這些請求

    • 使用 LiveData 的最大問題是所有數據轉換都將在主線程上完成

  • RxJava 雖然支持線程切換和背壓,但是 RxJava 那麼多傻傻分不清楚的操作符,實際上在項目中常用的可能只有幾個例如 ObservableFlowableSingle 等等,如果我們不去了解背後的原理,造成內存泄露是很正常的事,大家可以從 StackOverflow 上查看一下,有很多因爲 RxJava 造成內存泄露的例子

  • RxJava 入門的門檻很高,學習過的朋友們,我相信能夠體會到從入門到放棄是什麼感覺

  • 解決回調地獄的問題

而相對於以上的不足,Flow 有以下優點:

  • Flow 支持線程切換、背壓

  • Flow 入門的門檻很低,沒有那麼多傻傻分不清楚的操作符

  • 簡單的數據轉換與操作符,如 map 等等

  • Flow 是對 Kotlin 協程的擴展,讓我們可以像運行同步代碼一樣運行異步代碼,使得代碼更加簡潔,提高了代碼的可讀性

  • 易於做單元測試

Kotlin Flow 如何在 MVVM 中使用

Jetpack 的視圖模型 MVVM 架構由 View + DataBinding + ViewModel + Model 組成,如下所示,我相信下面這張圖大家非常熟悉了,

接下來我們一起來探究一下 Kotlin Flow 在 MVVM 當中每層是如何實現的。

Kotlin Flow 在數據源中的使用

在 PokemonGo 項目中,進入詳情頁,會檢查本地是否有數據,如果沒有會去請求 pokeapi 詳情頁接口,獲得最新的數據,然後存儲在數據庫中。

Flow 是協程的擴展,如果要在 Room 和 Retrofit 中使用,Room 和 Retrofit 需要支持協程纔可以,在 Retrofit >= 2.6.0 和 Room >= 2.1 版本都支持協程,我們來看一下 Room 和 Retrofit 數據源的配置。

Room: PokemonGo/app/src/main/java/com/hi/dhl/pokemon/data/local/PokemonInfoDao.kt

@Query("SELECT * FROM PokemonInfoEntity where name = :name")
suspend fun getPokemon(name: String): PokemonInfoEntity?

或者直接返回 Flow<PokemonInfoEntity>

@Query("SELECT * FROM PokemonInfoEntity where name = :name")
fun getPokemon(name: String): Flow<PokemonInfoEntity>

Retrofit: PokemonGo/app/src/main/java/com/hi/dhl/pokemon/data/remote/PokemonService.kt

@GET("pokemon/{name}")
suspend fun fetchPokemonInfo(@Path("name") name: String): NetWorkPokemonInfo

如上所見在方法前增加了用 suspend 進行了修飾,只有被   suspend 修飾的方法,纔可以在協程中調用。

按照如上配置,在數據源的工作就完成了,相比於 RxJava 的 ObservableFlowableSingleCompletableMaybe 使用場景要簡單太多了,我們來看一下在 Repositories 中是如何使用的。

Kotlin Flow 在 Repositories 中的使用

如果我們想在 Flow 中使用 Retrofit 或者 Room 進行網絡請求或者查詢數據庫的操作,我們需要將使用 suspend 修飾符的操作放到 flow { ... } 中執行,最後使用 emit() 方法更新數據,將數據發送給 ViewModel,代碼如下所示: PokemonGo/app/src/main/java/com/hi/dhl/pokemon/data/repository/PokemonRepositoryImpl.kt

flow {
    val pokemonDao = db.pokemonInfoDao()
    // 查詢數據庫是否存在,如果不存在請求網絡
    var infoModel = pokemonDao.getPokemon(name)
    if (infoModel == null) {
        // 網絡請求
        val netWorkPokemonInfo = api.fetchPokemonInfo(name)
        // 將網路請求的數據,換轉成的數據庫的 model,之後插入數據庫
        infoModel = netWorkPokemonInfo.let {
            PokemonInfoEntity(
                name = it.name,
                height = it.height,
                weight = it.weight,
                experience = it.experience
            )
        }
        // 插入更新數據庫
        pokemonDao.insertPokemon(infoModel)
    }
    // 將數據源的 model 轉換成上層用到的 model,
    // ui 不能直接持有數據源,防止數據源的變化,影響上層的 ui
    val model = mapper2InfoModel.map(infoModel)
    // 更新數據,將數據發送給 ViewModel
    emit(model)
}.flowOn(Dispatchers.IO) // 通過 flowOn 切換到 IO 線程

將上面的代碼簡化如下所示:

flow {
    // 進行網絡或者數據庫操作
    emit(model)
}.flowOn(Dispatchers.IO) // 通過 flowOn 切換到 IO 線程

正如你所見,將耗時操作放到 flow { ... } 裏面,通過 flowOn(Dispatchers.IO) 切換到 IO 線程,最後通過 emit() 方法將數據發送給 ViewModel,接下來我們來看一下如何在 ViewModel 中接受 Flow 發送的數據。

Kotlin Flow 在 ViewModel 中的使用

在 ViewModel 中使用 Flow 之前在 Jetpack 成員 Paging3 實踐以及源碼分析(一) 文章也有提到, 這裏我們在深入分析一下,在 ViewModel 中接受 Flow 發送的數據有三種方法,根據實際情況去調用。 PokemonGo/app/src/main/java/com/hi/dhl/pokemon/ui/detail/DetailViewModel.kt

方法一

在 LifeCycle 2.2.0 之前使用的方法,使用兩個 LiveData,一個是可變的,一個是不可變的,如下所示:

// 私有的 MutableLiveData 可變的,對內訪問
private val _pokemon = MutableLiveData<PokemonInfoModel>()

// 對外暴露不可變的 LiveData,只能查詢
val pokemon: LiveData<PokemonInfoModel> = _pokemon

viewModelScope.launch {
    polemonRepository.featchPokemonInfo(name)
        .onStart {
            // 在調用 flow 請求數據之前,做一些準備工作,例如顯示正在加載數據的進度條
        }
        .catch {
            // 捕獲上游出現的異常
        }
        .onCompletion {
            // 請求完成
        }
        .collectLatest {
            // 將數據提供給 Activity 或者 Fragment
            _pokemon.postValue(it)
        }
}
  • 準備一私有的 MutableLiveData,只對內訪問

  • 對外暴露不可變的 LiveData

  • viewModelScope.launch 方法中執行協程代碼塊
  • collectLatest 是末端操作符,收集 Flow 在 Repositories 層發射出來的數據,在一段時間內發送多次數據,只會接受最新的一次發射過來的數據
  • 調用 _pokemon.postValue 方法將數據提供給 Activity 或者 Fragment

方法二

在 LifeCycle 2.2.0 之後,可以用更精簡的方法來完成,使用 LiveData 協程構造方法 (coroutine builder),這個方法也是在 PokemonGo 項目中用到的方法。

@OptIn(ExperimentalCoroutinesApi::class)
fun fectchPokemonInfo(name: String) = liveData<PokemonInfoModel> {
    polemonRepository.featchPokemonInfo(name)
        .onStart { // 在調用 flow 請求數據之前,做一些準備工作,例如顯示正在加載數據的進度條 }
        .catch { // 捕獲上游出現的異常 }
        .onCompletion { // 請求完成 }
        .collectLatest {
            // 更新 LiveData 的數據
            emit(it)
        }
}
  • liveData{ ... } 協程構造方法提供了一個協程代碼塊,產生的是一個不可變的 LiveData, emit() 方法則用來更新 LiveData 的數據
  • collectLatest 是末端操作符,收集 Flow 在 Repositories 層發射出來的數據,在一段時間內發送多次數據,只會接受最新的一次發射過來的數據

PS:需要注意的是 flow { ... }liveData{ ... } 內部都有一個 emit() 方法。

方法三:

調用 Flow 的擴展方法 asLiveData() 返回一個不可變的 LiveData,供 Activity 或者 Fragment 調用。

@OptIn(ExperimentalCoroutinesApi::class)
suspend fun fectchPokemonInfo3(name: String) =
    polemonRepository.featchPokemonInfo(name)
        .onStart {
            // 在調用 flow 請求數據之前,做一些準備工作,例如顯示正在加載數據的按鈕
        }
        .catch {
            // 捕獲上游出現的異常
        }
        .onCompletion {
            // 請求完成
        }.asLiveData()

因爲 polemonRepository.featchPokemonInfo(name) 是一個用 suspend 修飾的方法,所以在 ViewModel 中調用也需要使用 suspend 來修飾。

爲什麼說調用 asLiveData() 方法會返回一個不可變的 LiveData,我們來看一下源碼:

fun <T> Flow<T>.asLiveData(
    context: CoroutineContext = EmptyCoroutineContext,
    timeoutInMs: Long = DEFAULT_TIMEOUT
): LiveData<T> = liveData(context, timeoutInMs) {
    collect {
        emit(it)
    }
}

asLiveData() 方法其實就是對 方法二 中的 liveData{ ... } 的封裝

  • asLiveData 是 Flow 的擴展函數,返回值是一個 LiveData
  • liveData{ ... } 協程構造方法提供了一個協程代碼塊,在 liveData{ ... } 中執行協程代碼
  • collect 是末端操作符,收集 Flow 在 Repositories 層發射出來的數據
  • 最後調用 LiveData 中的 emit() 方法更新 LiveData 的數據

DataBinding(數據綁定)

在 PokemonGo 項目中使用了 DataBinding 進行的數據綁定。

DataBinding(數據綁定)實際上是 XML 佈局中的另一個視圖結構層次,視圖 (XML) 通過數據綁定層不斷地與 ViewModel 交互,如下所示: PokemonGo/app/src/main/res/layout/activity_details.xml

<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>
        <variable
            name="viewModel"
            type="com.hi.dhl.pokemon.ui.detail.DetailViewModel" />

    </data>
    
    ......
    <androidx.appcompat.widget.AppCompatTextView
        android:id="@+id/weight"
        android:text="@{viewModel.pokemon.getWeightString}"/>
    ......
    
</layout>

這是獲取神奇寶貝的詳細信息,通過 DataBinding 以聲明方式將數據(神奇寶貝的體重)綁定到界面上,更多使用參考項目中的代碼。

如何處理 ViewModel 的三種方式

如果不使用數據綁定,在 Activity 或者 Fragment 中如何處理 ViewModel 的三種方式。 PokemonGo/app/src/main/java/com/hi/dhl/pokemon/ui/detail/DetailsFragment.kt

方式一:

使用兩個 LiveData,一個是可變的,一個是不可變的,在 Activity 或者 Fragment 中調用對外暴露不可變的 LiveData 即可,如下所示:

// 方法一
mViewModel.pokemon.observe(this, Observer {
    // 將數據顯示在頁面上
})

方式二:

使用 LiveData 協程構造方法 (coroutine builder) 提供的協程代碼塊,產生的是一個不可變的 LiveData,處理方式 同方法一 ,在 Activity 或者 Fragment 中調用這個不可變的 LiveData 即可,如下所示:

// 方法二
mViewModel.fectchPokemonInfo2(mPokemonModel.name).observe(this, Observer {
    // 將數據顯示在頁面上
})

方式三:

調用 Flow 的擴展方法 asLiveData() 返回一個不可變的 LiveData,在 Activity 或者 Fragment 調用這個不可變的 LiveData 即可,如下所示:

// 方法三
lifecycleScope.launch {
    mViewModel.apply {
        fectchPokemonInfo3(mPokemonModel.name).observe(this@DetailsFragment, Observer {
            // 將數據顯示在頁面上
        })
    }
}

到這裏關於 Kotlin Flow 在 MVVM 當中每層的實踐就分析完了,如果使用過 RxJava 的小夥伴們應該會非常熟悉,對於沒有使用過 RxJava 的小夥伴們,入門的門檻也是非常低的,強烈建議至少體驗一次,體驗過之後,我認爲你會跟我一樣愛上它的。

神奇寶貝 (PokemonGo) 基於 Jetpack + MVVM + Repository + Data Mapper + Kotlin Flow 的實戰項目,我也正在爲 PokemonGo 項目設計更多的場景,也會加入更多的 Jetpack 成員,可以點擊下方鏈接前往查看。

PokemonGo GitHub 地址:https://github.com/hi-dhl/PokemonGo

PokemonGo

結語

致力於分享一系列 Android 系統源碼、逆向分析、算法、翻譯、Jetpack 源碼相關的文章,正在努力寫出更好的文章,如果這篇文章對你有幫助給個 star,文章中有什麼沒有寫明白的地方,或者有什麼更好的建議歡迎留言,歡迎一起來學習,在技術的道路上一起前進。

正在建立一個最全、最新的 AndroidX Jetpack 相關組件的實戰項目 以及 相關組件原理分析文章,目前已經包含了 App Startup、Paging3、Hilt 等等,正在逐漸增加其他 Jetpack 新成員,倉庫持續更新,可以前去查看: https://github.com/hi-dhl/AndroidX-Jetpack-Practice , 如果這個倉庫對你有幫助,請倉庫右上角幫我點個贊。

算法

由於 LeetCode 的題庫龐大,每個分類都能篩選出數百道題,由於每個人的精力有限,不可能刷完所有題目,因此我按照經典類型題目去分類、和題目的難易程度去排序。

  • 數據結構:數組、棧、隊列、字符串、鏈表、樹……

  • 算法:查找算法、搜索算法、位運算、排序、數學、……

每道題目都會用 Java 和 kotlin 去實現,並且每道題目都有解題思路、時間複雜度和空間複雜度,如果你同我一樣喜歡算法、LeetCode,可以關注我 GitHub 上的 LeetCode 題解: https://github.com/hi-dhl/Leetcode-Solutions-with-Java-And-Kotlin ,一起來學習,期待與你一起成長。

Android 10 源碼系列

正在寫一系列的 Android 10 源碼分析的文章,瞭解系統源碼,不僅有助於分析問題,在面試過程中,對我們也是非常有幫助的,如果你同我一樣喜歡研究 Android 源碼,可以關注我 GitHub 上的 https://github.com/hi-dhl/Android10-Source-Analysis ,文章都會同步到這個倉庫。

  • 0xA01 Android 10 源碼分析:APK 是如何生成的

  • 0xA02 Android 10 源碼分析:APK 的安裝流程

  • 0xA03 Android 10 源碼分析:APK 加載流程之資源加載

  • 0xA04 Android 10 源碼分析:APK 加載流程之資源加載(二)

  • 0xA05 Android 10 源碼分析:Dialog 加載繪製流程以及在 Kotlin、DataBinding 中的使用

  • 0xA06 Android 10 源碼分析:WindowManager 視圖綁定以及體系結構

  • 0xA07 Android 10 源碼分析:Window 的類型 以及 三維視圖層級分析

  • 更多......

Android 應用系列

  • 再見吧 buildSrc, 擁抱 Composing builds 提升 Android 編譯速度

  • 爲數不多的人知道的 Kotlin 技巧以及 原理解析

  • Jetpack 最新成員 AndroidX App Startup 實踐以及原理分析

  • Jetpack 成員 Paging3 實踐以及源碼分析(一)

  • Jetpack 新成員 Paging3 網絡實踐及原理分析(二)

  • Jetpack 新成員 Hilt 實踐(一)啓程過坑記

  • Jetpack 新成員 Hilt 實踐之 App Startup(二)進階篇

  • Jetpack 新成員 Hilt 與 Dagger 大不同(三)落地篇

  • 全方面分析 Hilt 和 Koin 性能

  • 神奇寶貝(PokemonGo)  眼前一亮的 Jetpack + MVVM 極簡實戰

精選譯文

作者目前正在整理和翻譯一系列精選國外的技術文章,不僅僅是翻譯,很多優秀的英文技術文章提供了很好思路和方法,每篇文章都會有 譯者思考 部分,對原文的更加深入的解讀,大家可以去作者的 GitHub 查看 https://github.com/hi-dhl/Technical-Article-Translation

相關文章