在 View 上使用挂起函数 | 实战
遇到的问题
我们有一个示例应用: Tivi,它可以展示 TV 节目的详细信息。关于节目信息,应用内罗列了每一季和每一集。当用户点击其中的某一集时,该集的详细信息将以点击处展开的动画来展示 (0.2 倍速展示):
Tivi https://tivi.app/
fun onEpisodeItemClicked(view: View, episode: Episode) {
// 通知 InboxRecyclerView 展开剧集项
// 向其传入需要展开的项目的 id
recyclerView.expandItem(episode.id)
}
InboxRecyclerView https://github.com/saket/InboxRecyclerView
实际效果并没有从点击的条目展开,而是从顶部展开了一个看似随机的条目。这并不是我们的预期效果,引发该问题的原因有如下几点:
我们在点击事件的监听器中使用的 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()
耦合严重
两个月以后,动画设计师要求在其中增加一个淡入淡出的过渡动画。您可能需要跟踪这部分过渡动画,查看每一个回调才能找到确切的位置触发新动画,之后您还要进行测试...
无论如何,测试动画都是很困难的,使用混乱的回调更是让问题雪上加霜。为了在回调中使用断言判断是否执行了某些操作,您的测试必须包含所有的动画类型。本文并未真正涉及测试,但是使用协程可以让其更加简单。
使用协程解决问题
在前一篇文章中,我们已经学习了如何使用挂起函数封装回调 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
https://developer.android.google.cn/reference/android/support/constraint/motion/MotionLayout
MultiListenerMotionLayout https://gist.github.com/chrisbanes/a7371683c224464bf6bda5a25491aee0
/**
* 等待过渡动画结束,目的是让指定 [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()
// 确保指定的季份列表已经展开,目标剧集已经被加载
viewModel.expandSeason(nextEpisodeToWatch.seasonId)
// 1.等待新的数据下发
// 2.等待 RecyclerView 适配器对比新的数据集
// 滑动 RecyclerView 直到指定的剧集展示出来
recyclerView.scrollToItemId(nextEpisodeToWatch.id)
/**
* 等待给定的[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)
}
}
AdapterDataObserver https://developer.android.google.cn/reference/androidx/recyclerview/widget/RecyclerView.AdapterDataObserver.html
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)
}
}
})
}
}
RecyclerView https://developer.android.google.cn/reference/androidx/recyclerview/widget/RecyclerView.html
SmoothScroller https://developer.android.google.cn/reference/androidx/recyclerview/widget/RecyclerView.SmoothScroller.html
suspend fun View.awaitAnimationFrame() = suspendCancellableCoroutine<Unit> { continuation ->
val runnable = Runnable {
continuation.resume(Unit)
}
// 如果协程被取消,移除回调
continuation.invokeOnCancellation { removeCallbacks(runnable) }
// 最后发布 runnable 对象
postOnAnimation(runnable)
}
postOnAnimation()
https://developer.android.google.cn/reference/android/view/View.html#postOnAnimation(java.lang.Runnable)
最终效果
打破回调链
推荐阅读