Kotlin 協程入門這一篇就夠了

本篇文章已受權微信公衆號 guolin_blog (郭霖)獨家發佈android

協程的做用

協程經過替代回調(callback)來簡化異步代碼git

聽起來蠻抽象的,來看代碼github

fun fetchDocs() {
   		val result = get("developer.android.com")
   		show(result)
	}
複製代碼

Android系統爲了保證界面的流暢和及時響應用戶的輸入事件,主線程須要保持每16ms一次的刷新(調用 onDraw()函數),因此不能在主線程中作耗時的操做(好比 讀寫數據庫,讀寫文件,作網絡請求,解析較大的 Json 文件,處理較大的 list 數據)。數據庫

get()經過接口獲取用戶數據,若是在主線程中調用fetchDocs()函數就會阻塞(block)主線程,App 會卡頓甚至崩潰。數組

因此須要在子線程中調用get()函數,這樣主線程就能夠刷新界面和處理用戶輸入,待get()函數執行完畢後經過 callback 拿到結果。安全

fun fetchDocs() {
        get("developer.android.com") { result ->
            show(result)
        }
    }
複製代碼

callback 是個不錯的方式,可是 callback 被過分使用後代碼可讀性會變差(迷之縮進),並且 callback 不能使用 exception。爲了解決這樣的問題,歡迎協程(coroutine)閃亮登場

suspend fun fetchDocs() {
        val result = get("developer.android.com")
        show(result)
    }

    suspend fun get(url: String) =
            withContext(Dispatchers.IO) {
                ...
            }
複製代碼

明明是同步的寫法爲何不會阻塞主線程? 對,由於suspend微信

suspend修飾的函數比普通函數多兩個操做(suspend 和 resume)網絡

  • suspend:暫停當前協程的執行,保存全部的局部變量
  • resume:從協程被暫停的地方繼續執行協程

get() 函數一樣也是一個suspend函數。架構

suspend修飾的函數並不意味着運行在子線程中併發

若是須要指定協程運行的線程,就須要指定Dispatchers ,經常使用的有三種:

  • Dispatchers.Main:Android中的主線程,能夠直接操做UI
  • Dispatchers.IO:針對磁盤和網絡IO進行了優化,適合IO密集型的任務,好比:讀寫文件,操做數據庫以及網絡請求
  • Dispatchers.Default:適合CPU密集型的任務,好比解析JSON文件,排序一個較大的list

經過withContext()能夠指定Dispatchers,這裏的get()函數裏的withContext代碼塊中指定了協程運行在Dispatchers.IO中。

來看下這段代碼的具體執行流程

動畫出處見文末參考文檔

  • 每一個線程有一個調用棧(call stack), Kotlin使用它來追蹤哪一個函數在執行和它的局部變量
  • 當調用到suspend修飾的函數的時候,Kotlin須要追蹤正在運行的協程而不是正在執行的函數
  • 綠色線條表示一個suspend的標記,綠色上面的是協程,綠色下面的是一個正常的函數
  • Kotlin 像正常函數同樣調用fetchDocs() 函數,在調用棧上加一個 entry,這裏也存儲着fetchDocs()函數的局部變量
  • 繼續往下執行,直到找到另外一個suspend函數的調用(這裏指的是 get() 函數調用),這時候Kotlin要去實現suspend操做(將函數的狀態從堆棧複製到一個地方,以便之後保存,全部suspend的協程都會被放在這裏)
  • 而後調用get()函數,一樣新建一個entry,當調用到withContext()(withContext函數被 suspend 修飾)的時候,一樣 執行suspend操做(過程和前面同樣)。此時主線程裏的全部協程都被 suspend,因此主線程能夠作其餘事情(執行 onDraw,響應用戶輸入)
  • 等待幾秒後,網絡請求會返回,這時Kotlin會執行resume操做(獲取保存狀態並複製回來,從新放回到調用棧上),以後會正常往下執行,若是fetchDocs()發成錯誤,會在這裏拋出異常

協程的組成

val viewModelJob = Job()    //用來取消協程
    
    val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob)   //初始化CoroutineScope 指定協程的運行所在線程傳入 Job 方便後面取消協程

    uiScope.launch { //啓動一個協程
        updateUI() //suspend函數運行在協程內或者suspend另一個函數內
    }
複製代碼
suspend fun updateUI() {
    delay(1000L) //delay是一個 suspend 函數
    textView.text = "Hello, from coroutines!"
}
複製代碼
viewModelJob.cancel()//取消協程
複製代碼
  • 啓動一個協程須要CoroutineScope,爲何須要? 一會解釋
  • CoroutineScope接受CoroutineContext做爲參數,CoroutineContext由一組協程的配置參數組成,能夠指定協程的名稱,協程運行所在線程,異常處理等等。能夠經過plus操做符來組合這些參數。上面的代碼指定了協程運行在主線程中,而且提供了一個Job,可用於取消協程
    • CoroutineName(指定協程名稱)
    • Job(協程的生命週期,用於取消協程)
    • CoroutineDispatcher,能夠指定協程運行的線程
  • 有了CoroutineScope以後能夠經過一系列的Coroutine builders來啓動協程,協程運行在Coroutine builders的代碼塊裏面
    • launch 啓動一個協程,返回一個Job,可用來取消協程;有異常直接拋出
    • async 啓動一個帶返回結果的協程,能夠經過Deferred.await()獲取結果;有異常並不會直接拋出,只會在調用 await 的時候拋出
    • withContext 啓動一個協程,傳入CoroutineContext改變協程運行的上下文

結構化併發(Structured concurrency

若是在 foo 裏協程啓動了bar 協程,那麼 bar 協程必須在 foo 協程以前完成

foo 裏協程啓動了bar 協程 ,可是bar 並無在 foo 完成以前執行完成,因此不是結構化併發

foo 裏協程啓動了 bar 協程 ,而且 barfoo 完成以前執行完成,因此是結構化併發

結構化併發可以帶來什麼優點呢?下面一點點闡述。

協程的泄漏

儘管協程自己是輕量級的,可是協程作的工做通常比較重,好比讀寫文件或者網絡請求。使用代碼手動跟蹤大量的協程是至關困難的,這樣的代碼比較容易出錯,一旦對協程失去追蹤,那麼就會致使泄漏。這比內存泄漏更加嚴重,由於失去追蹤的協程在resume的時候可能會消耗內存,CPU,磁盤,甚至會進行再也不必要的網絡請求。

如何避免泄漏呢?這其實就是CoroutineScope 的做用,經過launch或者async啓動一個協程須要指定CoroutineScope,當要取消協程的時候只須要調用CoroutineScope.cancel() ,kotlin 會幫咱們自動取消在這個做用域裏面啓動的協程。

結構化併發能夠保證當一個做用域被取消,做用域裏面的全部協程會被取消

若是使用架構組件(Architecture Components),比較適合在ViewModel中啓動協程,而且在onCleared回調方法中取消協程

override fun onCleared() {
        super.onCleared()
        viewModelJob.cancel() //取消ViewModel中啓動的協程
    }
複製代碼

本身寫CoroutineScope比較麻煩,架構組件提供了viewModelScope這個擴展屬性,能夠替代前面的uiScope

看下viewModelScope這個擴展屬性是如何實現的:

val ViewModel.viewModelScope: CoroutineScope
        get() {
            val scope: CoroutineScope? = this.getTag(JOB_KEY)
            if (scope != null) {
                return scope
            }
            return setTagIfAbsent(JOB_KEY,
                CloseableCoroutineScope(Job() + Dispatchers.Main))
        }

internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope {
    override val coroutineContext: CoroutineContext = context

    override fun close() {
        coroutineContext.cancel()
    }
}
複製代碼

一樣是初始化一個CoroutineScope,指定Dispatchers.Main和 Job

##ViewModel
    @MainThread
    final void clear() {
        mCleared = true;
        // Since clear() is final, this method is still called on mock objects
        // and in those cases, mBagOfTags is null. It'll always be empty though
        // because setTagIfAbsent and getTag are not final so we can skip
        // clearing it
        if (mBagOfTags != null) {
            for (Object value : mBagOfTags.values()) {
                // see comment for the similar call in setTagIfAbsent
                closeWithRuntimeException(value);
            }
        }
        onCleared();
    }

    private static void closeWithRuntimeException(Object obj) {
        if (obj instanceof Closeable) {
            try {
                ((Closeable) obj).close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }
複製代碼

clear()中會自動取消做用域中的協程。有了viewModelScope這個擴展屬性能夠少些不少模板代碼。

再看一個稍複雜的場景,同時發起兩個或者多個網絡請求。這就意味着要開啓更多的協程,隨處開啓協程可能致使潛在的泄漏問題,調用者可能不知道新開啓的協程,所以也無法追蹤他們。 這時候就須要coroutineScope或者supervisorScope(注意不是CoroutineScope)。

suspend fun fetchTwoDocs() {
    coroutineScope {
        launch { fetchDoc(1) }
        launch { fetchDoc(2) }
    }
}
複製代碼

這個示例中,同時發起兩個網絡請求。在suspend 函數裏面能夠經過coroutineScopesupervisorScope 安全地啓動協程。爲了不泄漏,咱們但願fetchTwoDocs這樣的函數返回的時候,在函數內部啓動的協程都能執行完成。

結構化併發保證當suspend函數返回的時候,函數裏面的全部工做都已經完成

Kotlin能夠保證使用coroutineScope不會從fetchTwoDocs函數中發生泄漏,coroutineScopesuspend本身直到在它裏面啓動的全部協程執行完成。正是由於這樣,fetchTwoDocs不會在coroutineScope內部啓動的協程完成前返回。

若是有更多的協程呢?

suspend fun loadLots() {
        coroutineScope {
            repeat(1000) {
                launch { fetchDoc(it) }
            }
        }
    }
複製代碼

這裏在suspend函數中啓動了更多的協程,會泄露嗎?並不會。

動畫出處見文末參考文檔

因爲這裏的loadLots是一個suspend函數,因此loadLots函數會在一個CoroutineScope中被調用,coroutineScope構造器會使用這個CoroutineScope做爲父做用域生成一個新的CoroutineScope。在coroutineScope代碼塊內部,launch函數會在這個新的CoroutineScope中啓動新的協程,這個新的CoroutineScope會追蹤這些新的協程,當全部的協程執行完畢,loadLots函數纔會返回。

coroutineScopesupervisorScope會等到全部的子協程執行完畢。

使用coroutineScope 或者 supervisorScope能夠安全地在suspend函數裏面啓動新的協程,不會形成泄漏,由於老是會suspend調用者直到全部的協程執行完畢。coroutineScope會新建一個子做用域(child scope),因此若是父做用域被取消,它會把取消的信息往下傳遞給全部新的協程。

另外coroutineScopesupervisorScope的區別在於:coroutineScope會在任意一個協程發生異常後取消全部的子協程的運行,而supervisorScope並不會取消其餘的子協程。

如何保證收到異常

前面有介紹過async裏面若是發生異常是不會直接拋出的,直到 await 獲得調用,因此下面的代碼不會拋出異常。

val unrelatedScope = MainScope()
// example of a lost error
suspend fun lostError() {
    // async without structured concurrency
    unrelatedScope.async {
        throw InAsyncNoOneCanHearYou("except")
    }
}
複製代碼

可是coroutineScope會等到協程執行完畢,因此發生異常後會拋出。下面的代碼會拋出異常。

suspend fun foundError() {
    coroutineScope {
        async { 
            throw StructuredConcurrencyWill("throw")
        }
    }
}
複製代碼

結構化併發保證當協程出錯時,協程的調用者或者他的作用戶會獲得通知

因而可知 結構化併發能夠保證代碼更加安全,避免了協程的泄漏問題

  • 看成用域被取消,裏面全部的協程被取消,於是能夠取消再也不須要的任務
  • suspend函數返回,裏面的工做能保證完成,於是能夠追蹤正在執行的任務
  • 當協程出錯,調用者或者做用域會收到通知,從而能夠進行異常處理

參考文檔:

 Coroutines on Android (part I): Getting the background 

 Coroutines on Android (part II): Getting started 

 Understand Kotlin Coroutines on Android (Google I/O'19) 

 Using Kotlin Coroutines in your Android App 

相關文章
相關標籤/搜索