专家之路上的Flow高级秘籍

公众号「稀有猿诉」 原文链接 专家之路上的Flow高级秘籍
『君不见,黄河之水天上来,奔流到海不复回。』

学习与河流一样,一方面学无止境,又是逆水行舟,不进则退,因为其他人都在卷。前文一篇文章讲了Flow的基础,大多数情况下够用了,但是不能停止卷,因为你不卷,就会被别人卷。一旦涉及到复杂的应用场景,就需要用到一些高级的API。今天就来学习一下Flow的高级特性,当遇到问题时也能更从容的应对。

上下文切换

Flow是基于协程的,是用协程来实现并发,前面也提到过像flow {...},在上游生产数据,以及中游做变幻时,都是可以直接调用suspend,耗时甚至是阻塞的函数的。而终端操作符如collect则是suspend的,调用者(也就是消费者)需要负责确保collect是在协程中调用。我们还知道Flow是是冷流,消费者终端才会触发上游生产者生产,所以对于flow {...}来说,它的上游和中游运行的上下文来自于终端调用者的上下文,这个叫做『上下文保留』(context preservation),我们可以用一个 来验证一下:

Kotlin 复制代码
fun main() = runBlocking {
    // Should be main by default
    simple().collect { log("Got: $it") }

    // Collect in a specified context
    withContext(Dispatchers.Default) {
        simple().collect { log("Now got: $it") }
    }
}

private fun simple(): Flow<Int> = flow {
    log("Started the simple flow")
    for (i in 1..3) {
        delay(100)
        log("Producing $i")
        emit(i)
    }
}

输出如下:

Bash 复制代码
[main @coroutine#1] Started the simple flow
[main @coroutine#1] Producing 1
[main @coroutine#1] Got: 1
[main @coroutine#1] Producing 2
[main @coroutine#1] Got: 2
[main @coroutine#1] Producing 3
[main @coroutine#1] Got: 3
[DefaultDispatcher-worker-1 @coroutine#1] Started the simple flow
[DefaultDispatcher-worker-1 @coroutine#1] Producing 1
[DefaultDispatcher-worker-1 @coroutine#1] Now got: 1
[DefaultDispatcher-worker-1 @coroutine#1] Producing 2
[DefaultDispatcher-worker-1 @coroutine#1] Now got: 2
[DefaultDispatcher-worker-1 @coroutine#1] Producing 3
[DefaultDispatcher-worker-1 @coroutine#1] Now got: 3

从这个 可以清楚的看到,Flow的context是来自于终端调用者的。

用flowOn来指定上下文

有时候使用终端调用者的上下文可能不太方便,因为生产者与消费者的模式其实是解耦的,它们不应该相互受制于对方,对于关键的并发的上下文更是如此。比如说在GUI的应用中,明显应该在工作线程中生产数据,在UI线程中消费数据,从上面的例子来看,由终端调用者来决定上游上下文明显不可取。有同学举手了,欺负我没学过协程是吧?我可以在Flow内部使用withContext来指定上下文啊,我们来试试:

Kotlin 复制代码
fun main() = runBlocking {
    // Should be main by default
    simple().collect { log("Got: $it") }
}

private fun simple(): Flow<Int> = flow {
    withContext(Dispatchers.Default) {
        log("Started the simple flow")
        for (i in 1..3) {
            delay(100)
            log("Producing $i")
            emit(i)
        }
    }
}

这位同学可以直接出去了,因为你的代码crash 了:

Bash 复制代码
[DefaultDispatcher-worker-1 @coroutine#1] Started the simple flow
[DefaultDispatcher-worker-1 @coroutine#1] Producing 1
Exception in thread "main" java.lang.IllegalStateException: Flow invariant is violated:
		Flow was collected in [CoroutineId(1), "coroutine#1":BlockingCoroutine{Active}@545486c7, BlockingEventLoop@13bfcf14],
		but emission happened in [CoroutineId(1), "coroutine#1":DispatchedCoroutine{Active}@27015c5a, Dispatchers.Default].
		Please refer to 'flow' documentation or use 'flowOn' instead

意思大概是说Flow内部不让直接用withContext来切上下文,破坏了Flow的不变式,想切上下文要用flowOn。而且仔细看,异常是由emit函数抛出来的。

其实Flow的设计者已经考虑到了这个问题,并且给出了优雅的方式,如果想切换Flow内部(也即上游和中游)的运行上下文,要用flowOn函数:

Kotlin 复制代码
fun main() = runBlocking {
    // Should be main by default
    simple().collect { log("Got: $it") }
}

private fun simple(): Flow<Int> = flow {
    log("Started the simple flow")
    for (i in 1..3) {
        delay(100)
        log("Producing $i")
        emit(i)
        Thread.sleep(50)
    }
}.flowOn(Dispatchers.Default)
//[DefaultDispatcher-worker-1 @coroutine#2] Started the simple flow
//[DefaultDispatcher-worker-1 @coroutine#2] Producing 1
//[main @coroutine#1] Got: 1
//[DefaultDispatcher-worker-1 @coroutine#2] Producing 2
//[main @coroutine#1] Got: 2
//[DefaultDispatcher-worker-1 @coroutine#2] Producing 3
//[main @coroutine#1] Got: 3

这回就和谐多了,后台搞生产,UI只展示,完美!还需要特别注意的是函数flowOn只影响它的上游,不影响它的下游,更不会影响终端 ,终端永远都在其调用者的上下文中,来看一个 :

Kotlin 复制代码
withContext(Dispatchers.Main) {
    val singleValue = intFlow // will be executed on IO if context wasn't specified before
        .map { ... } // Will be executed in IO
        .flowOn(Dispatchers.IO)
        .filter { ... } // Will be executed in Default
        .flowOn(Dispatchers.Default)
        .single() // Will be executed in the Main
}

第一个flowOn切到IO ,只影响到它前面的创建和map,第二次切换到Default ,只影响filter。single是终端,是在Main ,因为它的调用者是在Main里面。

注意,注意: Flow是一个数据流,保持其数据流的特点是相当重要的,无论是正常数据,异常数据,还是出错都是一种数据,应该让其自上而下的流动,在中游变幻时或者终端时通过操作符来处理。所以,像硬性的上下文切换,或者异常的try/catch都是不允许的。这就是所谓的流的不变性(Flow invariant)。后面讲异常时还会提到这点。

任意上下文的Flow builders

从前面的学习我们知道了,下下文保留的特性,终端会决定上游生产者的上下文,当然也可以通过flowOn来改变上下文。Flow builder其实就是一个生产者,异步的emit数据。但有些时候生产数据时的上下文,也就是调用emit时的上下文,是不确定的。比如说安卓 上面的各种回调(callback)有些是回调在调用者的线程里,有些则不是。flow {...}中的emit就不能在异步的回调里面调用,这时就要用callbackFlow {...}。callbackFlow专门适用于把现有的一些回调转为Flow,最典型的应用就是位置信息:

Kotlin 复制代码
fun locationFlow(): Flow<Location> = callbackFlow {
	val listener = object : LocationListener {
		override fun onLocationUpdate(loc: Location) {
			trySend(location)
		}
	}
	locationManager.reqisterLocaitonUpdates(listener)
	
	awaitClose {
		locationManager.unregisterLocationUpdates(listener)
	}
}

如果这个Flow,用flow {}去创建会抛异常,因为emit没法在回调中使用。callbackFlow会在回调中发射数据,并在awaitClose代码块中反注册回调以清理资源。awaitClose会在这个流结束时(完成或者被取消)被回调到,以有机会进行资源清理。

其实,无论是flow {}还是callbackFlow {}都是channelFlow {}的简单化,channelFlow非常复杂,也超级强大,它可以自带buffer,自带并发,适用于创建一些非常复杂的Flow。在多数时候flow {}和callbackFlow {}就够我们用了。

扩展阅读:

副作用函数

Flow是一个数据流,核心思想是把数据的生产和处理和最终消费分开,上游只负责生产数据,各种操作都应该由中游操作符来做,最终数据由终端消费掉。需要加强数据的封装性,和流的不变性,不破坏管道,用各种转换器来对数据进行操作。那么,对于流何时开始,每个数据何时产生,流什么时候终止,这些事件对于调试来说是很有帮助的。Flow的设计者给出了一系列副作用函数来做之些事情。副作用的意思就是这些函数不会对流本身产生影响。

  • onStart Flow开始生产之前会调用此函数。
  • onEach 在生产(emit)每个数据之前调用此函数,这个函数最常用被用来打日志,以查看每个产生的数据。
  • onCompletion 当Flow终止时或者被取消后会调用此函数。
  • onSubscritpion 有消费者了时调用此函数(也就是有人collect了此Flow时)。

异常,取消和错误处理

这一小节重点来看看非正常代码逻辑的处理。先来看看异常处理(Exception handling)。

用catch函数来处理Flow过程中的异常

代码随时都可能抛出异常,所以异常处理是一个必须要考虑的事情。当然可以在Flow的各个节点如上游生产,中游变幻和下游终端的代码块里面各种try/catch。一来是不够优雅,再者这会破坏Flow的不变性或者说一致性,它就是管道,数据在里面流动,不应该加以过多的干扰,想要对数据处理应该用操作符。也就是说要让异常(包括其他错误也是如此)对Flow是透明的,意思是说Flow并不关心是否有异常。所以提供了一个catch函数,它的作用是捕获并处理上游操作中发生的异常:

Kotlin 复制代码
simple()
    .catch { e -> emit("Caught $e") } // emit on exception
    .collect { value -> println(value) }

需要注意catch与flowOn一样,只影响上游发生的异常,管不了下游:

Kotlin 复制代码
flow { emitData() }
    .map { computeOne(it) }
    .catch { ... } // catches exceptions in emitData and computeOne
    .map { computeTwo(it) }
    .collect { process(it) } // throws exceptions from process and computeTwo

取消Flow

Flow没有显式的取消函数。Flow是冷流,有消费者时才会去生产数据,消费者停止消费了,Flow自然也就被取消了。终端操作都是suspend的,也就是要在协程中调用,因此取消终端调用的协程,就会取消Flow。

错误处理

其实没有特别的错误处理函数,前面的异常算是一个,如果上游没有抛出异常,就不会有其他错误了,因为错误也是数据的一种类型,并且是由我们自己根据场景来定义的。比如说从网络获取新闻列表,正常时的数据当然是一个个的新闻条目。出错了,比如无网络,或者服务器无响应,这时可能返回一个空的条目,里面有错误的具体信息。但这都是由业务逻辑决定的,是业务逻辑层面的东西。对于Flow而言,都还是有数据的,都是一种数据,具体数据的解读,那是消费者终端的事情,Flow并不关心。

唯一算得上错误处理的函数就是onEmpty,它会在Flow是空的时候,也就是不生产任何数据的时候被回调。可以在onEmpty里面生产emit数据,比如产生一个带有错误信息的数据,或者产生一个默认值。因为Flow为空,不产生emit任何数据时,管子是空的数据没有流动,Flow的整个链路,特别是终端collect是不会被执行的,这时可能会有问题,比如UI根本无法做出任何react,除非你设置过了默认UI状态,否则可能会不对。这个时候如果用onEmpty去产生一些默认值或者错误信息的话,就能激活整个Flow,终端能做出预期的响应。

重试机制

另一个非常有用的函数就是retry,它可以预设一个条件,当条件满足时就会触发重新collect。Flow是冷流,有消费者collect时才会触发生产者emit数据,因此重新collect就能让Flow重新emit数据流。

背压

Flow是异步数据流,响应式编程范式,上游生产数据,下游终端消费数据。有时候可能会遇到这样一种情况,就是上游数据生产的速度超过了下游终端的消费速度,这会造成数据流积压在管道中,终端无法及时响应。这种情况称为『背压(Back pressure)』。想像一下一个水管,如果进水速度大于水龙头流出的速度,水就会积压在水管里,如果水管是比较薄弱的(如气球),那么它会膨胀,最后爆掉。

通常情况下,当上游是较为可控的生产者时,不会产生背压,但如果是一些不是开发人员可控的,如硬件(触摸事件,位置信息,传感器,摄像头),其他系统(系统框架的回调,或者服务器的Push)等等,就会产生背压,这时必须进行相应的处理。所有的FRP式异步数据流API都必须处理『背压』,Flow也有相应的API来处理:

  • buffer 把生产者的emit的数据缓存,然后用Channel以并发的方式流向中游和下游,可以简单理解为并发地调用collect。正常情况下Flow是顺序的(Sequentially),就是数据从上游到中游再到终端,按顺序流动,先生产的数据先流到collect,这就是顺序的数据流sequentially。用上buffer后,就是会是并发的流,先emit的数据不一定先到collect,这就是concurrently。明显,能用buffer的前提是终端处理数据时没有对数据顺序的依赖。
  • conflate 也会像buffer一样启动并发式emit数据,但未能及时被终端消费掉的数据会被丢弃,终端只处理最新数据。
  • collectLatest 当有新的数据流出来时,终端只处理最新的数据,此之的终端处理会被取消掉(如果还没有处理完)。

转为热流

常规的Flow都是冷的(cold flow),但有时热流(hot flow)也有它的应用场景,Flow API中也有创建热流的方法。

StateFlow

StateFlow是一个『状态持有』流,它仅包含一个当前元素value,可以用过update来更新此状态。它是一个热流,可以有多个终端colloctor,每次更新都会把当前的值emit给所有的终端。

可以用构造方法MutableStateFlow创建一个StateFlow,或者通过函数stateIn来把一个冷流转化为一个StateFlow。

StateFlow是比较常用的,在安卓开发中,几乎所有的ViewModel都会用StateFlow来暂存UI状态数据。

SharedFlow

比StateFlow更为通用的便是通用的热流SharedFlow。可以通过构造方法MutableSharedFlow来创建SharedFlow,或者通过函数sharedIn把一个冷流转为SharedFlow。

SharedFlow可以有多个终端collector,所以可以实现一对多的通知,如实现观察者模式,或者像设置/配置更新,或者广播等等就可以考虑用SharedFlow来实现。

扩展阅读:

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

原创不易,「打赏」「点赞」「在看」「收藏」「分享」 总要有一个吧!

相关推荐
seven272911 分钟前
Android MQTT关于断开连接disconnect报错原因
android·mqtt·disconnect报错
小林爱1 小时前
【Compose multiplatform教程12】【组件】Box组件
前端·kotlin·android studio·框架·compose·多平台
Maplee4 小时前
Compose 转场动画之 Transition
android·前端
weixin_482565535 小时前
Android IC读写器安卓小程序 3
android·小程序
hvinsion6 小时前
Python PDF批量加密工具
android·python·pdf
m0_748230446 小时前
【MySQL】数据库开发技术:内外连接与表的索引穿透深度解析
android·mysql·数据库开发
marui19826 小时前
hadoop sql 执行log
android·ide·android studio
liangmou21217 小时前
解释小部分分WPI函数(由贪吃蛇游戏拓展)
android·游戏·c#
你听得到117 小时前
《Flutter性能优化全攻略:从首屏渲染到性能监测,附案例代码详解》
android·flutter
HelloBan7 小时前
记一次使用投屏软件scrcpy导致Android设备横竖屏切换的问题
android