本文是探索協程如何簡化異步 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 代碼從鏈式回調中解放出來吧!