破解 Kotlin 協程(8) - Android 篇

關鍵詞:Kotlin 協程 Android Ankojava

Android 上面使用協程來替代回調或者 RxJava 其實是一件很是輕鬆的事兒,咱們甚至能夠在更大的範圍內結合 UI 的生命週期作控制協程的執行狀態~android

本文涉及的 MainScope 以及 AutoDispose 源碼:kotlin-coroutines-androidgit

1. 配置依賴

咱們曾經提到過,若是在 Android 上作開發,那麼咱們須要引入github

implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutine_version'
複製代碼

這個框架裏面包含了 Android 專屬的 Dispatcher,咱們能夠經過 Dispatchers.Main 來拿到這個實例;也包含了 MainScope,用於與 Android 做用域相結合。api

Anko 也提供了一些比較方便的方法,例如 onClick 等等,若是須要,也能夠引入它的依賴:安全

//提供 onClick 相似的便捷的 listener,接收 suspend Lambda 表達式
implementation "org.jetbrains.anko:anko-sdk27-coroutines:$anko_version"
//提供 bg 、asReference,還沒有沒有跟進 kotlin 1.3 的正式版協程,不過代碼比較簡單,若是須要能夠本身改造
implementation "org.jetbrains.anko:anko-coroutines:$anko_version"
複製代碼

簡單來講:bash

  • kotlinx-coroutines-android 這個框架是必選項,主要提供了專屬調度器
  • anko-sdk27-coroutines 是可選項,提供了一些 UI 組件更爲簡潔的擴展,例如 onClick,但它也有本身的問題,咱們後面詳細探討
  • anko-coroutines 僅供參考,現階段(2019.4)因爲還沒有跟進 1.3 正式版協程,所以在 1.3 以後的版本中儘可能不要使用,提供的兩個方法都比較簡單,若是須要,可自行改造使用。

協程的原理和用法咱們已經探討了不少了,關於 Android 上面的協程使用,咱們就只給出幾點實踐的建議。app

2. UI 生命週期做用域

Android 開發常常想到的一點就是讓發出去的請求可以在當前 UI 或者 Activity 退出或者銷燬的時候可以自動取消,咱們在用 RxJava 的時候也有過各類各樣的方案來解決這個問題。框架

2.1 使用 MainScope

協程有一個很自然的特性能剛夠支持這一點,那就是做用域。官方也提供了 MainScope 這個函數,咱們具體看下它的使用方法:異步

val mainScope = MainScope()
launchButton.setOnClickListener {
    mainScope.launch {
        log(1)
        textView.text = async(Dispatchers.IO) {
            log(2)
            delay(1000)
            log(3)
            "Hello1111"
        }.await()
        log(4)
    }
}
複製代碼

咱們發現它其實與其餘的 CoroutineScope 用起來沒什麼不同的地方,經過同一個叫 mainScope 的實例啓動的協程,都會遵循它的做用域定義,那麼 MainScope 的定義時怎樣的呢?

public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)
複製代碼

原來就是 SupervisorJob 整合了 Dispatchers.Main 而已,它的異常傳播是自上而下的,這一點與 supervisorScope 的行爲一致,此外,做用域內的調度是基於 Android 主線程的調度器的,所以做用域內除非明確聲明調度器,協程體都調度在主線程執行。所以上述示例的運行結果以下:

2019-04-29 06:51:00.657 D: [main] 1
2019-04-29 06:51:00.659 D: [DefaultDispatcher-worker-1] 2
2019-04-29 06:51:01.662 D: [DefaultDispatcher-worker-2] 3
2019-04-29 06:51:01.664 D: [main] 4
複製代碼

若是咱們在觸發前面的操做以後當即在其餘位置觸發做用域的取消,那麼該做用域內的協程將再也不繼續執行:

val mainScope = MainScope()

launchButton.setOnClickListener {
    mainScope.launch {
        ...
    }
}

cancelButton.setOnClickListener {
    mainScope.cancel()
    log("MainScope is cancelled.")
}
複製代碼

若是咱們快速依次點擊上面的兩個按鈕,結果就顯而易見了:

2019-04-29 07:12:20.625 D: [main] 1
2019-04-29 07:12:20.629 D: [DefaultDispatcher-worker-2] 2
2019-04-29 07:12:21.046 D: [main] MainScope is cancelled.
複製代碼

2.2 構造帶有做用域的抽象 Activity

儘管咱們前面體驗了 MainScope 發現它能夠很方便的控制全部它範圍內的協程的取消,以及可以無縫將異步任務切回主線程,這都是咱們想要的特性,不過寫法上仍是不夠美觀。

官方推薦咱們定義一個抽象的 Activity,例如:

abstract class ScopedActivity: Activity(), CoroutineScope by MainScope(){
    override fun onDestroy() {
        super.onDestroy()
        cancel()
    }
}
複製代碼

這樣在 Activity 退出的時候,對應的做用域就會被取消,全部在該 Activity 中發起的請求都會被取消掉。使用時,只須要繼承這個抽象類便可:

class CoroutineActivity : ScopedActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_coroutine)
        launchButton.setOnClickListener {
            launch { // 直接調用 ScopedActivity 也就是 MainScope 的方法
                ...
            }
        }
    }
    
    suspend fun anotherOps() = coroutineScope {
        ...
    }
}
複製代碼

除了在當前 Activity 內部得到 MainScope 的能力外,還能夠將這個 Scope 實例傳遞給其餘須要的模塊,例如 Presenter 一般也須要與 Activity 保持一樣的生命週期,所以必要時也能夠將該做用域傳遞過去:

class CoroutinePresenter(private val scope: CoroutineScope): CoroutineScope by scope{
    fun getUserData(){
        launch { ... }
    }
}
複製代碼

多數狀況下,Presenter 的方法也會被 Activity 直接調用,所以也能夠將 Presenter 的方法生命成 suspend 方法,而後用 coroutineScope 嵌套做用域,這樣 MainScope 被取消後,嵌套的子做用域同樣也會被取消,進而達到取消所有子協程的目的:

class CoroutinePresenter {
    suspend fun getUserData() = coroutineScope {
        launch { ... }
    }
}
複製代碼

2.3 更友好地爲 Activity 提供做用域

抽象類不少時候會打破咱們的繼承體系,這對於開發體驗的傷害仍是很大的,所以咱們是否是能夠考慮構造一個接口,只要 Activity 實現這個接口就能夠擁有做用域以及自動取消的能力呢?

首先咱們定義一個接口:

interface ScopedActivity {
    val scope: CoroutineScope
}
複製代碼

咱們有一個樸實的願望就是但願實現這個接口就能夠自動得到做用域,不過問題來了,這個 scope 成員要怎麼實現呢?留給接口實現方的話顯然不是很理想,本身實現吧,又礙於本身是個接口,所以咱們只能這樣處理:

interface MainScoped {
    companion object {
        internal val scopeMap = IdentityHashMap<MainScoped, MainScope>()
    }
    val mainScope: CoroutineScope
        get() = scopeMap[this as Activity]!!
}
複製代碼

接下來的事情就是在合適的實際去建立和取消對應的做用域了,咱們接着定義兩個方法:

interface MainScoped {
    ...
    fun createScope(){
        //或者改成 lazy 實現,即用到時再建立
        val activity = this as Activity
        scopeMap[activity] ?: MainScope().also { scopeMap[activity] = it }
    }

    fun destroyScope(){
        scopeMap.remove(this as Activity)?.cancel()
    }
}
複製代碼

由於咱們須要 Activity 去實現這個接口,所以直接強轉便可,固然若是考慮健壯性,能夠作一些異常處理,這裏做爲示例僅提供核心實現。

接下來就是考慮在哪兒完成建立和取消呢?顯然這件事兒用 Application.ActivityLifecycleCallbacks 最合適不過了:

class ActivityLifecycleCallbackImpl: Application.ActivityLifecycleCallbacks {
    ...
    override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
        (activity as? MainScoped)?.createScope()
    }

    override fun onActivityDestroyed(activity: Activity) {
        (activity as? MainScoped)?.destroyScope()
    }
}
複製代碼

剩下的就是在 Application 裏面註冊一下這個監聽了,這個你們都會,我就不給出代碼了。

咱們看下如何使用:

class CoroutineActivity : Activity(), MainScoped {
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        launchButton.setOnClickListener {            
            scope.launch {
                ...
            }
        }
    }
}
複製代碼

咱們也能夠增長一些有用的方法來簡化這個操做:

interface MainScoped {
    ...
    fun <T> withScope(block: CoroutineScope.() -> T) = with(scope, block)
}
複製代碼

這樣在 Activity 當中還能夠這樣寫:

withScope {
    launch { ... }
}   
複製代碼

注意,示例當中用到了 IdentityHashMap,這代表對於 scope 的讀寫是非線程安全的,所以不要在其餘線程試圖去獲取它的值,除非你引入第三方或者本身實現一個 IdentityConcurrentHashMap,即使如此,從設計上 scope 也不太應該在其餘線程訪問。

按照這個思路,我提供了一套更加完善的方案,不只支持 Activity 還支持 support-fragment 版本在 25.1.0 以上的版本的 Fragment,而且相似於 Anko 提供了一些有用的基於 MainScope 的 listener 擴展,引入這個框架便可使用:

api 'com.bennyhuo.kotlin:coroutines-android-mainscope:1.0'
複製代碼

3. 謹慎使用 GlobalScope

3.1 GlobalScope 存在什麼問題

咱們以前作例子常用 GlobalScope,但 GlobalScope 不會繼承外部做用域,所以你們使用時必定要注意,若是在使用了綁定生命週期的 MainScope 以後,內部再使用 GlobalScope 啓動協程,意味着 MainScope 就不會起到應有的做用。

這裏須要當心的是若是使用了一些沒有依賴做用域的構造器,那麼必定要當心。例如 Anko 當中的 onClick 擴展:

fun View.onClick( context: CoroutineContext = Dispatchers.Main, handler: suspend CoroutineScope.(v: View) -> Unit
) {
    setOnClickListener { v ->
        GlobalScope.launch(context, CoroutineStart.DEFAULT) {
            handler(v)
        }
    }
}
複製代碼

也許咱們也就是圖個方便,畢竟 onClick 寫起來可比 setOnClickListener 要少不少字符,同時名稱上看也更加有事件機制的味道,但隱藏的風險就是經過 onClick 啓動的協程並不會隨着 Activity 的銷燬而被取消,其中的風險須要本身思考清楚。

固然,Anko 會這麼作的根本緣由在於 OnClickListener 根本拿不到有生命週期加持的做用域。不用 GlobalScope 就沒法啓動協程,怎麼辦?結合咱們前面給出的例子,其實這個事兒徹底有別的解法:

interface MainScoped {
    ...
    fun View.onClickSuspend(handler: suspend CoroutineScope.(v: View) -> Unit) {
        setOnClickListener { v ->
            scope.launch {   handler(v)   }
        }
    }
}
複製代碼

咱們在前面定義的 MainScoped 接口中,能夠經過 scope 拿到有生命週期加持的 MainScope 實例,那麼直接用它啓動協程來運行 OnClickListener 問題不就解決了嘛。因此這裏的關鍵點在於如何拿到做用域。

這樣的 listener 我已經爲你們在框架中定義好啦,請參見 2.3。

3.2 協程版 AutoDisposable

固然除了直接使用一個合適的做用域來啓動協程以外,咱們還有別的辦法來確保協程及時被取消。

你們必定用過 RxJava,也必定知道用 RxJava 發了個任務,任務還沒結束頁面就被關閉了,若是任務遲遲不回來,頁面就會被泄露;若是任務後面回來了,執行回調更新 UI 的時候也會大機率空指針。

所以你們必定會用到 Uber 的開源框架 AutoDispose。它其實就是利用 ViewOnAttachStateChangeListener ,當 View 被拿下的時候,咱們就取消全部以前用 RxJava 發出去的請求。

static final class Listener extends MainThreadDisposable implements View.OnAttachStateChangeListener {
    private final View view;
    private final CompletableObserver observer;

    Listener(View view, CompletableObserver observer) {
      this.view = view;
      this.observer = observer;
    }

    @Override public void onViewAttachedToWindow(View v) { }

    @Override public void onViewDetachedFromWindow(View v) {
      if (!isDisposed()) {
      //看到沒看到沒看到沒?
        observer.onComplete();
      }
    }

    @Override protected void onDispose() {
      view.removeOnAttachStateChangeListener(this);
    }
  }
複製代碼

考慮到前面提到的 Anko 擴展 onClick 沒法取消協程的問題,咱們也能夠搞一個 onClickAutoDisposable

fun View.onClickAutoDisposable ( context: CoroutineContext = Dispatchers.Main, handler: suspend CoroutineScope.(v: View) -> Unit
) {
    setOnClickListener { v ->
        GlobalScope.launch(context, CoroutineStart.DEFAULT) {
            handler(v)
        }.asAutoDisposable(v)
    }
}
複製代碼

咱們知道 launch 會啓動一個 Job,所以咱們能夠經過 asAutoDisposable 來將其轉換成支持自動取消的類型:

fun Job.asAutoDisposable(view: View) = AutoDisposableJob(view, this)
複製代碼

那麼 AutoDisposableJob 的實現只要參考 AutoDisposable 的實現依樣畫葫蘆就行了 :

class AutoDisposableJob(private val view: View, private val wrapped: Job)
    //咱們實現了 Job 這個接口,但沒有直接實現它的方法,而是用 wrapped 這個成員去代理這個接口
     : Job by wrapped, OnAttachStateChangeListener {
    override fun onViewAttachedToWindow(v: View?) = Unit

    override fun onViewDetachedFromWindow(v: View?) {
        //當 View 被移除的時候,取消協程
        cancel()
        view.removeOnAttachStateChangeListener(this)
    }

    private fun isViewAttached() =
            Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT && view.isAttachedToWindow || view.windowToken != null

    init {
        if(isViewAttached()) {
            view.addOnAttachStateChangeListener(this)
        } else {
            cancel()
        }

        //協程執行完畢時要及時移除 listener 省得形成泄露
        invokeOnCompletion() {
            view.removeOnAttachStateChangeListener(this)
        }
    }
}
複製代碼

這樣的話,咱們就可使用這個擴展了:

button.onClickAutoDisposable{
    try {
        val req = Request()
        val resp = async { sendRequest(req) }.await()
        updateUI(resp)
    } catch (e: Exception) {
        e.printStackTrace()
    }
}
複製代碼

button 這個對象從 window 上撤下來的時候,咱們的協程就會收到 cancel 的指令,儘管這種狀況下協程的執行不會跟隨 ActivityonDestroy 而取消,但它與 View 的點擊事件緊密結合,即使 Activity 沒有被銷燬,View 自己被移除時也會直接將監聽中的協程取消掉。

若是你們想要用這個擴展,我已經幫你們放到 jcenter 啦,直接使用:

api "com.bennyhuo.kotlin:coroutines-android-autodisposable:1.0"
複製代碼

添加到依賴當中便可使用。

4. 合理使用調度器

在 Android 上使用協程,更多的就是簡化異步邏輯的寫法,使用場景更多與 RxJava 相似。在使用 RxJava 的時候,我就發現有很多開發者僅僅用到了它的切線程的功能,並且因爲自己 RxJava 切線程 API 簡單易用,還會形成不少無腦線程切換的操做,這樣其實是很差的。那麼使用協程就更要注意這個問題了,由於協程切換線程的方式被 RxJava 更簡潔,更透明,原本這是好事情,就怕被濫用。

比較推薦的寫法是,絕大多數 UI 邏輯在 UI 線程中處理,即便在 UI 中用 Dispatchers.Main 來啓動協程,若是涉及到一些 io 操做,使用 async 將其調度到 Dispatchers.IO 上,結果返回時協程會幫咱們切回到主線程——這很是相似 Nodejs 這樣的單線程的工做模式。

對於一些 UI 不相關的邏輯,例如批量離線數據下載任務,一般默認的調度器就足夠使用了。

5. 小結

這一篇文章,主要是基於咱們前面講了的理論知識,進一步往 Android 的具體實戰角度遷移,相比其餘類型的應用,Android 做爲 UI 程序最大的特色就是異步要協調好 UI 的生命週期,協程也不例外。一旦咱們把協程的做用域規則以及協程與 UI 生命週期的關係熟稔於心,那麼相信你們使用協程時必定會駕輕就熟的。


歡迎關注 Kotlin 中文社區!

中文官網:www.kotlincn.net/

中文官方博客:www.kotliner.cn/

公衆號:Kotlin

知乎專欄:Kotlin

CSDN:Kotlin中文社區

掘金:Kotlin中文社區

簡書:Kotlin中文社區

相關文章
相關標籤/搜索