如果你还不懂 Kotlin Flow,这里有一万字

对于那些希望以结构化且协程友好的方式处理数据的开发者而言,Kotlin Flow 已然成为了主流选择。要是你曾在安卓或普通 Kotlin 代码中花费时间应对回调、RxJava 或其他响应式框架,就会明白管理异步数据有多棘手。借助 Kotlin 协程的强大功能,Flow 提供了一种更简单、更直观的解决方案。

该文主要帮助读者理解和使用 Flow,如果你对 Flow 处于跃跃欲试的阶段或者刚开始尝试,那么本文,会帮助你迈开勇敢的一步------拥抱 Flow

由于行文的原因,部分词语会混用,它们的意思实际上是一样的;同时会碰到一些新的概念。我在这里,会做统一的解释:

  • 流/Flow
  • 通道/Channel
  • 冷流/Cold Flow
  • 热流/Hot Flow
  • 上游/UpStream:发送数据方
  • 下游/DownStream:接收数据方
  • 背压:下游的接收速度影响到了上游的发送速度

ChannelFlow 是不一样的,但是因为和 Flow 关系比较近,所以会稍微提到,这部分也可以不看,并不会影响对于 Flow 的理解。

引言

对于新手来说,诸如冷流、热流和结构化并发这类术语可能会令人心生畏惧。一个 Flow 是 "冷" 或 "热" 究竟是什么意思呢?这些 Flow 与协程如何交互?

本文我们将从一无所知到深入理解 Kotlin Flow。我们将探究 Flow 的独特之处,了解如何有效使用冷流和热流,掌握处理错误和并发的方法,以及学习如何进行测试和优化性能。

到最后,你一定能轻松地将 Flow 集成到你的下一个(或当前的!)Kotlin 项目中。

神奇的 Kotlin Flow

Flow 是什么

在深入探究之前,让我们先来明确一下 Flow 到底是什么。在 Kotlin 中,Flow 是一种类型,它代表异步计算的一系列值。

从概念上讲,你可以把 Flow 想象成一系列随着时间推移而逐个到达的事件、小项或数据点,而非一次性全部出现。

这有什么用呢?

现代应用程序常常需要处理动态或不断变化的数据,比如网络响应、数据库更新、用户界面状态变化、传感器读数等等。有了 Flow,你无需每次在需要数据时都手动拉取,它能让你自动对新数据作出响应。

在没有 Flow 之前,实时更新是如何做到的?

轮询,也就是写一个死循环,不断的要数据。但是这样有个问题,下游实际上并不知道数据何时会发生变化,上游只能被动的通知下游,当下游索要数据的时候,上游才更新数据。

Flow 与协程是如何关联的

Flow 是构建在协程之上的。

当你创建 Flow 或者从 Flow 中收集数据时,都是在一个挂起函数中进行的。

如果你已经使用过协程,就会知道协程提供了一种以直观的顺序式风格编写异步代码的方式,避免了回调的复杂性。

Flow 继承了这些协程原则,这意味着:

  • 无线程阻塞:由于 Flow 在挂起函数中运行,所以你可以暂停执行而不阻塞主线程。
  • 结构化并发:Flow 得益于这种设计原则,该原则能巧妙地组织异步任务,并确保如果父协程被取消,其所有子任务(比如你的 Flow 发出的数据)也会被取消。

可以将协程(进而也包括 Flow)视为一张安全网,能防止你的应用陷入未取消的作业或残留的后台任务所造成的混乱局面。这在资源管理至关重要的安卓应用和服务器应用中极为重要 。

Flow 是一种强大的抽象概念,它通过利用协程和结构化并发,简化了异步的、事件驱动的编程。

结构化并发:Flow 的基础

什么是结构化并发

如果你刚接触 Kotlin,结构化并发可能听起来像个谜,但它其实相当直观。

想象一下,你正在计划一次包含多个活动的旅行:航班、酒店预订、参观古迹等等。如果你取消了整个旅行,自然会取消所有预订、机票、酒店预订等等,这样你就不会继续为已取消的计划付费或前往。

在编程中,结构化并发类似:当你在某个作用域中启动一个协程时,它就成为该作用域的一个 "子" 协程。如果该作用域(即 "父" 作用域)被取消,所有子协程(包括你正在收集的任何 Flow)也会被取消。你不必手动清理每个 Flow,也不用担心忘记取消某些操作。这大大提高了代码的清晰度,并有助于避免资源泄漏以及在必须手动管理订阅时可能出现的其他问题。

同时,结构化并发类似于结构化编程------使用控制流结构来封装顺序语句和子程序。这使得开发者可以像编写同步代码那样编写异步代码。

相信使用过 Kotlin 协程的开发者一定深有体会。

Flow 是如何融入的

从设计本质上讲,Flow 依赖于结构化并发。当你在一个协程作用域(比如安卓系统中的 lifeCycleScope)内收集 Flow 时,这个 Flow 的生命周期会自动与父作用域绑定。例如,如果用户回退当前页面,相关的作用域就会被取消,Flow 收集操作也随之取消。这确保了应用程序不会在后台继续运行不必要的任务。

这是一个强大的概念:你在实现并发的同时,不用担心数据流的生命周期比需要它们的组件更长。

Flow 的冷与热

冷流和热流可以类比成不同类型的电视节目内容。

冷流电视节目

冷流类似于观看爱奇艺上的电影。每次按下播放键,节目都会从头开始,确保每位观众都能从第一集开始完整体验整个故事情节。这意味着在有人主动开始收集数据之前,这种流处于非活跃状态,不会消耗资源。

只有开始收集数据的时候,冷流才会开始产生数据。

热流电视节目

热流就像看中央五台的 NBA 比赛一样,无论是否有人在看,它都会一直播放。如果你从比赛的第四节才开始看,那么前三节的精彩内容你就会错过了,所有观众都在同时收看同一场比赛,同一场直播。

热流始终在主动发送数据,多个收集器共享这一股数据流,这意味着新的收集器可能接收不到在它们开始监听之前发送的数据。

在 Kotlin 中有两种主要的热流类型:

  • SharedFlow 就像电视直播:如果你加入得晚,那么你只能看到从加入时间点开始往后的内容。你可以将其配置为重播最近发出的数据(就像短期数字视频录像机的功能),但默认情况下,新的收集器只能接收在它们开始采集后发出的新数据。
  • StateFlow 就像一个电视频道,无论你何时收看,总是显示最近的一帧画面,哪怕你错过了之前的播出内容。它需要一个初始值,并始终保持最新发出的数据,确保新的收集器能够立即接收到当前状态。

相比之下:

  • SharedFlow 可以配置各种重放和缓冲策略。SharedFlow 创建之后,是没有值的。
  • StateFlow 始终只保存一个值,并需要一个初始值。即 StateFlow 在创建之后,就已经有值了。

这两种类型都允许多个收集器同时观察同一个数据流。

为什么叫"共享(Shared)"这个词呢?

SharedFlow 得名是因为所有下游收集器共享一个上游生产者。每个订阅者(大致)在同一时间看到相同的数据,这样就能避免产生重复的生产者或网络调用。可以想象有一条工厂传送带为许多工人输送物品的场景。

为什么 StateFlow 代表状态(而 SharedFlow 不代表)

StateFlow 有其特定设定:它始终保存一个状态快照。这个单一的、粘性的值使其非常适合用于 UI 模型、配置标志,或者适用于任何 "最新状态是什么?" 很重要的场景。

例如 UI 上的设置项,无论我们内部有没有新的设置变更,设置项始终会有一个值。

相比之下,SharedFlow 则比较中立;它将每一次数据发送都视为一个独立事件。除非设置重放,否则它不会存储历史数据。可将其用于即发即忘的通知(如轻提示、命令指令、分析信息发送),在这些场景中,记住最后一个值可能毫无意义,甚至会有负面影响。

冷流和热流的运行原理

从技术上讲,冷流(Flow)是惰性运行的,只有在收集器订阅时才开始发送数据,这使得它们非常适合需要获取完整且一致数据集的任务,比如从数据库加载信息或进行网络请求。

而热流(SharedFlowStateFlow)是主动运行的,无论是否有活跃的收集器,都会持续发送数据,这使得它们非常适合实时应用程序,如实时股票更新或聊天消息,在这些应用中最新数据至关重要。

通过了解这些区别,开发人员可以选择合适的类型来满足其特定的数据流需求。

冷流:按需流

默认情况下,Kotlin 中的流的标准实现 Flow<T> 是冷的。这意味着在有人开始从它那里收集数据之前,它什么都不做。每次新的收集操作都会触发该流从头开始发射数据,为每个收集器创建一个新的数据流 。

为每个收集器创建一个新的数据流------这很重要。

一个便于理解的现实比喻是把它想象成新闻文章获取:

  • 只有当读者点击阅读时,文章才会被下载。
  • 每个读者都会获取文章的一份全新副本,以确保他们能读到最新版本。
  • 如果多位读者请求获取该文章,每个人的请求都会触发一次网络请求。

在代码中,用于 Ktor 网络请求的冷流示例可能如下:

Kotlin 复制代码
fun getArticleFlow(articleId: String): Flow<Article> = flow {
    // 创建一个新的HTTP客户端
    HttpClient(CIO) {
        install(ContentNegotiation) {
            json()
        }
    }.use { client ->
        // 发起网络请求并将响应解析为 Article 对象
        val response: Article = client.get("https://api.example.com/articles/$articleId") {
            contentType(ContentType.Application.Json)
        }.body()

        // 将单个文章发送给流收集器
        emit(response)
    }
    // 无需为关闭客户端显式使用try/catch/finally代码块!
}  

直到某个协程实际收集它时才会发起请求:

Kotlin 复制代码
lifecycleScope.launch {
    getArticleFlow("123").collect { article ->
        // 处理文章
        println("收到文章:${article.title}")
    }
}

当你调用 collect 时,才会实际发起网络请求并发出文章数据。如果之后又有其他收集器,它会触发一次新的网络请求,以确保每个收集器都能从服务器获取到文章的最新版本。

热流:始终在广播

与冷流对应的是,热流(如 StateFlowSharedFlow)就像一场实时广播。无论是否有人在收听,数据都会不断发出。如果你中途收听,你收听到的是当前正在播放的内容,而不是从开头开始的完整广播。

  • StateFlow 会保留最新的值,所以新的收集器可以立即接收到最新的数据。
  • SharedFlow 可以同时向多个订阅者广播,并且你可以配置它是否重播。默认情况下,它不会重播过去的事件;它只会从你开始收集的那一刻起开始广播。

当你有连续的数据想要与多个观察者共享时,热流是非常理想的选择。

可以想象一下在送餐应用中的实时位置更新,或者应用中许多部分都需要访问的实时时钟。

使用 stateIn 和 shareIn 将冷流转换为热流

ViewModel 只能暴露一个冷流时,每个新的收集器都会再次触发底层操作------又一次 Room 查询、又一次 Retrofit 调用、又一次 Sensor 订阅。

如果用户界面层需要一个单一的、持续的数据流,该流在屏幕可见时持续存在,并能立即将当前值传递给任何后来加入的观察者,此处就不合适冷流。

Kotlin 为你提供了两个转换操作符来实现这一点:

  • stateIn 可将冷流转换为 StateFlow。由于 StateFlow 始终持有当前值,因此它需要一个初始值,这非常适用于屏幕必须立即显示的 UI 模型。
  • shareIn 可将冷流转换为 SharedFlow。默认情况下它不保存状态,但它可以并行地将相同的事件广播给多个收集器,并且如果你有需要,还可以重放最近的 n 次发射。

stateIn:提升为永不从头开始的状态:

Kotlin 复制代码
// 一个冷流(每次都会从数据库进行全新读取)
val userFlow: Flow<User> = userDao.observeUser(id)

// ViewModel 对其进行提升处理,以便 Compose 始终获取到最新的用户信息
class ProfileViewModel(
    userRepository: UserRepository
) : ViewModel() {

    val uiState: StateFlow<User> = userRepository.userFlow.stateIn(
        scope = viewModelScope,  // 该流的生存范围
        started = SharingStarted.WhileSubscribed(5_000), // 最后一个收集器停止后仍保持活跃5秒
        initialValue = User.LOADING_PLACEHOLDER // 必填的初始值
    )
}

stateIn 会启动上游流一次,将其发出的值共享给每个收集器,并在内存中保留最后一项,这样新的收集器在启动时就能看到最新的数据。

可以将其想象成一个计分板:即使你暂时移开视线然后再看回来,也无需从比赛开始就观看,就能看到当前比分。

shareIn:广播实时数据

Kotlin 复制代码
// 每次收集都会启动其自己的传感器监听器(这不是我们期望的)
val rawGyro: Flow<GyroReading> = sensorApi.gyroscopeFlow()

// 在生命周期较长的服务范围内将其提升为热流
val sharedGyro: SharedFlow<GyroReading> = rawGyro.shareIn(
    scope = serviceScope,
    started = SharingStarted.Eagerly, // 立即启动,永不停止
    replay = 0 // 仅实时;若要实现回放功能,可使用大于 0 的值
)

当应用的许多部分需要观察相同的事件(例如位置更新、WebSocket 消息、分析数据发送),而又不想生成重复的生产者时,请使用 shareIn

如果较晚订阅的用户必须看到最新的项目,请将 replay 设置为 1;对于纯实时流,将其保持为 0 以最小化内存使用。

SharingStarted 参数应该如何设置

SharingStarted 告知 stateIn / shareIn 何时启动上游工作以及何时停止:

  • Lazily:首次有人收集时开始;所有人不再订阅后立即停止。
  • WhileSubscribed(timeoutMillis):与 Lazily 策略类似,但在最后一个订阅者断开连接后,会让上游在一段宽限期内保持运行,以缓解安卓系统上快速的配置更改问题。
  • Eagerly:立即开始且永不停止,即使没有人在监听;这对于那些无论有无观察者都必须运行的后台服务很有用。

常见陷阱

  • 调用 stateIn 时忘记初始值会导致编译错误,需提供一个占位符或缓存副本。
  • shareIn 使用较大的重放缓冲区会消耗大量内存;除非确实需要长期历史记录,否则应保持较小的重放值。
  • 选择错误的作用域可能会过于频繁地重启上游数据流。在安卓系统中,对于屏幕级状态,建议使用 viewModelScope;对于应用级数据流,则使用 ApplicationService 作用域。

探究 Kotlin 中的不同流

Kotlin 官方提供了三种主要类型的流(还有通道,它与流有略有不同)。

下面来看看每种类型的适用场景。

标准流(冷流)

普通的 Flow<T> 默认是冷流,非常适合一次性或延迟任务。如果你需要从网络中获取一些数据,对其进行转换,然后传递出去,冷流就是你的不二之选。 一旦所有的值都被发送出去,这个流就完成了。

Kotlin 复制代码
fun simpleFlow(): Flow<Int> = flow {
    for (i in 1..5) {
        delay(1000)
        emit(i)
    }
}

用例:

  • 按需从数据库或网络获取数据。
  • 仅在需要时进行计算。

SharedFlow(热流)

SharedFlow 是热流,能让多个订阅者同时接收相同的发射数据。它不会保留 "当前值",除非你显式地为其配置一些重放功能。它常用于广播事件。

Kotlin 复制代码
val mySharedFlow = MutableSharedFlow<Int>()

suspend fun produceValues() {
    for (i in 1..5) {
        delay(1000)
        mySharedFlow.emit(i) // 广播这些值
    }
}

用例:

  • 发送应用程序的多个部分都能观察到的全局事件或消息。
  • 日志框架或应用内的事件总线。

StateFlow(热流)

StateFlow 同样是热流,但它专门用于持有单个状态值。

当有新的收集器订阅时,它会立即接收到当前值(始终保持最新状态,所以它需要一个初始值,如果上游没有发送任何数据,当收集该流时,就会使用默认值)。

Kotlin 复制代码
val myStateFlow = MutableStateFlow(0)

suspend fun incrementState() {
    myStateFlow.value += 1
}

用例:

  • 界面状态管理:屏幕的状态可以存储在 StateFlow 中,任何观察它的可组合项或视图在状态变化时都会立即更新。
  • 用更适合协程的方式取代 Android 中诸如 LiveData 之类的旧模式。

通道

通道是构建流的基础通信原语,充当允许协程相互通信的管道。

它们具有一些独特的特性,使其在特定场景中很有用。

先到先得的传递方式:与 SharedFlow 不同(SharedFlow 会向所有收集器广播),通道会将每个值准确传递给一个接收者。这使得它们非常适合以下场景:

  • 用户界面事件处理(确保每次点击都只被处理一次);
  • 在多个消费者之间分配工作(比如任务队列);
  • 需要确保处理每个值的场景。

因此,尽管通道与流类似也会随着时间发射数据,但我们不能将它们归类为流,因为它们的行为有所不同。

什么时候用什么流

选择合适的流类型可能会决定你应用程序设计的成败。以下是一个快速指南:

  • 冷流:当你需要按需获取数据时非常适用。例如,按下按钮触发网络调用,并且你仅在该操作期间收集结果。

  • SharedFlow:当你希望多个观察者看到相同的事件流时很有用。例如,你的应用程序有一个全局事件总线,各种功能可以并行监听。

  • StateFlow:对于屏幕(或其他组件)开始观察时必须立即可访问的用户界面状态非常理想。当状态流的值发生变化时,Jetpack Compose 或其他响应式用户界面可以自动重新渲染。

  • 通道:如果你需要更精细、更低级别的控制(例如,在多个工作线程之间分配任务或协调复杂的生产者 - 消费者模式),通道可能是一个不错的选择,但它们不是流!

如何收集流的数据

定义好流之后,需要收集它才能接收到其值。

收集本身是一个挂起操作------确保该过程在结构化并发上下文中进行。

Kotlin 复制代码
suspend fun observeFlow() {
    simpleFlow().collect { value ->
        println("Received: $value")
    }
}

在安卓系统中,你通常会在 lifecycleScopeviewModelScope 内部执行此操作:

Kotlin 复制代码
    simpleFlow().collect { value ->
        // 更新 UI
    }
}

收集流是一个挂起操作,这让遵守安卓组件的生命周期变得简单,并且能在不需要后台任务时关闭它们。

你可以使用诸如 mapfilterflatMapLatestonStartonCompletion 等操作符对数据流进行转换或处理,形成强大的数据转换管道。

由于数据流的收集与协程作用域紧密相连,因此当作用域被取消时(例如用户导航离开),数据流会自动停止发送数据。

操作符

Flow 提供了一组操作符,让数据转换变得轻而易举。你可以链式调用诸如 mapfilteronStartonCompletionflatMapLatest 等众多操作符。这种构建"管道"的函数式风格可以替代嵌套回调或复杂的循环。例如:

Kotlin 复制代码
simpleFlow()
    .map { it * 2 }
    .filter { it > 5 }
    .onStart { println("Starting flow!") }
    .onCompletion { println("Flow completed!") }
    .collect { println("Final Value: $it") }

操作符使你能够定义按顺序运行的逻辑,每一步都对数据进行转换或做出响应。

与旧的异步模式相比,这正是流强大且简洁的部分原因。

实战举例

由于流在异步、事件驱动的环境中表现出色,因此它们几乎可以应用于任何地方,从用户界面状态管理到网络请求以及数据库更新。

与 Jetpack Compose 共舞

Jetpack Compose 是一款用于 Android 的现代用户界面工具包,它能在数据发生变化时自动重新组合用户界面。这与 StateFlow 完美契合。以下是基于 ViewModel 的基本状态管理方法:

Kotlin 复制代码
class CounterViewModel : ViewModel() {
    private val _count = MutableStateFlow(0)
    val count: StateFlow<Int> = _count

    fun increment() {
        _count.value++
    }
}

@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    val countValue by viewModel.count.collectAsState()

    Column {
        Text("Count: $countValue")
        Button(onClick = { viewModel.increment() }) {
            Text("Increment")
        }
    }
}

increment() 更新 _count.value 时, collectAsState() 调用会在 Compose 中触发一次重新组合,即时更新显示的计数器。

是不是很简洁?

Retrofit 的最佳拍档

网络请求是典型的异步任务------如果只想按需获取数据,那么非常适合使用冷流:

Kotlin 复制代码
interface ApiService {
    @GET("users")
    suspend fun getUsers(): List<User>
}

fun fetchUsersFlow(): Flow<List<User>> = flow {
    val users = apiService.getUsers()
    emit(users)
}

你可能还需要重复轮询的操作。这种情况下,可以将逻辑包装在一个带有延迟的 while (true) 循环中,持续发出新的数据。

Room 的青梅竹马

较新的 Room(安卓的数据库组件)原生就支持流,可谓是流的青梅竹马。

Room 中定义的数据库操作函数可以返回一个 Flow

下面这个例子,当我们每次调用 insertUser 时,getAllUsersFlow 返回的流就会重新发出新的数据:

Kotlin 复制代码
@Dao
interface UserDao {
    @Query("SELECT * FROM users")
    fun getAllUsersFlow(): Flow<List<User>>

    @Insert
    suspend fun insertUser(user: User)
}

class UserRepository(private val userDao: UserDao) {
    fun observeUsers(): Flow<List<User>> = userDao.getAllUsersFlow()
}

viewModelScopelifecycleScope 中收集此流,Room 能够确保当本地数据库中的数据发生变化时,UI 自动刷新,无需额外的进行 "获取数据 -> 更新 UI" 操作。

错误处理

当一个流执行时,它可能会以两种截然不同的方式完成:运行时错误或取消。将这两种情况同等对待是一种常见且严重的错误。理解它们的不同用途是编写健壮、无资源泄漏的异步代码的关键。

  • 运行时错误(如 IOExceptionIllegalStateException 等):这些错误代表任务本身出现了问题。比如网络请求失败,或者数据无法解析。我们通常的目标是通过重试或提供一个备用值来从这些错误中恢复。
  • 取消(CancellationException):这并非传统意义上的错误。它是来自父协程作用域的一个协同指令,告知流 "你的工作不再需要,请正常停止" 。唯一正确的响应是停止所有操作并清理任何已获取的资源。

取消机制

要正确处理取消操作,你必须首先了解 CancellationException 是什么,以及它是如何实现结构化并发的。

  • 含义:它是一个信号,而不是一次失败。可以把它想象成管理层下达的 "停工" 指令,而不是装配线上损坏的工具。
  • 作用:它是结构化并发的驱动因素。它会在协程层级结构中向下传播,确保当一个父任务被取消时,其所有子任务(包括正在运行的流)都能可靠地停止。

要了解其原理,让我们来看看它在何时以及为何会被抛出。

CancellationException 并非抛出协程就能取消。

正在运行的协程必须在特定的、可预测的称为检查点的位置配合取消请求。

当你调用 job.cancel() 时,你并不是直接抛出一个异常。你只是将 Job 上的一个开关从"活动"切换到"取消中"。实际上,异常是由可取消的挂起函数抛出的。

kotlinx.coroutines 中的几乎所有挂起函数(如 delay()yield()withContext() 或收集一个流)都是检查点。调用这些函数时,它们首先会检查协程的 Job 是否已被取消。

如果已被取消,它们会立即抛出 CancellationException,而不会执行其自身的工作。

这个过程会触发对协程树进行安全、可控的拆除:

  1. 下达命令:一个父 Job 被取消(例如,viewModelScope.cancel())。它会立即通知其所有子 Job 也必须取消。
  2. 到达检查点:一个子协程尝试调用诸如 delay(1000) 这样的挂起函数。
  3. 抛出异常:delay() 函数作为一个检查点,看到其 Job 上的"取消中"标志,并抛出 CancellationException
  4. 展开调用栈:该异常会停止协程的正常执行并展开其调用栈。这就是所谓的"终止"工作------检查点之后的代码永远不会被执行。随着调用栈的展开,finally 块会被执行,确保资源能够妥善清理。

为什么它是安全的,不会导致应用崩溃?

这是该设计最巧妙的部分。协程框架将 CancellationException 视为一种特殊的、非致命信号。像 launchasync 这样的协程构建器会在内部的 try-catch 块中包装你的代码。

当这个块捕获到 CancellationException 时,它会将其识别为取消请求的成功完成。它会悄然吞下该异常并继续执行,这就是为什么你的应用不会崩溃。

对于取消,有一条黄金法则:尊重它!

既然我们了解了其中的机制,那么这条最重要的规则就完全能理解了。

结构化并发依赖于允许 CancellationException 发挥其作用。

如果你在通用的 catch (e: Exception) 块中捕获此异常却不重新抛出它,协程就会忽略停止命令,变成一个僵尸协程,从而泄漏内存、CPU 和其他资源。

规则很简单:任何可能拦截 CancellationExceptioncatch 块都必须重新抛出该异常。

Kotlin 复制代码
// 关键点在这里
.catch { error ->
    if (error is CancellationException) {
        // 始终重新抛出取消信号
        throw error
    }
    // 在此处处理其他运行时错误
    emit(emptyList())
}

要正确处理取消操作,你必须首先了解它:

  • 它的含义:它是一个信号,而非故障。可以将其视为管理层下达的 "停止工作" 指令,而不是装配线上损坏的工具。
  • 抛出它的原因:当协程的父 Job 被取消时,它会自动抛出。例如,当用户离开屏幕并且安卓系统中的 viewModelScope 被清除时。
  • 它的作用:它实现了结构化并发。它会在协程层次结构中向下传播,确保当父任务被取消时,其所有子任务(包括正在运行的数据流)都能可靠地停止。

进行正确的清理:finally 块

如果 catch 块用于处理错误,那么像关闭客户端这样的清理任务应该用什么呢?

答案是 finally 块。

无论 try 块是正常退出、抛出错误还是被取消,finally 块都肯定会执行。

Kotlin 复制代码
// 这展示了如何正确使用 `finally` 块进行清理操作
fun getArticleFlow(articleId: String): Flow<Article> = flow {
    val client = HttpClient(...) // 1. 获取资源

    try {
        // 2. 执行工作
        val response: Article = client.get("...").body()
        emit(response)
    } finally {
        // 3. 这段代码总会运行,确保进行清理
        println("正在关闭 HTTP 客户端。")
        client.close()
    }
} 

现在,让我们看看在坚持这条黄金法则的同时如何处理操作错误。

处理错误的策略

处理操作错误主要有两种策略。

  • 传播(try-catch):当收集器需要了解故障情况时使用此方法。这对于一些关键错误很适用,在这些情况下,用户界面必须针对问题做出特定反应(例如,显示 "无法加载数据" 的界面)。
  • 回退(catch 操作符):当数据流能够优雅地恢复时使用此方法。这对于非关键错误或者当默认状态(如空列表)是可接受的结果时非常适用。

我们先来看看传播策略,也就是下游的 try-catch

当你希望某个错误终止数据流并由收集器进行处理时,你可以让错误进行传播。

实现这一点最直接的方法是将收集调用包裹在 try-catch 块中。数据流本身并不处理错误,它只是将问题传递给其使用者:

Kotlin 复制代码
// 此方法用于错误传播
lifecycleScope.launch {
    try {
        fetchUsersFlow().collect { users -> }
    } catch (e: CancellationException) {
        // 始终重新抛出CancellationException
        throw e
    } catch (e: Exception) {
        // 错误在此处传播。
        // 收集器决定如何处理(例如,显示错误消息)。
    }
}

注意对 CancellationException 的特别处理。虽然在许多情况下,简单的 catch (e: Exception) 也能奏效,但显式捕获 CancellationException 更为安全。这样能清楚表明,在处理其他异常时,你也考虑到了取消操作。

这种方法简洁明了,并且将错误处理逻辑保留在发起操作的组件中(例如,ViewModel 或 UI 层)。

如果在 fetchUsersFlow() 内部或收集过程中抛出任何异常,都会被你的 try-catch 块捕获。

接着,我们看看 catch 操作符,使用在上游回退策略。

当你不想让错误中断流程时,可以通过提供一个备用方案在前端处理它。catch 操作符会拦截异常,并允许你发出一个默认值,从而有效地从错误中"恢复"。从收集器的角度看,就好像没有发生过错误一样。

Kotlin 复制代码
// 此方法提供了一个备用方案
fun fetchWithFallback(): Flow<List<User>> = flow {
    emit(apiService.getUsers())
}.catch { error ->
    // 检查是否为取消操作并重新抛出异常
    if (error is CancellationException) {
        throw error
    }

    // 为其他错误提供备用方案
    emit(emptyList())
}

如上代码所述,如果发生网络故障,收集器接收到的是一个空列表而不是异常,这样应用程序就能正常继续运行。

我再多送一种策略,重试。

对于诸如不稳定网络连接之类的瞬时错误,retry 操作符非常实用。它可以重新执行上游流,你还可以将其与 catch 结合使用,以便在所有重试尝试都失败后提供备用方案。

Kotlin 复制代码
fun resilientFlow(): Flow<List<User>> = flow {
    emit(apiService.getUsers())
}
.retry(retries = 3) { cause -> cause is IOException }
.catch { error ->
    // 关键:此处仍需进行此项检查!
    if (error is CancellationException) throw error
    // 所有重试均用尽后的最终备用方案
    emit(emptyList())
}

这种方法在最终捕获最终错误并发出空列表之前,如果遇到 IOException,最多会尝试三次。

这种策略和回退策略其实是类似的,只不过该策略在碰到异常的时候有机会再尝试几次。

流的测试

测试异步代码可能会很棘手,但 Kotlin 的 kotlinx.coroutines.test 库能让你控制协程调度器和虚拟时间,从而简化了这一过程。

冷流的单元测试

对于发出有限数量项的冷流,你可以将它们收集到一个列表中,并与预期结果进行比较:

Kotlin 复制代码
class FlowUnitTest {
    @Test
    fun testSimpleFlow() = runTest {
        val values = simpleFlow().toList()
        assertEquals(listOf(1, 2, 3, 4, 5), values)
    }
}

runTest 函数可确保所有操作都在受控的测试环境中运行,而 toList() 会将流发出的数据收集到一个标准列表中。

热流的单元测试(StateFlow、SharedFlow)

测试 StateFlow

Kotlin 复制代码
@Test
fun `test state flow emits updated values`() = runTest {
    val stateFlow = MutableStateFlow(0)
    stateFlow.value = 1
    val values = mutableListOf<Int>()

    val job = launch {
        stateFlow.take(2).toList(values)
    }

    // 由于我们将值更新为 1,收集器应该看到 "1"
    assertEquals(listOf(1), values)

    job.cancel()
}

由于 StateFlow 始终保存最新值,因此收集器会立即看到1。

对于 SharedFlow,你可以采用类似的方法,在测试协程中启动一个收集器,发送值,并检查收集到了什么。

CashApp 中的 Turbine 工具(这是一个小型测试库,允许你在 "测试涡轮机" 中收集流,逐步断言值)也提供了一种结构化的方法来测试流,而无需手动管理列表。

性能与背压

在处理流时,尤其是高频或大量数据的流时,性能至关重要。

当生产者发送项目的速度快于消费者的处理速度时,就会出现背压。

在 Kotlin 流中,这个过程很大程度上是自动处理的------如果消费者跟不上,生产者会暂停,直到消费者再次准备好。

让 Flow 在另一个调度器运行

默认情况下,流会继承收集器的协程上下文。如果你需要在主线程之外进行 CPU 密集型工作,可以指定一个不同的调度器:

Kotlin 复制代码
fun heavyFlow(): Flow<Int> = flow {
    for (i in 1..1000) {
        // 一些繁重的计算
        emit(i)
    }
}.flowOn(Dispatchers.Default) // 在 Default 调度器上发射数据

上述代码会将繁重的工作从主线程中转移出来。

缓冲

Kotlin 提供了诸如 buffer()conflate()collectLatest() 之类的操作符,用于处理数据生成速度快于消费速度的情况:

  • buffer():将发射的数据临时存储在缓冲区中,以便生产者可以继续进行。消费者可以按照自己的节奏从这个缓冲区读取数据。
  • conflate():如果在消费者处理旧数据之前有新数据到达,它会丢弃旧数据,只保留最新的发射数据。
  • collectLatest():当新的发射数据到达时,取消任何正在进行的旧发射数据的处理工作,确保只处理最新的项目。

在只需要最新更新的场景中(例如快速更新的传感器),这些操作符可以显著提高性能。

基准测试

当性能至关重要时,要进行基准测试。你可以运行压力测试,查看数据流在负载下的表现,测量内存使用情况,并试验各种缓冲策略。kotlinx.coroutines.test 库能让你精确控制执行过程,从而提供帮助。对于实际生产级别的基准测试,你可能还需要在预发布环境中收集性能指标。

常见毛病

即使使用流,你也会遇到问题。

忘记收集

症状:我的流没有任何反应。

解决方案:确保你调用了 .collect { ... } 或其他终端操作符(如 toList()first() 等)。没有收集器,冷流永远不会启动。

多个收集器引发意外行为

症状:为什么我的数据被多次获取或处理?

解决方案:如果你只想要一个共享数据源,考虑使用 StateFlowSharedFlow 来替代冷流,因为每当有人收集冷流时,它都会被重新触发。

UI 卡顿

症状:应用的 UI 卡顿或者无响应。

解决方案:在主线程之外执行大量计算或阻塞式输入/输出操作。使用 flowOn(Dispatchers.IO)flowOn(Dispatchers.Default)。避免在流逻辑中出现阻塞 UI 线程的情况。

操作完成后流仍持续工作

症状:即使用户已经退出页面,流仍在不断发出数据。

解决方案:始终使用结构化并发。在一个作用域内启动流,当不再需要该流时,此作用域能够取消流。对于安卓系统,通常使用 lifecycleScopeviewModelScope

未处理的异常

症状:应用程序因意外错误而崩溃。

解决方案:使用 try-catchcatch 或全局处理器来妥善处理错误。同时还要确保没有忽视严重的异常。

总结

Kotlin 流为异步和响应式编程带来了全新的方法。通过与协程无缝结合,流继承了结构化并发的强大功能,使你能够管理流,而无需手动跟踪订阅,也避免了在不再需要任务时任务仍在运行的风险。

要根据不同的场景选择合适的流。冷流适用于按需生成的序列,StateFlow 用于即时数据访问,SharedFlow 用于数据广播,而当需要底层控制时则选择通道。

总结一下流的要点:

  • 流的类型:根据你的用例,在冷流(默认 Flow)、共享流(SharedFlow)和状态流(StateFlow)之间进行选择。冷流按需启动;热流持续运行或保存最新值。
  • 集成:流能自然地融入安卓开发,包括 Jetpack Compose、使用 Retrofit/Ktor 进行网络调用,以及 Room 数据库查询。
  • 错误处理:使用 try-catchcatch,并考虑对间歇性故障进行重试。确定你是要提供一个备用方案,还是将错误传播给更高级别的处理程序。
  • 测试:kotlinx.coroutines.test 库(以及像 Turbine 这样的第三方工具)可以让你轻松测试冷流和热流,能以结构化的方式控制虚拟时间并收集值。
  • 性能:内置的背压机制确保如果消费者跟不上,流能够优雅地暂停生产者。像 buffer()conflate()collectLatest() 这样的操作符可以进一步优化吞吐量。

展望未来,流应该会继续成为任何 Kotlin 开发者工具库的核心部分。它们用一种更简洁、地道的异步编程风格,取代了复杂的回调树和难以驾驭的响应式框架。无论你是在构建一个多屏幕的安卓应用,还是一个基于 Kotlin 的服务器,流都为处理随时间到达的数据提供了一种强大且易于上手的方法。

简而言之,Kotlin 流让你能够:

  • 以挂起、非阻塞的方式生成数据。
  • 用丰富的操作符对其进行转换。
  • 在尊重生命周期边界的结构化上下文中消费它。
  • 轻松地对其进行测试。

你可以自由探索一些高级模式,例如合并流(combinezip)、管理并发(flatMapMergeflatMapLatest),以及实现自定义的背压策略。

随着时间的推移和不断实践,你会发现流为 Kotlin 中的异步代码带来了一种更强大、更易于维护的解决方案。

相关推荐
阿巴斯甜11 小时前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker12 小时前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq952713 小时前
Andorid Google 登录接入文档
android
黄林晴14 小时前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab1 天前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿1 天前
Android MediaPlayer 笔记
android
Jony_1 天前
Android 启动优化方案
android
阿巴斯甜1 天前
Android studio 报错:Cause: error=86, Bad CPU type in executable
android
张小潇1 天前
AOSP15 Input专题InputReader源码分析
android
_小马快跑_1 天前
Kotlin | 协程调度器选择:何时用CoroutineScope配置,何时用launch指定?
android