使用更為安全的方式收集 Android UI 數據流

image

在 Android 應用中,通常需要從 UI 層收集 Kotlin 數據流,以便在屏幕上顯示數據更新。同時,您也會希望通過收集這些數據流,來避免產生不必要的操作和資源浪費 (包括 CPU 和內存),以及防止在 View 進入后臺時泄露數據。

本文將會帶您學習如何使用 LifecycleOwner.addRepeatingJobLifecycle.repeatOnLifecycle 以及 Flow.flowWithLifecycle API 來避免資源的浪費;同時也會介紹為什么這些 API 適合作為在 UI 層收集數據流時的默認選擇。

資源浪費

無論數據流生產者的具體實現如何,我們都 推薦 從應用的較底層級暴露 Flow<T> API。不過,您也應該保證數據流收集操作的安全性。

使用一些現存 API (如 CoroutineScope.launchFlow<T>.launchInLifecycleCoroutineScope.launchWhenX) 收集基于 channel 或使用帶有緩沖的操作符 (如 bufferconflateflowOnshareIn) 的冷流的數據是 不安全的,除非您在 Activity 進入后臺時手動取消啟動了協程的 Job。這些 API 會在內部生產者在后臺發送項目到緩沖區時保持它們的活躍狀態,而這樣一來就浪費了資源。

注意: 冷流 是一種數據流類型,這種數據流會在新的訂閱者收集數據時,按需執行生產者的代碼塊。

例如下面的例子中,使用 callbackFlow 發送位置更新的數據流:

// 基于 Channel 實現的冷流,可以發送位置的更新
fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
    val callback = object : LocationCallback() {
        override fun onLocationResult(result: LocationResult?) {
            result ?: return
            try { offer(result.lastLocation) } catch(e: Exception) {}
        }
    }
    requestLocationUpdates(createLocationRequest(), callback, Looper.getMainLooper())
        .addOnFailureListener { e ->
            close(e) // 在出現異常時關閉 Flow
        }
    // 在 Flow 收集結束時進行清理操作 
    awaitClose {
        removeLocationUpdates(callback)
    }
}

注意: callbackFlow 內部使用 channel 實現,其概念與阻塞 隊列 十分類似,并且默認容量為 64。

使用任意前述 API 從 UI 層收集此數據流都會導致其持續發送位置信息,即使視圖不再展示數據也不會停止!示例如下:

class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 最早在 View  處于 STARTED 狀態時從數據流收集數據,并在
        // 生命周期進入 STOPPED 狀態時 SUSPENDS(掛起)收集操作。
        // 在 View 轉為 DESTROYED 狀態時取消數據流的收集操作。
        lifecycleScope.launchWhenStarted {
            locationProvider.locationFlow().collect {
                // 新的位置!更新地圖
            } 
        }
        // 同樣的問題也存在于:
        // - lifecycleScope.launch { /* 在這里從 locationFlow() 收集數據 */ }
        // - locationProvider.locationFlow().onEach { /* ... */ }.launchIn(lifecycleScope)
    }
}

lifecycleScope.launchWhenStarted 掛起了協程的執行。雖然新的位置信息沒有被處理,但 callbackFlow 生產者仍然會持續發送位置信息。使用 lifecycleScope.launchlaunchIn API 會更加危險,因為視圖會持續消費位置信息,即使處于后臺也不會停止!這種情況可能會導致您的應用崩潰。

為了解決這些 API 所帶來的問題,您需要在視圖轉入后臺時手動取消收集操作,以取消 callbackFlow 并避免位置提供者持續發送項目并浪費資源。舉例來說,您可以像下面的例子這樣操作:

class LocationActivity : AppCompatActivity() {

    // 位置的協程監聽器
    private var locationUpdatesJob: Job? = null

    override fun onStart() {
        super.onStart()
        locationUpdatesJob = lifecycleScope.launch {
            locationProvider.locationFlow().collect {
                // 新的位置!更新地圖。
            } 
        }
    }

    override fun onStop() {
       // 在視圖進入后臺時停止收集數據
        locationUpdatesJob?.cancel()
        super.onStop()
    }
}

這是一個不錯的解決方案,美中不足的是有些冗長。如果這個世界有一個有關 Android 開發者的普遍事實,那一定是我們都不喜歡編寫模版代碼。不必編寫模版代碼的一個最大好處就是——寫的代碼越少,出錯的概率越小!

LifecycleOwner.addRepeatingJob

現在我們境遇相同,并且也知道問題出在哪里,是時候找出一個解決方案了。我們的解決方案需要: 1. 簡單;2. 友好或者說便于記憶與理解;更重要的是 3. 安全!無論數據流的實現細節如何,它都應能夠應對所有用例。

事不宜遲——您應該使用的 API 是 lifecycle-runtime-ktx 庫中所提供的 LifecycleOwner.addRepeatingJob。請參考下面的代碼:

class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 最早在 View  處于 STARTED 狀態時從數據流收集數據,并在
        // 生命周期進入 STOPPED 狀態時 STOPPED(停止)收集操作。
        // 它會在生命周期再次進入 STARTED 狀態時自動開始進行數據收集操作。
        lifecycleOwner.addRepeatingJob(Lifecycle.State.STARTED) {
            locationProvider.locationFlow().collect {
                // 新的位置!更新地圖
            } 
        }
    }
}

addRepeatingJob 接收 Lifecycle.State 作為參數,并用它與傳入的代碼塊一起,在生命周期到達該狀態時,自動創建并啟動新的協程;同時也會在生命周期低于該狀態時取消正在運行的協程

由于 addRepeatingJob 會在協程不再被需要時自動將其取消,因而可以避免產生取消操作相關的模版代碼。您也許已經猜到,為了避免意外行為,這一 API 需要在 Activity 的 onCreate 或 Fragment 的 onViewCreated 方法中調用。下面是配合 Fragment 使用的示例:

class LocationFragment: Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        // ...
        viewLifecycleOwner.addRepeatingJob(Lifecycle.State.STARTED) {
            locationProvider.locationFlow().collect {
                // 新的位置!更新地圖
            } 
        }
    }
}

注意: 這些 API 在 lifecycle:lifecycle-runtime-ktx:2.4.0-alpha01 庫或其更新的版本中可用。

使用 repeatOnLifecycle

出于提供更為靈活的 API 以及保存調用中的 CoroutineContext 的目的,我們也提供了 掛起函數 Lifecycle.repeatOnLifecycle 供您使用。repeatOnLifecycle 會掛起調用它的協程,并會在進出目標狀態時重新執行代碼塊,最后在 Lifecycle 進入銷毀狀態時恢復調用它的協程。

如果您需要在重復工作前執行一次配置任務,同時希望任務可以在重復工作開始前保持掛起,該 API 可以幫您實現這樣的操作。示例如下:

class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        lifecycleScope.launch {
            // 單次配置任務
            val expensiveObject = createExpensiveObject()

            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
                // 在生命周期進入 STARTED 狀態時開始重復任務,在 STOPED 狀態時停止
                // 對 expensiveObject 進行操作
            }

            // 當協程恢復時,`lifecycle` 處于 DESTROY 狀態。repeatOnLifecycle 會在
            // 進入 DESTROYED 狀態前掛起協程的執行
        }
    }
}

Flow.flowWithLifecycle

當您只需要收集一個數據流時,也可以使用 Flow.flowWithLifecycle 操作符。這一 API 的內部也使用 suspend Lifecycle.repeatOnLifecycle 函數實現,并會在生命周期進入和離開目標狀態時發送項目和取消內部的生產者。

class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        locationProvider.locationFlow()
            .flowWithLifecycle(this, Lifecycle.State.STARTED)
            .onEach {
                // 新的位置!更新地圖
            }
            .launchIn(lifecycleScope) 
    }
}

注意: Flow.flowWithLifecycle API 的命名以 Flow.flowOn(CoroutineContext) 為先例,因為它會在不影響下游數據流的同時修改收集上游數據流的 CoroutineContext。與 flowOn 相似的另一點是,Flow.flowWithLifecycle 也加入了緩沖區,以防止消費者無法跟上生產者。這一特點源于其實現中使用的 callbackFlow

配置內部生產者

即使您使用了這些 API,也要小心那些可能浪費資源的熱流,就算它們沒有被收集亦是如此!雖然針對這些熱流有一些合適的用例,但是仍要多加注意并在必要時進行記錄。另一方面,在一些情況下,即使可能造成資源的浪費,令處于后臺的內部數據流生產者保持活躍狀態也會利于某些用例,如: 您需要即時刷新可用數據,而不是去獲取并暫時展示陳舊數據。您可以根據用例決定生產者是否需要始終處于活躍狀態

您可以使用 MutableStateFlowMutableSharedFlow 兩個 API 中暴露的 subscriptionCount 字段來控制它們,當該字段值為 0 時,內部的生產者就會停止。默認情況下,只要持有數據流實例的對象還在內存中,它們就會保持生產者的活躍狀態。針對這些 API 也有一些合適的用例,比如使用 StateFlowUiState 從 ViewModel 中暴露給 UI。這么做很合適,因為它意味著 ViewModel 總是需要向 View 提供最新的 UI 狀態。

相似的,也可以為此類操作使用 共享開始策略 配置 Flow.stateInFlow.shareIn 操作符。WhileSubscribed() 將會在沒有活躍的訂閱者時停止內部的生產者!相應的,無論數據流是 Eagerly (積極) 還是 Lazily (惰性) 的,只要它們使用的 CoroutineScope 還處于活躍狀態,其內部的生產者就會保持活躍。

注意: 本文中所描述的 API 可以很好的作為默認從 UI 收集數據流的方式,并且無論數據流的實現方式如何,都應該使用它們。這些 API 做了它們要做的事: 在 UI 于屏幕中不可見時,停止收集其數據流。至于數據流是否應該始終處于活動狀態,則取決于它的實現。

在 Jetpack Compose 中安全地收集數據流

Flow.collectAsState 函數可以在 Compose 中收集來自 composable 的數據流,并可以將值表示為 State<T>,以便能夠更新 Compose UI。即使 Compose 在宿主 Activity 或 Fragment 處于后臺時不會重組 UI,數據流生產者仍會保持活躍并會造成資源的浪費。Compose 可能會遭遇與 View 系統相同的問題。

在 Compose 中收集數據流時,可以使用 Flow.flowWithLifecycle 操作符,示例如下:

@Composable
fun LocationScreen(locationFlow: Flow<Flow>) {

    val lifecycleOwner = LocalLifecycleOwner.current
    val locationFlowLifecycleAware = remember(locationFlow, lifecycleOwner) {
        locationFlow.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
    }

    val location by locationFlowLifecycleAware.collectAsState()
    
    // 當前位置,可以拿它做一些操作
}

注意,您 需要記得 生命周期感知型數據流使用 locationFlowlifecycleOwner 作為鍵,以便始終使用同一個數據流,除非其中一個鍵發生改變。

Compose 的副作用 (Side-effect) 便是必須處在 受控環境中,因此,使用 LifecycleOwner.addRepeatingJob 不安全。作為替代,可以使用 LaunchedEffect 來創建跟隨 composable 生命周期的協程。在它的代碼塊中,如果您需要在宿主生命周期處于某個 State 時重新執行一個代碼塊,可以調用掛起函數 Lifecycle.repeatOnLifecycle

對比 LiveData

您也許會覺得,這些 API 的表現與 LiveData 很相似——確實是這樣!LiveData 可以感知 Lifecycle,而且它的重啟行為使其十分適合觀察來自 UI 的數據流。同理 LifecycleOwner.addRepeatingJobsuspend Lifecycle.repeatOnLifecycle 以及 Flow.flowWithLifecycle 等 API 亦是如此。

在純 Kotlin 應用中,使用這些 API 可以十分自然地替代 LiveData 收集數據流。如果您使用這些 API 收集數據流,換成 LiveData (相對于使用協程和 Flow) 不會帶來任何額外的好處。而且由于 Flow 可以從任何 Dispatcher 收集數據,同時也能通過它的 操作符 獲得更多功能,所以 Flow 也更為靈活。相對而言,LiveData 的可用操作符有限,且它總是從 UI 線程觀察數據。

數據綁定對 StateFlow 的支持

另一方面,您會想要使用 LiveData 的原因之一,可能是它受到數據綁定的支持。不過 StateFlow 也一樣!更多有關數據綁定對 StateFlow 的支持信息,請參閱 官方文檔

在 Android 開發中,請使用 LifecycleOwner.addRepeatingJobsuspend Lifecycle.repeatOnLifecycleFlow.flowWithLifecycle 從 UI 層安全地收集數據流。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,461評論 6 532
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,538評論 3 417
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,423評論 0 375
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,991評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,761評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,207評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,268評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,419評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,959評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,782評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,983評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,528評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,222評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,653評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,901評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,678評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,978評論 2 374

推薦閱讀更多精彩內容

  • LiveData 的歷史要追溯到 2017 年。彼時,觀察者模式有效簡化了開發,但諸如 RxJava 一類的庫對新...
    谷歌開發者閱讀 2,675評論 2 11
  • Jetpack Room 對協程的支持越來越豐富: Room 2.1 版本增加了對協程的支持,并加入了一次性 (o...
    谷歌開發者閱讀 1,568評論 2 4
  • 我是黑夜里大雨紛飛的人啊 1 “又到一年六月,有人笑有人哭,有人歡樂有人憂愁,有人驚喜有人失落,有的覺得收獲滿滿有...
    陌忘宇閱讀 8,578評論 28 53
  • 信任包括信任自己和信任他人 很多時候,很多事情,失敗、遺憾、錯過,源于不自信,不信任他人 覺得自己做不成,別人做不...
    吳氵晃閱讀 6,208評論 4 8
  • 步驟:發微博01-導航欄內容 -> 發微博02-自定義TextView -> 發微博03-完善TextView和...
    dibadalu閱讀 3,153評論 1 3