Kotlin-协程(3)-挂起函数

3,071 阅读7分钟

协程-挂起函数

上一篇,我们知道了非阻塞挂起的核心是要执行一个挂起函数,挂起函数的作用就是启动线程执行耗时任务,耗时任务执行完毕,通知调用线程继续执行后续的代码。那么我们如何定义挂起函数呢?有哪些方式呢?接下来我们揭开它的面纱

定义挂起函数

挂起函数是协程的一个分水岭, 挂起函数前后的代码都是在调用线程执行的(当然我们可以通过调度器来改变这种状态,这个后续讲),挂起函数就是分割这2部分代码的关键。

// 1.启动一个协程
GlobalScope.launch {
    /**
     * 挂起函数前的代码
     */
    println("1:${Thread.currentThread().name}")
    /**
     * 执行挂起函数(分水岭)
     */
    delay(1000)
    /**
     * 挂起函数后的代码
     */
    println("2:${Thread.currentThread().name}")
}

我们知道 delay 就是自带的一个可以做延迟的挂起函数,它是如何定义的呢?

public suspend fun delay(timeMillis: Long) {
    if (timeMillis <= 0) return // don't delay
    return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
        cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
    }
}

定义挂起函数的关键需要通过一个 suspend 关键词修饰函数。那么 suspend 修饰的函数是如何在做到切换线程执行耗时任务,并通知调用线程执行完毕呢?

这里提醒大家 suspend 关键词,在 Kotlin 中起到的是一个提示的作用,提示此函数是一个挂起函数,它要在协程中运行,并且内部方法要调用其他的 suspend 函数,大家先记住我这句话,因为相对于生成的 Java 字节码 suspend 存在另外的功能,现在读不懂这句话也无所谓我们继续看后面的代码。

接下来我们定义一个属于自己的延迟的挂起函数。

fun main() {
    GlobalScope.launch {
        myDelay(2000)
        println("延迟消息执行!")
    }

    Thread.currentThread().join()
}

/**
 * 自定义延迟消息
 */
suspend fun myDelay(timeMillis: Long) = suspendCoroutine<Unit> { continuation ->
    /**
     * 启动新的线程做延迟
     */
    thread {
        /**
         * 线程执行延迟消息
         */
        Thread.sleep(timeMillis)
        /**
         * 通知调用线程执行完毕,你可以继续了
         */
        continuation.resume(Unit)
    }
}

看到了吗?我们创建的挂起函数是重新创建了一个线程执行延迟,然后通过一个 continuation 对象的 resume 方法,通知调用线程我执行完毕了(有没有感觉类似像写回调函数)。

看上面的 continuation 对象,有没有像回调函数啊?其实本身就是回调函数,不信你编译成 Java 代码去瞧一瞧,你就知道协程的原理了和我第一篇协程说过一样,编译器欺骗了你的眼睛,后续原理篇我带大家仔细看或者你尝试用 Java 去调用挂起函数。

continuation 可以通过 resume 可以传递一个结果,这个结果就是挂起函数的返回值。如果你不调用 continuationresume 方法,那么挂起函数后的代码永远不会执行。

不信你就试一试。若我们要没有调用 continuationresume 方法,虽然后面的代码没有被执行,但是调用线程没有被阻塞的哦,只有挂起函数内启动线程是处于阻塞状态。

实战

这篇章我们只讲解实战去定义挂起函数。大伙可以模仿,其实 Kotlin 为我们定义了很多 suspend 函数,方便我们去使用。

例如:当 Activity 启动后,我们就执行一个耗时操作加载数据,数据加载完毕后,用数据渲染界面。

class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        /**
         * 启动协程
         */
        GlobalScope.launch(Dispatchers.Main) {
            /**
             * 展示正在加载中
             */
            showLoadDialog()
            /**
             * 执行挂起函数,执行耗时任务
             */
            val reslut = loadData("1")
            /**
             * 填充界面
             */
            initView(reslut)
            /**
             * dismiss Dialog
             */
            dismissDialog()

        }
        ...
    }

    suspend fun loadData(parameter: String): List<String> {
        return withContext(Dispatchers.IO) {
            // 在线程中加载数据,我这里就模拟数据了
            val reslut = netRqeust(parameter)

            // 返回数据
            reslut.split("\n")
        }
    }
}

这时候有小伙伴问 withContext 是什么啊?其实 withContext 也是一个挂起函数,还记得我前面说过吗?挂起函数的作用就是执行一个其他的挂起函数。

当然我们也可以通过 suspendCoroutine 挂起函数,来定义我们的挂起函数。但是这种的形式我们还需要在自己去手动创建一个线程。

Kotlin 中为我们提供了一个 withContext 的挂起函数,它可以负责帮我们创建一个线程,并且帮我们调用 continuation 对象的 resume 方法,对应 resume 的参数就是传入的 lambda 表达式的返回值。

Dispatchers.IO 是一个线程调度器,意思是 withContextlambda 表达式,要在新的线程中执行,这个后续会详细讲解。

当然如果我们不想多抽取一个 loadData 的挂起函数也是可以的,我们可以直接执行 withContext 挂起函数。

class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        /**
         * 启动协程
         */
        GlobalScope.launch(Dispatchers.Main) {
            /**
             * 展示正在加载中
             */
            showLoadDialog()

            // 这里:我们直接执行 withContext 挂起函数
            val reslut = withContext(Dispatchers.IO){
                // 在线程中加载数据,我这里就模拟数据了
                val reslut = netRqeust()

                // 返回数据
                reslut.split("\n")
            }
            /**
             * 填充界面
             */
            initView(reslut)
            /**
             * dismiss Dialog
             */
            dismissDialog()

        }
        ...
    }
}

补充

这篇我们学会了如何去定义一个挂起函数,我们可以通过 withContext 来做创建线程和通知的功能,也可以通过 suspendCoroutine 挂起函数,手动处理返回结果。那我们用的时候如何选择呢?

其实很简单,如果以前存在一个方法,已经有异步请求的封装,但是你还想用协程封装下,想用协程的同步写法,那我们就应该使用 suspendCoroutine 挂起函数的形式去定义,其他的用 withContext 即可。

举个例子:
例如我们用别人的 SDK ,有一个方法是耗时的,对方已经为我们暴露了一个方法,我们只需要调用并传入 CallBack 回调即可。
此时我想将其封装成一个挂起函数要如何做呢?

fun main() {
    /**
     * sdk 为我们提供的耗时方法
     * 它内部肯定已经启动了线程做耗时操作
     */
    PushManager.initPush(object :PushManager.PushCallBack{
        override fun onSucceed() {

        }

        override fun onError() {
        }

    })
}

若我们想封装上面的方法,便可以使用调用 suspendCoroutine 挂起函数来实现。

fun main() {
   GlobalScope.launch {
       /**
        * 用同步的代码,写异步的功能
        * 协程的核心作用
        */
       val reslut = initPush()
       if (reslut=="成功"){
           // 执行成功的代码
       }
   }
}

suspend fun initPush()=suspendCoroutine<String>{
    PushManager.initPush(object :PushManager.PushCallBack{
        override fun onSucceed() {
            /**
             * 通知成功
             */
            it.resume("成功")
        }

        override fun onError() {
            /**
             * 通知失败
             */
            it.resumeWithException(RuntimeException("出错"))
        }

    })
}

最后大家应该就能理清楚了吧。其实在 2.7.0 以上 Retrofit 高版本的,已经支持协程了,我们定义的请求网络接口函数,可以直接通过 suspend 修饰。

这会大家就应该思考下 Retrofit 的定义的协程的 CallAdapter 是如何通过代理去实现的挂起函数。其实就是通过调用了 suspendCoroutine 挂起函数,因为 OkHttp 内部提供异步请求的方案。

请看 retrofit2 包下的 KotlinExtensions 里,为 Call 定义的一个 await 扩展函数。

suspend fun <T : Any> Call<T>.await(): T {
  // 调用 suspendCancellableCoroutine 挂起函数
  return suspendCancellableCoroutine { continuation ->
    continuation.invokeOnCancellation {
      cancel()
    }
    // 注意这里,使用的就是 OkHttp enqueue 方法,执行异步请求网络
    enqueue(object : Callback<T> {
      override fun onResponse(call: Call<T>, response: Response<T>) {
        if (response.isSuccessful) {
          val body = response.body()
          if (body == null) {
            val invocation = call.request().tag(Invocation::class.java)!!
            val method = invocation.method()
            val e = KotlinNullPointerException("Response from " +
                method.declaringClass.name +
                '.' +
                method.name +
                " was null but response body type was declared as non-null")
            // 通知执行出错
            continuation.resumeWithException(e)
          } else {
            // 通知执行完成,并 resume 数据。
            continuation.resume(body)
          }
        } else {
          // 通知出错
          continuation.resumeWithException(HttpException(response))
        }
      }

      override fun onFailure(call: Call<T>, t: Throwable) {
        // 通知出错
        continuation.resumeWithException(t)
      }
    })
  }
}

所以说当我们调用 retrofit 生成的 suspend 请求函数的时候,已经不需要在调用一次 withContext 切换线程了,这是很多人的一个误区。

interface OneDayMsgApi {
    @GET("/dsapi")
    suspend fun getOneDayMsg(): OneDayMsgBean
}
GlobalScope.launch(Dispatchers.Main) {
    showDialog()
    /**
     * 很多人喜欢在用一次 withContext 切换线程
     * 其实这样做是多此一举的
     */
    val oneDayMsg = withContext(Dispatchers.IO){
        OneDayMsgRetrofit.api.getOneDayMsg()
    }
    dissMissDialog()
}

最合适的写法是不需要写 withContext 的。

GlobalScope.launch(Dispatchers.Main) {
    showDialog()
    /**
     * 直接调用即可了 Retrofit 已经为我们定义了挂起函数了
     * 已经帮我们创建另外一个线程去执行了
     */
    val oneDayMsg = OneDayMsgRetrofit.api.getOneDayMsg()
    dissMissDialog()
}