【Kotlin 协程修仙录 · 炼虚境 · 初阶】 | 虚空造物:Channel 基础与协程间通信的管道艺术

161 阅读8分钟

image_11.png

前言

化神大圆满,你已驾驭 Flow 的异常天劫。冷流热流在你手中变幻自如,数据如长江大河,奔流不息。然而,你是否遇到过这样的场景?

你需要在两个协程之间传递数据——比如一个协程负责下载文件,另一个协程负责处理下载进度并更新 UI。你用 SharedFlow 实现了,但总觉得哪里不对劲:SharedFlow 是为了多播事件设计的,你却在用它做一对一的任务传递。更糟的是,你发现无法暂停上游生产——当 UI 来不及处理时,进度事件要么堆积,要么丢弃。

你需要的是一种更原始的、双向控制的协程间通信机制——它允许一个协程发送数据,另一个协程接收数据;当接收方来不及处理时,发送方可以挂起等待;当没有数据时,接收方也可以挂起等待。这正是 Kotlin 协程为你准备的管道——Channel

Channel 是一个挂起式的、可缓冲的、协程间的通信原语。它允许一个协程通过 send 发送数据,另一个协程通过 receive 接收数据。Channel 是协程世界中的 BlockingQueue,但它是非阻塞的——发送和接收都是挂起函数。

本讲是炼虚境的初阶修炼。你将:

  • 彻底搞懂 Channel 的概念,以及它与 Flow/SharedFlow 的本质区别。
  • 掌握 Channel 的四种容量策略:RENDEZVOUSBUFFEREDCONFLATEDUNLIMITED
  • 学会用 produce 构建器简化生产者-消费者模式。
  • 理解 Channel 的关闭与迭代。
  • 在 Android 中用 Channel 实现一个优雅的下载队列。

准备好虚空造物,构筑你的第一条协程管道了吗?我们开始。

千曲而后晓声,观千剑而后识器。虐它千百遍方能通晓其真意


什么是 Channel

Kotlin 协程的官方定义中:

Channel 是一个非阻塞的、可挂起的、先进先出(FIFO)的通信原语。它提供 sendreceive 两个挂起函数,允许协程之间安全地传递数据。Channel 可以配置有限的容量,当缓冲区满时 send 挂起,当缓冲区空时 receive 挂起。

如果你熟悉 Java,可以将 Channel 理解为协程版的 BlockingQueue,但 sendreceive挂起而非阻塞线程。

val channel = Channel<Int>()

// 生产者协程
launch {
    for (i in 1..5) {
        channel.send(i) // 发送数据,可能挂起
        println("发送:$i")
    }
    channel.close() // 关闭通道
}

// 消费者协程
launch {
    for (value in channel) { // 迭代接收,直到通道关闭
        delay(1000)
        println("接收:$value")
    }
}
flowchart LR
    subgraph Producer[生产者协程]
        P[send 发送]
    end
    
    subgraph Channel[Channel 管道]
        C[缓冲区]
    end
    
    subgraph Consumer[消费者协程]
        R[receive 接收]
    end
    
    P -->|数据| C
    C -->|数据| R
    
    style Producer fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
    style Channel fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style Consumer fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style P fill:#c8e6c9
    style C fill:#ffb74d
    style R fill:#90caf9

核心特性

  1. 挂起而非阻塞send 在缓冲区满时挂起,receive 在缓冲区空时挂起,线程不阻塞。
  2. 容量策略:可配置缓冲区大小及溢出行为。
  3. 可关闭close() 后不再接受新数据,接收方可消费剩余数据。
  4. 支持迭代for (value in channel) 自动处理关闭。

Channel vs Flow vs SharedFlow:三国演义

许多开发者初学 Channel 时会困惑:它和 Flow、SharedFlow 到底有什么区别?为什么有了 Flow 还要 Channel?

对比维度ChannelFlow(冷)SharedFlow(热)
设计目的协程间通信异步数据流计算多播事件/状态
通信模式点对点(可多个接收者,但每条数据只被一个接收)点对点(每个收集者独立流)点对多(所有订阅者收到相同数据)
背压控制缓冲区满时 send 挂起拉取模型,消费者控制节奏可配置缓冲与丢弃策略
历史重放无(冷流)replay
关闭需要显式 close()无需,流自然结束无需,但作用域取消时停止
典型场景任务队列、Actor 模式数据库查询、网络请求UI 状态、事件总线
flowchart LR
    subgraph Channel[Channel]
        direction TB
        C1[send] --> CB[缓冲区] --> C2[receive]
    end
    
    subgraph Flow[Flow 冷流]
        direction TB
        F1[生产者] --> F2[操作符] --> F3[collect]
    end
    
    subgraph SharedFlow[SharedFlow 热流]
        direction TB
        S1[emit] --> SC[缓存/重放] --> S2[订阅者1]
        SC --> S3[订阅者2]
    end
    
    style Channel fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
    style Flow fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style SharedFlow fill:#ffcdd2,stroke:#b71c1c,stroke-width:2px
    style C1 fill:#c8e6c9
    style CB fill:#ffb74d
    style C2 fill:#90caf9
    style F1 fill:#90caf9
    style F2 fill:#90caf9
    style F3 fill:#90caf9
    style S1 fill:#ef9a9a
    style SC fill:#ffb74d
    style S2 fill:#ef9a9a
    style S3 fill:#ef9a9a

简单选择法则

  • 需要协程间传递任务,且希望发送方可被背压挂起Channel
  • 需要声明式数据流转换,且每次收集都重新执行 → Flow
  • 需要多播事件或共享状态,且希望新订阅者收到历史 → SharedFlow / StateFlow

Channel 的四种容量策略

Channel 的构造函数接受一个 capacity 参数,它决定了缓冲区的行为和 send 的挂起时机。

val channel = Channel<Int>(capacity = Channel.RENDEZVOUS)
策略容量行为适用场景
RENDEZVOUS(默认)0sendreceive 必须会合——发送方挂起直到有接收方,反之亦然无缓冲,严格同步
BUFFERED64(默认)缓冲区满时 send 挂起典型的生产-消费队列
CONFLATED1(合并)缓冲区只保留最新值,新值覆盖旧值,send 永不挂起只关心最新状态(如位置)
UNLIMITED无限send 永不挂起,需注意内存生产者不受限,但消费者慢时可能 OOM
自定义 Int指定值缓冲区满时 send 挂起精细控制内存与吞吐
flowchart TD
    subgraph Rendezvous[RENDEZVOUS 无缓冲]
        RS[send] -->|挂起| RW[等待 receive]
        RR[receive] -->|挂起| RW2[等待 send]
        RW -.->|会合| RW2
    end
    
    subgraph Buffered[BUFFERED 有缓冲]
        BS[send] --> BB[缓冲区 64]
        BB --> BR[receive]
        BB -->|满时挂起| BS
    end
    
    subgraph Conflated[CONFLATED 合并]
        CS[send] --> CB[保留最新值]
        CS -->|覆盖旧值| CB
        CB --> CR[receive]
    end
    
    subgraph Unlimited[UNLIMITED 无限]
        US[send] --> UB[无限增长]
        UB --> UR[receive]
    end
    
    style Rendezvous fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
    style Buffered fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style Conflated fill:#ffcdd2,stroke:#b71c1c,stroke-width:2px
    style Unlimited fill:#e3f2fd,stroke:#1976d2,stroke-width:2px

示例:RENDEZVOUS 的会合行为

val channel = Channel<Int>(Channel.RENDEZVOUS)

launch {
    println("准备发送...")
    channel.send(1) // 挂起,直到有接收方
    println("发送完成")
}

launch {
    delay(1000)
    val value = channel.receive() // 此时发送方恢复
    println("接收:$value")
}
// 输出顺序:准备发送... → (1秒后)发送完成 → 接收:1

示例:CONFLATED 的合并行为

val channel = Channel<Int>(Channel.CONFLATED)

launch {
    repeat(5) {
        channel.send(it) // 永不挂起
        println("发送:$it")
    }
    channel.close()
}

launch {
    delay(500) // 模拟慢速消费者
    for (value in channel) {
        println("接收:$value")
    }
}
// 可能输出:发送:0,1,2,3,4 → 接收:0 → 接收:4
// 中间的值被覆盖丢失了

produce 构建器:简化生产者协程

Kotlin 提供了 produce 协程构建器,它创建一个新的协程,返回一个 ReceiveChannel。生产者协程内部可以使用 send 发射数据,当协程结束时自动关闭 Channel。

fun CoroutineScope.produceNumbers() = produce {
    for (i in 1..5) {
        delay(100)
        send(i)
    }
    // 协程结束,自动 close()
}

fun main() = runBlocking {
    val channel = produceNumbers()
    channel.consumeEach { value ->
        println(value)
    }
}

produce 的优势:

  • 自动管理 Channel 生命周期,无需手动 close()
  • 代码更紧凑,意图更清晰。
  • 返回的 ReceiveChannel 只能接收,类型安全。
sequenceDiagram
    participant Scope as 协程作用域
    participant Producer as produce 协程
    participant Channel as ReceiveChannel
    participant Consumer as 消费者协程

    Scope->>Producer: produce { }
    Producer->>Channel: 创建 Channel
    Producer-->>Scope: 返回 ReceiveChannel
    
    loop 生产数据
        Producer->>Producer: delay + send
        Producer->>Channel: 数据
    end
    
    Producer->>Channel: 协程结束,自动 close()
    
    Consumer->>Channel: consumeEach
    Channel-->>Consumer: 数据流

Channel 的关闭与迭代

Channel 用完后需要关闭,否则接收方可能永远挂起。关闭后:

  • send 会抛出 ClosedSendChannelException
  • receive 在缓冲区空时会抛出 ClosedReceiveChannelException
  • 使用 for 循环迭代时会自动在关闭后退出。

安全迭代:consumeEach

consumeEach 是一个终端操作符,它迭代所有元素,并在完成后(或异常时)自动取消 Channel,确保资源释放。

channel.consumeEach { value ->
    println(value)
}
// 等价于手动 try-finally

关闭状态机

stateDiagram-v2
    [*] --> Open : 创建
    Open --> Sending : send
    Open --> Receiving : receive
    Open --> Closed : close()
    Closed --> Closed : 不能再 send
    Closed --> Draining : 缓冲区还有数据
    Draining --> Closed : 缓冲区空
    Closed --> [*] : 完全关闭

实战:用 Channel 实现下载队列

场景:用户可能同时点击多个文件下载,我们希望串行处理(一次只下载一个),并实时显示当前下载进度。Channel 是实现任务队列的完美选择。

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch

class DownloadViewModel : ViewModel() {

    sealed class DownloadEvent {
        data class Progress(val url: String, val percent: Int) : DownloadEvent()
        data class Complete(val url: String) : DownloadEvent()
    }

    // 任务队列:无缓冲,保证串行
    private val downloadQueue = Channel<String>(Channel.RENDEZVOUS)
    
    // 进度事件流(用 SharedFlow 多播给 UI)
    private val _progressFlow = MutableSharedFlow<DownloadEvent>()
    val progressFlow: SharedFlow<DownloadEvent> = _progressFlow.asSharedFlow()

    init {
        // 启动唯一的消费者协程,逐个处理下载任务
        viewModelScope.launch {
            for (url in downloadQueue) {
                downloadFile(url)
                _progressFlow.emit(DownloadEvent.Complete(url))
            }
        }
    }

    fun enqueueDownload(url: String) {
        viewModelScope.launch {
            downloadQueue.send(url) // 如果消费者正忙,这里会挂起排队
        }
    }

    private suspend fun downloadFile(url: String) {
        // 模拟下载,每 10% 报告一次进度
        for (progress in 0..100 step 10) {
            delay(500)
            _progressFlow.emit(DownloadEvent.Progress(url, progress))
        }
    }
}
flowchart LR
    subgraph UI[UI 交互]
        B1[点击下载 A]
        B2[点击下载 B]
        B3[点击下载 C]
    end
    
    subgraph Queue[Channel 队列]
        Q[send 排队]
    end
    
    subgraph Worker[消费者协程]
        W[串行处理]
    end
    
    subgraph Events[进度事件 SharedFlow]
        P[进度更新]
    end
    
    B1 --> Q
    B2 --> Q
    B3 --> Q
    Q --> W
    W --> P --> UI
    
    style UI fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style Queue fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style Worker fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
    style Events fill:#c8e6c9,stroke:#388e3c,stroke-width:2px
    style Q fill:#ffb74d
    style W fill:#a5d6a7
    style P fill:#81c784

配合 Compose UI

@Composable
fun DownloadScreen(viewModel: DownloadViewModel = viewModel()) {
    val events by viewModel.progressFlow.collectAsState(initial = null)
    
    Column {
        Button(onClick = { viewModel.enqueueDownload("file1.zip") }) {
            Text("下载文件1")
        }
        Button(onClick = { viewModel.enqueueDownload("file2.zip") }) {
            Text("下载文件2")
        }
        
        when (val event = events) {
            is DownloadEvent.Progress -> Text("${event.url}: ${event.percent}%")
            is DownloadEvent.Complete -> Text("${event.url} 完成!")
            null -> Text("等待任务...")
        }
    }
}

设计要点

  • 使用 RENDEZVOUS 确保任务严格串行,消费者处理完一个才接收下一个。
  • 进度事件用 SharedFlow 多播,UI 可灵活订阅。
  • send 挂起自动形成等待队列,无需手动管理。

常见错误与避坑指南

错误 1:忘记关闭 Channel,导致接收方永久挂起

val channel = Channel<Int>()
launch {
    repeat(3) { channel.send(it) }
    // 忘记 close()
}
for (value in channel) { // 接收完 0,1,2 后永久挂起
    println(value)
}

正确:发送完后 channel.close(),或用 produce 构建器自动关闭。

错误 2:在 CONFLATED Channel 上期望收到所有值

val channel = Channel<Int>(Channel.CONFLATED)
repeat(10) { channel.send(it) }
// 消费者只能收到第一个和最后一个,中间丢失

正确:只关心最新状态时才用 CONFLATED,否则用 BUFFERED

错误 3:多个消费者同时从同一个 Channel 接收

val channel = Channel<Int>()
launch { for (v in channel) println("A: $v") }
launch { for (v in channel) println("B: $v") }
// 每个值只会被一个消费者收到,且分配是不确定的

正确:Channel 是点对点通信,如需多播,用 SharedFlowbroadcast Channel。

错误 4:在 UI 层直接使用 Channelreceive 导致挂起

// 错误:在 Composable 中直接调用挂起函数
@Composable
fun MyScreen() {
    val value = viewModel.channel.receive() // 编译错误
}

正确:通过 SharedFlowStateFlow 暴露数据给 UI,Channel 仅作为内部通信。


最佳实践

  1. 优先使用 produce 构建器:自动管理生命周期,代码更安全。
  2. 根据业务选择容量策略:任务队列用 RENDEZVOUSBUFFERED;状态更新用 CONFLATED
  3. 使用 consumeEach 安全迭代:自动处理关闭和异常。
  4. Channel 用于内部通信,对外暴露 Flow:保持架构清晰。
  5. 注意 Channel 的线程安全性:Channel 本身是线程安全的,但发送的值应为不可变或线程安全对象。
  6. 配合 actor 模式实现更复杂的状态管理(下一讲深入)。

总结与下回预告

恭喜,你已掌握 Channel 的虚空造物之术,炼虚境初阶修炼完成!

本讲核心收获

  • Channel 是协程间的挂起式通信管道,send/receive 不阻塞线程。
  • 四种容量策略:RENDEZVOUS(无缓冲)、BUFFERED(有缓冲)、CONFLATED(合并)、UNLIMITED(无限)。
  • produce 构建器简化生产者协程,自动管理 Channel 生命周期。
  • Channel 需显式关闭,consumeEach 是安全迭代的推荐方式。
  • Channel 用于点对点任务队列,多播场景应使用 SharedFlow

在下一讲 【炼虚境·中阶】 中,我们将深入 Channel 的高级应用:BroadcastChannel 的兴衰、actor 模式实现并发安全、以及 select 表达式的多路复用。届时你会明白:

  • 为什么 BroadcastChannel 被废弃,SharedFlow 如何取而代之?
  • 如何用 actor 构建无锁的并发状态?
  • select 如何同时等待多个 Channel 或挂起函数?

【当前境界修为面板】

当前境界修炼技能修炼进度修炼心得
炼虚境 · 初阶1、Channel 管道术
2、容量四策
3、produce 构建诀
当前进度35%
修为350/1000
下一突破[炼虚境 · 中阶] (需领悟:BroadcastChannel 迁移、actor 模式、select 多路复用)
Channel是协程间的BlockingQueue,但它不阻塞线程。send满则挂起,receive空则等待。

【本讲思考题】

  1. 表象题:以下代码的输出是什么?

    val channel = Channel<Int>(Channel.CONFLATED)
    channel.send(1)
    channel.send(2)
    println(channel.receive())
    println(channel.receive())
    
  2. 场景题:你需要在 ViewModel 中实现一个“消息中心”,多个协程可以向它发送日志消息,一个专门的协程负责将日志批量写入文件(每次收集 10 条或每 5 秒写入一次)。应该使用什么 Channel 策略?写出核心结构。

  3. 原理题Channelsend 挂起时,协程是如何被“唤醒”的?请从 AbstractSendChannel 和协程调度器的角度简述其内部挂起与恢复机制。


道友,炼虚境的大门已敞开。掌握了 Channel 的高级应用,你的协程通信将更加灵动。炼虚境·中阶见。

欢迎一键四连关注 + 点赞 + 收藏 + 评论