Kotlin 协程中的热数据源与冷数据源

Kotlin 协程中的热数据源与冷数据源

导言

数据源通常分为热数据源和冷数据源。热数据源会主动产生数据,生产过程通常独立于消费者;冷数据源只有在消费者真正需要数据时才开始执行。

本文整理 List、Sequence、Channel 和 Flow 的执行模型,并说明如何根据数据来源、是否需要挂起以及是否需要协程取消来选择合适的类型。

一、热数据源与冷数据源

热数据源

热数据源通常具有以下特点:

  • 创建或启动后就可能立即执行;
  • 不需要等待消费者请求;
  • 可以独立于消费者产生元素;
  • 可能保存已经产生的元素;
  • 多次使用时通常不需要重新计算。

常见的热数据源包括 List、Set、Channel、StateFlow 和 SharedFlow。

冷数据源

冷数据源通常具有以下特点:

  • 惰性执行;
  • 只有真正需要元素时才开始计算;
  • 通常不保存中间结果;
  • 只执行必要的操作;
  • 可以表示无限数据;
  • 每次使用时可能重新执行。

常见的冷数据源包括 Sequence、Java Stream、普通 Kotlin Flow 和 RxJava 数据流。

热数据源 冷数据源
List、Set Sequence
Channel Java Stream
StateFlow、SharedFlow 普通 Flow

这只是通常情况,并不是绝对规则。例如,buffer 和 channelFlow 会让 Flow 的上游提前执行,而 SharedFlow 和 StateFlow 本身就是热流。

二、List 是立即计算的数据集合

List 表示一个已经计算完成或已经存在的集合。调用方通常需要等待所有元素准备完成后,才能得到这个列表。

kotlin 复制代码
fun getList(): List<String> =
    List(3) { index ->
        Thread.sleep(1_000)
        "User$index"
    }

fun main() {
    val list = getList()

    println("Function started")

    list.forEach {
        println(it)
    }
}

输出:

text 复制代码
(约 3 秒后)
Function started
User0
User1
User2

List.map 也会立即执行:

kotlin 复制代码
val result = listOf(1, 2, 3).map {
    println("Processing $it")
    it * 2
}

调用 map 时,所有元素都已经被处理。

三、Sequence 是惰性数据源

如果元素可以逐个计算,并且希望生成一个元素后立即使用它,可以使用 Sequence。

kotlin 复制代码
fun getSequence(): Sequence<String> =
    sequence {
        repeat(3) { index ->
            Thread.sleep(1_000)
            yield("User$index")
        }
    }

fun main() {
    val sequence = getSequence()

    println("Function started")

    sequence.forEach {
        println(it)
    }
}

输出:

text 复制代码
Function started
(约 1 秒)
User0
(约 1 秒)
User1
(约 1 秒)
User2

Sequence 创建时不会执行:

kotlin 复制代码
val sequence = sequenceOf(1, 2, 3)
    .map {
        println("Processing $it")
        it * 2
    }

println("Sequence created")

此时只会打印 Sequence created。调用终止操作后才会真正计算:

kotlin 复制代码
val first = sequence.first()
println(first)

输出:

text 复制代码
Processing 1
2

first() 只需要第一个元素,因此后面的元素不会被处理。

中间操作与终止操作

中间操作只描述计算规则,例如:

text 复制代码
map、filter、take、drop、sortedBy

终止操作才会触发计算,例如:

text 复制代码
first()、find { ... }、toList()、count()、sum()、forEach { ... }

可以理解为:

text 复制代码
中间操作:描述如何计算
终止操作:真正执行计算

Sequence 可以表示无限数据

kotlin 复制代码
val numbers = generateSequence(1) {
    it + 1
}

println(numbers.take(5).toList())

输出:

text 复制代码
[1, 2, 3, 4, 5]

Sequence 也适合读取文件:

kotlin 复制代码
import java.io.File

fun countCharactersInFile(path: String): Int =
    File(path).useLines { lines ->
        lines.sumOf { line -> line.length }
    }

四、List 与 Sequence 的处理顺序

kotlin 复制代码
fun mapValue(value: Int): Int {
    print("m$value ")
    return value * value
}

fun filterValue(value: Int): Boolean {
    print("f$value ")
    return value >= 10
}

List 会先完成全部 map,再执行 find:

kotlin 复制代码
listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
    .map { mapValue(it) }
    .find { filterValue(it) }

输出:

text 复制代码
m1 m2 m3 m4 m5 m6 m7 m8 m9 m10 f1 f4 f9 f16 16

Sequence 会对每个元素完整执行处理链:

kotlin 复制代码
sequenceOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
    .map { mapValue(it) }
    .find { filterValue(it) }

输出:

text 复制代码
m1 f1 m2 f4 m3 f9 m4 f16 16

找到 16 后就停止,因此不会处理后面的元素。

五、Sequence 不能直接调用挂起函数

Sequence 的终止操作是普通函数,Sequence.iterator() 也不是挂起函数。因此不能在 Sequence 构建器中直接调用 delay 或其他普通挂起函数:

kotlin 复制代码
fun getSequence(): Sequence<String> =
    sequence {
        repeat(3) {
            delay(1_000) // 编译错误
            yield("User$it")
        }
    }

原因可以表示为:

text 复制代码
挂起函数 delay()
    ↓
Sequence 的普通 iterator()
    ↓
无法挂起协程

因此,下面这种分页网络请求不应该使用 Sequence:

kotlin 复制代码
fun allUsersSequence(api: UserApi): Sequence<User> =
    sequence {
        var page = 0

        do {
            // takePage 是挂起函数,无法在 sequence 中调用
            val users = api.takePage(page++)
            yieldAll(users)
        } while (!users.isNullOrEmpty())
    }

六、Sequence 可能阻塞其他协程

kotlin 复制代码
fun getSequence(): Sequence<String> =
    sequence {
        repeat(3) { index ->
            Thread.sleep(1_000)
            yield("User$index")
        }
    }

如果在单线程调度器中消费这个 Sequence:

kotlin 复制代码
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.newSingleThreadContext
import kotlinx.coroutines.withContext

suspend fun main() {
    newSingleThreadContext("main").use { dispatcher ->
        withContext(dispatcher) {
            launch {
                repeat(3) {
                    delay(100)
                    println("Processing on coroutine")
                }
            }

            getSequence().forEach {
                println(it)
            }
        }
    }
}

Thread.sleep(1_000) 阻塞了唯一线程,因此另一个协程必须等 Sequence 消费完成后才能运行。

这说明:

Sequence 的惰性不等于非阻塞,也不等于协程友好。

七、Flow 介绍

Flow 表示一个异步计算得到的值流。它的核心成员函数是挂起函数 collect:

kotlin 复制代码
interface Flow<out T> {
    suspend fun collect(
        collector: FlowCollector<T>
    )
}

collect 类似集合的 forEach,但它可以在等待数据时挂起当前协程,而不会阻塞线程。

kotlin 复制代码
flow.collect { value ->
    println(value)
}

如果数据生成过程需要调用挂起函数,应使用 Flow:

kotlin 复制代码
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow

fun getFlow(): Flow<String> =
    flow {
        repeat(3) { index ->
            delay(1_000)
            emit("User$index")
        }
    }

Flow 的消费操作也是挂起友好的:

kotlin 复制代码
suspend fun main() {
    getFlow().collect { value ->
        println(value)
    }
}

八、Flow 是冷数据源

创建 Flow 时,生产逻辑不会立即执行:

kotlin 复制代码
fun users(): Flow<String> = flow {
    println("Flow started")

    for (id in 1..3) {
        delay(500)
        emit("User$id")
    }
}

val userFlow = users()
println("Flow created")

只有调用 collect 后,才会打印 Flow started 并开始生成数据:

kotlin 复制代码
userFlow.collect { user ->
    println(user)
}

每次收集都会重新执行生产代码:

kotlin 复制代码
val flow = flow {
    println("Producing data")
    emit(1)
    emit(2)
}

flow.collect {
    println("First: $it")
}

flow.collect {
    println("Second: $it")
}

输出:

text 复制代码
Producing data
First: 1
First: 2
Producing data
Second: 1
Second: 2

flow { ... } 只是描述生产规则;真正执行发生在 collect、first、find 或 toList 等终止操作中。

九、Channel 与 Flow 的生命周期差异

Channel:立即启动生产协程

kotlin 复制代码
private fun CoroutineScope.makeChannel() = produce {
    println("Channel started")

    for (i in 1..3) {
        delay(1_000)
        send(i)
    }
}

produce 是 CoroutineScope 的扩展函数,因为它会立即启动一个新的生产协程。

kotlin 复制代码
suspend fun main(): Unit = coroutineScope {
    val channel = makeChannel()

    delay(1_000)

    println("Calling channel...")

    for (value in channel) {
        println(value)
    }

    println("Consuming again...")

    for (value in channel) {
        println(value)
    }
}

第二次消费没有输出,因为 Channel 中的元素通常只会被接收一次:

text 复制代码
生产者 ──发送 1──> Channel ──接收──> 消费者
                                      ↓
                              元素已经被取走

Channel 默认容量通常为 0,也就是 Rendezvous Channel。send 必须等接收者准备好后才能完成:

text 复制代码
produce 启动
    ↓
计算数据
    ↓
send(value)
    ↓
没有接收者,send 挂起
    ↓
接收者出现
    ↓
send 完成

Flow:收集时才开始生产

kotlin 复制代码
private fun makeFlow() = flow {
    println("Flow started")

    for (i in 1..3) {
        delay(1_000)
        emit(i)
    }
}

suspend fun main(): Unit = coroutineScope {
    val flow = makeFlow()

    delay(1_000)

    println("Calling flow...")

    flow.collect { value ->
        println(value)
    }

    println("Consuming again...")

    flow.collect { value ->
        println(value)
    }
}

每次 collect 都会从头开始执行 Flow,因此两次收集都会重新输出 Flow started 和 1、2、3。

十、使用 Flow 进行分页请求

Flow 适合产生通过网络分页获取的数据:

kotlin 复制代码
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow

fun allUsersFlow(api: UserApi): Flow<User> =
    flow {
        var page = 0

        do {
            val users = api.takePage(page++) ?: emptyList()
            emitAll(users)
        } while (users.isNotEmpty())
    }

emitAll(users) 会把当前页面的用户逐个发出。

只获取第一个用户:

kotlin 复制代码
val firstUser = allUsersFlow(api).first()

通常只需要请求第一页,找到第一个用户后就会停止收集。

获取全部用户:

kotlin 复制代码
val allUsers = allUsersFlow(api).toList()

这会持续请求下一页,直到遇到空页面。

查找指定用户:

kotlin 复制代码
val user = allUsersFlow(api)
    .find { it.id == targetId }

这会持续获取分页数据,直到找到目标用户或所有页面都处理完。

十一、Flow 支持取消和结构化并发

kotlin 复制代码
suspend fun loadUsers(api: UserApi): List<User> =
    allUsersFlow(api).toList()

如果调用它的父协程被取消,Flow 的收集过程也会被取消:

kotlin 复制代码
val job = launch {
    allUsersFlow(api).collect { user ->
        println(user)
    }
}

delay(1_000)
job.cancel()

取消后,后续页面请求不应该继续进行。这也是 Flow 比普通 Sequence 更适合网络和数据库数据流的重要原因。

十二、如何选择数据源

需求 推荐类型
所有数据已经准备好,并且需要重复读取 List 或 Set
同步、惰性、按需计算 Sequence
多个协程之间传递任务或事件 Channel
网络、数据库、分页和异步计算 Flow
保存并共享最新状态 StateFlow
广播事件给多个订阅者 SharedFlow

最终可以这样记忆:

text 复制代码
List:已经准备好的数据
Sequence:同步的惰性计算
Channel:协程之间的通信通道
Flow:支持挂起和取消的异步数据流

总结

热数据源是生产者主动生产数据,消费者从中接收;冷数据源则由消费者触发生产。

Sequence 适合按需计算,但它的消费操作是同步的,可能阻塞线程;Flow 专门为协程设计,支持挂起、取消、结构化并发和异步数据流。

在 Kotlin 协程中,一般可以遵循以下原则:

text 复制代码
完整数据已经准备好
    → List

需要同步惰性计算,不能调用挂起函数
    → Sequence

需要协程间任务通信
    → Channel

需要网络、数据库、分页、异步计算或协程取消
    → Flow
相关推荐
FlightYe1 小时前
linux系统编程(十七):内存管理基础
android·linux·ffmpeg·音视频·android runtime
蒲公英内测分发2 小时前
智能家居厂商有多个产品线,包装上的 App 下载入口该共用还是分开?
android·人工智能·数码相机·机器人·智能家居·智能硬件
hunterandroid2 小时前
[Android 从零到一] Dagger2 依赖注入深度解析:编译时注解处理与依赖图生成
android
美狐美颜SDK开放平台3 小时前
视频美颜SDK如何实现动态贴纸跟随?人脸追踪技术解析
android·人工智能·计算机视觉·音视频·直播美颜sdk
FLJwu3 小时前
运动相机图像传感器选型实战|第二篇:参数看着一模一样,为什么骑行录像疯狂果冻、拖影翻车?
android·c语言·开发语言·数码相机·运动相机·oem 量产
DengDongQi3 小时前
Compose 单 Activity 架构实现指南
android·架构·composer
FlightYe4 小时前
音视频修炼之容器与协议(二):HLS和DASH流媒体
android·linux·ffmpeg·音视频·android runtime·dash
allanGold4 小时前
Android Studio 无线调试
android·ide·android studio
Kapaseker4 小时前
AI 会干掉 React Native 吗?
android·kotlin