共计 7013 个字符,预计需要花费 18 分钟才能阅读完成。
本文是摸索协程如何简化异步 UI 编程系列的第二篇。第一篇偏重实践剖析,这一篇咱们通过实际来阐明如何解决理论问题。如果您心愿回顾之前的内容,能够在这里找到——《在 View 上应用挂起函数》。
让咱们学以致用,在理论利用中进行实际。
遇到的问题
咱们有一个示例利用: Tivi,它能够展现 TV 节目的详细信息。对于节目信息,利用内列举了每一季和每一集。当用户点击其中的某一集时,该集的详细信息将以点击处开展的动画来展现 (0.2 倍速展现):
利用中采纳 InboxRecyclerView 库来解决图中的开展动画:
fun onEpisodeItemClicked(view: View, episode: Episode) { | |
// 告诉 InboxRecyclerView 开展剧集项 | |
// 向其传入须要开展的我的项目的 id | |
recyclerView.expandItem(episode.id) | |
} |
InboxRecyclerView
的工作原理是通过咱们提供的条目 ID,在 RecyclerView
中找到对应项,而后执行动画。
接下来让咱们看一下须要解决的问题。在这些雷同 UI 界面顶部左近,展现了观看下一集的条目。这里应用和上面独立剧集雷同的视图类型,但却有不同的条目 ID。
为了便于开发,这里这两个条目复用了雷同的 onEpisodeItemClicked()
办法。但可怜的是,这导致了在点击的时候动画异样 (0.2 倍速展现):
实际效果并没有从点击的条目开展,而是从顶部开展了一个看似随机的条目。这并不是咱们的预期成果,引发该问题的起因有如下几点:
- 咱们在点击事件的监听器中应用的 ID 是间接通过 Episode 类来获取的。这个 ID 映射到了季份列表中的某一集;
- 该集的条目可能还没有被增加到 RecyclerView 中,须要用户开展该季份的列表,而后将其滑动展现到屏幕上,这样咱们须要的视图能力被 RecyclerView 加载。
因为上述起因,导致该依赖库执行回退,应用第一个条目进行开展。
现实的解决方案
咱们冀望行为是什么呢?咱们想要失去这样的成果 (0.2 倍速展现):
用伪代码来实现,大略是这样:
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 代码从链式回调中解放出来吧!