Kotlin 協程從入門到真香

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
    // 更新頁面
}

上面的代碼很簡潔,但要真看明白卻并不簡單,但我們大概能猜測這段代碼如果運行沒有問題的話,應該是先在子線程執行了兩次網絡請求,然后再將線程切換到了主線程進行頁面的刷新。

這不由得讓我們產生疑問:

  1. 這么簡短的代碼,真的可以做到線程的自動切換么?
  2. 這個 GlobalScope.launch 是干嘛的?
  3. 這個 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標記的方法本身不執行任何掛起操作,執行掛起操作要執行框架給我們提供的掛起函數,如:delaywithContextasync 等。 如果我們不在方法里面執行這些掛起函數,那么 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 的協程,真香!

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,702評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,143評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,553評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,620評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,416評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,940評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,024評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,170評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,709評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,597評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,784評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,291評論 5 357
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,029評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,407評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,663評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,403評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,746評論 2 370