Kotlin 之旅7 協程

小楠總發表於2017-12-21

####協程的基本概念

概念:各個子任務協作執行,解決了非同步問題。

相對於執行緒的搶佔式的排程,協程是協作執行的,不存在搶佔式的排程。

有關於更詳細的介紹參考這篇文章:

www.jianshu.com/p/d4a8358e8…

www.wendq.com/wd/201702/1…

#####支援協程的語言

Lua、C#、Kotlin等等

#####協程要解決的問題

非同步的問題,例如圖片的載入,然後在UI執行緒回撥。

通過類似於同步的程式碼來實現非同步的操作,簡化了非同步程式碼。協程是一種輕量級的併發方案,不像執行緒佔用很多的系統資源,它只是一塊記憶體,儲存了掛起的位置。

#####Kotliin對協程的支援

1.1開始支援,目前還是實驗性的API。但是目前已經趨於穩定了,大家可以放心學習使用。

#####那麼如何支援協程呢?

  1. 編譯器對suspend函式的編譯支援,編譯器會對其進行特殊處理
  2. 標準庫API的支援
  3. kotlinx.coroutine框架的支援

#####我們需要

我們需要掌握基本的API,瞭解協程的執行原理,瞭解kotlinx.coroutine框架。

相關的核心API如下:

建立協程,並不會馬上執行
createCoroutine()

建立並且開始執行協程(如果沒有建立的話)
startCoroutine()

掛起協程
suspendCoroutine

執行控制類,負責結果和異常的返回
Continuation介面,有resume和resumeWithException兩個方法

執行上下文,可以持有資源引數,執行排程,配合ContinuationInterceptor篡改Continuation,從而切換執行緒
CoroutineContext介面

協程控制攔截器,與CoroutineContext配合處理協程排程
ContinuationInterceptor介面
複製程式碼

####協程例子1.0--不使用協程

下面以載入網路圖片為例子講解一下協程的使用,下面先來看看不使用協程的時候吧。

這個例子我們要使用JFrame來顯示圖片,用Retrofit2.0來載入網路圖片資料。

先在build.gradle指令碼里面加上Retrofit:

dependencies {
	//Retrofit2.0相關依賴庫
    compile 'com.squareup.retrofit2:retrofit:2.3.0'
    compile 'com.squareup.retrofit2:converter-gson:2.3.0'
}
複製程式碼

下面我們通過object單例來對外提供網路訪問的HttpService服務:

object HttpService {

    val service by lazy {
        Retrofit.Builder()
                .baseUrl("http://www.imooc.com")
                .addConverterFactory(GsonConverterFactory.create())
                .build()
                .create(API::class.java)
    }

}
複製程式碼

其中API是我們的獲取網路圖片的API:

interface API {
    @GET
    fun getLogo(@Url fileUrl: String): Call<ResponseBody>
}
複製程式碼

接下來需要建立一個視窗,因為這裡要用到Java的相關API,因此我們繼承JFrame,並且對外提供按鈕監聽以及設定圖片的方法:

class MainWindow : JFrame() {

    private lateinit var button: JButton
    private lateinit var image: JLabel

    fun init() {
        button = JButton("點我獲取慕課網Logo")
        image = JLabel()
        image.size = Dimension(200, 80)

        contentPane.add(button, BorderLayout.NORTH)
        contentPane.add(image, BorderLayout.CENTER)
    }

    fun onButtonClick(listener: (ActionEvent) -> Unit) {
        button.addActionListener(listener)
    }

    fun setLogo(logoData: ByteArray) {
        image.icon = ImageIcon(logoData)
    }
}
複製程式碼

接下來開始正式編寫main函式了:

const val LOGO_URL = "/static/img/index/logo.png?t=1.1"

fun main(args: Array<String>) {

	//初始化視窗
    val frame = MainWindow()
    frame.title = "Coroutine@Bennyhuo"
    frame.setSize(200, 150)
    frame.isResizable = true
    frame.defaultCloseOperation = EXIT_ON_CLOSE
    frame.init()
    frame.isVisible = true

	//設定監聽
    frame.onButtonClick {
        HttpService.service.getLogo(LOGO_URL)
                .enqueue(object : Callback<ResponseBody> {
                    override fun onResponse(call: Call<ResponseBody>?, response: Response<ResponseBody>?) {
						//使用apply之後,其內部就可以直接呼叫成員函式了
                        response?.apply {
                            if (isSuccessful) {
								//獲取網路圖片的輸入流,通過readBytes讀取位元組資料
                                val imageData = body()?.byteStream()?.readBytes()
								//如果沒有資料,那麼丟擲異常
                                if (imageData == null) {
                                    throw HttpException(HttpError.HTTP_ERROR_NO_DATA)
                                } else {
									//如果正常,那麼顯示到視窗上面,但是需要通過SwingUtilities的invokeLater方法,從IO執行緒切換到UI執行緒
                                    SwingUtilities.invokeLater {
                                        frame.setLogo(imageData)
                                    }
                                }
                            }
                        }
                    }

                    override fun onFailure(call: Call<ResponseBody>?, t: Throwable?) {
						//獲取圖片失敗,直接丟擲異常
                        throw HttpException(HttpError.HTTP_ERROR_UNKNOWN)
                    }
                })
    }

}
複製程式碼

其中HttpException是我們自定義的一種異常:

object HttpError{
    const val HTTP_ERROR_NO_DATA = 999
    const val HTTP_ERROR_UNKNOWN = 998
}

data class HttpException(val code: Int): Exception()
複製程式碼

總結一下,我們不使用協程的時候,就是我們平常的寫法,程式碼有點噁心。

####協程例子2.0--協程,非非同步版本

協程是不會幫我們切換執行緒的,我們先來一個非非同步的版本吧。首先需要定義一個協程方法,注意需要加上suspend關鍵字:

/**
 * 開始協程
 */
fun startCoroutine(block: suspend () -> Unit) {
    block.startCoroutine(BaseContinuation())
}
複製程式碼

其中BaseContinuation是我們自定義的類:

class BaseContinuation : Continuation<Unit> {
    override val context: CoroutineContext = EmptyCoroutineContext

    override fun resume(value: Unit) {

    }

    override fun resumeWithException(exception: Throwable) {

    }

}
複製程式碼

然後,需要定義一個下載圖片的方法,在這個方法裡面執行了圖片下載工作,注意執行緒是不會切換的:

/**
 * 載入圖片
 */
suspend fun startLoadImage(url: String) = suspendCoroutine<ByteArray> { continuation ->
    log("下載圖片")
    try {
        val responseBody = HttpService.service.getLogo(url).execute()
        responseBody.apply {
            if (isSuccessful) {
                body()?.byteStream()?.readBytes()?.let(continuation::resume)
            } else {
                continuation.resumeWithException(HttpException(responseBody.code()))
            }
        }
    } catch (e: Exception) {
        continuation.resumeWithException(e)
    }
}
複製程式碼

最後,在main函式中就可以這樣呼叫了,十分簡潔明瞭:

frame.onButtonClick {
    log("協程之前")
    startCoroutine {
        log("協程開始")
        val imageData = startLoadImage(LOGO_URL)
        log("拿到圖片")
        frame.setLogo(imageData)
    }
    log("協程結束")
}
複製程式碼

其中,上面的log方法是這樣定義的:

val dateFormat = SimpleDateFormat("HH:mm:ss:SSS")

val now = {
    dateFormat.format(Date(System.currentTimeMillis()))
}

fun log(msg: String) = println("${now()} [${Thread.currentThread().name}] $msg")
複製程式碼

最終列印出來的結果如下:

12:44:40:823 [AWT-EventQueue-0] 協程之前
12:44:40:828 [AWT-EventQueue-0] 協程開始
12:44:40:830 [AWT-EventQueue-0] 下載圖片
12:44:51:340 [AWT-EventQueue-0] 拿到圖片
12:44:51:372 [AWT-EventQueue-0] 協程結束
複製程式碼

可以看到,協程預設情況下是不會幫我們切換執行緒的,是順序執行的。

####協程例子3.0--協程,非同步版本

在2.0版本的基礎之上,我們加入非同步程式碼:

定義一個AsyncTask:

private val pool by lazy {
    Executors.newCachedThreadPool()
}

class AsyncTask(val block: () -> Unit) {
    fun execute() {
        pool.execute(block)
    }
}
複製程式碼

接下來需要有執行緒切換,因此我們定義一個包裝類UiContinuationWrapper:

class UiContinuationWrapper<T>(val continuation: Continuation<T>) : Continuation<T> {
    override val context: CoroutineContext = EmptyCoroutineContext

    override fun resume(value: T) {
        SwingUtilities.invokeLater {
            continuation.resume(value)
        }
    }

    override fun resumeWithException(exception: Throwable) {
        SwingUtilities.invokeLater {
            continuation.resumeWithException(exception)
        }
    }
}
複製程式碼

在使用的時候,通過AsyncTask包裹實現非同步,通過UiContinuationWrapper包裝實現執行緒切換:

/**
 * 載入圖片
 */
suspend fun startLoadImage(url: String) = suspendCoroutine<ByteArray> { continuation ->
    log("非同步任務開始前")

    val uiContinuationWrapper = UiContinuationWrapper(continuation)

    AsyncTask {
        try {
            log("載入圖片")
            val responseBody = HttpService.service.getLogo(url).execute()
            responseBody.apply {
                if (isSuccessful) {
                    body()?.byteStream()?.readBytes()?.let{
                        SwingUtilities.invokeLater {
                            continuation.resume(it)
                        }
                    }
                } else {
                    uiContinuationWrapper.resumeWithException(HttpException(responseBody.code()))
                }
            }
        } catch (e: Exception) {
            uiContinuationWrapper.resumeWithException(e)
        }
    }.execute()
}
複製程式碼

####協程例子4.0--協程,非同步版本加強版

上面的例子裡面我們直接篡改了continuation,我們也可以通過Context來實現:

先來實現一個AsyncContext,對continuation進行攔截,篡改:

class AsyncContext : AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor {

    override fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T> {
        return UiContinuationWrapper(continuation.context.fold(continuation) { continuation, element ->
			//如果不是自身,以及具有攔截能力,那麼就用UiContinuationWrapper進行包裝篡改;
			//否則的話,直接返回
            if (element != this && element is ContinuationInterceptor) {
                element.interceptContinuation(continuation)
            } else continuation
        })
    }

}
複製程式碼

然後實現一個ContextContinuation,目的就是為了在例項化的時候可以傳入自定義的Context:

class ContextContinuation(override val context: CoroutineContext = EmptyCoroutineContext) : Continuation<Unit> {

    override fun resume(value: Unit) {

    }

    override fun resumeWithException(exception: Throwable) {

    }

}
複製程式碼

最後,在使用的時候,只需要在startCoroutine的時候初始化一次,以後就不用每次都篡改了:

/**
 * 開始協程
 */
fun startCoroutine(block: suspend () -> Unit) {
    block.startCoroutine(ContextContinuation(AsyncContext()))
}

/**
 * 載入圖片
 */
suspend fun startLoadImage(url: String) = suspendCoroutine<ByteArray> { continuation ->
    log("非同步任務開始前")

    AsyncTask {
        try {
            log("載入圖片")
            val responseBody = HttpService.service.getLogo(url).execute()
            responseBody.apply {
                if (isSuccessful) {
                    body()?.byteStream()?.readBytes()?.let {
                        SwingUtilities.invokeLater {
                            continuation.resume(it)
                        }
                    }
                } else {
                    continuation.resumeWithException(HttpException(responseBody.code()))
                }
            }
        } catch (e: Exception) {
            continuation.resumeWithException(e)
        }
    }.execute()
}
複製程式碼

####協程例子5.0--協程,非同步版本加強版封裝

主要的封裝就是把載入圖片的程式碼抽取出來,封裝一個專門協程非同步呼叫的耗時操作executeTask方法:

/**
 * 開始協程
 */
fun startCoroutine(block: suspend () -> Unit) {
    block.startCoroutine(ContextContinuation(AsyncContext()))
}

/**
 * 耗時操作
 */
suspend fun <T> executeTask(block: () -> T) = suspendCoroutine<T> { continuation ->
    AsyncTask {
        try {
            continuation.resume(block())
        } catch (e: Exception) {
            continuation.resumeWithException(e)
        }
    }.execute()
}


/**
 * 載入圖片
 */
fun startLoadImage(url: String): ByteArray {
    log("載入圖片")
    val responseBody = HttpService.service.getLogo(url).execute()
    if (responseBody.isSuccessful) {
        responseBody.body()?.byteStream()?.readBytes()?.let {
            return it
        }
        throw HttpException(HttpError.HTTP_ERROR_NO_DATA)
    } else {
        throw HttpException(responseBody.code())
    }
}
複製程式碼

通過這樣的封裝之後,startLoadImage就變成了簡單的函式了,可以用普通的方式來呼叫。(注意suspend方法需要在suspendCoroutine內使用)

在呼叫的時候,也比較簡單:

frame.onButtonClick {
   startCoroutine{
       executeTask {
           startLoadImage(LOGO_URL)
       }
   }
}
複製程式碼

####解決執行緒安全問題

在下面的程式碼中,startLoadImage方法是非同步執行的,而當傳入的LOGO_URL是var可變型別的話,就可能會導致執行緒安全問題(外部資料共享問題):

frame.onButtonClick {
   startCoroutine{
       executeTask {
           startLoadImage(LOGO_URL)
       }
   }
}
複製程式碼

解決的辦法就是,通過Context去攜帶URL:

class DownloadContext(val url: String) : AbstractCoroutineContextElement(Key) {
    companion object Key : CoroutineContext.Key<DownloadContext>
}
複製程式碼

接下來修改協程的程式碼:

/**
 * 開始協程,需要傳進來一個context,例如上面自定義的DownloadContext。Context都是可以疊加的。
 */
fun startCoroutine(context: CoroutineContext = EmptyCoroutineContext, block: suspend () -> Unit) {
    block.startCoroutine(ContextContinuation(context + AsyncContext()))
}

/**
 * 耗時操作,給block新增一個Receiver為CoroutineContext,相當於擴充套件的Lambda表示式
 */
suspend fun <T> executeTask(block: CoroutineContext.() -> T) = suspendCoroutine<T> { continuation ->
    AsyncTask {
        try {
            continuation.resume(block(continuation.context))
        } catch (e: Exception) {
            continuation.resumeWithException(e)
        }
    }.execute()
}
複製程式碼

UiContinuationWrapper裡面的context應該從continuation中獲取:

class UiContinuationWrapper<T>(val continuation: Continuation<T>) : Continuation<T> {
    override val context: CoroutineContext = continuation.context

    override fun resume(value: T) {
        SwingUtilities.invokeLater {
            continuation.resume(value)
        }
    }

    override fun resumeWithException(exception: Throwable) {
        SwingUtilities.invokeLater {
            continuation.resumeWithException(exception)
        }
    }
}
複製程式碼

最後呼叫的程式碼如下:

frame.onButtonClick {
    startCoroutine(DownloadContext(LOGO_URL)) {
        executeTask {
            startLoadImage(this[DownloadContext]!!.url)
        }
    }
}
複製程式碼

通過這樣就可以解決了執行緒安全的問題。

######Tpis:關於協程的異常捕獲,直接在外面try/catch即可。

####協程簡單原理分析

協程實際上會被編譯器編譯成狀態機,suspend函式即為狀態轉移,整個協程的執行過程如下:

協程執行過程.png

其中,正常的結果通過resume返回,異常的結果通過resumeWithException丟擲,完整的協程的執行過程如下:

完整的協程執行過程.png

有關於更深入的原理分析可以去研究編譯出來的位元組碼以及除錯程式觀看呼叫棧。

####協程的例子--序列生成器

fun main(args: Array<String>) {

	//每次迭代的時候,就會將掛起的協程執行(yield),返回結果,然後重新掛起    
	for (i in fibonacci) {
        println(i)
        if (i > 100) {
            break
        }
    }
}

//生成斐波那契懶數列
val fibonacci = buildSequence {
    yield(1)
    var current = 1
    var next = 1

    while (true) {
        yield(next)
        val tmp = current + next
        current = next
        next = tmp
    }
}
複製程式碼

####Kotlinx.coroutine框架介紹

Kotlinx.coroutine框架是官方對協程在不同平臺下的封裝,基本的模組如下:

Kotlinx.coroutine框架1.png
Kotlinx.coroutine框架2.png
Kotlinx.coroutine框架3.png

有興趣可以到GitHub找相關的程式碼學習。

如果覺得我的文字對你有所幫助的話,歡迎關注我的公眾號:

公眾號:Android開發進階

我的群歡迎大家進來探討各種技術與非技術的話題,有興趣的朋友們加我私人微信huannan88,我拉你進群交(♂)流(♀)

相關文章