【譯文】kotlin1.3 版本的協程

原文鏈接:https://antonioleiva.com/coroutines/

協程是 kotlin 中讓人激動的特性之一,使用協程,可以用一種優雅的方式來簡化異步編程,讓代碼更加可讀和易於理解。
使用協程,你可以用同步的方式寫異步代碼,而不是傳統的 Callback 方式來寫。同步方法的返回值就是異步計算的結果。
到底有什麼魔力發生呢?我們馬上即可學習它,在此之前,讓我們瞭解下爲什麼協程很有必要。
協程是 kotlin1.1 推出的實驗特性,在 kotlin1.3 版本發佈了最終的 Api,現在已經可以投入生產。

Coroutines goal: The problem

假設你需要開發一個登錄頁面,UI 如下:

用戶輸入用戶名和密碼,然後點擊登錄按鈕。

你的 App 代碼實現裏,需要向服務端發起請求來校驗登錄,然後請求該用戶的好友列表,最後顯示在屏幕上。

使用 kotlin 寫出來的代碼像這樣:

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
    }
 
}
}

代碼開始變得難以理解,我們看到了令人恐懼的嵌套回調,即下一個請求總是嵌套在了上一個請求的 callback 裏。

kotlin 的 lambdas 表達式,讓其不至於那麼難看。但誰知道呢?將來你依然需要添加請求,使其變的越來越糟糕。

此外,別忘了我們這使用的是簡單的方式,也就沒那麼高效了。

What are coroutines?

爲了輕鬆的理解協程。我們可以說協程就像線程一樣,但比線程更好。

首先,協程可以讓你有順序的寫異步代碼,大大的減輕了寫異步代碼的負擔。

其次,它們更加的高效,多個協程可以在同一個線程上跑起來。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
}

在上面的例子中,是一個協程通用的結構。有一個協程 builder(構造器) ,和一些的 在返回結果前中斷了 協程執行的中斷函數

然後,你可以在下一行代碼使用這個中斷函數的返回結果,像極了有序的編碼。kotlin中並不存在 coroutine 和 suspended 這兩個關鍵字,上述例子我們先了解通用的結構

Suspending functions

中斷函數(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

協程的上下文,是用於定義協程怎麼執行的規則和配置集合。它可以看作一個map,存儲了一系列 keys 和values。

dispatcher 是其中一個配置,dispatcher 可以指定協程執行在哪個線程。

dispatcher 提供兩種使用方式:

1.在需要使用的地方明確設置 dispatcher 類型。

2.通過協程的作用域(scope):關於scope在後面會細說

對於明確指定的使用方式,協程的構造器接收一個協程的上下文,作爲第一個參數,我們可以直接將dispatcher當做第一個參數傳進協程的構造器,dispatcher實現了CoroutineContext接口,因此可以這樣使用:

coroutine(Dispatchers.Main) {

​    progress.visibility = View.VISIBLE

​    ...

}

現在,這行changes the visibility將會在UI線程執行,這個協程裏的一切代碼都是在UI線程執行都。那中斷函數呢?

coroutine {

​    ...

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

​    val currentFriends = suspended { userService.requestCurrentFriends(user) }

​    ...

}

網絡請求也是運行在UI線程麼?如果是這樣的情況的話,他們將會阻塞UI線程

中斷函數在使用的時候,有不同的方式來配置 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) }

你可能想了解我們有哪些 dispatchers 和什麼時候使用,讓我們來認識它們:

Dispatchers

正如你看見的,Dispatchers是一個線程上下文,可以指定協程裏的代碼運行在哪個線程。有的dispatchers僅使用一個線程,如main線程。其他的Dispatchers定義了一個線程池。將運行所有接收到的協程

如果你記得,最開始的時候,我們使用了一個線程來運行多個協程,因此係統不會爲每一個協程都創建一個線程,但是會嘗試複用已經存在的線程。

我們有四個主要的 Dispatchers

Default:當沒有指定Dispatcher時它會被使用,我們可以明確的指定Dispatcher,這個Dispatcher可用於cpu密集型的計算任務。如一些計算,算法場景,它可以使用的線程數和cpu核心數一樣多,對於密集型任務,cpu是很繁忙的,所以同時運行多個線程沒有意義。

IO:當你需要運行 輸入 / 輸出 時會使用到它,通常的,當需要等待其他的系統迴應時,即會阻塞線程的的任務,如服務端請求,讀寫數據庫,文件,它不使用 cpu,可以同時讓多個線程跑起來,是線程數爲64的線程池。Android的應用是設備和網絡請求的交互,因此你可能更多的時候會使用到他們。

Unconfined:如果你不關心線程的使用,你可以使用這個。它很難控制線程的使用,因此當你不是很確定你要做什麼的時候,不建議使用它。

Main:這是一個和UI關聯的協程庫裏的特殊的Dispatcher,在Android中,他會使用UI線程。

你現在已經可以靈活的使用Dispatcher了。

Coroutine Builders

現在你能夠很輕鬆的切換執行線程,你需要學習怎麼創建一個新的協程,當然是使用協程構造器。

我們可以根據場景,選擇不同的協程構造器,你也可以自定義自己的協程構造器,通常情況下,協程庫提供給我們的已經足夠了,讓我們來看看:

runBlocking

這個協程構造器會阻塞當前的線程,直到在協程裏所有的任務都執行完畢,這違背了我們使用協程的初衷。所以他有什麼用處呢?

runBlocking對於測試中斷函數非常有用,在你的測試程序裏,runBlocking代碼體裏包含了中斷函數,你可以斷言結果,防止在中斷函數結束之前,測試線程提前結束。

fun testSuspendingFunction() = runBlocking {

​    val res = suspendingTask1()

​    assertEquals(0, res)

}

除了這個場景,你可能沒有更多的地方需要用到runBlocking

launch

這是主要的構造器,你會經常使用它,因爲它是創建協程最簡單的方式,區別於runBlocking,它不阻塞當前線程(前提是正確使用了dispatchers)

這個構造器總是需要作用域的,在接下來會學習到作用域,在那之前,我們先使用GlobalScope。

GlobalScope.launch(Dispatchers.Main) {

​    ...

}

launch會返回一個Job對象,一個實現了CoroutineContext的類。

Jobs有幾個有用的方法很有用,需要重點了解的是,一個job有一個父job,這個父 job可以控制子job

接下來介紹下Job的方法:

job.join

對於這個方法,可以阻塞當前job關聯的協程,直到所有的子jobs結束。所有在協程裏邊調用的中斷函數,都綁定了這個job。當所有子job結束後,當前協程才繼續執行。

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

​    doCoroutineTask()

​    val res1 = suspendingTask1()

​    val res2 = suspendingTask2()

​    process(res1, res2)

 }

job.join()

job.join()本身是一箇中斷函數,因此你需要在協程裏調用它

job.cancel

這個函數可以取消與其關聯的所有子jobs,舉個例子,suspendingTask1在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。

這個對象有一個新的函數叫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秒。

除此之外,同步兩個結果是簡單的,只需要調用兩者的await,讓協程框架完成。

Scopes

到目前爲止,我們有一套很不錯的代碼,使用簡單的方式來解決一些複雜的場景。但我們仍然還有一個問題。

想象下我們需要顯示好友列表在一個RecyclerView,但是當我們運行在其中一個後臺任務時,這個用戶關閉了Activity,這個Activity將會isFinishing狀態,因此所有的UI更新都會拋出異常。

我們可以怎樣解決這種場景呢?使用Scopes。我們看下各種Scopes的用法。

Global scope

這是一個常用scope,當協程的生命週期和App的生命週期一樣長久的話,使用這個scope,因此他們不應該和可以銷燬的組件綁定。我們在上述使用過它,因此現在應該簡單了。

GlobalScope.launch(Dispatchers.Main) {

​    ...

}

當你使用GlobalScope,總是需要問自己這個協程是不是伴隨整個App生命週期的。而不是僅僅一個頁面或組件。

Implement CoroutineScope

所有的類都可以實現這個接口(CoroutineScope)成爲一個作用域,這個僅僅需要重寫 coroutineContext屬性。

這裏,有至少兩個重要的東西需要配置,dispatcher和job

你需要記住,一個context可以組合多個context,組合的context需要不同的類型,所以這裏,通常的,你將定義兩個東西,

dispatcher,用於指定協程的dispatcher

job,可以在任何時候取消協程。

class MainActivity : AppCompatActivity(), CoroutineScope {

 

​    override val coroutineContext: CoroutineContext

​        get() = Dispatchers.Main + job

 

​    private lateinit var job: Job

 

}

這個plus(+)操作符在組合context時使用,如果兩個不同類型的context組合時。會創建一個CombinedContext,CombinedContext擁有兩個上下文的配置。

另一方面,如果兩個相同類型context組合,新的上下文使用第二個上下文的配置,即 instance:Dispatchers.Main + Dispatchers.IO == Dispatchers.IO

我們創建job可以使用了lateinit,在onCreate延遲初始化它。它將在onDestroy時被取消。

override fun onCreate(savedInstanceState: Bundle?) {

​    super.onCreate(savedInstanceState)

​    job = Job()

​    ...

}

 

override fun onDestroy() {

​    job.cancel()

​    super.onDestroy()

}

現在,當使用協程時,代碼變的簡單。你可以使用構造器,跳過協程的context,因爲已經在自定義作用域定義了包含 main dispatcher的上下文。

launch {

​    ...

}

當然,如果你的activitiy裏使用協程,將其提取到父類是很值得的

補充1 - 從 callbacks 轉爲協程

如果你開始考慮將協程應用到你的項目中,你可能會考慮將當前使用callback的代碼,轉爲協程。

suspend fun suspendAsyncLogin(username: String, password: String): User =

​    suspendCancellableCoroutine { continuation ->

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

​            continuation.resume(user)

​        }

​    }

suspendCancellableCoroutine方法返回一個continuation對象,這個對象可以返回callback的結果。只需要調用continuation.resume。結果將會通過中斷函數返回給父協程。

補充2 - 關於協程與 Rxjava

是的,提到協程,我也有相同的問題:"協程可以替代Rxjava麼?"答案是不。

如果你使用Rxjava僅是用於從主線程切換到子線程,你發現協程可以更加簡單的完成這工作,是的,你可以不需要Rxjava

如果你使用流式操作,轉換流,合併流等,Rxjava依然做的更出色,在協程有一個叫Channels 的東西能夠替代Rxjava的多數簡單使用場景,但是Rxjava流式操作更加讓人喜歡

值得一提的是kotlin有一個開源庫,可以在協程裏使用rxjava。

總結

協程提供了更多的可能性,以一種你可能沒想到的方式來簡化異步編程。
趕快來體驗協程吧!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章