使用更为安全的方式收集 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阅读 148,637评论 1 318
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 63,443评论 1 266
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 99,164评论 0 218
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 42,075评论 0 188
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 50,080评论 1 266
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 39,365评论 1 184
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 30,901评论 2 283
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 29,649评论 0 176
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,122评论 0 223
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 29,734评论 2 225
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,093评论 1 236
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 27,548评论 2 222
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,028评论 3 216
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,765评论 0 9
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,291评论 0 178
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 34,162评论 2 239
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 34,293评论 2 242

推荐阅读更多精彩内容

  • LiveData 的历史要追溯到 2017 年。彼时,观察者模式有效简化了开发,但诸如 RxJava 一类的库对新...
    谷歌开发者阅读 2,567评论 2 12
  • Jetpack Room 对协程的支持越来越丰富: Room 2.1 版本增加了对协程的支持,并加入了一次性 (o...
    谷歌开发者阅读 1,441评论 2 4
  • 我是黑夜里大雨纷飞的人啊 1 “又到一年六月,有人笑有人哭,有人欢乐有人忧愁,有人惊喜有人失落,有的觉得收获满满有...
    陌忘宇阅读 8,461评论 28 53
  • 信任包括信任自己和信任他人 很多时候,很多事情,失败、遗憾、错过,源于不自信,不信任他人 觉得自己做不成,别人做不...
    吴氵晃阅读 6,132评论 4 8
  • 步骤:发微博01-导航栏内容 -> 发微博02-自定义TextView -> 发微博03-完善TextView和...
    dibadalu阅读 3,087评论 1 3