關鍵詞:Kotlin 協程 Android Anko

Android 上面使用協程來替代回調或者 RxJava 實際上是一件非常輕鬆的事兒,我們甚至可以在更大的範圍內結合 UI 的生命周期做控制協程的執行狀態~

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

1. 配置依賴

我們曾經提到過,如果在 Android 上做開發,那麼我們需要引入

implementation org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutine_version

這個框架裡面包含了 Android 專屬的 Dispatcher,我們可以通過 Dispatchers.Main 來拿到這個實例;也包含了 MainScope,用於與 Android 作用域相結合。

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"

簡單來說:

  • kotlinx-coroutines-android 這個框架是必選項,主要提供了專屬調度器
  • anko-sdk27-coroutines 是可選項,提供了一些 UI 組件更為簡潔的擴展,例如 onClick,但它也有自己的問題,我們後面詳細探討
  • anko-coroutines 僅供參考,現階段(2019.4)由於尚未跟進 1.3 正式版協程,因此在 1.3 之後的版本中盡量不要使用,提供的兩個方法都比較簡單,如果需要,可自行改造使用。

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

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 中文社區!

中文官網:Kotlin 語言中文站

中文官方博客:Kotlin Blog - Kotlin 中文博客

公眾號:Kotlin

知乎專欄:Kotlin

CSDN:Kotlin中文社區

掘金:Kotlin中文社區

簡書:Kotlin中文社區


推薦閱讀:
相关文章