在 View 上使用掛起函數 | 實戰

本文是探索協程如何簡化異步 UI 編程系列的第二篇。第一篇側重理論分析,這一篇咱們經過實踐來講明如何解決實際問題。若是您但願回顧以前的內容,能夠在這裏找到——《在 View 上使用掛起函數》。html

讓咱們學以至用,在實際應用中進行實踐。java

遇到的問題

咱們有一個示例應用: Tivi,它能夠展現 TV 節目的詳細信息。關於節目信息,應用內羅列了每一季和每一集。當用戶點擊其中的某一集時,該集的詳細信息將以點擊處展開的動畫來展現 (0.2 倍速展現):android

應用中採用 InboxRecyclerView 庫來處理圖中的展開動畫:git

fun onEpisodeItemClicked(view: View, episode: Episode) {
    // 通知 InboxRecyclerView 展開劇集項
    // 向其傳入須要展開的項目的 id
    recyclerView.expandItem(episode.id)
}

InboxRecyclerView 的工做原理是經過咱們提供的條目 ID,在 RecyclerView 中找到對應項,而後執行動畫。github

接下來讓咱們看一下須要解決的問題。在這些相同 UI 界面頂部附近,展現了觀看下一集的條目。這裏使用和下面獨立劇集相同的視圖類型,但卻有不一樣的條目 ID。編程

爲了便於開發,這裏這兩個條目複用了相同的 onEpisodeItemClicked() 方法。但不幸的是,這致使了在點擊的時候動畫異常 (0.2 倍速展現):架構

實際效果並無從點擊的條目展開,而是從頂部展開了一個看似隨機的條目。這並非咱們的預期效果,引起該問題的緣由有以下幾點:app

  • 咱們在點擊事件的監聽器中使用的 ID 是直接經過 Episode 類來獲取的。這個 ID 映射到了季份列表中的某一集;
  • 該集的條目可能尚未被添加到 RecyclerView 中,須要用戶展開該季份的列表,而後將其滑動展現到屏幕上,這樣咱們須要的視圖才能被 RecyclerView 加載。

因爲上述緣由,致使該依賴庫執行回退,使用第一個條目進行展開。異步

理想的解決方案

咱們指望行爲是什麼呢?咱們想要獲得這樣的效果 (0.2 倍速展現):ide

用僞代碼來實現,大概是這樣:

fun onNextEpisodeToWatchItemClick(view: View, nextEpisodeToWatch: Episode) {
    // 通知 ViewModel 使 RecyclerView 的數據集中包含對應季份的劇集。
    // 這個操做會觸發數據拉取,而且會更新視圖狀態
    viewModel.expandSeason(nextEpisodeToWatch.seasonId)

    // 滑動 RecyclerView 展現指定的劇集
    recyclerView.scrollToItemId(nextEpisodeToWatch.id)

    // 使用以前的方法展開該條目
    recyclerView.expandItem(nextEpisodeToWatch.id)
}

可是在現實狀況下,應該更像以下的實現:

fun onNextEpisodeToWatchItemClick(view: View, nextEpisodeToWatch: Episode) {
    // 通知在 RecycleView 數據集中包含該集所在季份列表的 ViewModel,並觸發數據的更新
    viewModel.expandSeason(nextEpisodeToWatch.seasonId)

    // TODO 等待 ViewModel 分發新的狀態
    // TODO 等待 RecyclerView 的適配器對比新的數據集
    // TODO 等待 RecyclerView 將新條目佈局

    // 滑動 RecyclerView 展現指定的劇集
    recyclerView.scrollToItemId(nextEpisodeToWatch.id)

    // TODO 等待 RecyclerView 滑動結束

    // 使用以前的方法展開該條目
    recyclerView.expandItem(nextEpisodeToWatch.id)
}

咱們能夠發現,這裏須要不少等待異步操做完成的代碼。

此處的僞代碼看似不太複雜,但只要您着手實現這些功能,就會當即陷入回調地獄。下面是使用鏈式回調嘗試實現的架構:

fun expandEpisodeItem(itemId: Long) {
    recyclerView.expandItem(itemId)
}

fun scrollToEpisodeItem(position: Int) {
   recyclerView.smoothScrollToPosition(position)

   // 增長一個滑動監聽器,等待 RV 滑動中止
   recyclerView.addOnScrollListener(object : OnScrollListener() {
        override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
            if (newState == RecyclerView.SCROLL_STATE_IDLE) {
                expandEpisodeItem(episode.id)
            }
        }
    })
}

fun waitForEpisodeItemInAdapter() {
    // 咱們須要等待適配器包含指定條目的id
    val position = adapter.findItemIdPosition(itemId)
    if (position != RecyclerView.NO_POSITION) {
        // 目標項已經在適配器中了,咱們能夠滑動到該 id 的條目處
        scrollToEpisodeItem(itemId))
    } else {
       // 不然咱們等待新的條目添加到適配器中,而後在重試
       adapter.registerAdapterDataObserver(object : AdapterDataObserver() {
            override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
                waitForEpisodeItemInAdapter()
            }
        })
    }
}

// 通知 ViewModel 展開指定的季份數據
viewModel.expandSeason(nextEpisodeToWatch.seasonId)
// 咱們等待新的數據
waitForEpisodeItemInAdapter()

這段代碼還有缺陷,而且可能沒法正常運行,旨在說明回調會極大增長 UI 編程的複雜度。總的來講,這段代碼有以下的問題:

耦合嚴重

因爲咱們不得不經過回調的方式完成過渡動畫,所以每個動畫都須要明確接下來須要調用的方法: Callback #1 調用 Animation #2,Callback #2 調用 Animation #3,以此類推。這些動畫自己並沒有關聯,可是咱們強行將它們耦合到了一塊兒。

難以維護/更新

兩個月之後,動畫設計師要求在其中增長一個淡入淡出的過渡動畫。您可能須要跟蹤這部分過渡動畫,查看每個回調才能找到確切的位置觸發新動畫,以後您還要進行測試...

測試

不管如何,測試動畫都是很困難的,使用混亂的回調更是讓問題雪上加霜。爲了在回調中使用斷言判斷是否執行了某些操做,您的測試必須包含全部的動畫類型。本文並未真正涉及測試,可是使用協程可讓其更加簡單。

使用協程解決問題

在前一篇文章中,咱們已經學習瞭如何使用掛起函數封裝回調 API。讓咱們利用這些知識來優化咱們臃腫的回調代碼:

viewLifecycleOwner.lifecycleScope.launch {    
    // 等待適配器中已經包含指定劇集的 ID
    adapter.awaitItemIdExists(episode.id)
    // 找到指定季份的條目位置
    val seasonItemPosition = adapter.findItemIdPosition(episode.seasonId)

    // 滑動 RecyclerView 使該季份的條目顯示在其區域的最上方
    recyclerView.smoothScrollToPosition(seasonItemPosition)
    // 等待滑動結束
    recyclerView.awaitScrollEnd()

    // 最後,展開該集的條目,並展現詳細內容
    recyclerView.expandItem(episode.id)
}

可讀性獲得了巨大的提高!

新的掛起函數隱藏了全部複雜的操做,從而獲得了一個線性的調用方法序列,讓咱們來探究更深層次的細節...

MotionLayout.awaitTransitionComplete()

目前尚未 MotionLayout 的 ktx 擴展方法提供咱們使用,而且 MotionLayout 暫時不支持添加多個監聽。這意味着 awaitTransitionComplete() 的實現要比其餘方法複雜得多。

這裏咱們使用 MotionLayout 的子類來實現多監聽器的支持: MultiListenerMotionLayout

咱們的 awaitTransitionComplete() 方法以下定義:

/**
 * 等待過渡動畫結束,目的是讓指定 [transitionId] 的動畫執行完成
 * 
 * @param transitionId 須要等待執行完成的過渡動畫集
 * @param timeout 過渡動畫執行的超時時間,默認 5s
 */
suspend fun MultiListenerMotionLayout.awaitTransitionComplete(transitionId: Int, timeout: Long = 5000L) {
    // 若是已經處於咱們指定的狀態,直接返回
    if (currentState == transitionId) return

    var listener: MotionLayout.TransitionListener? = null

    try {
        withTimeout(timeout) {
            suspendCancellableCoroutine<Unit> { continuation ->
                val l = object : TransitionAdapter() {
                    override fun onTransitionCompleted(motionLayout: MotionLayout, currentId: Int) {
                        if (currentId == transitionId) {
                            removeTransitionListener(this)
                            continuation.resume(Unit)
                        }
                    }
                }
                // 若是協程被取消,移除監聽
                continuation.invokeOnCancellation {
                    removeTransitionListener(l)
                }
                // 最後添加監聽器
                addTransitionListener(l)
                listener = l
            }
        }
    } catch (tex: TimeoutCancellationException) {
        // 過渡動畫沒有在規定的時間內完成,移除監聽,並經過拋出取消異常來通知協程
        listener?.let(::removeTransitionListener)
        throw CancellationException("Transition to state with id: $transitionId did not" +
                " complete in timeout.", tex)
    }
}

Adapter.awaitItemIdExists()

這個方法很優雅,同時也很是有效。在 TV 節目的例子中,實際上處理了幾種不一樣的異步狀態:

// 確保指定的季份列表已經展開,目標劇集已經被加載
viewModel.expandSeason(nextEpisodeToWatch.seasonId)
// 1.等待新的數據下發
// 2.等待 RecyclerView 適配器對比新的數據集
// 滑動 RecyclerView 直到指定的劇集展現出來
recyclerView.scrollToItemId(nextEpisodeToWatch.id)

這個方法使用了 RecyclerView 的 AdapterDataObserver 來實現監聽適配器數據集的改變:

/**
 * 等待給定的[itemId]添加到了數據集中,並返回該條目在適配器中的位置
 */
suspend fun <VH : RecyclerView.ViewHolder> RecyclerView.Adapter<VH>.awaitItemIdExists(itemId: Long): Int {
    val currentPos = findItemIdPosition(itemId)
    // 若是該條目已經在數據集中了,直接返回其位置
    if (currentPos >= 0) return currentPos

    // 不然,咱們註冊一個觀察者,等待指定條目 id 被添加到數據集中。
    return suspendCancellableCoroutine { continuation ->
        val observer = object : RecyclerView.AdapterDataObserver() {
            override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
                (positionStart until positionStart + itemCount).forEach { position ->
                    // 遍歷新添加的條目,檢查 itemId 是否匹配
                    if (getItemId(position) == itemId) {
                        // 移除觀察者,防止協程泄漏
                        unregisterAdapterDataObserver(this)
                        // 恢復協程
                        continuation.resume(position)
                    }
                }
            }
        }
        // 若是協程被取消,移除觀察者
        continuation.invokeOnCancellation {
            unregisterAdapterDataObserver(observer)
        }
        // 將觀察者註冊到適配器上
        registerAdapterDataObserver(observer)
    }
}

RecyclerView.awaitScrollEnd()

須要特別注意等待滾動完成的方法: RecyclerView.awaitScrollEnd()

suspend fun RecyclerView.awaitScrollEnd() {
    // 平滑滾動被調用,只有在下一幀開始的時候,才真正的執行,這裏進行等待第一幀
    awaitAnimationFrame()
    // 如今咱們能夠檢測真實的滑動中止,若是已經中止,直接返回
    if (scrollState == RecyclerView.SCROLL_STATE_IDLE) return

    suspendCancellableCoroutine<Unit> { continuation ->
        continuation.invokeOnCancellation {
            // 若是協程被取消,移除監聽
            recyclerView.removeOnScrollListener(this)
            // 若是咱們須要,也能夠在這裏中止滾動
        }

        addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
                if (newState == RecyclerView.SCROLL_STATE_IDLE) {
                    // 確保移除監聽,防止協程泄漏
                    recyclerView.removeOnScrollListener(this)
                    // 最後,恢復協程
                    continuation.resume(Unit)
                }
            }
        })
    }
}

但願目前爲止,這段代碼仍是通俗易懂的。這個方法內部最棘手之處是須要在 fail-fast 檢查以前調用 awaitAnimationFrame()。如註釋中所說,因爲 SmoothScroller 真正開始執行的時間是動畫的下一幀,因此咱們等待一幀後再判斷滑動狀態。

awaitAnimationFrame() 方法封裝了 postOnAnimation() 來實現等待動畫的下一個動做,該事件一般發生在下一次渲染。這裏的實現相似前一篇文章中的 doOnNextLayout():

suspend fun View.awaitAnimationFrame() = suspendCancellableCoroutine<Unit> { continuation ->
    val runnable = Runnable {
        continuation.resume(Unit)
    }
    // 若是協程被取消,移除回調
    continuation.invokeOnCancellation { removeCallbacks(runnable) }
    // 最後發佈 runnable 對象
    postOnAnimation(runnable)
}

最終效果

最後,操做序列的效果以下圖所示 (0.2 倍速展現):

打破回調鏈

遷移到協程可使咱們可以擺脫龐大的回調鏈,過多的回調讓咱們難以維護和測試。

對於全部 API,將回調、監聽器、觀察者封裝爲掛起函數的方式基本相同。但願您此時已經能感覺到咱們文中例子的重複性。那麼接下來還請再接再礪,將您的 UI 代碼從鏈式回調中解放出來吧!

相關文章
相關標籤/搜索