1. 程式人生 > 實用技巧 >Kotlin進階學習5

Kotlin進階學習5

寫在前面

本文上接Kotlin進階學習4,上次的文章學習了泛型的進階知識,真是十分難理解的知識呢。這次(最後)來學習一下Kotlin中極具特色的協程。

協程

介紹

什麼是協程呢?它其實和執行緒有些類似,可以將它理解成一種輕量級的執行緒。要知道執行緒是十分重量級的,它需要依賴作業系統的排程的才能實現不同執行緒之間的切換。而使用協程卻可以僅在程式語言的層面就能實現不同協程的切換,從而大大提升了併發程式設計的執行效率。簡單來說,協程允許我們在單執行緒模式模擬多執行緒程式設計的效果,程式碼的掛起和恢復都是由程式語言控制的,和作業系統無關。

基本使用——GlobalScope.launch

Kotlin並沒有把協程納入標準庫中,因此我們需要匯入相應的依賴庫:

    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.0"

接下來我們新建一個CoroutinesTest.kt檔案,定義一個main()函式,開始學習協程。

首先的問題就是,如何開啟一個協程?最簡單的方式就是使用GlobalScope.launch函式:

fun main(){
    GlobalScope.launch{
        println("codes run in coroutine scope")
    }
}

GlobalScope.launch函式可以建立一個協程的作用域,這樣傳遞給launch函式的程式碼塊就是在協程中執行的了。但如果這時候你執行一下,發現沒有任何東西打印出來。這是為什麼呢?因為每次GlobalScope.launch函式建立的都是一個頂層協程,這種協程當應用程式執行結束的時候也會一起結束。因此我們的日誌還沒來得及列印呢,程式執行就結束了。

這個東西的解決也很簡單,我們讓程式睡一會就好了:

fun main(){
    GlobalScope.launch{
        println("codes run in coroutine scope")
    }
    Thread.sleep(1000)
}

這樣子,我們的日誌就會打印出來了:

但這樣還是有問題啊,如果我們的程式碼塊中的程式碼不能在1秒鐘內執行結束,就會被強制中斷。比如:

fun main(){
    GlobalScope.launch{
        println("codes run in coroutine scope")
        delay(1500)
        println("codes run in coroutine scope finished")
    }
    Thread.sleep(1000)
}

delay()函式可以讓協程延遲指定時間後執行,但和Thread.sleep()方法不一樣,它是一個非阻塞式的掛起函式,只會掛起當前協程,而不會影響其他協程。但Thread.sleep()方法會阻塞當前執行緒,這樣該執行緒下的所有協程式都會阻塞。delay()函式只能使用在協程的作用域或者其他掛起函式中。

這裡,我們讓協程掛起了1.5秒,而主執行緒卻只阻塞了1秒,執行一下發現,第二個日誌資訊沒有打印出來。因為還沒來得及執行,程式就結束了。

那麼有沒有什麼辦法讓程式在協程中的所有程式碼都執行完了之後再結束呢?當然可以了,使用runBlocking函式即可。

基本使用——runBlocking

我們直接上程式碼:

fun main(){
    runBlocking{
        println("codes run in coroutine scope")
        delay(1500)
        println("codes run in coroutine scope finished")
    }
}

runBlocking函式容易會建立一個協程的作用域,但它可以保證在協程作用域內的所有程式碼和子協程沒有全部執行完之前一直阻塞當前執行緒。需要注意的是,runBlocking應該只在測試環境使用,生產環境使用會造成一定的效能問題。

我們執行程式碼:

可以看到,兩條日誌都已經打印出來了。

可我們雖然讓程式碼執行在協程了,但好像沒啥好處啊。這是因為當前的程式碼都只在一個協程中執行,當碰到一些需要高併發的場景時,協程相比於執行緒的優勢就體現出來了。

那麼如何開啟多個協程呢?使用launch函式就可以了:

fun main() {
    runBlocking {
        launch {
            println("launch1")
            delay(1000)
            println("launch1 finished")
        }
        launch {
            println("launch2")
            delay(1000)
            println("launch2 finished") 
        }
    }
}

這裡的launch函式和我們之前使用的Global.launch函式不一樣,首先它必須在協程的作用域中才能使用,其次他會在當前協程下建立子協程,子協程的特點是如果外層作用域的協程結束了,該作用域下的所有子協程都會結束。執行看看結果:

可以看到,兩個協程交替列印日誌,說明他們確實像多執行緒那樣是併發執行的。但他們卻只執行在同一個執行緒中,只是由程式語言來決定如何在多個協程之間進行排程,這會使得協程的執行效率奇高。

基本使用——suspend

但隨著launch函式中的邏輯越來越複雜,我們可能會需要把一部分程式碼放到另一個單獨的函式。但那一個單獨的函式並沒有在協程作用域,怎麼使用delay()這樣的掛起函式呢?

為此Kotlin提供了一個suspend關鍵字,使用它可以將任意函式宣告成掛起函式,而掛起函式是可以互相呼叫的:

suspend fun printDot(){
    println(".")
    delay(1000)
}

這樣就可以在該函式中呼叫delay()函數了。

但是,suspend關鍵字只能將一個函式宣告為掛起函式,卻無法提供給它協程作用域的,比如你想在printDot()函式呼叫launch函式,肯定會失敗的。

這個問題可以藉助coroutineScope函式解決:

基本使用——corouitneScope

coroutineScope也是一個掛起函式。因此可以在任何其他掛起函式中使用。它的特點是會繼承外部的協程作用域並建立一個子作用域:

suspend fun printDot() = coroutineScope {
    launch {
        println(".")
        delay(1000)
    }
}

這樣,我們就可以在printDot()函式裡使用launch函數了。另外,coroutineScope函式還跟runBlocking函式有點像,可以保證其作用域中的所有程式碼和子協程在執行完之前,會一直阻塞當前協程。

需要注意的是,雖然coroutineScope函式與runBlocking很類似,但coroutineScope函式只會阻塞當前協程,既不影響其他協程,也不影響任何執行緒,因此是不會造成任何效能上的問題的。

協程的取消

上面我們學習了幾種開啟協程的方法,但並沒有學習取消協程的方法。不管是GlobalScope.launch函式還是launch函式,他們都會返回一個Job物件,呼叫Job物件的cancel方法就可以取消協程了:

val job = GlobalScope.launch{
    // 具體邏輯
}
job.cancel()

但如果我們建立頂層協程,當Activity關閉時,就需要逐個呼叫所有已建立協程的cancel()方法,這樣的程式碼肯定是無法維護的。因此,像GlobalScope.launch這種作用域構建器,在實際專案中也是不怎麼用的。以下是一種專案中常見的寫法:

val job = Job()
val scope = CoroutineScope(job)
scope.launch{
    // 具體邏輯
}
job.cancel()

我們首先建立了一個Job物件,然後傳入了CoroutineScope()函式,就可以隨便呼叫它的launch函式建立協程了。這樣想要關閉的話,直接使用job.cancel()就可以關閉所有協程了。

更多用法——async

上面的學習,我們已經知道了launch函式可以建立一個新協程,但launch函式並不能獲取執行的結果。因為他的返回值永遠是一個Job物件,那麼有沒有什麼辦法可以建立新協程並且獲取結果呢?可以使用async函式。

async函式必須在協程作用域使用,它會建立一個新的子協程,並返回一個Deferred物件,我們想獲取async函式程式碼塊的執行結果,只需要呼叫Deferred物件的await()函式即可。

fun main(){
    runBlocking{
        val result = async{
            5 + 5
        }.await()
        println(result)
    }
}

執行可以看到,我們獲得了結果。

但async函式還不止於此,事實上,在呼叫了async函式後,程式碼塊中的程式碼就會立即開始執行,當呼叫await()方法時,如果程式碼塊中程式碼還沒執行完,那麼await()方法會將當前協程阻塞住,直到可以獲得async函式的結果。

更多用法——withContext

withContext()是一個掛起函式,大致可以理解為async函式的一種簡化版寫法:

fun main(){
    runBlocking{
        val result = withContext(Dispatchers.Default){
            5 + 5
        }
        println(result)
    }
}

呼叫withContext()函式後,會立即執行程式碼塊中的程式碼,同時將當前執行緒阻塞住,當代碼塊中的程式碼執行完後,會將最後一行的執行結果作為返回值返回。不同的是,withContext()函式強制我們傳入一個執行緒引數。

我們已經瞭解到,協程是一種輕量級的執行緒,但這並不意味著我們不再需要執行緒了。Android中要求網路請求必須在子執行緒執行,即便開啟了協程,如果是在主執行緒中的協程,那麼程式依然會出錯。這時候我們就需要為協程指定一個具體的執行執行緒。

執行緒引數主要有三種值可選:Dispatchers.Default,Dispatchers.IO和Dispatchers.Main,Default表示一種預設的低併發的執行緒策略,當你執行的程式碼屬於計算密集型任務時,開啟過高的併發反而可能會影響任務的執行效率,此時就可以利用Dispatchers.Default。Dispatchers.IO表示會使用一種較高併發的執行緒策略,當你要執行的程式碼大多數時候處在阻塞或等待時,比如說網路請求,就可以使用這個執行緒。Dispatchers.Main表示不會開啟子執行緒,而是在Android主執行緒中執行。當然這個值只能在Android專案中使用,純Kotlin專案會報錯。

實際上,我們剛才使用的函式,除了coroutineScope函式外,都可以指定執行緒引數的,不過withContext()函式是強制要求的。

簡化回撥寫法

我們早就學過使用Retrofit進行網路請求了。但回撥機制大部分都是依靠匿名類實現的,用起來比較繁瑣。使用Kotlin的協程機制,使用suspendCoroutine函式就能大幅度簡化寫法。

suspendCoroutine函式必須在協程作用域或掛起函式才可以呼叫,接收一個Lambda表示式引數,主要作用是將當前協程立即掛起,然後在一個普通的執行緒中執行Lambda表示式中的程式碼。在Lambda引數列表上會傳入一個Continuation引數,呼叫它的resume()或者resumeWithException()就可以讓協程恢復了。

我們先來定義一個await()函式:

 suspend fun <T> Call<T>.await():T {
        return suspendCoroutine {
            continuation -> enqueue(object :Callback<T>{
            override fun onResponse(call: Call<T>, response: Response<T>) {
                val body = response.body()
                if(body != null){
                    // 請求成功,繼續
                    continuation.resume(body)
                }else{
                    // 請求成功但無結果,繼續
                    continuation.resumeWithException(RuntimeException("response body is null"))
                }
            }
            override fun onFailure(call: Call<T>, t: Throwable) {
                // 請求失敗,返回錯誤資訊
                continuation.resumeWithException(t)
            }
        })
     }
 }

這段程式碼看起來很複雜,解釋一下:首先await()函式是一個掛起函式,然後給他聲明瞭一個泛型T,並將await()函式定義成了Call的擴充套件函式,這樣所有返回值是Call的Retrofit請求都可以直接呼叫await()函數了。

接著,await()函式中使用了suspendCoroutine函式來掛起當前協程,並且由於擴充套件函式的原因,我們現在有了Call物件的上下文,可以直接使用enqueue()方法讓Retrofit函式發起請求。之後我們在其中寫邏輯程式碼即可。這樣,我們要實現一個Retrofit請求會變得極為簡單:

suspend fun getAppData(){
    try{
        val appList = ServiceCreator.create<AppService>().getAppData().await()
        // 處理資料
    }catch(e:Exception){
        // 處理異常
    }
}

這裡的ServiceCreator.create()是我們自己定義的Retrofit建立器:

object ServiceCreator {
    private const val BASE_URL = "填入URL"

    private val retrofit = Retrofit.Builder()
        .baseUrl(BASE_URL)
        .addConverterFactory(GsonConverterFactory.create())
        .build()
    fun <T> create(serviceClass: Class<T>) :T = retrofit.create(serviceClass)
    inline fun <reified T> create():T = create(T::class.java)
}

這樣下來,我們發起網路請求就十分簡單了。

總結

總的來說,我們的Kotlin學習也告一段落了。在這次的協程學習中,我們學習了很多相關的知識,並最後用它簡化了我們的程式碼。最後,希望看到這篇文章的你我路子越走越遠吧。