4. Kotlin协程进阶:Flow、StateFlow、SharedFlow与Channel

协程的基础用法大家应该都熟悉了。但真正到了生产环境,你会发现光有launchasync远远不够。数据流怎么处理?状态怎么共享?组件间怎么通信?

嗯,这就是我们今天要聊的内容。我个人觉得,这几个工具是区分「会用协程」和「用好协程」的分水岭。

4.1 Flow:冷数据流的正确姿势

先说说Flow。它本质上是一个冷数据流——什么意思?就是没有消费者的时候,它不会生产数据。这跟RxJava的Observable有点像,但更轻量,而且完全挂起式。

我在项目中遇到过这样一个场景:用户打开一个商品详情页,需要从三个接口拉数据——商品信息、评价列表、推荐商品。用Flow来编排,代码会非常干净。

// 商品详情页的数据流
fun loadProductDetail(productId: String): Flow<ProductDetailUiState> = flow {
    // 先展示骨架屏
    emit(ProductDetailUiState.Loading)
    
    // 并行请求三个接口
    val productInfo = async { repository.getProductInfo(productId) }
    val reviews = async { repository.getReviews(productId) }
    val recommendations = async { repository.getRecommendations(productId) }
    
    // 合并结果
    emit(ProductDetailUiState.Success(
        info = productInfo.await(),
        reviews = reviews.await(),
        recommendations = recommendations.await()
    ))
}.catch { e ->
    emit(ProductDetailUiState.Error(e.message ?: "未知错误"))
}.flowOn(Dispatchers.IO) // 指定上游在IO线程执行

这里有个细节要注意:flowOn只改变上游的上下文,下游的collect还是在原来的协程上下文里。说白了,你可以在IO线程拉数据,在主线程更新UI。

我的习惯:所有网络请求和数据库操作,都放在flowOn(Dispatchers.IO)后面。这样UI层永远不用关心线程切换的问题。

4.2 StateFlow:状态持有者的最佳选择

StateFlow,你可以把它理解成「可观察的状态容器」。它跟LiveData最大的区别是什么?

StateFlow需要初始值,而且它是无粘性的——等等,这话不准确。准确地说,StateFlow默认会重放最新值,但你可以通过WhileSubscribed策略来控制。

class UserViewModel : ViewModel() {
    // 私有化MutableStateFlow,对外暴露不可变的StateFlow
    private val _uiState = MutableStateFlow(UserUiState())
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
    
    fun updateUserName(name: String) {
        _uiState.update { it.copy(userName = name) }
    }
}

我曾经踩过一个坑:在Fragment的onViewCreated里直接collect StateFlow,结果每次屏幕旋转都会重新触发网络请求。为什么?因为StateFlow在Lifecycle.State.STARTED以下时,collect会挂起,但上游的flow还在跑。

避坑指南:我曾经在Activity里用lifecycleScope.launch去collect StateFlow,结果页面不可见时数据还在更新UI,直接Crash。后来改用repeatOnLifecycle(Lifecycle.State.STARTED)才解决。
// 正确的做法:在Lifecycle范围内收集
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { state ->
            // 更新UI
        }
    }
}

4.3 SharedFlow:事件总线的现代替代品

SharedFlow跟StateFlow最大的不同:它可以没有初始值,而且可以配置重放策略。说白了,StateFlow是「状态」,SharedFlow是「事件」。

你想想看,如果用户点击了一个按钮,需要弹出一个Snackbar。这个「弹出」是一个事件,不是状态。用StateFlow来存?不合适,因为屏幕旋转后状态还在,Snackbar会再次弹出。

class EventViewModel : ViewModel() {
    // SharedFlow配置:不重放,不缓存
    private val _events = MutableSharedFlow<UiEvent>(
        replay = 0,
        extraBufferCapacity = 1,
        onBufferOverflow = BufferOverflow.DROP_OLDEST
    )
    val events: SharedFlow<UiEvent> = _events.asSharedFlow()
    
    fun onButtonClick() {
        viewModelScope.launch {
            _events.emit(UiEvent.ShowSnackbar("操作成功"))
        }
    }
}

这里有个参数要特别注意:extraBufferCapacity。我刚开始用的时候没设这个值,结果emit的时候抛异常了——因为默认的SharedFlow没有缓冲区,emit会挂起直到有人collect。设成1之后,即使没有订阅者,事件也能发出去。

特性 StateFlow SharedFlow
初始值 必须 可选
重放 总是重放最新值 可配置(replay参数)
典型用途 UI状态 一次性事件
背压处理 无(总是最新值) 可配置溢出策略

4.4 Channel:协程间的管道通信

Channel,说白了就是协程版的BlockingQueue。一个协程往里放数据,另一个协程往外取。但它是挂起式的,不会阻塞线程。

我在项目中用Channel做过一个日志收集器:多个协程同时产生日志,一个消费者协程负责写入文件。

// 日志收集器示例
class LogCollector {
    private val logChannel = Channel<LogEntry>(Channel.BUFFERED)
    
    suspend fun log(entry: LogEntry) {
        logChannel.send(entry)
    }
    
    fun startCollecting(scope: CoroutineScope) {
        scope.launch(Dispatchers.IO) {
            for (entry in logChannel) {
                // 写入文件
                writeToFile(entry)
            }
        }
    }
}

Channel有几种类型:

  • RendezvousChannel:默认类型,没有缓冲区。send和receive必须同时准备好,否则挂起。
  • BufferedChannel:有缓冲区,满了之后send挂起。
  • ConflatedChannel:只保留最新值,旧值被覆盖。跟StateFlow有点像。
  • UnlimitedChannel:无限制缓冲区,小心OOM。
核心区别总结:
  • Flow:冷数据流,适合异步数据序列
  • StateFlow:热数据流,适合UI状态
  • SharedFlow:热数据流,适合事件
  • Channel:协程间通信,适合生产者-消费者模式

4.5 实战选择指南

说了这么多,到底什么时候用哪个?我给大家一个简单的判断标准:

  1. 数据是「状态」还是「事件」?状态用StateFlow,事件用SharedFlow。
  2. 数据是「冷」还是「热」?冷数据(如数据库查询)用Flow,热数据(如UI状态)用StateFlow。
  3. 需要点对点通信?用Channel。比如一个协程生产数据,一个协程消费数据。
  4. 需要多个订阅者?用SharedFlow。Channel是一对一的。

我个人习惯是:ViewModel里对外暴露StateFlow给UI层,内部用SharedFlow处理一次性事件。跨模块通信用Channel,但要注意生命周期管理。

嗯,最后说一句:这些工具没有银弹。你用得越多,越能体会到它们各自的适用场景。刚开始可能会选错,没关系,重构成本很低——因为它们的API设计都很相似。