学习Kotlin之协程入门(二)

更多的作用域构建器

回顾

  上一小节,学习了GlobalScope.launch、runBlocking、launch、coroutineScope这几种作用域构建器,都可以用来创建协程作用域。GlobalScope.launch和runBlocking函数是可以在任意地方调用,coroutineScope函数可以在协程作用域或挂起函数中调用,而launch函数只能在协程作用域中调用。

  同时我们也了解了,runBlocking会阻塞线程,因此只建议在测试环境中使用。而GlobalScope.launch每次创建的都是顶层协程,也不建议使用,除非明确就是要创建顶层协程。

为什么不建议使用顶层协程?

  因为管理成本高。比如我们在某个Activity中使用协程发起了一条网络请求,由于网络请求是耗时的,用户在服务器还没来得及响应的情况下就关闭了当前Activity,此时就应该取消这条网络请求或者不该进行回调。

  那么协程要怎么取消呢?不管是GlobalScope.launch函数还是launch函数,它们都会返回一个Job对象,只需要调用Job对象的cancle()方法就可以取消协程了,如下:

    val job = GlobalScope.launch { 
        //  处理具体逻辑
    }
    job.cancel()

  但如果我们每次创建的都是顶层协程,那么当Activity关闭时,就需要逐个调用所有已创建协程的cancel()方法,这种情况代码就很难维护了。因此,GlobalScope.launch这种协程作用域构建器,在实际项目中也是不太常用的。

实际项目常用的写法

    val job = Job()
    val scope = CoroutineScope(job)
    scope.launch { 
        //  处理具体逻辑
    }
    job.cancel()

  先创建一个Jon对象,传入CoroutineScope()函数中,它会返回一个CoroutineScope对象,有了这个对象,就可以调用它的launch函数来创建一个协程了。

  现在所以调用CoroutineScope的launch函数所创建的协程,都会被关联在Job对象的作用域下面,这样只需调用一次cancel()方法,就可以将同一作用域内的所有协程全部取消,这就大大降低了协程管理的成本。

 

async函数

  虽然launch函数可以创建一个新的协程,但是它只能用于执行一段逻辑,不能获取执行结果,因为它的返回值是一个Job对象。想要创建一个协程并获取它的执行结果,就要用到async函数了。

  async函数必须在协程作用域中才能调用,它会创建一个协程并返回一个Deferred对象,想要获取async函数代码块的执行结果,只需调用Deferred对象的await()方法:

fun main() {
    runBlocking { 
        val result = async { 
            5 + 5
        }.await()
        println(result)
    }
}

  使用runBlocking是为了方便学习测试的。

  以上操作我们就可以看到打印结果了。事实上,在调用了async函数之后,代码块中的代码会立刻执行,当调用await()方法时,如果代码块中的代码没有执行完,那么await()方法会将当前协程阻塞住,直到可以获取async函数的执行结果。

  所以如果我们连续使用两个async函数,并使用delay()方法延迟,如下:

fun main() {
    runBlocking {
        val result1 = async {
            delay(1000)
            5 + 5
        }.await()
        val result2 = async {
            delay(1000)
            1 + 2
        }.await()
    }
}

 

  这种情况下,第一个async函数中的代码会在执行完之前一直阻塞当前协程,完成之后才会执行第二个async函数,是一种串行关系,这种写法是非常低效的。

  实际上,这两个async函数是可以并行执行的,写法如下:

fun main() {
    runBlocking {
        val deferred1 = async {
            delay(1000)
            5 + 5
        }
        val deferred2 = async {
            delay(1000)
            1 + 2
        }
        println("result is ${deferred1.await() + deferred2.await()}.")
    }
}

 

  我们不需要在每次调用async函数之后就立刻使用await()方法获取结果,仅仅在需要用到async函数的执行结果的时候才调用await()方法进行获取,这样两个函数就是并行关系了。

 

withContext()函数

  这是一个比较特殊的作用域构建器,它是一个挂起函数,可以把它理解为async函数的简化版写法:

fun main() {
    runBlocking {
        val result = withContext(Dispatchers.Default) {
            5 + 5
        }
        println(result)
    }
}

 

  调用withContext()函数之后,会立即执行代码块中的代码,同时将外部协程挂起。当代码块中的代码全部执行完之后,会将最后一行的执行结果作为withContext()函数的返回值返回,所以相当于是 val result = async{ 5 + 5 }.await() 的写法。

  唯一不同的是,withContext()函数会强制要求指定一个线程参数

  这个参数需要好好理解一下:

  我们知道,协程是一种轻量级的线程的概念,因此很多传统编程情况下需要开启多线程执行的并发任务,现在只需要在一个线程下开启多个协程来执行就可以了。但是这并不意味着我们就不需要开启线程了,比如说Android中要求网络请求必须在子线程中进行,即使你开启了协程去执行网络请求,假如它是主线程当中的协程,那么程序仍然会出错。这个时候我们就应该通过线程参数给协程指定一个具体的运行线程。

  线程参数主要有以下3种值可选:Dispatchers.Default、Dispatchers.IO和Dispatchers.Main。Dispatchers.Default表示会使用一种默认低并发的线程策略,当你要执行的代码属于计算密集型任务时,开启过高的并发反而可能会影响任务的运行效率,此时就可以使用Dispatchers.Default。Dispatchers.IO表示会使用一种较高并发的线程策略,当你要执行的代码大多数时间是在阻塞和等待中,比如说执行网络请求时,为了能够支持更高的并发数量,此时就可以使用Dispatchers.IO。Dispatchers.Main则表示不会开启子线程,而是在Android主线程中执行代码,但是这个值只能在Android项目中使用,纯Kotlin程序使用这种类型的线程参数会出现错误。

  事实上,在我们刚才所学的协程作用域构建器中,除了coroutineScope函数之外,其他所有的函数都是可以指定这样一个线程参数的,只不过withContext()函数是强制要求指定的,而其他函数则是可选的。

 

  目前为止,掌握了协程中的一些最常用的用法,并且了解到协程的主要用途就是可以大幅度地提高并发编程的运行效率

  但Kotlin中的协程还可以对传统回调的写法进行优化。

使用协程简化回调

  我们之前学习了编程语言的回调机制,并使用这个机制实现了获取异步网络请求数据响应的功能。你会发现,回调机制基本上是依靠匿名类来实现的,但是匿名类的写法通常比较烦琐,比如以下代码:

        HttpUtil.sendHttpRequest(address, object : HttpCallbackListener{
            override fun onFinish(response: String) {
                TODO("Not yet implemented")
            }
            override fun onError(e: Exception) {
                TODO("Not yet implemented")
            }
        })

 

  发起多少个网络请求,就要编写多少次这样的匿名类实现。在过去,可能确实没有什么更加简单的写法了。不过现在,Kotlin的协程使我们能有更简单的写法,只需要借助suspendCoroutine函数就能将传统回调机制的写法大幅简化:

  suspendCoroutine函数必须在协程作用域或挂起函数中才能调用,它接收一个Lambda表达式参数,主要作用是将当前协程立即挂起,然后在一个普通的线程中执行Lambda表达式中的代码。Lambda表达式的参数列表上会传入一个Continuation参数,调用它的resume()方法或resumeWithException()可以让协程恢复执行。

  接下来就借助suspendCoroutine函数来对传统的回调写法进行优化,首先定义一个request()函数:

    suspend fun request(address: String): String {
        return suspendCoroutine { continuation -> 
            HttpUtil.sendHttpRequest(address, object : HttpCallbackListener {
                override fun onFinish(response: String) {
                    continuation.resume(response)
                }

                override fun onError(e: Exception) {
                    continuation.resumeWithException(e)
                }

            })
        }
    }

  这里,request()函数是一个挂起函数,并且接收address参数。在函数内部,调用了suspendCoroutine函数,这样当前协程就会被立刻挂起,而Lambda表达式中的代码则会在普通线程中执行。接着我们在Lambda表达式中调用HttpUtil.sendHttpRequest()方法发起网络请求,并通过传统回调方式监听请求结果。如果请求成功就调用Continuation的resume()方法恢复被挂起的协程,并传入服务器响应的数据,该值会成为suspendCoroutine函数的返回值。如果请求失败,就调用Continuation的resumeWithException()恢复被挂起的协程,并传入具体的异常原因。

 

  至此,不管之后发起多少次网络请求,都不需要再重复进行回调实现了。比如说获取百度首页的响应数据,就可以这样写:

    suspend fun getBaiduResponse() {
        try {
            val response = request("https://www.baidu.com/")
            //  对服务器响应的数据进行处理
        } catch (e: Exception){
            //  对异常情况进行处理
        }
    }

  由于getBaiduResponse()是一个挂起函数,因此当它调用了request()函数时,当前的协程就会被立刻挂起,然后一直等待网络请求成功或失败后,当前协程才能恢复运行。这样即使不使用回调的写法,我们也能够获得异步网络请求的响应数据,而如果请求失败,则会直接进入catch语句当中。

  不过这里会有一个问题,getBaiduResponse()函数被声明成了挂起函数,这样它也只能在协程作用域或其他挂起函数中调用了,使用起来就会有局限性。确实如此,因为suspendCoroutine函数本身就是要结合协程一起使用的。不过通过合理的项目架构设计,我们可以轻松地将各种协程的代码应用到一个普通的项目当中。

 

  我们还可以用suspendCoroutine函数来简化一下Retrofit发起的网络请求写法,之前的写法如下:

        val appService = ServiceCreator.create<AppService>()
        appService.getAppData().enqueue(object : Callback<List<App>> {
            override fun onResponse(call: Call<List<App>>, response: Response<List<App>>) {
                TODO("Not yet implemented")
            }

            override fun onFailure(call: Call<List<App>>, t: Throwable) {
                TODO("Not yet implemented")
            }

        })

  由于不同的Service接口返回的数据类型也不同,所以这次就要使用泛型的方式。定义一个await()函数:

    suspend fun <T> Call<T>.await(): T {
        return suspendCoroutine { continuation -> 
            enqueue(object : Callback<T> {
                override fun onResponse(call: Call<T>, response: Response<T>) {
                    val body = response.body()
                    if (body != null) continuation.resume(body)
                    else continuation.resumeWithException(RuntimeException("response body is null"))
                }
                override fun onFailure(call: Call<T>, t: Throwable) {
                    continuation.resumeWithException(t)
                }
            })
        }
    }

 

  这里await()函数仍是一个挂起函数,声明了一个泛型<T>,并将await()函数定义成了Call<T>的扩展函数,这样所有返回值是Call类型的Retrofit网络请求接口都可以直接调用await()函数。

  接着,await()函数使用了suspendCoroutine函数来挂起当前协程,并且由于扩展函数的原因,我们现在拥有了Call对象的上下文,那么这里就可以直接调用enqueue()方法让Retrofit发起网络请求。

  接下来,使用同样的方式对Retrofit响应的数据或者网络请求失败的情况进行处理就可以了。另外还有一点需要注意,在onResponse()回调当中,我们调用body()方法解析出来的对象是可能为空的。如果为空的话,这里的做法是手动抛出一个异常。

  有了await()函数之后,我们调用所有Retrofit的Service接口都会变得极其简单,比如:

    suspend fun getAppData() {
        try {
            val appList = ServiceCreator.create<AppService>().getAppData().await()
            //  对服务器响应的数据进行处理
        } catch (e: Exception) {
            //  对异常情况进行处理
        }
    }

 

posted @ 2022-03-16 20:58  PeacefulGemini  阅读(422)  评论(0)    收藏  举报
回顶部