Kotlin Coroutines(協程)講解

Sir在掘金發表於2019-03-01

前言

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

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

原文作者: Antonio Leiva

協程簡介

協程是 Kotlin 的一大特色。使用協程,可以簡化非同步程式設計,使程式碼可讀性更好、更容易理解。

使用協程,不同於傳統的回撥方式,可以使用同步的方式編寫非同步程式碼。同步方法返回的結果就是非同步請求的結果。

協程到底有什麼魔法?馬上為您揭曉。在這之前,我們需要知道為什麼協程這麼重要。

Kotlin 1.1 中 協程作為實驗特性,到現在 Kotlin 1.3 釋出了最終的 API,協程已經可以用於生產環境中。

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

獲取文中的完整示例點選 這裡

假設要做一個登陸介面:使用者輸入使用者名稱和密碼,然後點選登陸。

假設是這樣的流程:App 首先請求伺服器校驗使用者名稱和密碼,校驗成功後,然後請求該使用者的好友列表。

虛擬碼如下:

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,你可能會感興趣。

總結

協程為我們開啟了一個充滿無限可能性、更簡單實現非同步程式設計的世界。在此之前,這是不可想象的。

強烈推薦把協程用於你現有的專案當中。如果你想檢視完整的示例程式碼,點選這裡

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

相關文章