在 View 上使用挂起函数 | 实战

在 View 上使用挂起函数 | 实战

本文是探索协程如何简化异步 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 代码从链式回调中解放出来吧!

版权声明

禁止一切形式的转载-禁止商用-禁止衍生 申请授权

脉脉不得语
脉脉不得语
Zhengzhou Website
Android Developer | https://androiddevtools.cn and https://androidweekly.io WebMaster | GDG Zhengzhou Funder & Ex Organizer | http://Toast.show(∞) Podcast Host

你已经成功订阅到 Android 开发技术周报
太棒了!接下来,完成检验以获得全部访问权限 Android 开发技术周报
欢迎回来!你已经成功登录了。
Unable to sign you in. Please try again.
成功!您的帐户已完全激活,您现在可以访问所有内容。
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.
🍗