在 View 上使用掛起函數(shù)

image

Kotlin 協(xié)程 讓我們可以用同步代碼來(lái)建立異步問(wèn)題的模型。這是非常好的特性,但是目前大部分用例都專注于 I/O 任務(wù)或是并發(fā)操作。其實(shí)協(xié)程不僅在處理跨線程的問(wèn)題有優(yōu)勢(shì),還可以用來(lái)處理同一線程中的異步問(wèn)題。

我認(rèn)為有一個(gè)地方可以真正從中受益,那就是在 Android 視圖系統(tǒng)中使用協(xié)程。

Android 視圖 ?? 回調(diào)

Android 視圖系統(tǒng)中尤其熱衷于使用回調(diào): 目前在 Android Framework 中,view 和 widgets 類中的回調(diào)有 80+ 個(gè),在 Jetpack 中回調(diào)的數(shù)目更是超過(guò)了 200 個(gè) (這里也包含了沒(méi)有界面的依賴庫(kù))。

最常見(jiàn)的用法有以下幾項(xiàng):

然后還有一些通過(guò)接受 Runnable 來(lái)執(zhí)行異步操作的API,比如 View.post()、View.postDelayed() 等等。

正是因?yàn)?Android 上的 UI 編程從根本上就是異步的,所以造成了如此之多的回調(diào)。從測(cè)量、布局、繪制,到調(diào)度插入,整個(gè)過(guò)程都是異步的。通常情況下,一個(gè)類 (通常是 View) 調(diào)用系統(tǒng)方法,一段時(shí)間之后系統(tǒng)來(lái)調(diào)度執(zhí)行,然后通過(guò)回調(diào)觸發(fā)監(jiān)聽(tīng)。

KTX 擴(kuò)展方法

上述提及的 API,在 Jetpack 中都增加了擴(kuò)展方法來(lái)提高開(kāi)發(fā)效率。其中 View.doOnPreDraw()方法是我最喜歡的一個(gè),該方法對(duì)等待下一次繪制被執(zhí)行進(jìn)行了極大的精簡(jiǎn)。其實(shí)還有很多我常用的方法,比如 View.doOnLayout()、Animator.doOnEnd()

但是這些擴(kuò)展方法也是僅止步于此,他們只是將舊風(fēng)格的回調(diào) API 改成了 Kotlin 中比較友好的基于 lambda 風(fēng)格的 API。雖然用起來(lái)很優(yōu)雅,但我們只是在用另一種方式處理回調(diào),這還是沒(méi)有解決復(fù)雜的 UI 的回調(diào)嵌套問(wèn)題。既然我們?cè)谟懻摦惒讲僮?,那在這種情況下,我們可以使用協(xié)程優(yōu)化這些問(wèn)題么?

使用協(xié)程解決問(wèn)題

這里假定您已經(jīng)對(duì)協(xié)程有一定的理解,如果接下來(lái)的內(nèi)容對(duì)您來(lái)說(shuō)會(huì)有些陌生,可以通過(guò)我們今年早期的系列文章進(jìn)行回顧: 在 Android 開(kāi)發(fā)中使用協(xié)程 | 背景介紹

掛起函數(shù) (Suspending functions) 是協(xié)程的基礎(chǔ)組成部分,它允許我們以非阻塞的方式編寫(xiě)代碼。這種特性非常適用于我們處理 Android UI,因?yàn)槲覀儾幌胱枞骶€程,阻塞主線程會(huì)帶來(lái)性能上的問(wèn)題,比如: jank。

suspendCancellableCoroutine

在 Kotlin 協(xié)程庫(kù)中,有很多協(xié)程的構(gòu)造器方法,這些構(gòu)造器方法內(nèi)部可以使用掛起函數(shù)來(lái)封裝回調(diào)的 API。最主要的 API 是 suspendCoroutine()suspendCancellableCoroutine(),后者是可以被取消的。

我們推薦始終使用 suspendCancellableCoroutine(),因?yàn)檫@個(gè)方法可以從兩個(gè)維度處理協(xié)程的取消操作:

#1: 可以在異步操作完成之前取消協(xié)程。如果某個(gè) view 從它所在的層級(jí)中被移除,那么根據(jù)協(xié)程所處的作用域 (scope),它有可能會(huì)被取消。舉個(gè)例子: Fragment 返回出棧,通過(guò)處理取消事件,我們可以取消異步操作,并清除相關(guān)引用的資源。

#2: 在協(xié)程被掛起的時(shí)候,異步 UI 操作被取消或者拋出異常。并不是所有的操作都有已取消或出錯(cuò)的狀態(tài),但是這些操作有。就像后面 Animator 的示例中那樣,我們必須把這些狀態(tài)傳遞到協(xié)程中,讓調(diào)用者可以處理錯(cuò)誤的狀態(tài)。

等待 View 被布局完成

讓我們看一個(gè)例子,它封裝了一個(gè)等待 View 傳遞下一次布局事件的任務(wù) (比如說(shuō),我們改變了一個(gè) TextView 中的內(nèi)容,需要等待布局事件完成后才能獲取該控件的新尺寸):

suspend fun View.awaitNextLayout() = suspendCancellableCoroutine<Unit> { cont ->

    // 這里的 lambda 表達(dá)式會(huì)被立即調(diào)用,允許我們創(chuàng)建一個(gè)監(jiān)聽(tīng)器
    val listener = object : View.OnLayoutChangeListener {
        override fun onLayoutChange(...) {
            // 視圖的下一次布局任務(wù)被調(diào)用
            // 先移除監(jiān)聽(tīng),防止協(xié)程泄漏
            view.removeOnLayoutChangeListener(this)
            // 最終,喚醒協(xié)程,恢復(fù)執(zhí)行
            cont.resume(Unit)
        }
    }
    // 如果協(xié)程被取消,移除該監(jiān)聽(tīng)
    cont.invokeOnCancellation { removeOnLayoutChangeListener(listener) }
    // 最終,將監(jiān)聽(tīng)添加到 view 上
    addOnLayoutChangeListener(listener)

    // 這樣協(xié)程就被掛起了,除非監(jiān)聽(tīng)器中的 cont.resume() 方法被調(diào)用

}

此方法僅支持協(xié)程中一個(gè)維度的取消 (#1 操作),因?yàn)椴季植僮鳑](méi)有錯(cuò)誤狀態(tài)供我們監(jiān)聽(tīng)。

接下來(lái)我們就可以這樣使用了:

viewLifecycleOwner.lifecycleScope.launch {
    // 將該視圖設(shè)置為不可見(jiàn),再設(shè)置一些文字
    titleView.isInvisible = true
    titleView.text = "Hi everyone!"

    // 等待下一次布局事件的任務(wù),然后才可以獲取該視圖的高度
    titleView.awaitNextLayout()

    // 布局任務(wù)被執(zhí)行
    // 現(xiàn)在,我們可以將視圖設(shè)置為可見(jiàn),并其向上平移,然后執(zhí)行向下的動(dòng)畫(huà)
    titleView.isVisible = true
    titleView.translationY = -titleView.height.toFloat()
    titleView.animate().translationY(0f)
}

我們?yōu)?View 的布局創(chuàng)建了一個(gè) await 函數(shù)。用同樣的方法可以替代很多常見(jiàn)的回調(diào),比如 doOnPreDraw(),它是在 View 得到繪制時(shí)調(diào)用的方法;再比如 postOnAnimation(),在動(dòng)畫(huà)的下一幀開(kāi)始時(shí)調(diào)用的方法,等等。

作用域

不知道您有沒(méi)有發(fā)現(xiàn)這樣一個(gè)問(wèn)題,在上面的例子中,我們使用了 lifecycleScope 來(lái)啟動(dòng)協(xié)程,為什么要這樣做呢?

為了避免發(fā)生內(nèi)存泄漏,在我們操作 UI 的時(shí)候,選擇合適的作用域來(lái)運(yùn)行協(xié)程是極其重要的。幸運(yùn)的是,我們的 View 有一些范圍合適的 Lifecycle。我們可以使用擴(kuò)展屬性 lifecycleScope 來(lái)獲得一個(gè)綁定生命周期的 CoroutineScope。

LifecycleScope 被包含在 AndroidX 的 lifecycle-runtime-ktx 依賴庫(kù)中,可以在 這里****找到更多信息

我們最常用的生命周期的持有者 (lifecycle owner) 就是 Fragment 中的 viewLifecycleOwner,只要加載了 Fragment 的視圖,它就會(huì)處于活躍狀態(tài)。一旦 Fragment 的視圖被移除,與之關(guān)聯(lián)的 lifecycleScope 就會(huì)自動(dòng)被取消。又由于我們已經(jīng)為掛起函數(shù)中添加了對(duì)取消操作的支持,所以 lifecycleScope 被取消時(shí),所有與之關(guān)聯(lián)的協(xié)程都會(huì)被清除。

等待 Animator 執(zhí)行完成

我們?cè)賮?lái)看一個(gè)例子來(lái)加深理解,這次是等待 Animator 執(zhí)行結(jié)束:

suspend fun Animator.awaitEnd() = suspendCancellableCoroutine<Unit> { cont ->

    // 增加一個(gè)處理協(xié)程取消的監(jiān)聽(tīng)器,如果協(xié)程被取消,
    // 同時(shí)執(zhí)行動(dòng)畫(huà)監(jiān)聽(tīng)器的 onAnimationCancel() 方法,取消動(dòng)畫(huà)
    cont.invokeOnCancellation { cancel() }

    addListener(object : AnimatorListenerAdapter() {
        private var endedSuccessfully = true

        override fun onAnimationCancel(animation: Animator) {
            // 動(dòng)畫(huà)已經(jīng)被取消,修改是否成功結(jié)束的標(biāo)志
            endedSuccessfully = false
        }

        override fun onAnimationEnd(animation: Animator) {

            // 為了在協(xié)程恢復(fù)后的不發(fā)生泄漏,需要確保移除監(jiān)聽(tīng)
            animation.removeListener(this)
            if (cont.isActive) {

                // 如果協(xié)程仍處于活躍狀態(tài)
                if (endedSuccessfully) {
                    // 并且動(dòng)畫(huà)正常結(jié)束,恢復(fù)協(xié)程
                    cont.resume(Unit)
                } else {
                    // 否則動(dòng)畫(huà)被取消,同時(shí)取消協(xié)程
                    cont.cancel()
                }
            }
        }
    })
}

這個(gè)方法支持兩個(gè)維度的取消,我們可以分別取消動(dòng)畫(huà)或者協(xié)程:

#1: 在 Animator 運(yùn)行的時(shí)候,協(xié)程被取消 。我們可以通過(guò) invokeOnCancellation 回調(diào)方法來(lái)監(jiān)聽(tīng)協(xié)程何時(shí)被取消,這能讓我們同時(shí)取消動(dòng)畫(huà)。

#2: 在協(xié)程被掛起的時(shí)候,Animator 被取消 。我們通過(guò) onAnimationCancel() 回調(diào)來(lái)監(jiān)聽(tīng)動(dòng)畫(huà)被取消的事件,通過(guò)調(diào)用協(xié)程的 cancel() 方法來(lái)取消掛起的協(xié)程。

這就是使用掛起函數(shù)等待方法執(zhí)行來(lái)封裝回調(diào)的基本使用了。??

組合使用

到這里,您可能有這樣的疑問(wèn),"看起來(lái)不錯(cuò),但是我能從中收獲什么呢?" 單獨(dú)使用其中某個(gè)方法,并不會(huì)產(chǎn)生多大的作用,但是如果把它們組合起來(lái),便能發(fā)揮巨大的威力。

下面是一個(gè)使用 Animator.awaitEnd() 來(lái)依次運(yùn)行 3 個(gè)動(dòng)畫(huà)的示例:

viewLifecycleOwner.lifecycleScope.launch {
    ObjectAnimator.ofFloat(imageView, View.ALPHA, 0f, 1f).run {
        start()
        awaitEnd()
    }

    ObjectAnimator.ofFloat(imageView, View.TRANSLATION_Y, 0f, 100f).run {
        start()
        awaitEnd()
    }

    ObjectAnimator.ofFloat(imageView, View.TRANSLATION_X, -100f, 0f).run {
        start()
        awaitEnd()
    }
}

這是一個(gè)很常見(jiàn)的使用案例,您可以把這些動(dòng)畫(huà)放進(jìn) AnimatorSet 中來(lái)實(shí)現(xiàn)同樣的效果。

但是這里使用的方法適用于不同類型的異步操作: 我們使用一個(gè) ValueAnimator,一個(gè) RecyclerView 的平滑滾動(dòng),以及一個(gè) Animator 來(lái)舉例:

viewLifecycleOwner.lifecycleScope.launch {
    // #1: ValueAnimator
    imageView.animate().run {
        alpha(0f)
        start()
        awaitEnd()
    }

    // #2: RecyclerView smooth scroll
    recyclerView.run {
        smoothScrollToPosition(10)
        // 該方法和其他方法類似,等待當(dāng)前的滑動(dòng)完成,我們不需要刻意關(guān)注實(shí)現(xiàn)
        // 代碼可以在文末的引用中找到
        awaitScrollEnd()
    }

    // #3: ObjectAnimator
    ObjectAnimator.ofFloat(textView, View.TRANSLATION_X, -100f, 0f).run {
        start()
        awaitEnd()
    }
}

試著用 AnimatorSet 實(shí)現(xiàn)一下吧??!如果不用協(xié)程,那就意味著我們要監(jiān)聽(tīng)每一個(gè)操作,在回調(diào)中執(zhí)行下一個(gè)操作,這回調(diào)層級(jí)想想都可怕。

通過(guò)把不同的異步操作轉(zhuǎn)換為協(xié)程的掛起函數(shù),我們獲得了簡(jiǎn)潔明了地編排它們的能力。

我們還可以更進(jìn)一步...

如果我們希望 ValueAnimator 和平滑滾動(dòng)同時(shí)開(kāi)始,然后在兩者都完成之后啟動(dòng) ObjectAnimator,該怎么做呢?那么在使用了協(xié)程之后,我們可以使用 async() 來(lái)并發(fā)地執(zhí)行我們的代碼:

viewLifecycleOwner.lifecycleScope.launch {
    val anim1 = async {
        imageView.animate().run {
            alpha(0f)
            start()
            awaitEnd()
        }
    }

    val scroll = async {
        recyclerView.run {
            smoothScrollToPosition(10)
            awaitScrollEnd()
        }
    }

    // 等待以上兩個(gè)操作全部完成
    anim1.await()
    scroll.await()

    // 此時(shí),anim1 和滑動(dòng)都完成了,我們開(kāi)始執(zhí)行 ObjectAnimator
    ObjectAnimator.ofFloat(textView, View.TRANSLATION_X, -100f, 0f).run {
        start()
        awaitEnd()
    }
}

但是如果您還想讓滾動(dòng)延遲執(zhí)行怎么辦呢? (類似 Animator.startDelay 方法) 那么使用協(xié)程也有很好的實(shí)現(xiàn),我們可以用 delay() 方法:

viewLifecycleOwner.lifecycleScope.launch {
    val anim1 = async {
        // ...
    }

    val scroll = async {
        // 我們希望在 anim1 完成后,延遲 200ms 執(zhí)行滾動(dòng)
        delay(200)

        recyclerView.run {
            smoothScrollToPosition(10)
            awaitScrollEnd()
        }
    }

    // …
}

如果我們想重復(fù)動(dòng)畫(huà),那么我們可以使用 repeat() 方法,或者使用 for 循環(huán)實(shí)現(xiàn)。下面是一個(gè) view 淡入淡出 3 次的例子:

viewLifecycleOwner.lifecycleScope.launch {
    repeat(3) {
        ObjectAnimator.ofFloat(textView, View.ALPHA, 0f, 1f, 0f).run {
            start()
            awaitEnd()
        }
    }
}

您甚至可以通過(guò)重復(fù)計(jì)數(shù)來(lái)實(shí)現(xiàn)更精妙的功能。假設(shè)您希望淡入淡出在每次重復(fù)中逐漸變慢:

viewLifecycleOwner.lifecycleScope.launch {
    repeat(3) { repetition ->
        ObjectAnimator.ofFloat(textView, View.ALPHA, 0f, 1f, 0f).run {
            // 第一次執(zhí)行持續(xù) 150ms,第二次:300ms,第三次:450ms
            duration = (repetition + 1) * 150L
            start()
            awaitEnd()
        }
    }
}

在我看來(lái),這就是在 Android 視圖系統(tǒng)中使用協(xié)程能真正發(fā)揮作用的地方。我們就算不去組合不同類型的回調(diào),也能創(chuàng)建復(fù)雜的異步變換,或是將不同類型的動(dòng)畫(huà)組合起來(lái)。

通過(guò)使用與我們應(yīng)用中數(shù)據(jù)層相同的協(xié)程開(kāi)發(fā)原語(yǔ),還能使 UI 編程更便捷。對(duì)于剛接觸代碼的人來(lái)說(shuō), await 方法要比看似會(huì)斷開(kāi)的回調(diào)更具可讀性。

最后

希望通過(guò)本文,您可以進(jìn)一步思考協(xié)程還可以在哪些其他的 API 中發(fā)揮作用。

接下來(lái)的文章中,我們將探討如何使用協(xié)程來(lái)組織一個(gè)復(fù)雜的變換動(dòng)畫(huà),其中也包括了一些常見(jiàn) View 的實(shí)現(xiàn),感興趣的讀者請(qǐng)繼續(xù)關(guān)注我們的更新。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容