Android学习总结之Kotlin 协程

发布于:2025-04-06 ⋅ 阅读:(14) ⋅ 点赞:(0)

一、引言

       在 Android 开发中,异步任务处理是绕不开的话题。传统的线程、Handler、AsyncTask 等方案要么过于繁琐,要么存在生命周期管理问题。Kotlin 协程的出现,以优雅的语法和强大的结构化并发能力,成为解决异步编程难题的理想方案。本文将结合核心概念、使用场景和实战经验,带您全面掌握协程的精髓。

二、协程核心概念:轻量级的异步处理

       首先要明确的是,协程不是线程。线程是操作系统调度的基本单位,由操作系统负责管理和调度。而协程是在程序层面实现的一种并发编程机制,由协程库(如 kotlinx.coroutines)进行管理。

1. 什么是协程?

  • 定义:协程是基于线程的轻量级 “线程”,通过suspend挂起函数实现异步逻辑同步化。核心特点是可挂起恢复,避免阻塞主线程。
  • 优势:比线程更轻量(一个线程可运行多个协程),通过调度器灵活切换线程,确保 UI 线程安全。

2. 挂起函数(suspend function)

  • 定义:用suspend关键字修饰的函数,只能在协程体内或其他挂起函数中调用。
  • 核心作用:在不阻塞线程的前提下暂停执行,例如delay(1000)、网络请求等耗时操作。

3. 挂起 vs 阻塞

  • 挂起:不阻塞主线程,UI 可正常刷新(如withContext(Dispatchers.IO)处理耗时任务)。
  • 阻塞:导致主线程 ANR(如直接在 UI 线程执行磁盘读写)。

协程的挂起和恢复的工作原理(Continuation)

1. CPS 转换

Java 中没有 suspend 函数,suspend 是 Kotlin 特有的关键字。在编译时,Kotlin 编译器会对含有 suspend 关键字的函数进行转换,这种转换在 Kotlin 中被称为 CPS 转换(continuation - passing - style)。

例如,程序员编写的挂起函数代码:

suspend fun getUserInfo(): User {
    val user = User("asd123", "userName", "nickName")
    return user
}

 经过假想的中间态转换后(便于理解):

fun getUserInfo(callback: Callback<User>): Any? {
    val user = User("asd123", "userName", "nickName")
    callback.onSuccess(user)
    return Unit
}

最终转换后的代码:

fun getUserInfo(cont: Continuation<User>): Any? {
    val user = User("asd123", "userName", "nickName")
    cont.resume(user)
    return Unit
}

通过 Kotlin 生成字节码工具查看字节码并反编译成 Java 代码,也能验证确实会引入一个 Continuation 对象来实现恢复流程,这个 Continuation 对象包含了 Callback 的形态。它有两个重要作用:暂停并记住执行点位;记住函数暂停时刻的局部变量上下文。这就是我们可以用同步方式写异步代码的原因,因为 Continuation 帮我们完成了回调流程。

2. Continuation 源码分析

以下是 Continuation 源码的部分内容:

internal abstract class BaseContinuationImpl() : Continuation<Any?> {
    public final override fun resumeWith(result: Result<Any?>) {
        // 省略好多代码
        invokeSuspend()
        // 省略好多代码
    }

    protected abstract fun invokeSuspend(result: Result<Any?>): Any?
}

internal abstract class ContinuationImpl(
    completion: Continuation<Any?>?,
    private val _context: CoroutineContext?
) : BaseContinuationImpl(completion) {

    protected abstract fun invokeSuspend(result: Result<Any?>): Any?
}
3. 状态机原理

以一个包含多个挂起函数的协程代码为例:

suspend fun testCoroutine() {
    val user = apiService.getUserInfoSuspend() // 挂起函数  IO 线程
    tvNickName.text = user?.nickName // UI 线程更新界面
    val unReadMsgCount = apiService.getUnReadMsgCountSuspend(user?.token) // 挂起函数  IO 线程
    tvMsgCount.text = unReadMsgCount.toString() // UI 线程更新界面
}

经过 Kotlin 编译器编译后:

fun testCoroutine(completion: Continuation<Any?>): Any? {
    class TestContinuation(completion: Continuation<Any?>?) : ContinuationImpl(completion) {
        var label: Int = 0
        lateinit var user: Any
        lateinit var unReadMsgCount: Int
        var result = continuation.result
        var suspendReturn: Any? = null
        val sFlag = CoroutineSingletons.COROUTINE_SUSPENDED

        override fun invokeSuspend(_result: Result<Any?>): Any? {
            result = _result
            label = label or Int.Companion.MIN_VALUE
            return testCoroutine(this)
        }
    }

    val continuation = if (completion is TestContinuation) {
        completion
    } else {
        TestContinuation(completion)
    }

    var loop = true
    while (loop) {
        when (continuation.label) {
            0 -> {
                throwOnFailure(result)
                continuation.label = 1
                suspendReturn = getUserInfoSuspend(continuation)
                if (suspendReturn == sFlag) {
                    return suspendReturn
                } else {
                    result = suspendReturn
                }
            }
            1 -> {
                throwOnFailure(result)
                user = result as Any
                continuation.label = 2
                suspendReturn = getUnReadMsgCountSuspend(user.token, continuation)
                if (suspendReturn == sFlag) {
                    return suspendReturn
                } else {
                    result = suspendReturn
                }
            }
            2 -> {
                throwOnFailure(result)
                user = continuation.mUser as Any
                unReadMsgCount = continuation.unReadMsgCount as Int
                loop = false
            }
        }
    }
}

 

通过一个 label 标签控制分支代码的执行。当 label 为 0 时,首先将 label 设置为下一个分支的数值,然后执行第一个 suspend 方法并传递当前 Continuation,得到返回值。如果返回值是 COROUTINE_SUSPENDED,协程框架就直接返回,协程挂起。当第一个 suspend 方法执行完成后,会回调 Continuation 的 invokeSuspend 方法,进入第二个分支执行,以此类推,直到执行完所有 suspend 方法。每一个挂起点和初始挂起点对应的 Continuation 都会转化为一种状态,协程恢复只是跳转到下一种状态中。挂起函数将执行过程分为多个 Continuation 片段,并且利用状态机的方式保证各个片段是顺序执行的。

综上所述,协程的挂起和恢复的本质是 CPS + 状态机。

(一)内存占用

1. 线程的内存占用

每个线程在创建时都会分配一定数量的栈内存,默认情况下大约为 1MB。当系统需要启动大量线程时,会消耗大量的内存资源,这可能导致系统资源枯竭,影响系统的稳定性和性能。

2. 协程的内存占用

协程运行在现有的线程中,它们不需要单独的栈内存,而是共享调用栈。这使得协程的内存开销非常小,通常每个协程只占用几个 KB。这种特性使得同一线程可以管理和运行大量的协程,不受传统线程数量的限制。

(二)任务切换

1. 线程切换

线程切换由操作系统管理,涉及到用户态和内核态之间的切换。这个过程代价较高,需要保存和恢复 CPU 寄存器、程序计数器、内存栈等信息。频繁的线程切换会带来显著的性能开销。

2. 协程切换

协程切换在用户态完成,不涉及内核态切换,只是切换函数的上下文。与线程切换相比,协程切换的代价相对低很多,能够提高程序的执行效率。

(一)线程内存模型

在 JVM 中,每个线程都有自己独立的线程栈,每个栈帧包含局部变量、操作数栈和相关信息。当线程被挂起时,所有这些信息必须保存并在重新调度时恢复,这涉及到较多的内存操作和上下文切换。

(二)协程内存模型

协程的栈帧通常是堆上的对象。当协程挂起时,不需要切换线程,只是函数调用的上下文发生变化,协程状态会被保存到堆中。这种模型使得内存利用更加高效和灵活。

(三)协程堆栈帧

协程在挂起时,会将当前的堆栈帧转换为对象并存储在堆中。这个对象包含了所有当前帧的局部变量、挂起点以及其他必要信息。当协程恢复执行时,这个对象会重新转换为堆栈帧并继续执行。

(四)Continuation

Kotlin 中的挂起函数实质上会被编译器转换成带有回调的 Continuation 对象。该对象包含两个主要部分:

  • 上下文(Continuation Context):绑定的执行环境。
  • 恢复逻辑(Resume Logic):保存和处理挂起点的逻辑。

以下是 Continuation 接口的定义:

interface Continuation<in T> {
    val context: CoroutineContext
    fun resumeWith(result: Result<T>)
}

 

三、协程调度器:线程切换的指挥官

协程通过Dispatchers指定运行线程,常见调度器:

调度器 用途 示例场景
Dispatchers.Main 主线程,处理 UI 更新、LiveData 通知 更新 TextView 文本
Dispatchers.IO 非主线程,优化磁盘 / 网络 IO 文件读写、Retrofit 网络请求
Dispatchers.Default 非主线程,CPU 密集型任务 JSON 解析、数据排序
Dispatchers.Unconfined 不受限调度(不推荐) 测试或简单异步逻辑

最佳实践:耗时操作(IO/CPU)务必切换到非主线程调度器,避免阻塞 UI。

四、作用域与生命周期:避免协程泄漏的关键

1. 结构化并发:协程的 “作用域牢笼”

  • 协程必须在作用域(CoroutineScope)内启动,作用域负责追踪和取消子协程,避免内存泄漏。
  • 核心原则:协程的生命周期绑定到创建它的组件(如 Activity、ViewModel)。

2. 常用作用域

作用域 生命周期绑定 使用场景 取消时机
GlobalScope 进程级(危险!) 全局监听(极少使用) 进程结束时自动取消
MainScope() Activity/Fragment onCreate中启动协程 onDestroy时自动取消
ViewModelScope ViewModel ViewModel 内异步数据加载 ViewModel 销毁时取消
lifecycleScope Activity/Fragment AndroidX 组件专用 组件生命周期结束时取消

示例:在 Activity 中使用lifecycleScope

button.setOnClickListener {
    lifecycleScope.launch(Dispatchers.IO) {
        // 耗时操作
        withContext(Dispatchers.Main) { textView.text = "加载完成" }
    }
}

3. 手动创建作用域:CoroutineScope()

// 自定义作用域,手动管理取消
val myScope = CoroutineScope(Dispatchers.IO + Job())
// 启动协程
val job = myScope.launch { ... }
// 取消作用域
myScope.cancel()

五、协程构建器:启动协程的 “工具箱”

1. 核心构建器对比

构建器 阻塞当前线程? 返回值 执行模式 用途
launch Job(无返回值) 非阻塞异步 启动无需返回值的协程
async Deferred<T>(可获取结果) 非阻塞异步 启动需要返回值的协程
runBlocking 是(测试专用) Unit 阻塞当前线程 测试或简单协程入口
withContext 指定类型结果 串行切换线程 切换调度器并返回结果

2. 高级用法:启动模式

  • LAZY:延迟启动,调用start()await()时才执行(适合按需加载)。
val deferred = async(start = CoroutineStart.LAZY) { loadData() }
// 手动启动
deferred.start()
  • ATOMIC:创建后立即调度,首个挂起前不响应取消。
  • UNDISPATCHED:在当前线程执行直到首个挂起点(用于性能优化)。

(一)异步场景示例

以一个常见的异步场景为例:

  1. 第一步:通过接口获取当前用户的 token 及用户信息。
  2. 第二步:将用户的昵称展示在界面上。
  3. 第三步:利用获取到的 token 获取当前用户的消息未读数。
  4. 第四步:将未读数展示在界面上。

(二)现有方案实现及问题

以下是使用现有方案(回调函数)实现上述异步场景的代码:

apiService.getUserInfo().enqueue(object : Callback<User> {
    override fun onResponse(call: Call<User>, response: Response<User>) {
        val user = response.body()
        tvNickName.text = user?.nickName
        apiService.getUnReadMsgCount(user?.token).enqueue(object : Callback<Int> {
            override fun onResponse(call: Call<Int>, response: Response<Int>) {
                val tvUnReadMsgCount = response.body()
                tvMsgCount.text = tvUnReadMsgCount.toString()
            }
        })
    }
})

可以看到,当需要处理多个异步任务且存在嵌套关系时,代码会变得复杂,形成所谓的「回调地狱」(callback hell),这会降低代码的可读性和可维护性。

(三)协程实现及优势

使用协程实现上述异步场景的代码如下:

mainScope.launch {
    val user = apiService.getUserInfoSuspend() // IO 线程请求数据
    tvNickName.text = user?.nickName // UI 线程更新界面
    val unReadMsgCount = apiService.getUnReadMsgCountSuspend(user?.token) // IO 线程请求数据
    tvMsgCount.text = unReadMsgCount.toString() // UI 线程更新界面
}

suspend fun getUserInfoSuspend(): User? {
    return withContext(Dispatchers.IO) {
        // 模拟网络请求耗时操作
        delay(10)
        User("asd123", "userName", "nickName")
    }
}

suspend fun getUnReadMsgCountSuspend(token: String?): Int {
    return withContext(Dispatchers.IO) {
        // 模拟网络请求耗时操作
        delay(10)
        10
    }
}

在协程实现中,告别了回调函数,避免了回调地狱的问题。协程最大的优势在于可以让我们用同步的代码写出异步的效果,也就是将异步代码同步去写。同时,协程还能让程序员更方便地处理异步业务,更便捷地切换线程,保证主线程的安全。 

六、并发处理:并行与串行的选择

1. 并行执行:async + await

// 并发获取数据(耗时1s)
val time = measureTimeMillis {
    val one = async { doOne() } // 耗时1s
    val two = async { doTwo() } // 耗时1s
    println(one.await() + two.await()) // 30
}
println("耗时:$time ms") // 约1000ms

2. 串行执行:withContext 或 无async

// 串行执行(耗时2s)
val time = measureTimeMillis {
    val one = doOne() // 耗时1s
    val two = doTwo() // 耗时1s
    println(one + two) // 30
}
println("耗时:$time ms") // 约2000ms

 扩展追问:

概述:

        进程是程序在操作系统中的一次执行过程,是系统进行资源分配和调度的基本单位;线程是进程中的一个执行单元,是 CPU 调度和分派的基本单位;而协程是一种比线程更加轻量级的并发编程方式,它可以在一个线程中实现多个任务的并发执行。

进程、线程、协程对比表

维度 进程(Process) 线程(Thread) 协程(Coroutine)
定义 程序在操作系统中的一次执行实例,是资源分配的基本单位。 进程内的执行单元,是 CPU 调度的基本单位。 用户态轻量级 “线程”,由协程库管理,可在同一线程内协作式调度。
调度单位 操作系统内核调度 操作系统内核调度 协程库(用户态调度,无需内核参与)
上下文切换 内核态切换,开销极大(涉及内存地址空间、文件描述符等)。 内核态切换,开销较大(涉及寄存器、栈指针等)。 用户态切换,开销极小(仅保存协程状态到堆,无需内核干预)。
内存占用 独立地址空间(通常数 MB 到 GB 级)。 共享进程内存空间,每个线程栈默认约 1 MB。 共享线程内存,每个协程仅需几个 KB(无独立栈,共享调用栈)。
并发性 进程间并发,由操作系统控制。 线程间并发,由操作系统控制(抢占式多任务)。 协程间并发,由协程库控制(协作式多任务,需主动挂起)。
创建开销 高(需分配独立内存、文件句柄等资源)。 中(需分配线程栈、寄存器上下文)。 极低(仅创建协程对象,复用线程资源)。
切换开销 最高(涉及内核态上下文和地址空间切换)。 较高(内核态线程上下文切换)。 最低(用户态协程状态保存 / 恢复,无内核参与)。
资源隔离 完全隔离(地址空间、文件描述符等)。 部分隔离(共享进程内存,独立栈、寄存器)。 不隔离(共享线程内存,通过协程作用域管理生命周期)。
执行控制权 操作系统完全控制(不可预测抢占)。 操作系统完全控制(不可预测抢占)。 协程主动控制(通过 suspend 挂起,协作式恢复)。
典型用途 独立程序运行(如浏览器、IDE 等独立进程)。 多任务处理(如网络请求、文件读写等异步操作)。 高并发轻量任务(如海量 I/O 操作、事件驱动逻辑)。
代表技术 Linux 进程、Windows 进程。 Java 线程、POSIX 线程(pthread)。 Kotlin 协程、Go Goroutine、Python asyncio 协程。
生命周期 由操作系统管理(创建 / 销毁开销大)。 由操作系统管理(依赖进程生命周期)。 由协程库管理(绑定作用域,如 Android 的 lifecycleScope)。
上下文保存位置 硬盘或内存(进程切换时保存完整状态)。 内存(线程栈和寄存器状态)。 堆(协程状态封装为 Continuation 对象)。
阻塞影响 进程阻塞不影响其他进程。 线程阻塞会占用 CPU 时间片,影响同进程内其他线程。 协程阻塞不阻塞线程,可释放线程执行其他协程。

核心差异总结

  1. 调度层级

    • 进程和线程由 操作系统内核 调度,属于 内核态并发
    • 协程由 协程库 调度,属于 用户态并发,依赖于线程但更轻量。
  2. 资源开销

    • 进程:资源隔离性最强,但创建和切换开销最大;
    • 线程:共享进程资源,开销中等;
    • 协程:几乎无额外资源开销,可在单线程内运行数万个协程。
  3. 控制方式

    • 进程 / 线程:由操作系统强制抢占,不可预测;
    • 协程:通过 suspend 主动挂起,协作式恢复,适合细粒度异步控制。
  4. 适用场景

    • 进程:适合完全隔离的独立任务;
    • 线程:适合 CPU 密集型或需要系统级并发的任务;
    • 协程:适合 I/O 密集型、高并发轻量任务(如网络请求、UI 异步更新)。

总结

Kotlin 协程之所以被称为 “轻量级线程”,主要是因为它具有以下优点:

  • 内存占用更少:协程不需要独立的栈内存,而是共享调用栈,大大减少了内存开销。
  • 低切换开销:协程切换在用户态完成,无需与操作系统交互,切换代价小。
  • 高并发模型:在同一线程上可以高效地运行大量协程,不受传统线程创建和管理的限制。

通过使用协程,开发者可以更高效地处理并发任务,提高程序的性能和可维护性。


网站公告

今日签到

点亮在社区的每一天
去签到