Android Kotlin 协程

Android Kotlin 协程面试笔记

一、协程是什么?

协程是 Kotlin 提供的轻量级并发编程方案,核心是 "非阻塞的挂起" ------允许在代码中暂停执行(挂起),之后再恢复执行,全程不会阻塞底层线程。

协程 vs 线程:

  • 本质区别:协程是轻量级并发抽象,通过挂起函数实现非阻塞并发,上下文切换由编译器管理;线程是操作系统级的资源
  • 内存占用:一个进程可创建数万甚至数十万协程(内存占用仅几KB),而线程创建数量有限(通常数百个,每个线程占几MB)
  • 切换开销:协程切换在用户态完成,无需与操作系统交互

二、协程解决了什么痛点?

  1. 回调地狱:用同步代码风格写异步逻辑,代码从上到下线性执行,可读性极高
  2. 线程管理复杂 :无需手动创建和管理线程池,通过 Dispatcher 声明意图即可
  3. 资源泄漏 :通过结构化并发,将协程生命周期与业务组件绑定,避免内存泄漏

三、核心组件

1. CoroutineScope(协程作用域)

协程必须在 CoroutineScope 中启动,用于管理协程的生命周期。

kotlin 复制代码
// 自定义作用域(与Activity生命周期绑定)
private val scope = CoroutineScope(Dispatchers.Main + Job())

常用作用域

作用域 说明
viewModelScope 与ViewModel绑定,ViewModel销毁时自动取消
lifecycleScope 与Activity/Fragment生命周期绑定
GlobalScope 全局作用域,生命周期与应用一致(不推荐,易导致内存泄漏)

2. Dispatcher(调度器)

决定协程运行在哪个线程上:

调度器 用途
Dispatchers.Main Android主线程,用于更新UI
Dispatchers.IO IO线程池,适合网络请求、数据库操作
Dispatchers.Default 默认线程池,适合CPU密集型任务
Dispatchers.Unconfined 不限制线程,较少使用

3. 协程构建器

构建器 返回值 用途
launch Job 执行不关心返回结果的异步任务
async Deferred<T> 执行需要返回结果的异步任务,通过 await() 获取结果
runBlocking - 阻塞当前线程,主要用于测试或main函数
kotlin 复制代码
// launch:启动并忘记
val job = scope.launch {
    delay(1000)
    updateUI()
}

// async:启动并获取结果
val deferred = scope.async {
    fetchData() // 返回 String
}
val result = deferred.await() // 挂起等待结果

四、suspend 挂起函数

  • 使用 suspend 关键字修饰的函数
  • 挂起函数只能在协程体内或其他挂起函数内调用
  • 挂起 ≠ 阻塞:挂起不会阻塞线程,线程可以去执行其他任务

挂起和恢复流程

  1. 挂起 :遇到挂起点(如 delayawait),保存局部变量和状态到 Continuation 对象,释放线程
  2. 执行:异步任务在后台执行
  3. 恢复 :异步任务完成,调度器调用 continuation.resumeWith(),协程从挂起点继续执行

底层原理 :编译器将 suspend 函数转换为实现了 Continuation 接口的状态机label 变量充当程序计数器,决定执行哪个分支。

五、异常处理

1. try-catch

kotlin 复制代码
scope.launch {
    try {
        val result = fetchData()
    } catch (e: Exception) {
        // 处理异常
    }
}

2. CoroutineExceptionHandler

用于捕获根协程及其子协程的未处理异常:

kotlin 复制代码
val handler = CoroutineExceptionHandler { _, exception ->
    Log.e("Coroutine", "Caught: $exception")
}
val scope = CoroutineScope(Dispatchers.Main + handler)

注意launch 的异常会立即传播;async 的异常在调用 await() 时才抛出。

六、协程取消与结构化并发

结构化并发

  • 父协程取消时,自动取消所有子协程
  • 协程有明确的生命周期作用域,从根源上避免协程泄漏

取消机制

kotlin 复制代码
val job = scope.launch {
    while (isActive) { // 检查取消状态
        // 执行任务
    }
}
job.cancel() // 取消协程

注意 :调用 cancel() 不会立即终止协程,需要在协程内部配合检查。在 finally 中释放资源时,建议使用 withContext(NonCancellable) 确保执行。

超时控制

kotlin 复制代码
// 超时抛出 TimeoutCancellationException
withTimeout(5000) {
    fetchData()
}

// 超时返回 null
val result = withTimeoutOrNull(5000) {
    fetchData()
}

七、Android 实战示例

kotlin 复制代码
class MainViewModel : ViewModel() {
    
    fun loadData() {
        // viewModelScope 与 ViewModel 生命周期绑定
        viewModelScope.launch {
            // 切换到 IO 线程执行耗时操作
            val data = withContext(Dispatchers.IO) {
                fetchDataFromNetwork()
            }
            // 自动回到 Main 线程更新 UI
            _uiState.value = data
        }
    }
}

八、协程间通信

协程间通信主要依赖 Channel(通道)Flow / SharedFlow / StateFlow(热流) 两大体系。

1. Channel(通道)------ 一对一"点对点"通信

Channel 类似于 Java 中的 BlockingQueue,但区别在于它的 sendreceive挂起函数 (非阻塞等待),适合在生产者-消费者场景下传递数据流。

kotlin 复制代码
// 创建通道(容量为 0 表示必须发收同时准备好,即"会合")
val channel = Channel<String>()

// 生产者协程
val producer = scope.launch {
    repeat(3) {
        delay(500)
        channel.send("Message $it") // 挂起直到有接收者
    }
    channel.close() // 关闭表示数据发送完毕
}

// 消费者协程
val consumer = scope.launch {
    // 方式一:for 循环遍历(通道关闭时自动结束)
    for (msg in channel) {
        println("Received: $msg")
    }
    // 方式二:手动 receive(通道无数据且未关闭时挂起)
    // val first = channel.receive()
}

关键特性

  • 一对一:每条消息只能被一个消费者消费,消费即移除。
  • 背压处理 :消费者处理慢时,send 会自动挂起,天然实现流量控制。
  • 容量可选 :可指定缓冲区大小(如 Channel(10)),超过容量时生产者挂起。

面试冷知识 :早期 Kotlin 提供 produce {}actor {} 构建器,但现在更推荐直接用 scope.launch + Channel 或改用 Flow

2. Flow(冷流)基本使用

Flow 是冷流------没有 collect(收集)时不会执行任何代码,每次 collect 都会重新触发整个数据流。

kotlin 复制代码
// 1. 定义 Flow(数据生产方)
fun fetchDataFlow(): Flow<Int> = flow {
    for (i in 1..3) {
        delay(500) // 模拟异步请求
        emit(i)    // 发射数据
    }
}.flowOn(Dispatchers.IO) // 指定生产代码运行在 IO 线程

// 2. 收集 Flow(数据消费方,在 ViewModel 或 Activity 中)
viewModelScope.launch {
    fetchDataFlow()
        .map { data -> data * 2 } // 中间操作符(变换数据)
        .filter { it > 2 }        // 过滤
        .catch { e ->             // 捕获上游异常
            _uiState.value = "Error: ${e.message}"
        }
        .collect { value ->       // 终端操作符,触发执行
            // 自动在 Main 线程(因为 viewModelScope 默认 Main)
            updateUI(value) 
        }
}

注意点:Flow 是冷流,collect 执行时才生产数据;多个 collect 各自独立互不影响。线程切换用 flowOn(只影响上游),下游 collect 在调用者的协程上下文中执行。

九、小问题

问题 答案要点
launch 和 async 的区别? launch 无返回值(返回Job),async 有返回值(返回Deferred,需await获取)
Dispatchers 各有什么区别? Main(UI线程)、IO(IO密集)、Default(CPU密集)、Unconfined(不限制)
挂起和阻塞的区别? 挂起不阻塞线程,线程可执行其他任务;阻塞会让线程闲置等待
如何取消协程? 调用 job.cancel(),协程内部配合检查 isActive
协程异常怎么处理? try-catch 或 CoroutineExceptionHandler(注意 launch 立即抛出,async 在 await 时抛出)