遇到的问题
我们有一个示例应用: Tivi,它可以展示 TV 节目的详细信息。关于节目信息,应用内罗列了每一季和每一集。当用户点击其中的某一集时,该集的详细信息将以点击处展开的动画来展示 (0.2 倍速展示):
图片应用中采用 InboxRecyclerView 库来处理图中的展开动画:
fun onEpisodeItemClicked(view: View, episode: Episode) {
InboxRecyclerView 的工作原理是通过我们提供的条目 ID,在 RecyclerView 中找到对应项,然后执行动画。接下来让我们看一下需要解决的问题。在这些相同 UI 界面顶部附近,展示了观看下一集的条目。这里使用和下面独立剧集相同的视图类型,但却有不同的条目 ID。为了便于开发,这里这两个条目复用了相同的 onEpisodeItemClicked() 方法。但不幸的是,这导致了在点击的时候动画异常 (0.2 倍速展示):
图片实际效果并没有从点击的条目展开,而是从顶部展开了一个看似随机的条目。这并不是我们的预期效果,引发该问题的原因有如下几点:
-
我们在点击事件的监听器中使用的 ID 是直接通过 Episode 类来获取的。这个 ID 映射到了季份列表中的某一集;
-
该集的条目可能还没有被添加到 RecyclerView 中,需要用户展开该季份的列表,然后将其滑动展示到屏幕上,这样我们需要的视图才能被 RecyclerView 加载。
由于上述原因,导致该依赖库执行回退,使用第一个条目进行展开。
理想的解决方案
我们期望行为是什么呢?我们想要得到这样的效果 (0.2 倍速展示):
图片用伪代码来实现,大概是这样:
fun onNextEpisodeToWatchItemClick(view: View, nextEpisodeToWatch: Episode) {
但是在现实情况下,应该更像如下的实现:
fun onNextEpisodeToWatchItemClick(view: View, nextEpisodeToWatch: Episode) {
我们可以发现,这里需要很多等待异步操作完成的代码。此处的伪代码看似不太复杂,但只要您着手实现这些功能,就会立即陷入回调地狱。下面是使用链式回调尝试实现的架构:
fun expandEpisodeItem(itemId: Long) {
这段代码还有缺陷,并且可能无法正常运行,旨在说明回调会极大增加 UI 编程的复杂度。总的来说,这段代码有如下的问题:
耦合严重
由于我们不得不通过回调的方式完成过渡动画,因此每一个动画都需要明确接下来需要调用的方法: Callback #1 调用 Animation #2,Callback #2 调用 Animation #3,以此类推。这些动画本身并无关联,但是我们强行将它们耦合到了一起。难以维护/更新
两个月以后,动画设计师要求在其中增加一个淡入淡出的过渡动画。您可能需要跟踪这部分过渡动画,查看每一个回调才能找到确切的位置触发新动画,之后您还要进行测试...
测试
无论如何,测试动画都是很困难的,使用混乱的回调更是让问题雪上加霜。为了在回调中使用断言判断是否执行了某些操作,您的测试必须包含所有的动画类型。本文并未真正涉及测试,但是使用协程可以让其更加简单。
使用协程解决问题
在前一篇文章中,我们已经学习了如何使用挂起函数封装回调 API。让我们利用这些知识来优化我们臃肿的回调代码:
viewLifecycleOwner.lifecycleScope.launch {
可读性得到了巨大的提升!新的挂起函数隐藏了所有复杂的操作,从而得到了一个线性的调用方法序列,让我们来探究更深层次的细节...
MotionLayout.awaitTransitionComplete()
目前还没有 MotionLayout 的 ktx 扩展方法提供我们使用,并且 MotionLayout 暂时不支持添加多个监听。这意味着 awaitTransitionComplete() 的实现要比其他方法复杂得多。这里我们使用 MotionLayout 的子类来实现多监听器的支持: MultiListenerMotionLayout。
-
MotionLayout
https://developer.android.google.cn/reference/android/support/constraint/motion/MotionLayout
-
MultiListenerMotionLayouthttps://gist.github.com/chrisbanes/a7371683c224464bf6bda5a25491aee0
我们的 awaitTransitionComplete() 方法如下定义:
/**
Adapter.awaitItemIdExists()
这个方法很优雅,同时也非常有效。在 TV 节目的例子中,实际上处理了几种不同的异步状态:
// 确保指定的季份列表已经展开,目标剧集已经被加载
这个方法使用了 RecyclerView 的 AdapterDataObserver 来实现监听适配器数据集的改变:
/**
RecyclerView.awaitScrollEnd()
需要特别注意等待滚动完成的方法: RecyclerView.awaitScrollEnd()
suspend fun RecyclerView.awaitScrollEnd() {
希望目前为止,这段代码还是通俗易懂的。这个方法内部最棘手之处是需要在 fail-fast 检查之前调用 awaitAnimationFrame()。如注释中所说,由于 SmoothScroller 真正开始执行的时间是动画的下一帧,所以我们等待一帧后再判断滑动状态。
awaitAnimationFrame() 方法封装了 postOnAnimation() 来实现等待动画的下一个动作,该事件通常发生在下一次渲染。这里的实现类似前一篇文章中的 doOnNextLayout():
suspend fun View.awaitAnimationFrame() = suspendCancellableCoroutine<Unit> { continuation ->
-
postOnAnimation()
最终效果
最后,操作序列的效果如下图所示 (0.2 倍速展示):
图片打破回调链
迁移到协程可以使我们能够摆脱庞大的回调链,过多的回调让我们难以维护和测试。对于所有 API,将回调、监听器、观察者封装为挂起函数的方式基本相同。希望您此时已经能感受到我们文中例子的重复性。那么接下来还请再接再厉,将您的 UI 代码从链式回调中解放出来吧!
本文在开源项目:https://github.com/Android-Alvin/Android-LearningNotes 中已收录,里面包含不同方向的自学编程路线、面试题集合/面经、及系列技术文章等,资源持续更新中...
网友评论