我们在一生中最为辉煌的,并不是功成名就那一天,而是从悲叹和绝望中产生对人生挑战的欲望,并且勇敢迈向这种挑战的那一天
Flow不是什么新东西,它是响应式函数编程(Reactive Functional Programming, RFP)在Kotlin语境下的一种实现。相比于RxJava,Flow根植于Kotlin技术栈,可以利用Kotlin各种高效的语法糖,以及协程框架。
掌握Flow技术栈的难点在于理解响应式编程的理念,尤其是对于写面向对象程序10余年的我来说,属实有点转不过这个弯来。但没办法,一方面项目硬性需要,另一方面老旧的技术栈也必须时时更新。这个Flow啃也得啃下来。
整篇Blog预计分为6大部分,通过阅读这篇文章,应当可以建立起对响应式编程/Flow的基础认识,并能在项目中尝试应用这种技术。以下是目录:
- Flow的基本思想
- 一个简单的Flow例子
- Flow的传递:
Filter、Map、Concat - 在指定时间段内发布:
FlatMapLatest与CollectLatest - 与视图紧密相关的
StateFlow - 热流&冷流:
SharedFlow与Channels
1.Flow的基本思想
Flow应用了数据流的思想,开启一个Flow意味着在一端不断发射数据,数据可以来自于持久化存储,也可以来自于代码中循环生成。在另一端对数据进行消费。而在两端之间,则可以对数据进行变换、拼接、组装、过滤等等操作。发射的对象不仅是数据,还可以是数据传输的状态。
2.一个简单的Flow例子
接下来用一个例子展示Flow最简单的用法,模拟一个数据流从请求、获取、加工、使用以及停止的全过程。需要注意,这里使用了lifecycleScope,将flow的处理过程与Activity生命周期相绑定,在退出Activity后协程任务销毁,后续不再继续emit出来事件。
class FlowActivity: AppCompatActivity() {
override fun onCreate(b: Bundle?) {
super.onCreate(b)
setContentView(R.layout.activity_flow)
lifecycleScope.launch(Dispatchers.IO) {
loadData().collect {
LLOG(it)
}
}
}
private fun loadData() = flow {
emit("start loading...")
delay(1000)
emit("get data")
delay(1000)
emit("start filtering data...")
delay(1000)
emit("data is ready")
delay(1000)
emit("stop loading...")
delay(1000)
}
}
日志输出如下,可以看到delay的间隔基本也在1s,会有几ms的误差。如果业务需求需要更加精准地消除误差,可以参考CSDN这篇文章。
2024-06-24 09:15:36.571 W start loading...
2024-06-24 09:15:37.573 W get data
2024-06-24 09:15:38.577 W start filtering data...
2024-06-24 09:15:39.580 W data is ready
2024-06-24 09:15:40.586 W stop loading...
以上就是最简单的Flow使用方法,我们可以概括其流程如下:
- 建立
flow函数 - 在
flow函数中通过emit发送Flow对象 - 在监听者的位置通过对
Flow对象调用collect进行监听
3. Flow的传递:filter、map、concat
filter函数对Flow对象进行过滤,返回过滤后的Flow序列。
override fun onCreate(b: Bundle?) {
super.onCreate(b)
setContentView(R.layout.activity_flow)
lifecycleScope.launch(Dispatchers.IO) {
flow().filter {
it > 2
}.collect {
LLOG("$it") // 3, 4
}
}
}
private fun flow() = flow {
repeat(5) { // 0..4
emit(it)
}
}
map则是转换。当然我们可以将filter、map的操作全都写在collect函数中,从而用一个collect包含所有过滤转换逻辑。但这样的话,功能全都耦合在一起,不能实现关注点分离的目标,是一种反模式。
map、filter还可以结合使用,这正是响应式编程的强大之处。
onEach函数的用法与map相似,但不应当在onEach中对数据进行写操作,而只是读操作,且不影响后续流程
override fun onCreate(b: Bundle?) {
super.onCreate(b)
setContentView(R.layout.activity_flow)
lifecycleScope.launch(Dispatchers.IO) {
flow().map {
it * 2
}.collect {
LLOG("$it") // 0 2 4 6 8
}
}
}
private fun flow() = flow {
repeat(5) {
emit(it)
}
}
flatMapConcat函数的入参是Flow,它可以生成一个新的Flow序列,在其内部必须通过emit进行数据生成。从名字可知,它能用于“摊平”二维及以上的数据结构,例如将一个List<List<Any>>的结构以Flow<Any>的格式进行发送,并且在发送新的Flow时可以增加delay等自定义操作。
flow()
.map {
it * 2
}
.collect {
LLOG("$it")
}
// 等价于
flow()
.flatMapConcat {
flow { // 注意必须再次创建flow
emit(it * 2)
}
}
.collect {
LLOG("$it")
}
4.在指定时间段内发布:FlatMapLatest与CollectLatest
flatMapLatest函数就更厉害了,在它发射新的Flow之前如果有其它Flow输入到它,会中止当前Flow的发射,继而使用新收到的Flow对象进行计算并发射。通过下面这个demo可以很好理解。
override fun onCreate(b: Bundle?) {
super.onCreate(b)
setContentView(R.layout.activity_flow)
lifecycleScope.launch(Dispatchers.IO) {
flow()
.flatMapLatest {
flow {
delay(1000) // 在1s等待中不断有输入,从而刷新1s等待时长
emit(it) // 迟迟不发射,直到最后才发射一个9
}
}
.collect {
LLOG("$it")
}
}
}
private fun flow() = flow {
repeat(10) {
delay(400)
emit(it)
}
}
对于“计时区间内统计发生次数并警报”这样的需求,可以通过flatMapLatest优雅地进行实现:
// 连续1分钟心率低于60,则发出警报Flow
private suspend fun monitorHeartRate() {
heartRateDataSource()
.filter {
it >= 60 // 只保留高于60的流
}
.flatMapLatest {
flow {
delay(60_000) // 如果60s内都没有高于60的心率,则发出警报
emit(it)
}
}
.collect {
LLOG("EMERGENCY! heart rate dropped below 60!")
}
}
// 心率数据来源
private fun heartRateDataSource() = flow {
var heartRate = 0
repeat (1000) {
delay(1000) // 每1s采集一次
heartRate = Random.nextInt(45, 65)
LLOG("heart rate is $heartRate")
emit(heartRate)
}
}
与flatMapLatest相似,collectLatest也可用于处理某个时间段内最后一个流,但不同的是它用于终结,而非生成新的Flow。
override fun onCreate(b: Bundle?) {
super.onCreate(b)
setContentView(R.layout.activity_flow)
lifecycleScope.launch(Dispatchers.IO) {
flow()
.onEach {
LLOG("map to $it") // 0 1 2 ... 9
}
.collectLatest {
delay(200) // 不断刷新
LLOG("we got $it") // 9
}
}
}
private fun flow() = flow {
repeat(10) {
delay(100)
emit(it)
}
}
5. 与视图紧密相关的StateFlow
StateFlow持有的是State状态,作用类似于ViewModel,可以在页面进行旋转等操作时保存数据用于重建。我们声明一个简单的文本状态。在ViewModel中,通常不可见的变量用下划线_开头。
ViewModel通过双变量互为表里,做到了数据的读写分离。
- 不暴露对象
_textState作为数据源,可以通过changeText函数来修改 - 暴露对象
textState用于观察
class SimpleViewModel: ViewModel() {
private val _textState = MutableStateFlow("") // 数据源,不公开
val textState = _textState.asStateFlow() // 公开供观察
fun changeText(text: String) {
_textState.update { text }
// 也可以写作如下
// _textState.value = text
}
}
在Activit中调用接口更新文本,并观察textState。这段代码如果使用Compose来实现将更加简单。
override fun onCreate(b: Bundle?) {
super.onCreate(b)
setContentView(R.layout.activity_flow)
mTvTitle = findViewById(R.id.tv_title)
mEtContent = findViewById(R.id.et_content)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) { // 只在CREATED状态下监听
simpleViewModel.textState.collectLatest { // 监听最新状态
mTvTitle?.text = it
}
}
}
mEtContent?.addTextChangedListener(object: TextWatcher {
override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
}
override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
}
override fun afterTextChanged(p0: Editable) {
simpleViewModel.changeText(p0.toString())
}
})
}
SharedFlow与Channel
最后一部分知识是关于SharedFlow和Channel的,它们的共同点是都用于发送一次性的事件。与表示页面状态且可以幸免于onConfigurationChanged事件的的StateFlow不同。SharedFlow和Channel可用于通知条、Toast等一次性Fire的事件。
最特别的是SharedFlow,它是热流(Hot Flow)。热流是与冷流(Cold Flow)相对的概念,对于冷流而言,在发起collect调用之前,emit发射不会真正的发生,也就是说,collect会接收到数据源发射的全部事件。而热流则不同,接收者只会收到它开始注册以后的事件。
这里用SharedFlow实现一个Toast功能,能够在FlowActivity启动后正常收到Toast。
// SimpleViewModel.kt
class SimpleViewModel: ViewModel() {
private val _sharedFlow = MutableSharedFlow<Int>()
val sharedFlow = _sharedFlow.asSharedFlow()
private val _channel = Channel<Int>()
val channel = _channel.receiveAsFlow()
init {
viewModelScope.launch {
delay(1000) // 延时1s以便监听者已完成注册
_sharedFlow.emit(123123)
}
}
}
// FlowActivity.kt, onCreate()
lifecycleScope.launch(Dispatchers.Main) {
repeatOnLifecycle(Lifecycle.State.CREATED) { // 只在CREATED状态下监听
simpleViewModel.sharedFlow.collect {
LTOAST("shared flow : $it")
}
}
}
如果把对SharedFlow的应用改成Channel也是一样的。
// 发送
_channel.send(456456) // 注意发送的接口不同
// 接受
simpleViewModel.chanel.collect {
// Toast
}
必须要提一下,在尝试这个功能的时候,自己犯了一个严重的错误,最初我是把监听sharedFlow的代码直接写在了onCreate中。
override fun onCreate(b: Bundle?) {
...
lifecycleScope.launch(Dispatchers.Main) {
repeatOnLifecycle(Lifecycle.State.CREATED) { // 只在CREATED状态下监听
simpleViewModel.textState.collectLatest { // 监听最新状态
mTvTitle?.text = it
}
simpleViewModel.channel.collect {
LTOAST("channel: $it")
}
}
}
...
}
结果发现Toast迟迟没有触发,但是把上面监听textState的代码去掉后,下面的Toast就能正常显示了,对此的解释是
请注意 collect 操作会挂起当前协程,直到流完全收集完成。在给定的情景中,textState 的 collect 会挂起协程,导致其后面的 sharedFlow.collect 代码无法执行,除非 textState 的 collect 被取消或流完全收集完成。适合的做法是为每个 collect 调用启动一个新的协程。为了修正这一点,我们需要对每一个 collect 启用一个单独的协程。
非常清晰明了,原来在一个协程里不能容纳两个collect挂起,正所谓一山不容二虎。改正后的写法如下,为每个collect启动单独的一个协程,就能正常运转。但这样是否会导致协程数量过多难以管控?我对此存疑。
lifecycleScope.launch(Dispatchers.Main) {
repeatOnLifecycle(Lifecycle.State.CREATED) { // 只在CREATED状态下监听
// 启动一个新的协程用于监听textState
launch {
simpleViewModel.textState.collect {
mTvTitle?.text = it
}
}
// 启动另一个新的协程用于监听sharedFlow
launch {
simpleViewModel.sharedFlow.collect {
LTOAST("shared flow : $it")
}
}
}
}
ChatGPT:SharedFlow和Channel的区别
SharedFlow 和 Channel 是 Kotlin 协程库中用于通信和事件分发的两种不同的原语。它们之间的主要区别体现在数据的发送和接收模式上。
SharedFlow:
- SharedFlow 是一个热流(
hot stream),一旦创建就开始工作,无论是否有收集器(collector)。 - SharedFlow 可以有
多个观察者,并且它不会对新的订阅者重播之前的事件,除非显式配置了回放缓冲区(replay buffer)。 - SharedFlow 允许您配置回放缓冲区的大小来保存最近发射的值,并且还可以
配置额外的属性,例如当缓冲区满了如何处理(例如挂起,丢弃最旧的值)。 - 适用于事件分发的场景,特别当您需要
多个观察者响应同一事件流时。 - SharedFlow 在
collect时不会挂起发送者(emitter),发送者和接收者是相互独立的。
Channel:
- Channel 是一种通信原语,你可以将其看作是协程之间的一个队列。Channel 有
容量限制,当渠道满时发送方会挂起,直到有空间可以发送消息。 - 当 Channel 中的数据被接收者从队列中取出后,数据就被消耗掉,其他协程则
无法再次接收到这个数据(除非你将值重新发送)。 - Channel 可以配置为
有缓冲区或无缓冲区(rendezvous),还可以配置为一个互斥通道(Mutex Channel)或无限容量的通道(Unlimited Channe)。 - 当接收者准备好接收数据时,发送者会发送一个消息;如果接收者没有准备好,发送者有可能挂起,表现出了一种协同(
cooperative)的行为。 - 通常用于协程间的
点对点通信,任务分发或多生产者,单消费者(MPSC)的场景。
结论:
在许多场景下,SharedFlow 可以被用作 Channel 的替代品,尤其是需要事件广播,或当事件源与消费者之间的关系不尽相同的情况。它们俩各有优势,但根据使用场景和需求的不同而选择不同的工具。