Kotlin 协程看这篇就够了

766 阅读6分钟

Kotlin 协程是一套轻量级并发编程方案,旨在简化异步操作和并发任务的实现,解决传统异步编程中回调地狱和线程管理复杂等问题。Kotlin 协程是运行在线程中的,可以在单个线程上运行多个协程,因为协程支持挂起,不会使正在运行协程的线程阻塞。

要在 Android 中使用协程,首先需要引入依赖

    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
    implementation "androidx.activity:activity-ktx:1.5.1"

协程

启动协程

CoroutineScope(Dispatchers.IO).launch {
    // I/O 线程
}

launch 是一个函数,用于创建协程并将其函数主体的执行分派给相应的调度程序,Dispatchers.IO 指示此协程应在 I/O 线程上执行。

withContext 函数可以切换到指定线程,并在闭包中逻辑执行完后自动把线程切回去继续执行。

CoroutineScope(Dispatchers.Main).launch {
    // 主线程执行操作
    withContext(Dispatchers.IO) {
        //切换到 I/O 线程
    }
    //todo 主线程接着操作
}

如果一个函数任务比较耗时,我们可以把它写成 suspend 函数,suspend 用于暂停执行当前协程,并保存所有局部变量,在执行到某一个 suspend 函数时,这个协程就会被挂起。

举个例子,我们有一个耗时任务,这里用 delay 模拟耗时,它的作用是等一段时间后再继续往下执行代码。

private suspend fun executeTask() {
    delay(3000)
}
        CoroutineScope(Dispatchers.Main).launch {
            executeTask()
            Log.i(tag, "Task execution completed")
        }
        Log.i(tag, "Jump out")

当我们 launch 启动协程时,遇到了 suspend 耗时函数,这个协程就会被挂起,此时会跳出该协程,先打印出 Jump out,等挂起执行完之后又会重新切回,所以后面才会打印出 Task execution completed

Kotlin 提供了三个调度程序,以用于指定应在何处运行协程:

  • Dispatchers.Default:主要用于 CPU 密集型任务的调度器,它会使用共享的后台线程池,当有一些复杂的计算任务,如大规模的数据处理、加密算法等,使用这个是不错的选择。
  • Dispatchers.IO:主要用于执行 I/O 操作,如网络请求、文件读写等。
  • Dispatchers.Main:主要用于在主线程中执行协程。

Dispatchers.Main 的内部实现依赖于 Android 的主线程 Looper 和 Handler 机制。它通过将协程任务包装成 Runnable 并投递到主线程的 Looper 中,实现了协程代码在主线程上的执行。
Dispatchers.IO 的内部实现基于线程池机制,通过动态调整线程数量和任务队列来高效执行任务。

除了 launch,我们还可以用 async 来启动一个协程。它俩的区别在于

  • launch:启动新协程不将结果返回给调用方
  • async:启动新协程并允许使用 await 的挂起函数返回结果

比如有两个协程,这两个并行的协程需要 getData 在返回结果之前完成,getData 返回两个任务的执行结果之和。

private suspend fun executeTask1(): Int {
    delay(1000)
    return 1
}

private suspend fun executeTask2(): Int {
    delay(2000)
    return 2
}
    // coroutineScope 用于启动一个或多个协程
    private suspend fun getData() = coroutineScope {
        val task1 = async { executeTask1() }
        val task2 = async { executeTask2() }
        task1.await() + task2.await()
    }

await 针对单个协程,awaitAll 针对多个协程。

CoroutineScope 会跟踪它使用 launch 或 async 创建的所有协程,我们可以随时调用 scope.cancel() 以取消正在运行的协程。在 Android 中,某些 KTX 库为某些生命周期类提供自己的 CoroutineScope。例如,ViewModel 有 viewModelScope,Lifecycle 有 lifecycleScope 。

Job 是协程的句柄,使用 launch 或 async 创建的每个协程都会返回一个 Job 实例,该实例是相应协程的唯一标识并管理其生命周期。

val job = lifecycleScope.launch {
    delay(5000)
    Log.i(tag, "finish")
}
findViewById<Button>(R.id.btn).setOnClickListener {
    job.cancel()
}

在我们日常开发中,最常见的使用场景就是用协程去处理网络请求,现在 Retrofit 也原生支持协程了。

implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
interface MyService {
    @GET("gallery/{imageType}/response")
    suspend fun getImages(@Path("imageType") imageType: String): List<String>

    companion object {
        fun createApi(): MyService = Retrofit.Builder()
            .baseUrl(BASE_URL)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(MyService::class.java)
    }
}

        lifecycleScope.launch {
            try {
                val result = MyService.createApi().getImages("banner")
                if (result.isNotEmpty()) {
                    refreshView(result) //刷新视图
                }
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }

由此可见,协程最方便的就是省去切线程的步骤,用同步代码处理耗时的异步网络请求,省去 Retrofit 中的网络回调。

协程中捕获异常除了 try-catch 外,还可以使用 CoroutineExceptionHandler

val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
    Log.i(tag, "Caught Exception: $throwable")
}

val scope = CoroutineScope(Job() + exceptionHandler)

scope.launch {
    throw RuntimeException("Test exception")
}

协程的底层依赖线程池,但协程的挂起不会阻塞线程。当一个协程挂起时,其线程会被释放,供其他协程或任务使用。

为什么说“协程挂起不会阻塞线程”?
协程在挂起时主动通知调度器:“我现在需要等待,你可以用这个线程做其他事情”。线程不会像传统阻塞调用(如 Thread.sleep())那样傻等,而是立即执行其他任务,挂起函数内部通过异步回调实现非阻塞。

调度器如何管理线程?
以 Dispatchers.IO 为例:底层使用 Executors 创建的线程池,当一个协程挂起时,调度器的 Worker 线程会从任务队列中取出下一个协程任务执行。

在 kotlin 协程中,有个概念叫做通道 Channel,属于热流,实际上它就是个队列,是一个面向多协程之间数据传输的 BlockQueue,用于协程间通信。Channel 允许我们在不同的协程间传递数据,通俗地讲,就是不同的协程可以往同一个管道里面写入或者读取数据,它使用 send 和 receive 两个方法往管道里面写入和读取数据,这两个方法是非阻塞的挂起函数,所以必须在协程中使用。

val channel = Channel<Int>()

lifecycleScope.launch(Dispatchers.IO) {
    for (i in 0..5) {
        channel.send(i)
    }
}

lifecycleScope.launch {
    delay(1000)
    for (i in channel) {
        Log.i(tag, "channel: $i") //会打印出 0-6
    }
}

lifecycleScope.launch(Dispatchers.IO) {
    delay(2000)
    channel.send(6)
    //停止接受新元素
    channel.close()
}

由此可见,我们在协程外定义 Channel, 多个协程就可以访问同一个 Channel,达到协程间通信的目的。

Flow

  • 冷流:只有订阅者订阅时,才开始执行发射数据流,冷流和订阅者只能是一对一关系,当有多个不同的订阅者时,消息是重新发送的。
  • 热流:无论有没有订阅者订阅,事件都会发生。当热流有多个订阅者时,热流和订阅者们是一对多的关系,可以与多个订阅者共享信息。

flow 是冷流,flow 中的代码直到被 collect 调用时才会执行。

val dataFlow = flowOf(1,2,3)

lifecycleScope.launch {
    dataFlow.collect {
        Log.i(tag, "dataFlow.collect: $it")
    }
}

如果需要定时取消 flow 的执行,可使用 withTimeoutOrNull 添加超时即可,如下所示,只会打印出 1

val dataFlow = flow {
    for (i in 1..3) {
        delay(1000)
        emit(i)
    }
}

lifecycleScope.launch {
    withTimeoutOrNull(2000) {
        dataFlow.collect {
            Log.i(tag, "dataFlow.collect: $it")
        }
    }
}

操作符

map 操作符将 flow 的输入转换为新的输出,比如将 Int 转换成 String

val dataFlow = flowOf(1, 2, 3, 4, 5)

lifecycleScope.launch {
    dataFlow.map {
        "NO.$it"
    }.collect {
        Log.i(tag, "collect: $it")
    }
}

transform 操作符与 map 操作符相似但又不完全一样,map 是一对一的变换,而 transform 可以完全控制流的数据。

val dataFlow = flowOf(1, 2, 3, 4, 5)

lifecycleScope.launch {
    dataFlow.transform {
        if (it > 3) {
            emit("NO.$it")
        }
    }.collect {
        Log.i(tag, "collect: $it")
    }
}

filter 过滤操作符

lifecycleScope.launch {
    dataFlow.filter {
        it > 3
    }.collect {
        Log.i(tag, "collect: $it")
    }
}

flowOn 指定线程的切换

val dataFlow = flow {
    emit(1)
}.flowOn(Dispatchers.IO)

lifecycleScope.launch {
    dataFlow.flowOn(Dispatchers.Main).collect {
        Log.i(tag, "collect: $it")
    }
}

组合操作符 combine 可以连接两个不同的 flow

val dataFlow1 = flowOf(1, 2, 3)
val dateFlow2 = flowOf("a", "b", "c")
lifecycleScope.launch {
    dataFlow1.combine(dateFlow2) { p1, p2 ->
        "$p1 -> $p2"
    }.collect {
        Log.i(tag, "collect $it")
    }
}

组合操作符 merge 用于将多个流合并

val dataFlow1 = flowOf(1, 2, 3)
val dateFlow2 = flowOf("a", "b", "c")
lifecycleScope.launch {
    listOf(dataFlow1, dateFlow2).merge().collect {
        Log.i(tag, "collect: $it")
    }
}

StateFlow 和 SharedFlow

StateFlow 和 SharedFlow 是 Flow API,允许数据流以最优方式发出状态更新并向多个使用方发出值,属于热流。

这两者的区别在于,StateFlow 只有在值改变时才会返回,如果发生更新但值没有变化时,StateFlow 不会回调 collect 函数,而 SharedFlow 支持发出和收集重复值。

SharedFlow

class MainViewModel : ViewModel() {

    val sharedFlow = MutableSharedFlow<Int>()
    var count = 1

    fun changeValue() {
        viewModelScope.launch {
            count++
            sharedFlow.emit(count)
        }
    }

}
val mainViewModel by viewModels<MainViewModel>()

lifecycleScope.launch {
    mainViewModel.sharedFlow.collect {
        Log.i(tag, "sharedFlow.collect: $it")
    }
}

findViewById<Button>(R.id.btn).setOnClickListener {
    // 点击按钮改变值
    mainViewModel.changeValue()
}

StateFlow

class MainViewModel : ViewModel() {
    
    var count = 0
    val stateFlowFlow = MutableStateFlow(0)

    fun changeValue() {
        viewModelScope.launch {
            count++
            stateFlowFlow.value = count
        }
    }

}
val mainViewModel by viewModels<MainViewModel>()

lifecycleScope.launch {
    mainViewModel.stateFlowFlow.collect {
        Log.i(tag, "stateFlowFlow.collect: $it")
    }
}

findViewById<Button>(R.id.btn).setOnClickListener {
    // 点击按钮改变值
    mainViewModel.changeValue()
}

StateFlow 和 LiveData 具有相似之处,两者都是可观察的数据容器类,但两者的行为有所不同:

  • StateFlow 需要将初始状态传递给构造函数,而 LiveData 不需要。
  • 当 Activity 进入 STOPPED 状态时,liveData.observe 会自动取消注册使用方,而从 StateFlow 或其他数据流收集数据的操作并不会自动停止。如需实现相同的行为,官方推荐使用 repeatOnLifecycle 来构建协程,当视图处于 STARTED 状态时会开始收集流,并且在 RESUMED 状态时保持收集,最终在视图进入 STOPPED 状态时结束收集过程。
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1'
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        mainViewModel.stateFlowFlow.collect {
            Log.i(tag, "stateFlowFlow.collect: $it")
        }
    }
}

并发问题

我们先看个例子,这里重复888次,每次开启一个协程。

val scope = CoroutineScope(Dispatchers.Default) //创建协程作用域,Default 和 IO 都支持并发
var count = 0
repeat(888) {
    scope.launch {
        count++
        Log.i(tag, "currentThread: ${Thread.currentThread().name},count: $count")
    }
}

然后看如下打印:

image.png

最后并没有打印出888,也运行在两个不同的线程中,所以,说明协程中同样存在并发问题,那要怎么去解决协程的并发问题呢?当然,你也可以用 Java 的方式去解决,比如 synchronized, Atomic 机制等,下面介绍两种协程的方式。

单线程模式

使用 Dispatchers.Unconfined,协程始终运行在单线程中。

val scope = CoroutineScope(Dispatchers.Unconfined)
var count = 0
repeat(888) {
    scope.launch {
        count++
        Log.i(tag, "currentThread: ${Thread.currentThread().name},count: $count")
    }
}

Mutex

使用 Mutex 可以确保在同一时间只有一个协程可以访问被锁定的代码块

val mutex = Mutex()
val scope = CoroutineScope(Dispatchers.Default)
var count = 0
repeat(888) {
    scope.launch {
        mutex.withLock {
            count++
            Log.i(tag, "currentThread: ${Thread.currentThread().name},count: $count")
        }
    }
}