美文网首页
在 View 上使用挂起函数 | 实战

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

作者: 谷歌开发者 | 来源:发表于2020-10-30 17:45 被阅读0次

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

    相关文章

      网友评论

          本文标题:在 View 上使用挂起函数 | 实战

          本文链接:https://www.haomeiwen.com/subject/biidvktx.html