在 View 上使用掛起函數

Kotlin 協程 讓咱們能夠用同步代碼來創建異步問題的模型。這是很是好的特性,可是目前大部分用例都專一於 I/O 任務或是併發操做。其實協程不只在處理跨線程的問題有優點,還能夠用來處理同一線程中的異步問題。html

我認爲有一個地方能夠真正從中受益,那就是在 Android 視圖系統中使用協程。java

Android 視圖  💘 回調

Android 視圖系統中尤爲熱衷於使用回調: 目前在 Android Framework 中,view 和 widgets 類中的回調有 80+ 個,在 Jetpack 中回調的數目更是超過了 200 個 (這裏也包含了沒有界面的依賴庫)。android

最多見的用法有如下幾項:git

而後還有一些經過接受 Runnable 來執行異步操做的API,好比 View.post()、View.postDelayed() 等等。github

正是由於 Android 上的 UI 編程從根本上就是異步的,因此形成了如此之多的回調。從測量、佈局、繪製,到調度插入,整個過程都是異步的。一般狀況下,一個類 (一般是 View) 調用系統方法,一段時間以後系統來調度執行,而後經過回調觸發監聽。編程

KTX 擴展方法

上述說起的 API,在 Jetpack 中都增長了擴展方法來提升開發效率。其中 View.doOnPreDraw()方法是我最喜歡的一個,該方法對等待下一次繪製被執行進行了極大的精簡。其實還有不少我經常使用的方法,好比 View.doOnLayout()Animator.doOnEnd()api

可是這些擴展方法也是僅止步於此,他們只是將舊風格的回調 API 改爲了 Kotlin 中比較友好的基於 lambda 風格的 API。雖然用起來很優雅,但咱們只是在用另外一種方式處理回調,這仍是沒有解決複雜的 UI 的回調嵌套問題。既然咱們在討論異步操做,那在這種狀況下,咱們可使用協程優化這些問題麼?併發

使用協程解決問題

這裏假定您已經對協程有必定的理解,若是接下來的內容對您來講會有些陌生,能夠經過咱們今年早期的系列文章進行回顧: 在 Android 開發中使用協程 | 背景介紹app

掛起函數 (Suspending functions) 是協程的基礎組成部分,它容許咱們以非阻塞的方式編寫代碼。這種特性很是適用於咱們處理 Android UI,由於咱們不想阻塞主線程,阻塞主線程會帶來性能上的問題,好比: jank異步

suspendCancellableCoroutine

在 Kotlin 協程庫中,有不少協程的構造器方法,這些構造器方法內部可使用掛起函數來封裝回調的 API。最主要的 API 是 suspendCoroutine() 和 suspendCancellableCoroutine(),後者是能夠被取消的。

咱們推薦始終使用 suspendCancellableCoroutine(),由於這個方法能夠從兩個維度處理協程的取消操做:

#1: 能夠在異步操做完成以前取消協程。若是某個 view 從它所在的層級中被移除,那麼根據協程所處的做用域 (scope),它有可能會被取消。舉個例子: Fragment 返回出棧,經過處理取消事件,咱們能夠取消異步操做,並清除相關引用的資源。

#2: 在協程被掛起的時候,異步 UI 操做被取消或者拋出異常。並非全部的操做都有已取消或出錯的狀態,可是這些操做有。就像後面 Animator 的示例中那樣,咱們必須把這些狀態傳遞到協程中,讓調用者能夠處理錯誤的狀態。

等待 View 被佈局完成

讓咱們看一個例子,它封裝了一個等待 View 傳遞下一次佈局事件的任務 (好比說,咱們改變了一個 TextView 中的內容,須要等待佈局事件完成後才能獲取該控件的新尺寸):

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

    // 這裏的 lambda 表達式會被當即調用,容許咱們建立一個監聽器
    val listener = object : View.OnLayoutChangeListener {
        override fun onLayoutChange(...) {
            // 視圖的下一次佈局任務被調用
            // 先移除監聽,防止協程泄漏
            view.removeOnLayoutChangeListener(this)
            // 最終,喚醒協程,恢復執行
            cont.resume(Unit)
        }
    }
    // 若是協程被取消,移除該監聽
    cont.invokeOnCancellation { removeOnLayoutChangeListener(listener) }
    // 最終,將監聽添加到 view 上
    addOnLayoutChangeListener(listener)

    // 這樣協程就被掛起了,除非監聽器中的 cont.resume() 方法被調用

}

此方法僅支持協程中一個維度的取消 (#1 操做),由於佈局操做沒有錯誤狀態供咱們監聽。

接下來咱們就能夠這樣使用了:

viewLifecycleOwner.lifecycleScope.launch {
    // 將該視圖設置爲不可見,再設置一些文字
    titleView.isInvisible = true
    titleView.text = "Hi everyone!"

    // 等待下一次佈局事件的任務,而後才能夠獲取該視圖的高度
    titleView.awaitNextLayout()

    // 佈局任務被執行
    // 如今,咱們能夠將視圖設置爲可見,並其向上平移,而後執行向下的動畫
    titleView.isVisible = true
    titleView.translationY = -titleView.height.toFloat()
    titleView.animate().translationY(0f)
}

咱們爲 View 的佈局建立了一個 await 函數。用一樣的方法能夠替代不少常見的回調,好比 doOnPreDraw(),它是在 View 獲得繪製時調用的方法;再好比 postOnAnimation(),在動畫的下一幀開始時調用的方法,等等。

做用域

不知道您有沒有發現這樣一個問題,在上面的例子中,咱們使用了 lifecycleScope 來啓動協程,爲何要這樣作呢?

爲了不發生內存泄漏,在咱們操做 UI 的時候,選擇合適的做用域來運行協程是極其重要的。幸運的是,咱們的 View 有一些範圍合適的 Lifecycle。咱們可使用擴展屬性 lifecycleScope 來得到一個綁定生命週期的 CoroutineScope

LifecycleScope 被包含在 AndroidX 的  lifecycle-runtime-ktx 依賴庫中,能夠在 這裏****找到更多信息

咱們最經常使用的生命週期的持有者 (lifecycle owner) 就是 Fragment 中的 viewLifecycleOwner,只要加載了 Fragment 的視圖,它就會處於活躍狀態。一旦 Fragment 的視圖被移除,與之關聯的 lifecycleScope 就會自動被取消。又因爲咱們已經爲掛起函數中添加了對取消操做的支持,因此 lifecycleScope 被取消時,全部與之關聯的協程都會被清除。

等待 Animator 執行完成

咱們再來看一個例子來加深理解,此次是等待 Animator 執行結束:

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

    // 增長一個處理協程取消的監聽器,若是協程被取消,
    // 同時執行動畫監聽器的 onAnimationCancel() 方法,取消動畫
    cont.invokeOnCancellation { cancel() }

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

        override fun onAnimationCancel(animation: Animator) {
            // 動畫已經被取消,修改是否成功結束的標誌
            endedSuccessfully = false
        }

        override fun onAnimationEnd(animation: Animator) {

            // 爲了在協程恢復後的不發生泄漏,須要確保移除監聽
            animation.removeListener(this)
            if (cont.isActive) {

                // 若是協程仍處於活躍狀態
                if (endedSuccessfully) {
                    // 而且動畫正常結束,恢復協程
                    cont.resume(Unit)
                } else {
                    // 不然動畫被取消,同時取消協程
                    cont.cancel()
                }
            }
        }
    })
}

這個方法支持兩個維度的取消,咱們能夠分別取消動畫或者協程:

#1: 在 Animator 運行的時候,協程被取消 。咱們能夠經過 invokeOnCancellation 回調方法來監聽協程什麼時候被取消,這能讓咱們同時取消動畫。

#2: 在協程被掛起的時候,Animator 被取消 。咱們經過 onAnimationCancel() 回調來監聽動畫被取消的事件,經過調用協程的 cancel() 方法來取消掛起的協程。

這就是使用掛起函數等待方法執行來封裝回調的基本使用了。🏅

組合使用

到這裏,您可能有這樣的疑問,"看起來不錯,可是我能從中收穫什麼呢?" 單獨使用其中某個方法,並不會產生多大的做用,可是若是把它們組合起來,便能發揮巨大的威力。

下面是一個使用 Animator.awaitEnd() 來依次運行 3 個動畫的示例:

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()
    }
}

這是一個很常見的使用案例,您能夠把這些動畫放進 AnimatorSet 中來實現一樣的效果。

可是這裏使用的方法適用於不一樣類型的異步操做: 咱們使用一個 ValueAnimator,一個 RecyclerView 的平滑滾動,以及一個 Animator 來舉例:

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

    // #2: RecyclerView smooth scroll
    recyclerView.run {
        smoothScrollToPosition(10)
        // 該方法和其餘方法相似,等待當前的滑動完成,咱們不須要刻意關注實現
        // 代碼能夠在文末的引用中找到
        awaitScrollEnd()
    }

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

試着用 AnimatorSet 實現一下吧🤯!若是不用協程,那就意味着咱們要監聽每個操做,在回調中執行下一個操做,這回調層級想一想均可怕。

經過把不一樣的異步操做轉換爲協程的掛起函數,咱們得到了簡潔明瞭地編排它們的能力。

咱們還能夠更進一步...

**若是咱們但願 ValueAnimator 和平滑滾動同時開始,而後在二者都完成以後啓動 ObjectAnimator,該怎麼作呢?**那麼在使用了協程以後,咱們可使用 async() 來併發地執行咱們的代碼:

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

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

    // 等待以上兩個操做所有完成
    anim1.await()
    scroll.await()

    // 此時,anim1 和滑動都完成了,咱們開始執行 ObjectAnimator
    ObjectAnimator.ofFloat(textView, View.TRANSLATION_X, -100f, 0f).run {
        start()
        awaitEnd()
    }
}

可是若是您還想讓滾動延遲執行怎麼辦呢? (相似 Animator.startDelay 方法) 那麼使用協程也有很好的實現,咱們能夠用 delay() 方法:

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

    val scroll = async {
        // 咱們但願在 anim1 完成後,延遲 200ms 執行滾動
        delay(200)

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

    // …
}

若是咱們想重複動畫,那麼咱們可使用 repeat() 方法,或者使用 for 循環實現。下面是一個 view 淡入淡出 3 次的例子:

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

您甚至能夠經過重複計數來實現更精妙的功能。假設您但願淡入淡出在每次重複中逐漸變慢:

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

在我看來,這就是在 Android 視圖系統中使用協程能真正發揮做用的地方。咱們就算不去組合不一樣類型的回調,也能建立複雜的異步變換,或是將不一樣類型的動畫組合起來。

經過使用與咱們應用中數據層相同的協程開發原語,還能使 UI 編程更便捷。對於剛接觸代碼的人來講, await 方法要比看似會斷開的回調更具可讀性。

最後

但願經過本文,您能夠進一步思考協程還能夠在哪些其餘的 API 中發揮做用。

接下來的文章中,咱們將探討如何使用協程來組織一個複雜的變換動畫,其中也包括了一些常見 View 的實現,感興趣的讀者請繼續關注咱們的更新。

相關文章
相關標籤/搜索