Jetpack系列(七) -- Kotlin协程

2,020 阅读10分钟

前言

时间: 23/09/30 ( 中秋快乐 )过节去了,文章完善和发布花了些时间,见谅

AndroidStudio版本: Giraffe 2022.3.1 JDK:17 开发语言: Kotlin

Gradle版本: 8.0 Gradle plugin Version: 8.1.1

概述

Android的线程模型就分为两个,主线程和子线程。在主线程即 UI 线程中,是不允许进行一些像网络请求、I/O操作的耗时操作的。这些耗时操作必须放在子线程中,否则主线程在执行耗时操作而长时间无响应,导致ANR异常。

在Android开发中,常见的子线程实现就是 Runnable 、AsyncTask 以及 Java 的new Thread。其中 AsyncTask 已经弃用。而要实现线程间通信呢( 比如拿到数据后要显示在 UI 中 ),我们一般会用到 Handler 和 Message 。将子线程的数据传递给主线程,因为子线程是不允许操作 UI 的。当然线程还有更高级的用法,例如创建线程池来管理线程等。

本节要讲的,就是Kotlin对于Java的优势之一,Koltin协程。

一个app有且仅有一个进程,一个进程可以有很多线程。而线程中可以有很多协程,某种意义上来说,协程又是一种轻量级的线程。

协程的单元概念

要使用协程,就必须要了解两个Kotlin协程的概念。协程作用域挂起函数

挂起函数

挂起函数需要在另一个挂起函数或者在协程作用域中调用,否则会报错。

举个栗子

image-20230929112702245

Suspend function 'delay' should be called only from a coroutine or another suspend function

suspend function就是说的挂起函数。delay是一个被定义好的 延时作用的挂起函数,如果直接写在主线程中就会提示这样的信息。

挂起函数其实没有具体作用,开发者可以把耗时操作放在挂起函数中,这样我们在调用这个方法的时候,如果不规范( 没有在规定代码段调用 )会提醒我们。

至于自定义挂起函数,那就很简单了。我们只需要在需要被定义成挂起函数的fun关键字前添加suspend关键字即可。如下

    private suspend fun loadData() {
        delay(5000L)
        Log.d(TAG, "-- loadData --")
    }

协程作用域

前面说到,挂起函数需要在另一个挂起函数或协程作用域中调用,否则会报错。那什么是协程作用域呢?

协程作用域(Coroutine Scope)是在协程中用来管理协程生命周期的概念。它定义了协程的上下文(Context)和生命周期,并确保在协程完成或被取消时,相关资源被正确释放。

通俗点讲就是,我们在使用协程时,需要像构建线程一样构建一个协程,而这个协程的“管理范围”内的代码区域,就是协程作用域。

我们可以在这个区域调用挂起函数,用以实现耗时操作。

协程的创建与使用

同线程一般,协程创建也有很多中方式。并且每种方式都有特点。

Kotlin在最新版本已经完全支持协程的一些方法,所以并不需要引入任何依赖包,就能够实现协程的使用。如果你使用过程中遇到问题,说明你使用的kotlin版本比较早,可以百度查一下如何引入依赖包,这里就不作赘述。

  • GlobalScope.launch

    使用 GlobalScope.launch {} 构建一个协程,具体代码

            val job = GlobalScope.launch {
                delay(5000L)
                Log.d(TAG, "Thread This: ${Thread.currentThread().name}")
            }
    

    这样子就能在 默认线程 中创建一个协程,如果需要指定在某个子线程中创建协程,则可以给launch方法设置一个 Dispatchers 参数值。如下

            val job = GlobalScope.launch(Dispatchers.IO) {
                delay(5000L)
                Log.d(TAG, "Thread This: ${Thread.currentThread().name}")
            }
    

    当然 Dispatchers 还有其它参数值,例如Main、Default 和 Unconfined( 这个干嘛的还不清楚,一般用不到 )。

    参数作用
    Dispatchers.Default默认,一般用作密集任务或运算
    Dispatchers.IO常用于网络请求、文件读写数据库操作等
    Dispatchers.Main表示在主线程中进行,可以直接操作UI

    需要注意的时,GlobalScope是一个顶层协程。在实际开发中很少用,并且在使用的时候会报 warning ,提示这是一个敏感操作。

    image-20230929125537156

  • CoroutineScope

            val job = Job()
            //CoroutineScope(job).launch
            Log.d(TAG, "----start delay----")
            CoroutineScope(job).launch {
                delay(5000L)
                Log.d(TAG, "print this message after delay 5s (CoroutineScope)")
            }
    		job.cancel()//取消协程
    
  • async 函数

    async函数也可以构建一个协程作用域,并且它可以返回一个 Deferred 对象。但是它本身也需要在协程作用域中才能调用。

            //必须在协程作用域中调用的async
            Log.d(TAG, "----start delay----")
            CoroutineScope(job).launch {
                val result = async {
                    delay(5000L)
                    return@async "print this message after delay 5s (async)"
                }.await()
                Log.d(TAG, result)
            }
    

    事实上这个 return@async 是可有可无的,因为在 async 的作用域中,会自动拿到值并返回。例子

            Log.d(TAG, "----start delay----")
            CoroutineScope(job).launch {
                val result = async {
                    delay(5000L)
                    val test = ArrayList<String>()
                    test.add("print this message after delay 5s (async)")
                    test//return@async
                }.await()
                Log.d(TAG, result[0])
            }
    

    async的 await() 会把 Deferred 中的 T 返回,如上,返回类型分别是 String 和 ArrayList 。而这个方法有一个特性,就是会堵塞程序运行,直到它拿到了应该拿到的值或异常报错。

    通过下面的代码可以看出。

            CoroutineScope(job).launch {
                val startTime = System.currentTimeMillis()
                val result1 = async {
                    delay(5000L)
                    return@async "result1"
                }.await()
    
                val result2 = async {
                    delay(3000L)
                    return@async "result2"
                }.await()
                Log.d(TAG, "--$result1-- || --$result2--")//分别执行两个result
                val endTime = System.currentTimeMillis()
                Log.d(TAG, "-- execute time: ${endTime - startTime} --")
            }
    

    image-20230930235112804

    通过log打印可以看出执行时间是5000+3000ms,也就是这两个async方法是相继执行的,而非同时执行。那如何同时执行呢?很简单,只需要在需要的时候执行 await() 方法即可。

    //换成如下
            CoroutineScope(job).launch {
                val startTime = System.currentTimeMillis()
                val result1 = async {
                    delay(5000L)
                    return@async "result1"
                }
    
                val result2 = async {
                    delay(3000L)
                    return@async "result2"
                }
                Log.d(TAG, "-- ${result1.await()}-- || --${result2.await()} --")
                val endTime = System.currentTimeMillis()
                Log.d(TAG, "-- execute time: ${endTime - startTime} --")
            }
    

    image-20230930235513099

  • withContext 函数

    withContext 函数是一个挂起函数,前面讲到 挂起函数 需要在其它挂起函数或协程作用域中调用。withContext也是如此,来看一下 下面的示例代码。

    	onCreate() {
    		...
    		CoroutineScope(job).launch(Dispatchers.Main) {
                val data1 = loadData(1)
                binding.tvShow.text = data1
                val data2 = loadData(2)
                binding.tvShow2.text = data2
            }
        }
    
        /**
         * 挂起函数 示例
         * @param need which data u need?
         * @return yes, which data u need!
         * @author Jensen
         */
        private suspend fun loadData(need: Int) =
            withContext(Dispatchers.IO) {
                delay(2000L)//模拟耗时操作
                val datas = ArrayList<String>()
                datas.add("get data1 successfully")
                datas.add("get data2 successfully")
                datas[need-1]
            }
    

    我们获取了从 loadData() 方法获取了两次值,使用delay方法模拟耗时操作的延时,可以看到在 CoroutineScope 的协程作用域中,使用很是方便。并且在设置了 launch 的参数值为 Dispatchers.Main 后,可以直接在写成作用域中实现 UI 更新,这样的代码不但简单而且优雅,而且简单。

    可能有人会觉得有点懵逼,那就给出传统 线程 实现这个同等操作的代码以作对比。

    	onCreate() {
    		...
    		loadDataLoser()
        }
    
        private fun loadDataLoser() {
            Thread {
                Thread.sleep(2000L)//模拟耗时操作
                runOnUiThread {
                    binding.tvShow.text = "get data1 successfully"
                    loadData2Loser()
                }
            }.start()
        }
        private fun loadData2Loser() {
            Thread {
                Thread.sleep(2000L)//模拟耗时操作
                runOnUiThread {
                    binding.tvShow2.text = "get data2 successfully"
                }
            }.start()
        }
    

    应该也挺好理解的,在 loadDataLoser 中又嵌套了一个 请求数据的方法。

    事实上,在不使用协程的情况下,这可能是最简单的一种数据请求嵌套,现实情况往往更加复杂,因为耗时操作例如网络请求会有各种问题出现。最基础的就是异常处理,而如果使用协程,我们只需要写好两个挂起函数,然后在协程作用域中一次调用两个方法即可(将我上面的一个方法改成两个不含参数的方法即可)。

    而这也为什么推荐使用协程的重大原因。

Retrofit与协程产生的化学反应

既然协程如此牛波一,那就做一个网络请求示例吧,至于Retrofit框架,如果不熟悉的、感兴趣的可以去看看前几年写的文章。Let's go!

  • 添加Retrofit框架依赖包

        implementation("com.squareup.retrofit2:retrofit:2.9.0")
        implementation("com.squareup.retrofit2:converter-gson:2.9.0")
    
  • 新建一个 UserBean,用以接受网络请求返回的 json 数据

    class UserBean {
        var login: String? = null
        var id = 0
        var node_id: String? = null
        var avatar_url: String? = null
    
        override fun toString(): String {
            return "UserBean(login=$login, id=$id, node_id=$node_id, avatar_url=$avatar_url)"
        }
    }
    

    我这里只使用了一部分返回的数据,程序可以正常运行。具体业务开发因人而异。

  • 还是老规矩,新建一个 ApiService 接口类来构建网络请求具体的方法

    interface ApiService {
        @GET("users") fun queryDataLoser(): Call<List<UserBean>>
    }
    

    GET方法,参数是 网络请求的一部分,下面写 baseurl 的时候说

    准备工作ok

传统方法

在 Activity 中添加以下代码

        //onCreate()方法中,关于view就自己照着写就行了,不影响整体代码
		val retrofit = Retrofit.Builder()
            .baseUrl("https://api.github.com/")
            .addConverterFactory(GsonConverterFactory.create())
            .build()
        val apiService = retrofit.create<ApiService>()

        apiService.queryDataLoser().enqueue(object : Callback<List<UserBean>> {//网络请求发起
            override fun onResponse(
                call: Call<List<UserBean>>,
                response: Response<List<UserBean>>
            ) {
                if (response.body() != null) {
                    //显示出请求的结果
                    binding.tvShow.text = response.body()!![0].toString()
                } else {
                   binding.tvShow2.let {
                        it.text = "Ohuo, I don't get any thing"
                        it.setTextColor(getColor(R.color.warning))
                        it.textSize = 22f
                    }
                }
            }

            override fun onFailure(call: Call<List<UserBean>>, t: Throwable) {
                binding.tvShow2.let {
                    it.text = "Ohuo, Faild to connect network"
                    it.setTextColor(getColor(R.color.error))
                    it.textSize = 22f
                }
                t.printStackTrace()
            }

        })

包括了请求success后的逻辑处理和请求failure后的异常处理。由于 Retrofit 在这两个方法中都替开发者实现了 handler,所以可以直接进行 UI 操作。

但是就算是这样——简单的逻辑判断,结果显示。仍然是 一坨 代码块。并且 hardcode 这么多,这大概就是shi山吧。那如果软件的需求是在第一次网络请求成功之后,在进行其它的网络请求,按照这种编写方式,一般来说有两种方式来满足需求。

一是在 onResponse 中再进行一次 enqueue 嵌套,光想想就觉得,好乱啊。如果是使用 okhttp 的话呢,那可能更乱。

二是什么,把需求打回去,或者删库跑路。

总之,网络请求不管是使用什么框架,传统的实现方式就是这么真实,没有过硬的代码实力和逻辑思考能力,可能开发者就陷入其中了。

上面提到的 @GET 注解中的 "users" 和 baseurl,都是我们http请求的“网址”的一部分,Retrofit会自动拼接成一个完整的 URL 。以上示例完整的URL为 api.github.com/users

协程方式

在 ApiService 中添加一个挂起函数,需要注意的是,这里返回的是 List,而不再是 Call<>。

@GET("users") suspend fun queryData(): List<UserBean>

then Activity中,看仔细了哈

        CoroutineScope(job).launch(Dispatchers.Main) {
            val result = apiService.queryData()
            binding.tvShow.text = result[0].toString()
        }

可能有人会问,这不是在Main线程中执行的query方法吗?为什么网络方法可以正常执行呢?还是那句话,Retrofit内部对 suspend 函数做了相关的处理,会自动切换到子线程来执行这个挂起函数。当然开发者也可以自己来完成这个操作,也很简单。

        CoroutineScope(job).launch(Dispatchers.Main) {
            val result = withContext(Dispatchers.IO) {
                apiService.queryData()
            }
            binding.tvShow.text = result[0].toString()
        }

到这里,可能有人注意到了一个问题,异常处理怎么办啊?

有一个最简单的方法——try-catch。事实上呢,我们在进行任何耗时操作的时候,都需要使用 try-catch 来捕获程序的异常,否则说不定什么时候代码就来一个空指针异常或者其它问题导致程序crash,并且加 try-catch 能够通过捕获到的异常信息分析出问题原因。

        CoroutineScope(job).launch(Dispatchers.Main) {
            try {
                val result = withContext(Dispatchers.IO) {
                    apiService.queryData()
                }
                binding.tvShow.text = result[0].toString()
            } catch (e: IOException) {
                binding.tvShow2.let {
                    it.text = "Ohuo, Faild to connect network"
                    it.setTextColor(getColor(R.color.error))
                    it.textSize = 22f
                }
                e.printStackTrace()
            } finally {
                Toast.makeText(this@MainActivity, "over!", Toast.LENGTH_SHORT).show()
            }

        }

其它

构建协程作用域的方法其实不止上述所说的,在 UI 页面可以使用 lifecycleScope 来构建协程。

lifecycleScope.launch{}
.launchWhenCreated{}
.launchWhenResume{}

但是后两个都已经被弃用,因为在使用中可能存在资源泄露。推荐使用挂起函数repeatOnLifecycle来替代这种绑定生命周期的协程创建形式。但是这种方式在实际开发中运用还是较少

repeatOnLifecycle(/* Lifecycle.State */Lifecycle.State.CREATED) {}

总结

本节讲了Kotlin协程的基本使用,也描述了 协程 相对于 常规的传统方式 的代码开发的优势。还举例描述了在Retrofit网络框架中使用协程的方法。

关于 Kotlin 协程的原理,本想简单说一下,但是由于原理涉及的实在过于繁琐,或者说是 难,所以本节没有就Kotlin协程原理展开分析,后续有机会出一篇文章专门讲讲。感兴趣的可以去看看这篇文章——Kotlin Jetpack 实战: 图解协程原理

下节就讲述一下Google推荐使用的基于 Dagger2 的依赖注入框架组件 Hilt 。

Demo地址(GitHub)

欢迎点赞评论