Kotlin Coroutines(協程)講解

前言

翻譯好的文章也是一種學習方式react

原文標題:Coroutines in Kotlin 1.3 explained: Suspending functions, contexts, builders and scopesgit

原文做者: Antonio Leivagithub

協程簡介

協程是 Kotlin 的一大特點。使用協程,能夠簡化異步編程,使代碼可讀性更好、更容易理解。算法

使用協程,不一樣於傳統的回調方式,可使用同步的方式編寫異步代碼。同步方法返回的結果就是異步請求的結果。數據庫

協程到底有什麼魔法?立刻爲您揭曉。在這以前,咱們須要知道爲何協程這麼重要。編程

Kotlin 1.1 中 協程做爲實驗特性,到如今 Kotlin 1.3 發佈了最終的 API,協程已經能夠用於生產環境中。服務器

協程的目標:先看一下現存的一些問題

獲取文中的完整示例點擊 這裏網絡

假設要作一個登錄界面:用戶輸入用戶名和密碼,而後點擊登錄。異步

假設是這樣的流程:App 首先請求服務器校驗用戶名和密碼,校驗成功後,而後請求該用戶的好友列表。async

僞代碼以下:

progress.visibility = View.VISIBLE

userService.doLoginAsync(username, password) { user ->

    userService.requestCurrentFriendsAsync(user) { friends ->

        val finalUser = user.copy(friends = friends)
        toast("User ${finalUser.name} has ${finalUser.friends.size} friends")

        progress.visibility = View.GONE
    }

}
複製代碼

步驟以下:

  1. 顯示一個進度條;
  2. 請求服務器校驗用戶名和密碼;
  3. 等待校驗成功後,請求服務器獲取好友列表;
  4. 最後,隱藏進度條;

狀況還能夠更復雜,想象一下,不只要請求好友列表,還須要請求推薦好友列表,並把兩次結果合併進一個列表。

有兩種選擇:

  1. 最簡單的方式就是,在請求無缺友列表以後,再請求推薦好友列表,可是這種方式不夠高效,由於後者並不依賴前者的請求結果;
  2. 這種方式相對複雜一些,同時請求好友列表和推薦好友列表,並同步兩次請求的結果;

一般狀況下,想要偷懶的人可能會選擇第一種方式:

progress.visibility = View.VISIBLE

userService.doLoginAsync(username, password) { user ->

    userService.requestCurrentFriendsAsync(user) { currentFriends ->

        userService.requestSuggestedFriendsAsync(user) { suggestedFriends ->
            val finalUser = user.copy(friends = currentFriends + suggestedFriends)
            toast("User ${finalUser.name} has ${finalUser.friends.size} friends")

            progress.visibility = View.GONE
        }

    }

}
複製代碼

到這裏,代碼開始變得複雜了,出現了可怕的回調地獄:後一個請求老是嵌套在前一個請求的結果回調裏面,縮進變得愈來愈多。

因爲使用的是 Kotlinlambdas,可能看起來並無那麼糟糕。可是隨着請求的增多,代碼變得愈來愈難以管理。

別忘了,咱們使用的仍是一種相對簡單但並不高效的一種方式。

什麼是協程(Coroutine

簡單來講,協程像是輕量級的線程,但並不徹底是線程。

首先,協程可讓你順序地寫異步代碼,極大地下降了異步編程帶來的負擔;

其次,協程更加高效。多個協程能夠共用一個線程。一個 App 能夠運行的線程數是有限的,可是能夠運行的協程數量幾乎是無限的;

協程實現的基礎是可中斷的方法(suspending functions)。可中斷的方法能夠在任意的地方中斷協程的執行,直到該可中斷的方法返回結果或者執行完成。

運行在協程中的可中斷的方法(一般狀況下)不會阻塞當前線程,之因此是一般狀況下,由於這取決於咱們的使用方式。具體下面會講到。

coroutine {
    progress.visibility = View.VISIBLE

    val user = suspended { userService.doLogin(username, password) }
    val currentFriends = suspended { userService.requestCurrentFriends(user) }

    val finalUser = user.copy(friends = currentFriends)
    toast("User ${finalUser.name} has ${finalUser.friends.size} friends")

    progress.visibility = View.GONE
}
複製代碼

上面的示例是協程的經常使用使用範式。首先,使用一個協程構造器(coroutine builder)建立一個協程,而後,一個或多個可中斷的方法運行在協程中,這些方法將會中斷協程的執行,直到它們返回結果。

可中斷的方法返回結果後,咱們在下一行代碼就可使用這些結果,很是像順序編程。注意實際上 Kotlin 中並不存在 coroutinesuspended 這兩個關鍵字,上述示例只是爲了便於演示協程的使用範式。

可中斷的方法(suspending functions

可中斷的方法有能力中斷協程的執行,當可中斷的方法執行完畢後,接着就可使用它們返回的結果。

val user = suspended { userService.doLogin(username, password) }
val currentFriends = suspended { userService.requestCurrentFriends(user) }
複製代碼

可中斷的方法能夠運行在相同的或不一樣的線程,這取決於你的使用方式。可中斷的方法只能運行在協程中或其餘可中斷的方法中。

聲明一個可中斷的方法,只須要使用 suspend 保留字:

suspend fun suspendingFunction() : Int  {
    // Long running task
    return 0
}
複製代碼

回到最初的示例,你可能會問上述代碼運行在哪一個線程,咱們先看這一行代碼:

coroutine {
    progress.visibility = View.VISIBLE
    ...
}
複製代碼

你認爲這行代碼運行在哪一個線程呢?你肯定它是運行在 UI 線程嗎?若是不是,App 就會崩潰,因此弄明白運行在哪一個線程很重要。

答案就是這取決於協程上下文coroutine context)的設置。

協程上下文(Coroutine Context

協程上下文是一系列規則和配置的集合,它決定了協程的運行方式。也能夠理解爲,它包含了一系列的鍵值對。

如今,你只須要知道 dispatcher 是其中的一個配置,它能夠指定協程運行在哪一個線程。

dispatcher 有兩種方式能夠配置:

  1. 明確指定須要使用的 dispatcher;
  2. 由協程做用域(coroutine scope)決定。這裏先不展開說,後面會詳細說明;

具體來講,協程構造器(coroutine builder)接收一個協程上下文(coroutine context)做爲第一個參數,咱們能夠傳入要使用的 dispatcher。由於 dispatcher 實現了協程上下文,因此能夠做爲參數傳入:

coroutine(Dispatchers.Main) {
    progress.visibility = View.VISIBLE
    ...
}
複製代碼

如今,改變進度條可見性的代碼就運行在了 UI 線程。不只如此,協程內的全部代碼都運行在 UI 線程。那麼問題來了,可中斷的方法會怎麼運行?

coroutine {
    ...
    val user = suspended { userService.doLogin(username, password) }
    val currentFriends = suspended { userService.requestCurrentFriends(user) }
    ...
}
複製代碼

這些請求服務的代碼也是運行在主線程嗎?若是真是這樣的話,它們會阻塞主線程。究竟是不是呢,仍是那句話,這取決於你的使用方式。

可中斷的方法有多種辦法配置要使用的 dispatcher,其中最經常使用的方法是 withContext

withContext

在協程內部,這個方法能夠輕易地改變代碼運行時所在的上下文。它是一個可中斷的方法,因此調用它會中斷協程的執行,直到該方法執行完成。

這樣以來,咱們就可讓示例中那些可中斷的方法運行在不一樣的線程中:

suspend fun suspendLogin(username: String, password: String) =
        withContext(Dispatchers.Main) {
            userService.doLogin(username, password)
        }
複製代碼

上面這些代碼會運行在主線程,因此仍然會阻塞 UI 。可是,如今咱們能夠輕易地指定使用不一樣的 dispatcher:

suspend fun suspendLogin(username: String, password: String) =
        withContext(Dispatchers.IO) {
            userService.doLogin(username, password)
        }
複製代碼

如今咱們使用了 IO dispatcher, 上述代碼會運行在子線程。另外,withContext 自己就是一個可中斷的方法,因此,咱們不必讓它運行在另外一個可中斷方法中。因此咱們也能夠這樣寫:

val user = withContext(Dispatchers.IO) { userService.doLogin(username, password) }
val currentFriends = withContext(Dispatchers.IO) { userService.requestCurrentFriends(user) }
複製代碼

目前爲止,咱們認識了兩個 dispatcher,下面咱們詳細介紹一下全部的 dispatcher 的使用場景。

  • Default: 當咱們未指定 dispatcher 的時候會默認使用,固然,咱們也能夠明確設置使用它。它通常用於 CPU 密集型的任務,特別是涉及到計算、算法的場景。它可使用和 CPU 核數同樣多的線程。正由於是密集型的任務,同時運行多個線程並無意義,由於 CPU 將會很繁忙。

  • IO: 它用於輸入/輸出的場景。一般,涉及到會阻塞線程,須要等待另外一個系統響應的任務,好比:網絡請求、數據庫操做、文件讀寫等,均可以使用它。由於它不使用 CPU ,能夠同一時間運行多個線程,默認是數量爲 64 的線程池。Android App 中有不少網絡請求的操做,因此你可能會常常用到它。

  • UnConfined: 若是你不在意啓動了多少個線程,那麼你可使用它。它使用的線程是不可控制的,除非你特別清楚你在作什麼,不然不建議使用它。

  • Main: 這是 UI 相關的協程庫裏面的一個 dispatcher,在 Android 編程中,它使用的是 UI 線程。

如今,你應該能夠很靈活地使用各類 dispatcher 了。

協程構造器(Coroutine Builders

如今,你能夠輕鬆地切換線程了。接下來,咱們學習一下如何啓動一個新的協程:固然要靠協程構造器了。

根據實際狀況,咱們能夠選擇使用不一樣的協程構造器,固然咱們也能夠建立自定義的協程構造器。不過一般狀況下,協程庫提供的已經知足咱們的使用了。具體以下:

runBlocking

這個協程構造器會阻塞當前線程,直到協程內的全部任務執行完畢。這好像違背了咱們使用協程的初衷,因此什麼場景下會用到它呢?

runBlocking 對於測試可中斷的方法很是有用。在測試的時候,將可中斷的方法運行在 runBlocking 構建的協程內部,這樣就能夠保證,在這些可中斷的方法返回結果前當前測試線程不會結束,這樣,咱們就能夠校驗測試結果了。

fun testSuspendingFunction() = runBlocking {
    val res = suspendingTask1()
    assertEquals(0, res)
}
複製代碼

可是,除了這個場景外,你也許不會用到 runBlocking 了。

launch

這個協程構造器很重要,由於它能夠很輕易地建立一個協程,你可能會常常用到它。和 runBlocking 相反的是,它不會阻塞當前線程(前提是咱們使用了合適的 dispatcher)。

這個協程構造器一般須要一個做用域(scope),關於做用域的概念後面會講到,咱們暫時使用全局做用域(GlobalScope):

GlobalScope.launch(Dispatchers.Main) {
    ...
}
複製代碼

launch 方法會返回一個 JobJob 繼承了協程上下文(CoroutineContext)。

Job 提供了不少有用的方法。須要明確的是:一個 Job 能夠有一個父 Job,父 Job 能夠控制子 Job。下面介紹一下 Job 的方法:

job.join

這個方法能夠中斷與當前 Job 關聯的協程,直到全部子 Job 執行完成。協程內的全部可中斷的方法與當前 Job 相關聯,直到子 Job 所有執行完成,與當前 Job 關聯的協程才能繼續執行。

val job = GlobalScope.launch(Dispatchers.Main) {

    doCoroutineTask()

    val res1 = suspendingTask1()
    val res2 = suspendingTask2()

    process(res1, res2)

}

job.join()
複製代碼

job.join() 是一個可中斷的方法,因此它應該在協程內部被調用。

job.cancel

這個方法能夠取消全部與其關聯的子 Job,假如 suspendingTask1() 正在執行的時候 Job 調用了 cancel() 方法,這時候,res1 不會再被返回,並且 suspendingTask2() 也不會再執行。

val job = GlobalScope.launch(Dispatchers.Main) {

    doCoroutineTask()

    val res1 = suspendingTask1()
    val res2 = suspendingTask2()

    process(res1, res2)

}

job.cancel()
複製代碼

job.cancel() 是一個普通方法,因此它沒必要運行在協程內部。

async

這個協程構造器將會解決咱們在剛開始演示示例的時候提到的一些難題。

async 容許並行地運行多個子線程任務,它不是一個可中斷方法,因此當調用 async 啓動子協程的同時,後面的代碼也會當即執行。async 一般須要運行在另一個協程內部,它會返回一個特殊的 Job,叫做 Deferred

Deferred 有一個新的方法叫作 await(),它是一個可中斷的方法,當咱們須要獲取 async 的結果時,須要調用 await() 方法等待結果。調用 await() 方法後,會中斷當前協程,直到其返回結果。

在下面的示例中,第二個和第三個請求須要依賴第一個請求的結果,請求好友列表和推薦好友列表原本能夠並行請求的,若是都使用 withContext,顯然會浪費時間:

GlobalScope.launch(Dispatchers.Main) {

    val user = withContext(Dispatchers.IO) { userService.doLogin(username, password) }
    val currentFriends = withContext(Dispatchers.IO) { userService.requestCurrentFriends(user) }
    val suggestedFriends = withContext(Dispatchers.IO) { userService.requestSuggestedFriends(user) }

    val finalUser = user.copy(friends = currentFriends + suggestedFriends)
}
複製代碼

假如每一個請求耗時 2 秒,總共須要使用 6 秒。若是咱們使用 async 替代呢:

GlobalScope.launch(Dispatchers.Main) {

    val user = withContext(Dispatchers.IO) { userService.doLogin(username, password) }
    val currentFriends = async(Dispatchers.IO) { userService.requestCurrentFriends(user) }
    val suggestedFriends = async(Dispatchers.IO) { userService.requestSuggestedFriends(user) }

    val finalUser = user.copy(friends = currentFriends.await() + suggestedFriends.await())

}
複製代碼

這時,第二個和第三個請求會並行運行,因此總耗時將會減小到 4 秒。

做用域(Scope

到目前爲止,咱們使用簡單的方式輕鬆地實現了複雜的操做。可是,仍有一個問題未解決。

假如咱們要使用 RecyclerView 顯示朋友列表,當請求仍在進行的時候,客戶關閉了 activity,此時 activity 處於 isFinishing 的狀態,任何更新 UI 的操做都會致使 App 崩潰。

咱們怎麼處理這種場景呢?固然是使用做用域(scope)了。先來看看都有哪些做用域:

Global scope

它是一個全局的做用域,若是協程的運行週期和 App 的生命週期同樣長的話,建立協程的時候可使用它。因此它不該該和任何能夠被銷燬的組件綁定使用。

它的使用方式是這樣的:

GlobalScope.launch(Dispatchers.Main) {
    ...
}
複製代碼

當你使用它的時候,要再三肯定,要建立的協程是否須要伴隨 App 整個生命週期運行,而且這個協程沒有和界面、組件等綁定。

自定義協程做用域

任何類均可以繼承 CoroutineScope 做爲一個做用域。你須要作的惟一一件事就是重寫 coroutineContext  這個屬性。

在此以前,你須要明確兩個重要的概念 dispatcher  和 Job

不知道你是否還記得,一個上下文(context)能夠是多個上下文的組合。組合的上下文須要是不一樣的類型。因此,你須要作兩件事情:

  • 一個 dispatcher: 用於指定協程默認使用的 dispatcher
  • 一個 job: 用於在任何須要的時候取消協程;
class MainActivity : AppCompatActivity(), CoroutineScope {

    override val coroutineContext: CoroutineContext
        get() = Dispatchers.Main + job

    private lateinit var job: Job

}
複製代碼

操做符號 + 用於組合上下文。若是兩種不一樣類型的上下文相組合,會生成一個組合的上下文(CombinedContext),這個新的上下文會同時擁有被組合上下文的特性。

若是兩個相同類型的上下文相組合,新的上下文等同於第二個上下文。即 Dispatchers.Main + Dispatchers.IO == Dispatchers.IO

咱們可使用延遲初始化(lateinit)的方式建立一個 Job。這樣咱們就能夠在 onCreate() 方法中初始化它,在 onDestroy() 方法中取消它。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    job = Job()
    ...
}

override fun onDestroy() {
    job.cancel()
    super.onDestroy()
}
複製代碼

這樣以來,使用協程就方便多了。咱們只管建立協程,而不用關心使用的上下文。由於咱們已經在自定義的做用域裏面聲明瞭上下文,也就是包含了 main dispatcher 的那個上下文:

launch {
    ...
}
複製代碼

若是你的全部 activity 都須要使用協程,將上述代碼提取到一個父類中是頗有必要的。

附錄1 - 回調方式轉爲協程

若是你已經考慮將協程用於現有的項目,你可能會考慮怎麼將現有的回調風格的代碼轉爲協程:

suspend fun suspendAsyncLogin(username: String, password: String): User =
    suspendCancellableCoroutine { continuation ->
        userService.doLoginAsync(username, password) { user ->
            continuation.resume(user)
        }
    }
複製代碼

suspendCancellableCoroutine() 這個方法返回一個 continuation 對象,continuation 能夠用於返回回調的結果。只要調用 continuation.resume() 方法,這個回調結果就能夠做爲這個可中斷方法的結果返回給協程。

附錄2 - 協程和 RxJava

每次提到協程都會有人問起,協程能夠替代 RxJava 嗎?簡單地回答就是:不能夠。

客觀地來講,根據狀況而定:

  1. 若是你使用 RxJava 只是用來從主線程切換到子線程。你也看到了,協程能夠輕鬆地實現這一點。這種狀況下,徹底能夠替代 RxJava
  2. 若是你使用 RxJava 用來流式編程,合併流、轉換流等。RxJava 依然更有優點。協程中有一個 Channels 的概念,能夠替代 RxJava 實現一些簡單的場景,可是一般狀況下,你可能更傾向於使用 RxJava 的流式編程。

值得一提的是,這裏有一個開源庫,能夠在協程中使用 RxJava,你可能會感興趣。

總結

協程爲咱們打開了一個充滿無限可能性、更簡單實現異步編程的世界。在此以前,這是不可想象的。

強烈推薦把協程用於你現有的項目當中。若是你想查看完整的示例代碼,點擊這裏

趕快開啓你的協程之旅吧!

相關文章
相關標籤/搜索