Android开发学习笔记——Kotlin协程

时间:2024-10-08 15:06:40

Android开发学习笔记——Kotlin协程

  • Android中的异步编程
  • 协程
    • 基本使用
      • runBlocking
        • 简单使用
      • suspend挂起
  • 总结

Android中的异步编程

我们知道,Android App的进程也是一个DVM,内部有许多线程在执行,比如,主UI线程(Main Thread),垃圾回收线程等。其中主UI线程负责执行我们写的应用代码。对于只做很少的I/O操作或耗时操作的App,单一线程开发模式问题不大,但是如果有大量IO或者CPU计算的任务,我们就必须在其他线程内完成了。

在实际的开发过程中,我们通常会要求应用的帧率达到60帧,也就是说每16毫秒就必须进行界面重绘,重绘一帧,这意味着如果我们在主线程上执行的任务超过16毫秒,就会出现丢帧现象,而如果主阻塞时间过长,影响了主线程的运行,甚至还会出现ANR即应用程序无响应错误。这也就意味着,在应用程序的开发中,我们必须在影响主线程的情况下来执行耗时操作。而这也就涉及到了Android中的多线程知识,即异步编程。
在Android开发中,提供有多种异步编程的方法,如:Thread&Handler、HandlerThread、IntentService和AsnycTask等方法。而这些方法,实际上都是基于多线程实现的,即创建一个子线程,在子线程中执行耗时操作。然而,使用线程来解决耗时问题也是存在一定的缺陷的,如下:

  • 线程安全问题。在Android开发中,UI操作必须在子线程中执行,因此在处理多线程任务时,我们需要进行不同线程间的通信来更新UI,否则就会导致线程安全问题。
  • 线程切换代价高昂。线程并非廉价的,线程需要进行昂贵的上下文切换,而因为线程安全问题,每次耗时任务执行完毕,我们都必须切换到主线程来更新UI。
  • 线程并不是无限的。在底层系统的限制下,线程的数量并不是无限的,这就可能导致严重的瓶颈。
  • 回调的复杂。由于需要进行异步通信,我们在进行多线程任务时,经常会使用到回调,当情况复杂时就会造成大量的回调嵌套,降低代码的可读性和可维护性。

协程

在Kotlin中,进行异步编程的方式是协程。那么什么是协程呢?官方是如此描述的:

协程通过将复杂性放入库来简化异步编程。程序的逻辑可以在协程中顺序地表达,而底层库会为我们解决其异步性。该库可以将用户代码的相关部分包装为回调、订阅相关事件、在不同线程(甚至不同机器)上调度执行,而代码则保持如同顺序执行一样简单。

从这个描述中,我们就可以明白,其本质上就可以看作是一个线程框架。其最大优点在于可以快速切换不同的线程,使我们的异步程序以同步代码的形式表达出来,从而避免了回调。如下,当我们需要网络请求获取数据并更新界面时,如果使用线程的形式,我们通常需要通过回调来更新界面,如下:

api.getUser(new Callback<User>() {
    @Override
    public void success(User user) {
        runOnUiThread(new Runnable() {
            @Override
            public void run() {
                nameTv.setText(user.name);
            }
        })
    }
    
    @Override
    public void failure(Exception e) {
        ...
    }
});
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

我们可以看到,因为需要进行回调操作,整个代码量被提升且可读性降低,但是,如果我们使用协程来完成的话,我们完全可以以一种同步的形式顺序执行,简单而又明了。如下:

coroutineScope.launch(Dispatchers.Main) {   // 在主线程开启协程
    val user = api.getUser() // IO 线程执行网络请求
    nameTv.text = user.name  // 主线程更新 UI
}
  • 1
  • 2
  • 3
  • 4

协程就像非常轻量级的线程。线程是由系统调度的,线程切换或线程阻塞的开销都比较大。而协程依赖于线程,但是协程挂起时不需要阻塞线程,几乎是无代价的,协程是由开发者控制的。所以协程也像用户态的线程,非常轻量级,一个线程中可以创建任意个协程。

当然,协程很重要的一点就是当它挂起的时候,它不会阻塞其他线程。协程底层库也是异步处理阻塞任务,但是这些复杂的操作被底层库封装起来,协程代码的程序流是顺序的,不再需要一堆的回调函数,就像同步代码一样,也便于理解、调试和开发。它是可控的,线程的执行和结束是由操作系统调度的,而协程可以手动控制它的执行和结束。

基本使用

在使用前,首先,我们需要导入协程的依赖,如下:

implementation ":kotlinx-coroutines-core:1.4.2"
  • 1

kotlin中创建协程的方式主要存在三种方式,如下:

//方法一:使用 runBlocking 顶层函数
runBlocking {
//耗时操作
}
//方法二:使用 GlobalScope 单例对象直接调用launch开启协程
GlobalScope.launch {
//耗时操作
}
//方法三:行通过 CoroutineContext 创建一个 CoroutineScope 对象
val coroutineScope = CoroutineScope(Dispatchers.Default)
coroutineScope.launch {
//耗时操作
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

其中方法一是线程阻塞的,在实际开发中很少用到;而方法二和三实际上都是本质都是相同的,都是使用了CoroutineScope的launch方法,只不过方法二是一个单例模式,其生命周期和app相同,且无法取消,因此,我们通常更推荐使用方法三。接下来,我们分别简单介绍这几个方法。

runBlocking

runBlocking是一个阻塞式的函数,也就是说,其中的代码都会阻塞线程,在实际开发中,一般我们很少使用。官方描述:运行一个新的协程并且阻塞当前可中断的线程直至协程执行完成,该函数不应从一个协程中使用,该函数被设计用于桥接普通阻塞代码到以挂起风格(suspending style)编写的库,以用于主函数与测试。
也就是说这个函数主要是用来测试的,因为在挂起函数不可以在main函数中被调用,所以,当我们需要测试协程时就可以使用该函数来进行测试.

private fun testRunBlocking(){
        Log.e("test", "main thread start")
        runBlocking {
            repeat(5){
                Log.e("test", "runBlocking $it")
                delay(1000)
            }
        }
        Log.e("test", "main thread end")
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

运行结果如下图所示:
在这里插入图片描述

对于上述所说的方法二和方法三,只要我们查看其源码,我们就会发现,实际上二者都是调用了方法,只不过方法二使用了一个单例模式。所以,对于方法二和三,我们只需要搞懂方法即可。如下所示:

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
        LazyStandaloneCoroutine(newContext, block) else
        StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

从源码可以发现,launch()是CoroutineScope的一个扩展方法。我们目前可以忽略其方法的实现,只看其方法的声明,我们可以发现,该方法存在三个参数,返回一个Job对象。各个参数如下:

  • context(CoroutineContext):协程上下文。指定协程运行的线程并进行线程切换,kotlini提供了以下四种值,如下:
说明
默认值。可以在主线程之外执行 cpu 密集型的工作。例如对列表进行排序和解析 JSON。在线程池中执行。
主线程。协程在主线程中进行,可以进行UI操作
IO线程。在主线程之外执行磁盘或网络 I/O。在线程池中执行
在调用的线程中执行
  • start(CoroutineStart):协程启动模式。kotlin中协程存在四种启动模式,如下表所示:
启动模式 说明
默认的模式,立即执行协程体
只有在需要的情况下运行
立即执行协程体,但在开始运行之前无法取消
立即在当前线程执行协程体,直到第一个 suspend 调用
  • block(suspend CoroutineScope.() -> Unit):协程主体。这就是我们执行异步任务的协程主体部分,是一个用suspend关键字修饰的一个无参,无返回值的函数类型。

  • 返回值Job:对当前创建的协程的引用。可以通过Job对象的start、cancel、join等方法来控制协程的启动和取消。

简单使用

对launch方法有了一个基本认识后,我们就可以来学习下简单使用launch方法了。首先,我们创建一个协程,如下:

Log.e("test", "main thread start")
GlobalScope.launch {
    //耗时操作
    Thread.sleep(1000)
    Log.e("test", "launch")
}
Log.e("test", "main thread stop")
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

运行程序,我们发现主线程并未被阻塞,“launch”在1秒后输出,运行结果如下:在这里插入图片描述
但是,这样简单的使用下,我们会发现这和线程比并没有多大的差别。我们说过,协程的最大优势在于能够快速切换线程,将异步转化为同步形式。在一个常见场景下,我们可能需要通过网络请求获取一些参数,然后再进行界面更新,此时如果我们使用子线程执行的话,我们可能需要通过异步通信Handler等方式来切换到主线程更新UI,但是对于协程而言,我们只需要使用withContext方法切换线程,然后方法中执行完毕后会自动切换到主线程执行,如下:

Log.e("test", "main thread start")
        GlobalScope.launch(Dispatchers.Main) {
            //耗时操作
            val result = withContext(Dispatchers.IO){
                Thread.sleep(1000)
                Log.e("test", "launch")
                return@withContext "this is a  message"
            }
            Log.e("test", "update UI")
            tvTest.text = result
        }
        Log.e("test", "main thread stop")
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

我们可以看到,我们通过withContext方法切换到IO线程获取到result,然后协程在withContext执行完毕后自动切回主线程,然后更新tvTest,这种方法无疑比各种回调更加清晰明了,从代码看,就像是同步执行一般,顺序执行下来非常清晰。运行程序,主线程也没有被阻塞,在一秒后界面数据被更新,具体结果如下:
在这里插入图片描述
界面在1秒后正常更新:
在这里插入图片描述

同时,我们还可以将withContext放入到一个挂起函数中,如下:

Log.e("test", "main thread start")
GlobalScope.launch(Dispatchers.Main) {
    val result = getMessage()//模拟网络请求获取数据
    Log.e("test", "update UI")
    tvTest.text = result
}
Log.e("test", "main thread stop")
        
/**
 * 耗时方法
 */
private suspend fun getMessage() = withContext(Dispatchers.IO){
    Thread.sleep(1000)
    Log.e("test", "launch")
    return@withContext "this is a data"
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

执行结果完全相同。我们可以看到,如此一来,代码就更加清晰了,这个耗时操作就好像一个同步执行的方法一样,值得注意的是,withContext必须在协程或者挂起函数中使用,否则会报错。挂起函数就是suspend修饰的函数,具体我们之后再讲述。

从上述例子中,我们就可以完全发现协程的优势所在了,将异步操作转换为同步表达,使代码变得更加清晰明了。

从上述例子中,我们就可以完全发现协程的优势所在了,将异步操作转换为同步表达,使代码变得更加清晰明了。接下来,我们就可以来学习一下suspend挂起操作了。

suspend挂起

首先,我们需要明白挂起是什么?挂起即挂起协程,将协程在当前执行其代码的线程中脱离,转由其它线程执行。
虽然使用协程比我们使用多线程方式进行异步操作要方便很多,且使用方法也存在很多差别,但是我们需要明白其内部肯定也是通过多线程的形式来实现的,其只是一个更为简单便捷的线程框架。所以,其内部操作实际上也是在操作线程。因此,协程脱离后肯定是在其它线程中执行了。我们可以修改下上述代码,打印出当前线程id,如下:

Log.e("test", "main thread start")
GlobalScope.launch(Dispatchers.Main) {
    Log.e("test", "main thread id : ${Thread.currentThread().id}")
    val result = getMessage()//模拟网络请求获取数据
    Log.e("test", "update UI---thread id : ${Thread.currentThread().id}")
    tvTest.text = result
}
Log.e("test", "main thread stop")
        
        
 /**
 * 耗时方法
 */
private suspend fun getMessage() : String{
    Log.e("test", "getMessage thread id : ${Thread.currentThread().id}")
    withContext(Dispatchers.IO){
        Thread.sleep(1000)
        Log.e("test", "launch----thread id : ${Thread.currentThread().id}")
    }
    Log.e("test", "return --getMessage thread id : ${Thread.currentThread().id}")
    return "this is a data"
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

运行结果如下:
在这里插入图片描述

从运行结果,我们发现,withContext方法执行时,线程从主线程中被切换到子线程,而在withContext之后的代码此时被阻塞,执行完毕后又自动切换到了主线程,即resume。所以,我们可以知道,协程中的挂起,实际上就是线程的切换,不同的时,协程中,切换线程后会自定resume到原来的的线程中。而对于suspend这个修饰符,我们可以看作协程的代码块中,线程执行到了 suspend 函数这里的时候,就暂时不再执行剩余的协程代码,跳出协程的代码块。当然,实际上从上述例子,我们可以看出suspend并不是真正的挂起函数,而是withContext,只有执行到了withContext后,函数才被挂起。suspend只是起到了一个说明作用。

我们可以这样理解:当执行到挂起函数时,挂起函数及其之后的代码都被切换到另一个线程中执行,而当挂起函数执行完毕后,又会自动切换到原线程,然后在原线程中执行挂起函数之后的代码。

那么挂起函数切换到那个线程执行呢?这就是又调度器决定的了,而Dispatchers就会为我们指定到对应的线程中执行。Dispatchers 调度器,它可以将协程限制在一个特定的线程执行,或者将它分派到一个线程池,或者让它不受限制地运行。

总结

总之Kotlin 协程并没有脱离 Kotlin 或者 JVM 创造新的东西,它只是将多线程的开发变得更简单了,可以说是因为 Kotlin 的诞生而顺其自然出现的东西,从语法上看它很神奇,但从原理上讲,它并不是魔术。

也就是说,Kotlin协程尽管比我们使用Thread&Handler、AsncyTask等方式处理异步任务方便许多,但是其本质实际上并没有脱离多线程的使用,我们可以将其看作是一个线程处理框架。了解了launch和挂起函数的意义后,我们就对kotlin协程有了一个大致的了解,当然协程中还有一些知识,如async等,这些还需要去学习。