遇到的问题
我们有一个示例应用: Tivi,它可以展示 TV 节目的详细信息。关于节目信息,应用内罗列了每一季和每一集。当用户点击其中的某一集时,该集的详细信息将以点击处展开的动画来展示 (0.2 倍速展示):
fun onEpisodeItemClicked(view: View, episode: Episode) {// 通知 InboxRecyclerView 展开剧集项// 向其传入需要展开的项目的 idrecyclerView.expandItem(episode.id)}
实际效果并没有从点击的条目展开,而是从顶部展开了一个看似随机的条目。这并不是我们的预期效果,引发该问题的原因有如下几点:
我们在点击事件的监听器中使用的 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() {// 我们需要等待适配器包含指定条目的idval 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 {// 等待适配器中已经包含指定剧集的 IDadapter.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
/*** 等待过渡动画结束,目的是让指定 [transitionId] 的动画执行完成** @param transitionId 需要等待执行完成的过渡动画集* @param timeout 过渡动画执行的超时时间,默认 5s*/suspend fun MultiListenerMotionLayout.awaitTransitionComplete(transitionId: Int, timeout: Long = 5000L) {// 如果已经处于我们指定的状态,直接返回if (currentState == transitionId) returnvar listener: MotionLayout.TransitionListener? = nulltry {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)}}
RecyclerView.awaitScrollEnd()
suspend fun RecyclerView.awaitScrollEnd() {// 平滑滚动被调用,只有在下一帧开始的时候,才真正的执行,这里进行等待第一帧awaitAnimationFrame()// 现在我们可以检测真实的滑动停止,如果已经停止,直接返回if (scrollState == RecyclerView.SCROLL_STATE_IDLE) returnsuspendCancellableCoroutine<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)}}})}}
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)
最终效果
打破回调链
推荐阅读