使用更為安全的方式收集 Android UI 數(shù)據(jù)流

image

在 Android 應(yīng)用中,通常需要從 UI 層收集 Kotlin 數(shù)據(jù)流,以便在屏幕上顯示數(shù)據(jù)更新。同時(shí),您也會(huì)希望通過(guò)收集這些數(shù)據(jù)流,來(lái)避免產(chǎn)生不必要的操作和資源浪費(fèi) (包括 CPU 和內(nèi)存),以及防止在 View 進(jìn)入后臺(tái)時(shí)泄露數(shù)據(jù)。

本文將會(huì)帶您學(xué)習(xí)如何使用 LifecycleOwner.addRepeatingJobLifecycle.repeatOnLifecycle 以及 Flow.flowWithLifecycle API 來(lái)避免資源的浪費(fèi);同時(shí)也會(huì)介紹為什么這些 API 適合作為在 UI 層收集數(shù)據(jù)流時(shí)的默認(rèn)選擇。

資源浪費(fèi)

無(wú)論數(shù)據(jù)流生產(chǎn)者的具體實(shí)現(xiàn)如何,我們都 推薦 從應(yīng)用的較底層級(jí)暴露 Flow<T> API。不過(guò),您也應(yīng)該保證數(shù)據(jù)流收集操作的安全性。

使用一些現(xiàn)存 API (如 CoroutineScope.launchFlow<T>.launchInLifecycleCoroutineScope.launchWhenX) 收集基于 channel 或使用帶有緩沖的操作符 (如 bufferconflateflowOnshareIn) 的冷流的數(shù)據(jù)是 不安全的,除非您在 Activity 進(jìn)入后臺(tái)時(shí)手動(dòng)取消啟動(dòng)了協(xié)程的 Job。這些 API 會(huì)在內(nèi)部生產(chǎn)者在后臺(tái)發(fā)送項(xiàng)目到緩沖區(qū)時(shí)保持它們的活躍狀態(tài),而這樣一來(lái)就浪費(fèi)了資源。

注意: 冷流 是一種數(shù)據(jù)流類型,這種數(shù)據(jù)流會(huì)在新的訂閱者收集數(shù)據(jù)時(shí),按需執(zhí)行生產(chǎn)者的代碼塊。

例如下面的例子中,使用 callbackFlow 發(fā)送位置更新的數(shù)據(jù)流:

// 基于 Channel 實(shí)現(xiàn)的冷流,可以發(fā)送位置的更新
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) // 在出現(xiàn)異常時(shí)關(guān)閉 Flow
        }
    // 在 Flow 收集結(jié)束時(shí)進(jìn)行清理操作 
    awaitClose {
        removeLocationUpdates(callback)
    }
}

注意: callbackFlow 內(nèi)部使用 channel 實(shí)現(xiàn),其概念與阻塞 隊(duì)列 十分類似,并且默認(rèn)容量為 64。

使用任意前述 API 從 UI 層收集此數(shù)據(jù)流都會(huì)導(dǎo)致其持續(xù)發(fā)送位置信息,即使視圖不再展示數(shù)據(jù)也不會(huì)停止!示例如下:

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

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

lifecycleScope.launchWhenStarted 掛起了協(xié)程的執(zhí)行。雖然新的位置信息沒(méi)有被處理,但 callbackFlow 生產(chǎn)者仍然會(huì)持續(xù)發(fā)送位置信息。使用 lifecycleScope.launchlaunchIn API 會(huì)更加危險(xiǎn),因?yàn)橐晥D會(huì)持續(xù)消費(fèi)位置信息,即使處于后臺(tái)也不會(huì)停止!這種情況可能會(huì)導(dǎo)致您的應(yīng)用崩潰。

為了解決這些 API 所帶來(lái)的問(wèn)題,您需要在視圖轉(zhuǎn)入后臺(tái)時(shí)手動(dòng)取消收集操作,以取消 callbackFlow 并避免位置提供者持續(xù)發(fā)送項(xiàng)目并浪費(fèi)資源。舉例來(lái)說(shuō),您可以像下面的例子這樣操作:

class LocationActivity : AppCompatActivity() {

    // 位置的協(xié)程監(jiān)聽(tīng)器
    private var locationUpdatesJob: Job? = null

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

    override fun onStop() {
       // 在視圖進(jìn)入后臺(tái)時(shí)停止收集數(shù)據(jù)
        locationUpdatesJob?.cancel()
        super.onStop()
    }
}

這是一個(gè)不錯(cuò)的解決方案,美中不足的是有些冗長(zhǎng)。如果這個(gè)世界有一個(gè)有關(guān) Android 開(kāi)發(fā)者的普遍事實(shí),那一定是我們都不喜歡編寫(xiě)模版代碼。不必編寫(xiě)模版代碼的一個(gè)最大好處就是——寫(xiě)的代碼越少,出錯(cuò)的概率越小!

LifecycleOwner.addRepeatingJob

現(xiàn)在我們境遇相同,并且也知道問(wèn)題出在哪里,是時(shí)候找出一個(gè)解決方案了。我們的解決方案需要: 1. 簡(jiǎn)單;2. 友好或者說(shuō)便于記憶與理解;更重要的是 3. 安全!無(wú)論數(shù)據(jù)流的實(shí)現(xiàn)細(xì)節(jié)如何,它都應(yīng)能夠應(yīng)對(duì)所有用例。

事不宜遲——您應(yīng)該使用的 API 是 lifecycle-runtime-ktx 庫(kù)中所提供的 LifecycleOwner.addRepeatingJob。請(qǐng)參考下面的代碼:

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

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

addRepeatingJob 接收 Lifecycle.State 作為參數(shù),并用它與傳入的代碼塊一起,在生命周期到達(dá)該狀態(tài)時(shí),自動(dòng)創(chuàng)建并啟動(dòng)新的協(xié)程;同時(shí)也會(huì)在生命周期低于該狀態(tài)時(shí)取消正在運(yùn)行的協(xié)程

由于 addRepeatingJob 會(huì)在協(xié)程不再被需要時(shí)自動(dòng)將其取消,因而可以避免產(chǎn)生取消操作相關(guān)的模版代碼。您也許已經(jīng)猜到,為了避免意外行為,這一 API 需要在 Activity 的 onCreate 或 Fragment 的 onViewCreated 方法中調(diào)用。下面是配合 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 庫(kù)或其更新的版本中可用。

使用 repeatOnLifecycle

出于提供更為靈活的 API 以及保存調(diào)用中的 CoroutineContext 的目的,我們也提供了 掛起函數(shù) Lifecycle.repeatOnLifecycle 供您使用。repeatOnLifecycle 會(huì)掛起調(diào)用它的協(xié)程,并會(huì)在進(jìn)出目標(biāo)狀態(tài)時(shí)重新執(zhí)行代碼塊,最后在 Lifecycle 進(jìn)入銷(xiāo)毀狀態(tài)時(shí)恢復(fù)調(diào)用它的協(xié)程。

如果您需要在重復(fù)工作前執(zhí)行一次配置任務(wù),同時(shí)希望任務(wù)可以在重復(fù)工作開(kāi)始前保持掛起,該 API 可以幫您實(shí)現(xiàn)這樣的操作。示例如下:

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

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

            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
                // 在生命周期進(jìn)入 STARTED 狀態(tài)時(shí)開(kāi)始重復(fù)任務(wù),在 STOPED 狀態(tài)時(shí)停止
                // 對(duì) expensiveObject 進(jìn)行操作
            }

            // 當(dāng)協(xié)程恢復(fù)時(shí),`lifecycle` 處于 DESTROY 狀態(tài)。repeatOnLifecycle 會(huì)在
            // 進(jìn)入 DESTROYED 狀態(tài)前掛起協(xié)程的執(zhí)行
        }
    }
}

Flow.flowWithLifecycle

當(dāng)您只需要收集一個(gè)數(shù)據(jù)流時(shí),也可以使用 Flow.flowWithLifecycle 操作符。這一 API 的內(nèi)部也使用 suspend Lifecycle.repeatOnLifecycle 函數(shù)實(shí)現(xiàn),并會(huì)在生命周期進(jìn)入和離開(kāi)目標(biāo)狀態(tài)時(shí)發(fā)送項(xiàng)目和取消內(nèi)部的生產(chǎn)者。

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) 為先例,因?yàn)樗鼤?huì)在不影響下游數(shù)據(jù)流的同時(shí)修改收集上游數(shù)據(jù)流的 CoroutineContext。與 flowOn 相似的另一點(diǎn)是,Flow.flowWithLifecycle 也加入了緩沖區(qū),以防止消費(fèi)者無(wú)法跟上生產(chǎn)者。這一特點(diǎn)源于其實(shí)現(xiàn)中使用的 callbackFlow

配置內(nèi)部生產(chǎn)者

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

您可以使用 MutableStateFlowMutableSharedFlow 兩個(gè) API 中暴露的 subscriptionCount 字段來(lái)控制它們,當(dāng)該字段值為 0 時(shí),內(nèi)部的生產(chǎn)者就會(huì)停止。默認(rèn)情況下,只要持有數(shù)據(jù)流實(shí)例的對(duì)象還在內(nèi)存中,它們就會(huì)保持生產(chǎn)者的活躍狀態(tài)。針對(duì)這些 API 也有一些合適的用例,比如使用 StateFlowUiState 從 ViewModel 中暴露給 UI。這么做很合適,因?yàn)樗馕吨?ViewModel 總是需要向 View 提供最新的 UI 狀態(tài)。

相似的,也可以為此類操作使用 共享開(kāi)始策略 配置 Flow.stateInFlow.shareIn 操作符。WhileSubscribed() 將會(huì)在沒(méi)有活躍的訂閱者時(shí)停止內(nèi)部的生產(chǎn)者!相應(yīng)的,無(wú)論數(shù)據(jù)流是 Eagerly (積極) 還是 Lazily (惰性) 的,只要它們使用的 CoroutineScope 還處于活躍狀態(tài),其內(nèi)部的生產(chǎn)者就會(huì)保持活躍。

注意: 本文中所描述的 API 可以很好的作為默認(rèn)從 UI 收集數(shù)據(jù)流的方式,并且無(wú)論數(shù)據(jù)流的實(shí)現(xiàn)方式如何,都應(yīng)該使用它們。這些 API 做了它們要做的事: 在 UI 于屏幕中不可見(jiàn)時(shí),停止收集其數(shù)據(jù)流。至于數(shù)據(jù)流是否應(yīng)該始終處于活動(dòng)狀態(tài),則取決于它的實(shí)現(xiàn)。

在 Jetpack Compose 中安全地收集數(shù)據(jù)流

Flow.collectAsState 函數(shù)可以在 Compose 中收集來(lái)自 composable 的數(shù)據(jù)流,并可以將值表示為 State<T>,以便能夠更新 Compose UI。即使 Compose 在宿主 Activity 或 Fragment 處于后臺(tái)時(shí)不會(huì)重組 UI,數(shù)據(jù)流生產(chǎn)者仍會(huì)保持活躍并會(huì)造成資源的浪費(fèi)。Compose 可能會(huì)遭遇與 View 系統(tǒng)相同的問(wèn)題。

在 Compose 中收集數(shù)據(jù)流時(shí),可以使用 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()
    
    // 當(dāng)前位置,可以拿它做一些操作
}

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

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

對(duì)比 LiveData

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

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

數(shù)據(jù)綁定對(duì) StateFlow 的支持

另一方面,您會(huì)想要使用 LiveData 的原因之一,可能是它受到數(shù)據(jù)綁定的支持。不過(guò) StateFlow 也一樣!更多有關(guān)數(shù)據(jù)綁定對(duì) StateFlow 的支持信息,請(qǐng)參閱 官方文檔

在 Android 開(kāi)發(fā)中,請(qǐng)使用 LifecycleOwner.addRepeatingJobsuspend Lifecycle.repeatOnLifecycleFlow.flowWithLifecycle 從 UI 層安全地收集數(shù)據(jù)流。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,702評(píng)論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,143評(píng)論 3 415
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人,你說(shuō)我怎么就攤上這事。” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 175,553評(píng)論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我,道長(zhǎng),這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 62,620評(píng)論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,416評(píng)論 6 405
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 54,940評(píng)論 1 321
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,024評(píng)論 3 440
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書(shū)人閱讀 42,170評(píng)論 0 287
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,709評(píng)論 1 333
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,597評(píng)論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 42,784評(píng)論 1 369
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,291評(píng)論 5 357
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,029評(píng)論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 34,407評(píng)論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 35,663評(píng)論 1 280
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 51,403評(píng)論 3 390
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 47,746評(píng)論 2 370

推薦閱讀更多精彩內(nèi)容