Kotlin 協程 的實戰

1,前言

一轉眼kotlin已經轉正兩年多,KT的各種語法糖、高階函數、擴展函數等等。真的是讓人愛不釋手。一點都不吹牛逼,剛開始用Kotlin的時候,我完全不知道 協程 這個概念。後來記得有個朋友問我說:你知道協程嗎?我說我肯定知道啊,我從上大學的時候就一直在用,買車票什麼的都是用他買的啊。比12306好用。


他來了個很無奈的表情說:不是說你買票的那個攜程,是Kotlin的協程,協程!!協程!!協程。
我心想:What?還有這個東西嗎。我就回了他說:老子不知道,你能把我咋地。雖說嘴很硬,但是心裏虛啊,趕緊打開瀏覽器,搜索框輸入了: **“協程”**兩個字,很用力按下了回車,走你。
哎喲,臥槽,第一個出來的還真就是攜程網的廣告 “放心價格,放心服務”斜眼笑~~~~。不信的可以百度試一下,哈哈

2,瞭解

查閱了一番資料過後,才發現 協程 這個概念好多年前就已經有了,近幾年才廣泛使用起來,阿里也是開源了好幾個協程相關的框架,聽說淘寶就是用協程來渡過雙十一的,之前做項目一直用的Java,Java並沒有提供對協程的直接支持,對這個概念一直沒有過接觸,像Go、Python都是提供了對協程直接支持的。當然了,今天的主角,Kotlin也是提供了對 協程支持的。
我們暫時理解爲:他跟線程差不多,線程的調度是基於CPU算法和優先級,還是要跟底層打交道的,協程是完全由應用程序來調用的,但是他還是要基於線程來運行的。他比線程更經量,開銷很小。

3,實戰

光說不練假把戲,經過一些嘗試後,分享一下我在項目中的使用,說到異步呢,在我們Android程序中最常用的就是網絡請求吧,UI線程不能進行耗時操作,我們會把網絡請求、文件讀寫這些耗時操作放在子線程中,現在我們可以用協程來實現。

3.1網絡請求

說到網絡請求就要說到我們的網紅庫 Retrofit,好多項目中都是用RxJava+Retrofit來進行網絡請求,自從開始使用協程,也放棄了使用RxJava,在Retrofit 2.6 之前。想用協程配合Retrofit來進行網絡請求,我們的請求結果還要做一次轉換,對此呢,我們Android界的大咖 JakeWharton還專門寫了個庫 retrofit2-kotlin-coroutines-adapter 來做轉換,有興趣的可以看一下。不過,Retrofit 2.6 之後,直接對kotlin 的協程做了支持,也不需要用到這個庫了。我們來看一下實際代碼,依然使用鴻大大的WanAndroid API來做例子。
比如我們要獲取Banner圖片 我們的 XXService:

     /**
     * 玩安卓輪播圖
     */
    @GET("banner/json")
    suspend fun getBanner(): BaseResult<List<BannerBean>>

和之前我們寫的有什麼區別呢:

  1. 前面多了suspend關鍵字,帶有這個關鍵字的函數,只有在協程中才能調用,在普通函數調用會報錯的,編譯也過不了
  2. 返回結果只直接寫對應的Bean就好了,不需要固定類型來包裝

下邊的用法是在ViewModel中來使用的,如果想在Activity或者Fragment中使用,是一樣的,只不過啓動協程的時候寫法有些不同。
下面在我們的VIewModel中:

    private val repository by lazy {
        RetrofitClient.getInstance().create(HomeService::class.java)
    }

     fun getBanner() {
        viewModelScope.launch {
            val result = repository.getBannerData()
            if (result.errorCode == 0) {
                LogUtils.d(result.data)
            }
        }
    }

這樣一個簡單的網絡請求就完成,viewModelScope.launch {} 這個就是在ViewModel中啓動一個協程,他會在ViewModel銷燬的時候,自動取消他自己和在他內部啓動的所有協程 相對於RxJava來說,我們每次都要關心生命週期防止內存泄露,是不是加方便些呢,這樣我們不用關心內存泄露的問題了。所以我們要啓動子協程,都要寫在他內部,除非有特殊需求,比如頁面銷燬了,要做些其他工作。否則都儘量在他內部啓動。
好了,我們再看上面的代碼,會發現有個問題,**viewModelScope.launch {}**是直接啓動在主線程的,所以協程也會運行在主線程中,那我們怎麼能讓網絡請求去影響到UI呢,絕對不能忍。我們可以在啓動一個子協程讓他運行在IO線程上。修改如下:

       viewModelScope.launch {
            val result = withContext(Dispatchers.IO) { homeRepository.getBanner() }
            if (result.errorCode == 0) {
                LogUtils.d(result.data)
            }
        }

這下就正常了,是不是相當方便,代碼也清晰了很多,既然有們都要在viewModelScope.launch {} 中啓動協程我們就把他再封裝一下做一優化吧,順便加上錯誤處理,我們在BaseViewModel中加入方法:

    // 之後我們 全部在 launchUI 中啓動協程
    fun launchUI(block: suspend CoroutineScope.() -> Unit) {
        viewModelScope.launch { block() }  
    }
 //....
    /**
    * 錯誤處理
    **/
    fun launch(
        block: suspend CoroutineScope.() -> Unit,
        error: suspend CoroutineScope.(Throwable) -> Unit = {},
        complete: suspend CoroutineScope.() -> Unit = {}
    ) {
        launchUI {
            try {
                block()
            } catch (e: Throwable) {
                error(e)
            } finally {
                complete()
            }
        }
    }

那我們的VIewModel中的getBanner方法這樣寫就好了:

    fun getBanner() {
        launch({
            val result = repository.getBanner()
            if (result.errorCode == 0) {
                LogUtils.d(result.data)
            }
        })

       // 如果要處理error,如下
         /*launch({
            val result = repository.getBanner()
            if (result.errorCode == 0) {LogUtils.d(result.data)}
        }, {
            //處理error
            LogUtils.d(it.message)
        })*/
    }

又有小夥伴說了,那我想把code不等於0的時候全拋出錯誤,統一處理怎麼辦?
那我們就再封裝一下,在BaseView中加入:
我們把統一異常處理先抽出來:

     /**
     * 異常統一處理
     */
    private suspend fun <T> handleException(
        block: suspend CoroutineScope.() -> BaseResult<T>,
        success: suspend CoroutineScope.(BaseResult<T>) -> Unit,
        error: suspend CoroutineScope.(ResponseThrowable) -> Unit,
        complete: suspend CoroutineScope.() -> Unit
    ) {
        coroutineScope {
            try {
                success(block())
            } catch (e: Throwable) {
                error(ExceptionHandle.handleException(e))
            } finally {
                complete()
            }
        }
    }

然後再寫一個 executeResponse 方法來過濾:

    /**
     * 請求結果過濾
     */
    private suspend fun <T> executeResponse(
        response: BaseResult<T>,
        success: suspend CoroutineScope.(T) -> Unit
    ) {
        coroutineScope {
            if (response.errorCode == 0 ) success(response.data)
            else throw ResponseThrowable(response.errorCode, response.errorMsg)
        }
    }

最後我們再寫一個 launchOnlyresult 方法把他們結合起來:

    fun <T> launchOnlyresult(
        block: suspend CoroutineScope.() -> BaseResult<T>,
        success: (T) -> Unit,
        error: (ResponseThrowable) -> Unit = { },
        complete: () -> Unit = {}
    ) {
       launchUI {
            handleException(
                { withContext(Dispatchers.IO) { block() } },
                { res ->
                    executeResponse(res) { success(it) }
                },
                {
                    error(it)
                },
                {
                    complete()
                }
            )
        }
    }

異常類的代碼就不貼了,沒什麼好說的,末尾會給Demo地址,在裏面看吧,現在我們獲取Banner數據就變成這樣了:

     fun getBanner() {
        launchOnlyresult({ repository.getBanner() }, {
              LogUtils.d(it)  // it是Banner 數據 
        })
     // 處理Error 
        /*launchOnlyresult({ homeRepository.getBannerData() }, {
            mBanners.value = it
        },{
            LogUtils.d(it.errMsg)
        })*/
    }

我們的一個請求已經可以簡單成這個樣子了,相比於用RxJava的方式是不是更舒服呢。說到這裏有的兄弟可能就說了,單個網絡請求確實很簡單,但是如果多個呢?還有些請求要依賴其他請求的結果呢?我們在業務邏輯越來越複雜,RxJava有多種操作符來使用,你這個要怎麼搞?
你以爲我協程只能幹這點事嗎,太小看我了,帶着這些問題我們再來說下協程的另一個東西 Flow 異步流

3.2 Flow

帶着上面的問題我們看下Flow 能幹什麼,看着名字可能有些陌生,但是我們瞭解之後肯定又會非常熟悉。他翻譯成中文是 意思,我們在協程中,做異步可以返回一個值,當我們想返回多個值的時候,Flow就開始展現他的作用了,我們看下具體使用場景:
我們看玩安卓的 導航數據項目列表數據 兩個接口,獲取項目列表的時候需要用到,導航數據接口裏邊的 id,我們來用Flow實現
首先是Servie:

    /**
     * 導航數據
     */
    @GET("project/tree/json")
    suspend fun naviJson(): BaseResult<List<NavTypeBean>>

    /**
     * 項目列表
     * @param page 頁碼,從0開始
     */
    @GET("project/list/{page}/json")
    suspend fun getProjectList(@Path("page") page: Int, @Query("cid") cid: Int): BaseResult<HomeListBean>

ViewModel中的實現:

    @ExperimentalCoroutinesApi
    @FlowPreview
    fun getFirstData() {
       launchUI {
            flow { emit(homeRepository.getNaviJson()) }
                .flatMapConcat {
                    return@flatMapConcat if (it.isSuccess()) {
                        // 業務操作 ....
                        // LogUtils.d(it)  // it 是BaseResult<List<NavTypeBean>>
                        // ...
                        flow { emit(homeRepository.getProjectList(page, it.data[0].id)) }
                    } else throw ResponseThrowable(it.errorCode, it.errorMsg)
                }.onStart{
                    // 會在 emit 發射之前調用 
                }
                .flowOn(Dispatchers.IO) // 這個是指煩氣發射的所在協程
                .onCompletion { 
                    // 流執行完畢會調用
                }
                .catch { 
                    // 遇到錯誤時會調用
                }
                .collect { 
                    // 收集 ,FLow只有在我們
                    LogUtils.d(it)  // it 是BaseResult<HomeListBean>
                }

        }
    }

有的兄弟可能看到上邊代碼會說,似曾相識啊,沒錯跟RxJava是一個思想,Flow只能運行在協程中,上邊的代碼優化過後:是這個樣子的:

    @ExperimentalCoroutinesApi
    @FlowPreview
    fun getFirstData() {
        launchUI {
            launchFlow { homeRepository.getNaviJson() }
                .flatMapConcat {
                    return@flatMapConcat if (it.isSuccess()) {
                        navData.addAll(it.data)
                        it.data.forEach { item -> navTitle.add(item.name) }
                        launchFlow { homeRepository.getProjectList(page, it.data[0].id) }
                    } else throw ResponseThrowable(it.errorCode, it.errorMsg)
                }
                .onStart { defUI.showDialog.postValue(null) }
                .flowOn(Dispatchers.IO)
                .onCompletion { defUI.dismissDialog.call() }
                .catch {
                    // 錯誤處理
                    val err = ExceptionHandle.handleException(it)
                    LogUtils.d("${err.code}: ${err.errMsg}")
                }
                .collect {
                    if (it.isSuccess()) items.addAll(it.data.datas)
                }
        }

    }

Demo中使用了LiveData 更新數據,如果把所有東西都貼出來實在有點多,只放了部分代碼。簡單介紹了一下。來簡單說下這些操作符的作用吧:

  • flow:構建器,他可以發射數據多個數據,用**emit()**來發射
  • flatMapConcat :這個是在一個流收集完成之後,再收集下一個流
  • onStart:這個看名字估計也能猜出來,就是在發射之前做一些事情,我們可以在這裏再 emit()一個數據,他會在flow裏邊的數據發射之前發射,我們上邊的例子,是在OnStart裏邊打開了等待框
  • flowOn:這個就是指定我們的流運行在那個協程裏邊,我們指定的是 Dispatchers.IO
  • onCompletion :是在所有流都收集完成了,就會觸發,我們可以在這裏取消等待框再合適不過了
  • catch:這個就是遇到錯誤的時候會觸發,我們我錯誤處理就是在這裏來做了
  • collect:這個就是收集器的意思,我們的結果都在這裏來處理。也只有我們調用了這個收集方法,數據才真正的開始發射了,這也是官方說的一句話,流是冷的,就是這個意思

臥槽,無情,這TM明明就跟RxJava是孿生兄弟啊,你說的沒錯,FLow,他還有好多操作符供我們使用。比如 :
zip 合併流
flatMapMerge 讓流併發進行
transform 轉換操作符
在這裏就不一 一列舉了。
他還提供了 轉換成 響應式流 Reactive Streams(RxJava)的方法。
相信熟悉RxJava的你,分分鐘鍾就可以上手的

3.2其他小例子

3.2.1 驗證碼倒計時

有了上面的介紹,我們對協程肯定有了或多或少的瞭解,我在公司項的新項目中也已經開始使用了,再分享個小例子,新項目中一第一個做的就是登錄功能,既然是登錄就少不了驗證碼倒計時,我們用 協程+LiveData 來實現他:

 @ExperimentalCoroutinesApi
 fun getSmsCode(phone: String) {
     viewModelScope.launch {
            flow {
                (60 downTo 0).forEach {
                    delay(1000)
                    emit("$it s")
                }
            }.flowOn(Dispatchers.Default)
                .onStart {
                     // 倒計時開始 ,在這裏可以讓Button 禁止點擊狀態
                }
                .onCompletion {
                    // 倒計時結束 ,在這裏可以讓Button 恢復點擊狀態
                }
                .collect {
                    // 在這裏 更新LiveData 的值來顯示到UI
                    smsCode.value = it
                }
        }
}

這裏用了一個delay(1000),他和線程的sleep() 類似,但是他是非阻塞的,他不會阻塞線程運行,但他會讓協程進入等待狀態,我們上面的代碼就是每隔一秒,發射一個值,用LiveData去更新Button的文字顯示。 這樣一個倒計時功能就實現了。

最後

打個小小小廣告吧,我封裝的一個基於MVVM的快速開發框架,上面的代碼都是用的裏邊的例子,Demo也是用的鴻大大的 WanAndroid 接口。
偷偷告訴你,公司的一個新型小項目,我已經開始用了。
Demo地址: https://github.com/AleynP/MVVMLin
有興趣的可以看一下,我現在也是處於學習的過程,有不足的地方還望多多指點,我們共同進步。

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