關鍵詞:Kotlin 協(xié)程 Android Anko
Android 上面使用協(xié)程來替代回調或者 RxJava 實際上是一件非常輕松的事兒,我們甚至可以在更大的范圍內結合 UI 的生命周期做控制協(xié)程的執(zhí)行狀態(tài)~
本文涉及的 MainScope 以及 AutoDispose 源碼:kotlin-coroutines-android
1. 配置依賴
我們曾經提到過,如果在 Android 上做開發(fā),那么我們需要引入
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 的正式版協(xié)程,不過代碼比較簡單,如果需要可以自己改造
implementation "org.jetbrains.anko:anko-coroutines:$anko_version"
簡單來說:
- kotlinx-coroutines-android 這個框架是必選項,主要提供了專屬調度器
- anko-sdk27-coroutines 是可選項,提供了一些 UI 組件更為簡潔的擴展,例如 onClick,但它也有自己的問題,我們后面詳細探討
- anko-coroutines 僅供參考,現(xiàn)階段(2019.4)由于尚未跟進 1.3 正式版協(xié)程,因此在 1.3 之后的版本中盡量不要使用,提供的兩個方法都比較簡單,如果需要,可自行改造使用。
協(xié)程的原理和用法我們已經探討了很多了,關于 Android 上面的協(xié)程使用,我們就只給出幾點實踐的建議。
2. UI 生命周期作用域
Android 開發(fā)經常想到的一點就是讓發(fā)出去的請求能夠在當前 UI 或者 Activity 退出或者銷毀的時候能夠自動取消,我們在用 RxJava 的時候也有過各種各樣的方案來解決這個問題。
2.1 使用 MainScope
協(xié)程有一個很天然的特性能剛夠支持這一點,那就是作用域。官方也提供了 MainScope 這個函數(shù),我們具體看下它的使用方法:
val mainScope = MainScope()
launchButton.setOnClickListener {
mainScope.launch {
log(1)
textView.text = async(Dispatchers.IO) {
log(2)
delay(1000)
log(3)
"Hello1111"
}.await()
log(4)
}
}
我們發(fā)現(xiàn)它其實與其他的 CoroutineScope 用起來沒什么不一樣的地方,通過同一個叫 mainScope 的實例啟動的協(xié)程,都會遵循它的作用域定義,那么 MainScope 的定義時怎樣的呢?
public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)
原來就是 SupervisorJob 整合了 Dispatchers.Main 而已,它的異常傳播是自上而下的,這一點與 supervisorScope 的行為一致,此外,作用域內的調度是基于 Android 主線程的調度器的,因此作用域內除非明確聲明調度器,協(xié)程體都調度在主線程執(zhí)行。因此上述示例的運行結果如下:
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
如果我們在觸發(fā)前面的操作之后立即在其他位置觸發(fā)作用域的取消,那么該作用域內的協(xié)程將不再繼續(xù)執(zhí)行:
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 發(fā)現(xiàn)它可以很方便的控制所有它范圍內的協(xié)程的取消,以及能夠無縫將異步任務切回主線程,這都是我們想要的特性,不過寫法上還是不夠美觀。
官方推薦我們定義一個抽象的 Activity,例如:
abstract class ScopedActivity: Activity(), CoroutineScope by MainScope(){
override fun onDestroy() {
super.onDestroy()
cancel()
}
}
這樣在 Activity 退出的時候,對應的作用域就會被取消,所有在該 Activity 中發(fā)起的請求都會被取消掉。使用時,只需要繼承這個抽象類即可:
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 { ... }
}
}
多數(shù)情況下,Presenter 的方法也會被 Activity 直接調用,因此也可以將 Presenter 的方法生命成 suspend 方法,然后用 coroutineScope 嵌套作用域,這樣 MainScope 被取消后,嵌套的子作用域一樣也會被取消,進而達到取消全部子協(xié)程的目的:
class CoroutinePresenter {
suspend fun getUserData() = coroutineScope {
launch { ... }
}
}
2.3 更友好地為 Activity 提供作用域
抽象類很多時候會打破我們的繼承體系,這對于開發(fā)體驗的傷害還是很大的,因此我們是不是可以考慮構造一個接口,只要 Activity 實現(xiàn)這個接口就可以擁有作用域以及自動取消的能力呢?
首先我們定義一個接口:
interface ScopedActivity {
val scope: CoroutineScope
}
我們有一個樸實的愿望就是希望實現(xiàn)這個接口就可以自動獲得作用域,不過問題來了,這個 scope 成員要怎么實現(xiàn)呢?留給接口實現(xiàn)方的話顯然不是很理想,自己實現(xiàn)吧,又礙于自己是個接口,因此我們只能這樣處理:
interface MainScoped {
companion object {
internal val scopeMap = IdentityHashMap<MainScoped, MainScope>()
}
val mainScope: CoroutineScope
get() = scopeMap[this as Activity]!!
}
接下來的事情就是在合適的實際去創(chuàng)建和取消對應的作用域了,我們接著定義兩個方法:
interface MainScoped {
...
fun createScope(){
//或者改為 lazy 實現(xiàn),即用到時再創(chuàng)建
val activity = this as Activity
scopeMap[activity] ?: MainScope().also { scopeMap[activity] = it }
}
fun destroyScope(){
scopeMap.remove(this as Activity)?.cancel()
}
}
因為我們需要 Activity 去實現(xiàn)這個接口,因此直接強轉即可,當然如果考慮健壯性,可以做一些異常處理,這里作為示例僅提供核心實現(xiàn)。
接下來就是考慮在哪兒完成創(chuàng)建和取消呢?顯然這件事兒用 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 里面注冊一下這個監(jiān)聽了,這個大家都會,我就不給出代碼了。
我們看下如何使用:
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 的讀寫是非線程安全的,因此不要在其他線程試圖去獲取它的值,除非你引入第三方或者自己實現(xiàn)一個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 啟動協(xié)程,意味著 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 啟動的協(xié)程并不會隨著 Activity 的銷毀而被取消,其中的風險需要自己思考清楚。
當然,Anko 會這么做的根本原因在于 OnClickListener 根本拿不到有生命周期加持的作用域。不用 GlobalScope 就無法啟動協(xié)程,怎么辦?結合我們前面給出的例子,其實這個事兒完全有別的解法:
interface MainScoped {
...
fun View.onClickSuspend(handler: suspend CoroutineScope.(v: View) -> Unit) {
setOnClickListener { v ->
scope.launch { handler(v) }
}
}
}
我們在前面定義的 MainScoped 接口中,可以通過 scope 拿到有生命周期加持的 MainScope 實例,那么直接用它啟動協(xié)程來運行 OnClickListener 問題不就解決了嘛。所以這里的關鍵點在于如何拿到作用域。
這樣的 listener 我已經為大家在框架中定義好啦,請參見 2.3。
3.2 協(xié)程版 AutoDisposable
當然除了直接使用一個合適的作用域來啟動協(xié)程之外,我們還有別的辦法來確保協(xié)程及時被取消。
大家一定用過 RxJava,也一定知道用 RxJava 發(fā)了個任務,任務還沒結束頁面就被關閉了,如果任務遲遲不回來,頁面就會被泄露;如果任務后面回來了,執(zhí)行回調更新 UI 的時候也會大概率空指針。
因此大家一定會用到 Uber 的開源框架 AutoDispose。它其實就是利用 View 的 OnAttachStateChangeListener ,當 View 被拿下的時候,我們就取消所有之前用 RxJava 發(fā)出去的請求。
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 無法取消協(xié)程的問題,我們也可以搞一個 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 的實現(xiàn)只要參考 AutoDisposable 的實現(xiàn)依樣畫葫蘆就好了 :
class AutoDisposableJob(private val view: View, private val wrapped: Job)
//我們實現(xiàn)了 Job 這個接口,但沒有直接實現(xiàn)它的方法,而是用 wrapped 這個成員去代理這個接口
: Job by wrapped, OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View?) = Unit
override fun onViewDetachedFromWindow(v: View?) {
//當 View 被移除的時候,取消協(xié)程
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()
}
//協(xié)程執(zhí)行完畢時要及時移除 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 上撤下來的時候,我們的協(xié)程就會收到 cancel 的指令,盡管這種情況下協(xié)程的執(zhí)行不會跟隨 Activity 的 onDestroy 而取消,但它與 View 的點擊事件緊密結合,即便 Activity 沒有被銷毀,View 本身被移除時也會直接將監(jiān)聽中的協(xié)程取消掉。
如果大家想要用這個擴展,我已經幫大家放到 jcenter 啦,直接使用:
api "com.bennyhuo.kotlin:coroutines-android-autodisposable:1.0"
添加到依賴當中即可使用。
4. 合理使用調度器
在 Android 上使用協(xié)程,更多的就是簡化異步邏輯的寫法,使用場景更多與 RxJava 類似。在使用 RxJava 的時候,我就發(fā)現(xiàn)有不少開發(fā)者僅僅用到了它的切線程的功能,而且由于本身 RxJava 切線程 API 簡單易用,還會造成很多無腦線程切換的操作,這樣實際上是不好的。那么使用協(xié)程就更要注意這個問題了,因為協(xié)程切換線程的方式被 RxJava 更簡潔,更透明,本來這是好事情,就怕被濫用。
比較推薦的寫法是,絕大多數(shù) UI 邏輯在 UI 線程中處理,即使在 UI 中用 Dispatchers.Main 來啟動協(xié)程,如果涉及到一些 io 操作,使用 async 將其調度到 Dispatchers.IO 上,結果返回時協(xié)程會幫我們切回到主線程——這非常類似 Nodejs 這樣的單線程的工作模式。
對于一些 UI 不相關的邏輯,例如批量離線數(shù)據(jù)下載任務,通常默認的調度器就足夠使用了。
5. 小結
這一篇文章,主要是基于我們前面講了的理論知識,進一步往 Android 的具體實戰(zhàn)角度遷移,相比其他類型的應用,Android 作為 UI 程序最大的特點就是異步要協(xié)調好 UI 的生命周期,協(xié)程也不例外。一旦我們把協(xié)程的作用域規(guī)則以及協(xié)程與 UI 生命周期的關系熟稔于心,那么相信大家使用協(xié)程時一定會得心應手的。
歡迎關注 Kotlin 中文社區(qū)!
中文官網:https://www.kotlincn.net/
中文官方博客:https://www.kotliner.cn/
公眾號:Kotlin
知乎專欄:Kotlin
CSDN:Kotlin中文社區(qū)