1. 前言
隨著 Kotlin 的不斷更新以及官方的推薦加持,越來越多的項目開始接受 Kotlin 作為主要的編寫語言。但非常多的 Android 開發者依然只是停留在使用 Kotlin 的使用上,對于 Kotlin 的一些新鮮事物還保留觀望態度,而 Kotlin 協程就是一個非常特別的事物。
雖然在 Go、Python 等編程語言上,早已有了協程的概念,但對于 Android 開發者來說,協程是一個非常新鮮的概念。于是就出現了一大批博客開始吹噓協程有多么好用,性能有多么高,甚至一大批人都認為 Kotlin 協程必將代替 RxJava。恰好最近開言英語正好準備切換到 Kotlin 協程,這里就簡單做些基本介紹,希望對大家理解有所幫助。
2. Kotin 的協程到底是什么?
當接觸到一個全新的概念的時候,我們通常會比較懵逼,所以通常來說,我們都應該有一個概念認知。
那么協程到底是什么呢? 當在網上搜索協程時,我們會看到:
- Kotlin 官方文檔說「本質上,協程是輕量級的線程」。
- 很多技術文章提到「協程是在用戶態直接對線程進行管理」、「 Kotlin協程是一種異步編程的同步順序寫法」等等。
官方的定義太抽象,博客的定義太浮夸,以至于我們還是搞不懂,覺得晦澀難懂。
個人覺得雖然大多數時候我們要追求極致,但其實也并不需要咬文嚼字。簡單地講,Kotlin 的協程就是一個封裝在線程上面的線程框架。
3. 協程的好處
Kotlin 協程可以用看起來同步的代碼寫出實質上異步的操作,當然如果你熟悉 Dart,你就會理解很深刻了。它有兩個非常關鍵的亮點:
- 耗時函數自動后臺,從而提高性能;
- 線程的「自動切回」
所以,Kotlin 的協程在 Android 開發上的核心好處就是:消除回調地域。
4. 協程的使用
協程一般情況用于需要進行線程切換的場景中,對于我們 Android 開發者來說,網絡請求應該是一個非常常見的場景了。
我們不妨來假設一下,我們需要做兩個網絡請求然后更新頁面,此時我們如果用普通的網絡請求寫法一定是這樣。
這里以 Retrofit 作為網絡請求框架為例。
先奉上通用的代碼:
val retrofit = Retrofit.Builder()
.baseUrl("https://api.github.com/")
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJava3CallAdapterFactory.createWithScheduler(Schedulers.io()))
.build()
val api = retrofit.create(Api::class.java)
再看看普通的網絡請求代碼:
api.listRepos("nanchen2251")
.enqueue(object : Callback<List<Repo>?> {
override fun onFailure(call: Call<List<Repo>?>, t: Throwable) {
}
override fun onResponse(call: Call<List<Repo>?>, response: Response<List<Repo>?>) {
val name1 = response.body()?.get(0)?.name ?: ""
api.listRepos("google")
.enqueue(object : Callback<List<Repo>?> {
override fun onFailure(call: Call<List<Repo>?>, t: Throwable) {
}
override fun onResponse(
call: Call<List<Repo>?>,
response: Response<List<Repo>?>
) {
val name2 = response.body()?.get(0)?.name ?: ""
val names = "$name1-$name2"
runOnUiThread{
// ... 使用 names 做頁面更新操作
}
}
})
}
})
很明顯,我們的需求其實是希望兩個網絡請求并行,然后再請求均完成后,更新頁面。但我們通常的寫法把原本應該并行的網絡請求寫成了串行,這讓網絡請求的時長遠超正常的操作,顯然是非常糟糕的。
當然我們可以考慮直接用 RxJava 來解決此類嵌套問題,用 RxJava 實現這個問題代碼大致如下:
Single.zip(
api.listReposRx("nanchen2251"),
api.listReposRx("google"),
BiFunction { repos1, repos2 -> "${repos1[0].name} - ${repos2[0].name}" }
).observeOn(AndroidSchedulers.mainThread())
.subscribe(object : SingleObserver<String> {
override fun onSuccess(combined: String) {
val names = combined
// ... 使用 names 做頁面更新操作
}
override fun onSubscribe(d: Disposable) {
}
override fun onError(e: Throwable) {
}
})
可以發現,我們使用 Retrofit && RxJava 完美解決了串行請求帶來的性能問題,而且代碼也不再是回調地域,非常輕便。
那么如果我們使用 Kotlin 的協程,代碼要怎么編寫呢?
GlobalScope.launch(Dispatchers.Main) {
val nanchen2251 = async { api.listReposKt("nanchen2251") }
val google = async { api.listReposKt("google") }
val name1 = nanchen2251.await()[0].name
val name2 = google.await()[0].name
// 更新頁面
}
上面的代碼很簡潔,但要真看明白卻并不簡單,但我們大概能猜測這段代碼如果運行沒有問題的話,應該是先在子線程執行了兩次網絡請求,然后再將線程切換到了主線程進行頁面的刷新。
這不由得讓我們產生疑問:
- 這么簡短的代碼,真的可以做到線程的自動切換么?
- 這個 GlobalScope.launch 是干嘛的?
- 這個 async 又是什么關鍵字?
5. 協程的原理
5.1 協程的自動切換
可能你已經在其他地方聽說了協程可以自動切換線程,但一定好奇它的實現原理,但真正當你去看協程的源碼的時候,你會發現其實一臉懵逼,我們不妨思考一下,如果我們想實現一個可以自動切換線程的代碼,我們會怎么做?
可能會是這樣:
private val executor = ThreadPoolExecutor(5, 20, 1, TimeUnit.MINUTES, LinkedBlockingDeque())
private fun ioCode() {
println("我是子線程執行的耗時代碼")
}
private fun classicIoCode(uiThread: Boolean = true, block: () -> Unit) {
println("我在主線程")
executor.execute {
ioCode()
if (uiThread) {
runOnUiThread {
block()
}
} else {
block()
}
}
}
協程的本質是還是線程池使用的上層封裝,所以這么我們需要使用一個線程池的類 ThreadPoolExecutor
。我們替換一個參數 uiThread
代表是否需要切換線程,當為 true 的時候,代表需要切回主線程執行后面的 lambda 表達式。
實際上,當你真正去查看協程的代碼的時候,你會發現協程的原理大概也就是這樣,只是代碼的魯棒性和拓展性比上面的代碼更好。
如果我們去調用的話,可能代碼是這樣:
classicIoCode1(true) {
uiCode()
}
private fun uiCode() {
println("我是主線程執行的代碼")
}
上面的代碼,對應到協程寫法就是:
GlobalScope.launch(Dispatchers.Main) { // 主線程開啟協程
ioCode()
uiCode()
}
private suspend fun ioCode() {
withContext(Dispatchers.IO) {
println("我是子線程執行的耗時代碼")
}
}
其運行過程為:
5.2 suspend 的本質
上面的代碼中,出現了一個 suspend
關鍵字,很多同學在初學 Kotlin 的協程的時候,總是會誤以為 suspend
這個關鍵字非常奇特,總會和協程的線程自動切換混淆在一起,甚至有的同學直接認為是 suspend
實現了線程切換。
其實并不是這樣,suspend
的本質是什么?這里直接說結論:
-
suspend
并不是拿來切線程的; - 外面的協程告知的切回哪個線程,上下文信息
-
suspend
的關鍵作用:標記和提醒。
suspend
關鍵字用來標記方法是一個掛起的方法,掛起方法只能在協程、或者另外的掛起函數中調用。因為掛起的本身是協程操作,依賴協程的上下文。 但是 suspend
標記的函數并不是一定會有掛起操作,suspend
只是一個標記,在編譯時能夠生成協程對應的類,我們自定義的suspend標記的方法本身不執行任何掛起操作,執行掛起操作要執行框架給我們提供的掛起函數,如:delay
,withContext
,async
等。 如果我們不在方法里面執行這些掛起函數,那么 suspend
標記的意義只是,標記這個方法需要在協程里調用,編譯器也會提示我們這個 suspend
是不必要的。
值得強調的是:創建函數的人,一定要注意把其聲明為 suspend
掛起函數,方便調用者 IDE 直接提示。
6. Kotlin 協程會替代 RxJava 嗎
Kotlin 的協程是否讓 RxJava 的響應式編程光輝不在了呢?答案取決于你詢問的對象。狂信徒和營銷者們會毫不猶豫地是是是。如果真是這樣的話,開發者們遲早會將 Rx 代碼用協程重寫一遍,抑或從一開始就用協程來寫。 因為 協程 目前還是實驗性的,所以目前的諸如性能瓶頸之類的不足,都將逐漸解決。
RxJava 是響應式編程的佼佼者, 響應式編程的好處之一是大多數情況下都不必去理會諸如線程、取消信息的傳遞和操作符的結構等惱人的東西。RxJava 之類的庫已經設計好了 API 并將這些底層的大麻煩封裝起來了,通常情況下,程序員只需要使用即可。
對于多個耗時操作需要同步進行,RxJava 有 zip
關鍵字進行合并,而協程有 async
&& await
。RxJava 把回調變成了鏈式,而 Kotlin 的協程直接去掉了回調,放棄了操作符,更加干凈。
RxJava 和 Kotlin 的協程都有自己的優勢所在,本質上 Kotlin 的協程也不是為了替代 RxJava,二者各有千秋。
Kotlin 的協程在 Jetpack 上組件增加了大量的協程應用場景,所以總的來說,還是推薦大家一起使用 Kotlin 的協程的。
7. Kotlin 協程如何避免「協漏」?
在 Android 開發中,耗時操作一般都需要在 onDestroy
方法中進行 cancel,如果不在頁面銷毀的時候進行 cancel 的話,大概率會發生協漏。
協漏:Kotlin 協程產生的內存協漏。
所以對于上面的代碼,我們應該選擇一個合適的時機進行耗時代碼的取消。
對于 RxJava 的使用方式,我們可以把請求放入 CompositeDisposable
中,然后在 onDestroy
中進行取消。
private val disposable = CompositeDisposable()
override fun onCreate(savedInstanceState: Bundle?) {
Single.zip(
api.listReposRx("nanchen2251"),
api.listReposRx("google"),
BiFunction { repos1, repos2 -> "${repos1[0].name} - ${repos2[0].name}" }
).observeOn(AndroidSchedulers.mainThread())
.subscribe(object : SingleObserver<String> {
override fun onSuccess(combined: String) {
val names = combined
// ... 使用 names 做頁面更新操作
}
override fun onSubscribe(d: Disposable) {
disposable.add(d)
}
override fun onError(e: Throwable) {
// ... 處理異常操作
}
})
}
override fun onDestroy() {
disposable.dispose()
super.onDestroy()
}
而在 Kotlin 的協程中,我們可以這樣寫:
private val jobs = ArrayList<Job>()
override fun onCreate(savedInstanceState: Bundle?) {
val job = GlobalScope.launch(Dispatchers.Main) {
val nanchen2251 = async { api.listReposKt("nanchen2251") }
val google = async { api.listReposKt("google") }
val name1 = nanchen2251.await()[0].name
val name2 = google.await()[0].name
// 更新頁面
}
jobs.add()
}
override fun onDestroy() {
jobs.forEach {
it.cancel()
}
super.onDestroy()
}
實際上,我們在 Android 開發中,基本上不會使用 GlobalScope,這個只是一個全局性的 CoroutineScope
,查看源碼發現,我們其實有相當多的 CoroutineScope
實現。比如我們上面要執行在主線程的 Scope,我們可以直接使用 MainScope
進行替代,代碼就變成了:
private val scope = MainScope()
override fun onCreate(savedInstanceState: Bundle?) {
scope.launch {
val nanchen2251 = async { api.listReposKt("nanchen2251") }
val google = async { api.listReposKt("google") }
val name1 = nanchen2251.await()[0].name
val name2 = google.await()[0].name
// 更新頁面
}
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
而如果你使用 Jetpack 組件的話,你會發現更簡單,我們直接就可以使用 LifecycleCoroutineScope
,比如上面代碼直接變成了:
lifecycleScope.launch{
val nanchen2251 = async { api.listReposKt("nanchen2251") }
val google = async { api.listReposKt("google") }
val name1 = nanchen2251.await()[0].name
val name2 = google.await()[0].name
// 更新頁面
}
從此媽媽再也不用擔心 Kotlin 的協程異步代碼造成內存協漏了。
jetpack 對 Kotlin 的協程支持還有很多,比如 Lifecycle、ViewModel、LiveData、Room 都對協程有所支持,感興趣的可以到官網進行查閱。
8. 總結
Kotlin 協程并沒有脫離 Kotlin 或者 JVM 創造新的東西,他就是 kotlin 提供給我們的一套線程操作 API,其所有魔法都來自線程。
協程利用掛起代替阻塞,讓我們能用同步的方式寫出異步的代碼,代碼結構更加清晰,從語法上看它很神奇,但從原理上講,本質上就是線程切換,通過切走再切回來的操作,代替回調,抹平代碼的層級。
協程通過 suspend
關鍵字,將方法是否耗時在創建時就區分出來,確定耗時方法只能在協程上調用,從機制上避免了卡頓,防止一不小心在主線程調用了耗時代碼。對于規范工程代碼,減少程序 ANR 有極大的幫助。 希望通過這篇文章能幫助大家上手 Kotlin 協程,消除對于協程的誤解,不再覺得害怕不敢上手,能夠真正通過協程提高代碼質量,優化代碼結構,必要的時候還能用它來提升性能。到時候真的能從心底里說一聲:Kotlin 的協程,真香!。