使用Kotlin Flow进行函数式编程

998 阅读8分钟

我们在一生中最为辉煌的,并不是功成名就那一天,而是从悲叹和绝望中产生对人生挑战的欲望,并且勇敢迈向这种挑战的那一天

Flow不是什么新东西,它是响应式函数编程(Reactive Functional Programming, RFP)在Kotlin语境下的一种实现。相比于RxJava,Flow根植于Kotlin技术栈,可以利用Kotlin各种高效的语法糖,以及协程框架。

掌握Flow技术栈的难点在于理解响应式编程的理念,尤其是对于写面向对象程序10余年的我来说,属实有点转不过这个弯来。但没办法,一方面项目硬性需要,另一方面老旧的技术栈也必须时时更新。这个Flow啃也得啃下来。

整篇Blog预计分为6大部分,通过阅读这篇文章,应当可以建立起对响应式编程/Flow的基础认识,并能在项目中尝试应用这种技术。以下是目录:

  1. Flow的基本思想
  2. 一个简单的Flow例子
  3. Flow的传递:FilterMapConcat
  4. 在指定时间段内发布:FlatMapLatestCollectLatest
  5. 与视图紧密相关的StateFlow
  6. 热流&冷流:SharedFlowChannels

1.Flow的基本思想

image.png

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使用方法,我们可以概括其流程如下:

  1. 建立flow函数
  2. flow函数中通过emit发送Flow对象
  3. 在监听者的位置通过对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则是转换。当然我们可以将filtermap的操作全都写在collect函数中,从而用一个collect包含所有过滤转换逻辑。但这样的话,功能全都耦合在一起,不能实现关注点分离的目标,是一种反模式。

mapfilter还可以结合使用,这正是响应式编程的强大之处。

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

最后一部分知识是关于SharedFlowChannel的,它们的共同点是都用于发送一次性的事件。与表示页面状态且可以幸免于onConfigurationChanged事件的的StateFlow不同。SharedFlowChannel可用于通知条、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的区别

SharedFlowChannel 是 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 的替代品,尤其是需要事件广播,或当事件源与消费者之间的关系不尽相同的情况。它们俩各有优势,但根据使用场景和需求的不同而选择不同的工具。

参考资料