在项目中应用了 MVVM 模式之后,就能享受到 DiffUtil 带来的计算最小变动集的便利性,以及在列表项更新时能用上自带动画。

但是因为页面更新应用了动画,使得页面响应点击时出现了问题。

问题现象

在我的页面中,列表每一项都带有一个订阅按钮,对应数据有一个 “是否已经订阅” 的字段。每次点击按钮时,都根据目前数据是否已经订阅,向服务器发送订阅或者取消订阅的请求,然后根据请求的返回结果是否成功,使用 DiffUtil 更新当前页面列表项。

示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class ItemViewHolder(itemView: View, private val onCheck: ((Int, Boolean) -> Unit)? = null) :
RecyclerView.ViewHolder(itemView) {
private var data: Item? = null
private val titleView = itemView.findViewById<TextView>(R.id.title)
private val checkButton = itemView.findViewById<TextView>(R.id.button)

init {
checkButton.setOnClickListener(::onClick)
}

private fun onClick(view: View) {
onCheck?.invoke(adapterPosition, data?.checked ?: false)
}

fun bind(bindData: Item) {
data = bindData
titleView.text = bindData.title
checkButton.isSelected = bindData.checked
}
}

data class Item(
val id: Long,
val title: String,
var checked: Boolean = false
)

代码看起来很美好,但是测试反馈过来说:快速多次点击按钮时,会重复发出完全一样的请求,而后续的请求则毫无疑问地失败了。

问题分析

得知问题后,马上开始开始着手分析。经过不停地断点调试和输出 log 之后(过程省略),会发现连续点击时,响应点击事件的 View 及其 ViewHolder 都一直是同一个对象;在替换动画播放完成后再次点击,响应点击事件的就变成了一个新的 View 了。

继续深入研究, RecyclerView 的动画默认是在 DefaultItemAnimator 中实现的。 而对于拥有相同 id 的数据,仅仅是其中的部分数据有更新,没有进行位置变换的话, DiffUtil 会调用 adapter 的 notifyItemChanged 方法通知更新。 Change 的动画在 DefaultItemAnimator 中的实现可以参见方法 animateChangeImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
void animateChangeImpl(final ChangeInfo changeInfo) {
final RecyclerView.ViewHolder holder = changeInfo.oldHolder;
final View view = holder == null ? null : holder.itemView;
final RecyclerView.ViewHolder newHolder = changeInfo.newHolder;
final View newView = newHolder != null ? newHolder.itemView : null;
if (view != null) {
final ViewPropertyAnimator oldViewAnim = view.animate().setDuration(
getChangeDuration());
mChangeAnimations.add(changeInfo.oldHolder);
oldViewAnim.translationX(changeInfo.toX - changeInfo.fromX);
oldViewAnim.translationY(changeInfo.toY - changeInfo.fromY);
oldViewAnim.alpha(0).setListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animator) {
dispatchChangeStarting(changeInfo.oldHolder, true);
}
@Override
public void onAnimationEnd(Animator animator) {
oldViewAnim.setListener(null);
view.setAlpha(1);
view.setTranslationX(0);
view.setTranslationY(0);
dispatchChangeFinished(changeInfo.oldHolder, true);
mChangeAnimations.remove(changeInfo.oldHolder);
dispatchFinishedWhenDone();
}
}).start();
}
if (newView != null) {
final ViewPropertyAnimator newViewAnimation = newView.animate();
mChangeAnimations.add(changeInfo.newHolder);
newViewAnimation.translationX(0).translationY(0).setDuration(getChangeDuration())
.alpha(1).setListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animator) {
dispatchChangeStarting(changeInfo.newHolder, false);
}
@Override
public void onAnimationEnd(Animator animator) {
newViewAnimation.setListener(null);
newView.setAlpha(1);
newView.setTranslationX(0);
newView.setTranslationY(0);
dispatchChangeFinished(changeInfo.newHolder, false);
mChangeAnimations.remove(changeInfo.newHolder);
dispatchFinishedWhenDone();
}
}).start();
}
}

可以得知, DefaultItemAnimator 对于 change 的动画实现是将旧的 viewHolder 的不透明度 (alpha) 渐变到0,将新的 viewHolder 的不透明度渐变到1。呈现出来的效果便是在同一个位置,旧 viewHolder 淡出,被淡入的新 viewHolder 替换。

而上述点击的问题就出在动画播放过程中,点击事件被即将消失的旧 viewHolder 捕获了。

解决方案

在知道问题原因之后,就需要开始找解决办法了。既然问题原因出在动画播放过程中的点击响应,那么解决的思路应该是:在动画播放过程中不接受点击事件的分发,在动画播放完成后恢复响应。对此我想出了几个解决的思路。

业务方处理点击的生效和禁用

最开始的思路是,既然点击事件的监听是在 ViewHolder 中设置的,那么在需要禁用和启用对应事件的时候,由 ViewHolder 自行判断就好了。然而紧接着遇到的问题是, ViewHolder 能收到的外部通知仅仅是 adapter 中的 onBindViewHolder ,它在动画开始前就被调用,而动画完成后没有任何事件通知,因此无法实现点击事件的响应恢复。

另一个问题在于,如果将这一步交给业务方的 ViewHolder 自行实现,将会出现大量的模版代码,不利于项目的维护。

基于这两个原因,这一个思路连实验代码都没有写便被我放弃了。

Animator 中自动设置是否响应点击

既然问题出在动画过程中,而系统也为我们开放了动画管理的接口,那么很自然地会想到自己复写动画的开始和结束,对应设置点击的启用与否。

在上面的 animateChangeImpl 代码中,可以看到第 15 行和第 37 行分别调用了 dispatchChangeStarting ,并且将当前传入的 viewHolder 是新是旧也作为参数传入,正适合我们进行控制,马上开始尝试:

ClickAnimator.kt:

1
2
3
4
5
6
7
8
9
10
11
class ClickItemAnimator : DefaultItemAnimator() {
override fun onChangeStarting(item: RecyclerView.ViewHolder?, oldItem: Boolean) {
super.onChangeStarting(item, oldItem)
item?.itemView?.isClickable = false
}

override fun onChangeFinished(item: RecyclerView.ViewHolder?, oldItem: Boolean) {
super.onChangeFinished(item, oldItem)
item?.itemView?.isClickable = true
}
}

在我的设想中,动画开始时直接禁用整个 itemView 的是否可点击属性,在动画播放完成后将其恢复就能实现想要的效果。然而实际运行起来发现问题依旧,经过分析可知 isClickable 属性仅对单个 view 有效,其中的子 view 不受影响,而再次尝试 enable 属性也是如此,那么便只能尝试将其子 view 都进行设置了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ClickItemAnimator : DefaultItemAnimator() {
override fun onChangeStarting(item: RecyclerView.ViewHolder?, oldItem: Boolean) {
super.onChangeStarting(item, oldItem)
item.itemView?.let { setClickable(it, false) }
}

override fun onChangeFinished(item: RecyclerView.ViewHolder?, oldItem: Boolean) {
super.onChangeFinished(item, oldItem)
item.itemView?.let { setClickable(it, true) }
}

private fun setClickable(view: View, clickable: Boolean) {
view.isClickable = clickable
if (view is ViewGroup) {
view.children.filter { it.visibility == View.VISIBLE }.forEach { setClickable(it, clickable) }
}
}
}

然而在实际运行前便想到了另一个问题: itemView 中不一定每一个子 view 都是需要响应点击的,我们不应该在动画完成后遍历设置,否则可能会破坏其原有的业务功能;暂存其原本的属性也不现实,会需要维护一个巨大的缓存池,并且维护其与实际的 itemView 的对应关系也需要巨大的精力。

为 itemView 嵌套自定义 View ,按需拦截点击事件

如果不可以遍历设置 itemView 中的点击属性,一个折衷的办法可以是自定义一个 ViewGroup ,响应 Animator 的设置,按需拦截传入的点击事件。

然而这个办法需要修改 itemView 的根布局,不适合接入到已有的项目中;而且多一层布局嵌套可能会对绘制性能产生影响,因此这个方法没有进行尝试验证。

Animator 分发事件,业务自行响应

既然上述两个思路都有各自的缺陷而不可行,那么可以将两个方案组合起来各取优点使用:由 Animator 进行动画的开始、结束事件分发,各业务的 ViewHolder 按需进行响应的接入,达到按需响应、改动量小的目标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class ClickItemAnimator : DefaultItemAnimator() {

override fun onChangeStarting(item: RecyclerView.ViewHolder?, oldItem: Boolean) {
(item as? OnItemAnimationListener)?.onItemAnimationStatus(false)
super.onChangeStarting(item, oldItem)
}

override fun onChangeFinished(item: RecyclerView.ViewHolder?, oldItem: Boolean) {
(item as? OnItemAnimationListener)?.onItemAnimationStatus(true)
super.onChangeFinished(item, oldItem)
}
}

interface OnItemAnimationListener {
fun onItemAnimationStatus(shouldClickEnabled: Boolean)
}

class ClickItemViewHolder(itemView: View, onCheck: ((Int, Boolean) -> Unit)) :
ItemViewHolder(itemView, onCheck), OnItemAnimationListener {
private var handleClick = true

override fun onClick(view: View) {
onCheck?.takeIf { handleClick }?.invoke(adapterPosition, data?.checked ?: false)
}

override fun onItemAnimationStatus(shouldClickEnabled: Boolean) {
handleClick = shouldClickEnabled
}

override fun bind(bindData: Item) {
handleClick = true
super.bind(bindData)
}
}

在这里,需要在动画过程中禁用点击的 viewHolder 自行实现接口 OnItemAnimationListener ,根据其传入参数设置标记位;在实际点击事件中判断标记位属性值,来决定是否需要发起业务逻辑。这样需要修改的代码量少,同时也不会对原有的业务逻辑产生影响。

使用 TAG 暂存点击属性,遍历子view(更新)

(后续更新)

在项目中使用了方法 3 之后,我仍在思考有没有更加方便地结局问题的办法。方法 2 其原本的弊端在于不能在恢复点击时将全部子 view 都设置成可点击的,需要有一个方便取用的位置记录其原本的属性,其实有一个方便的位置,即存在 view 的 tag 里面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class TagAnimator : DefaultItemAnimator() {

override fun onChangeStarting(item: RecyclerView.ViewHolder?, oldItem: Boolean) {
item?.itemView?.let(::setTag)
super.onChangeStarting(item, oldItem)
}

override fun onChangeFinished(item: RecyclerView.ViewHolder?, oldItem: Boolean) {
item?.itemView?.let(::restoreTag)
super.onChangeFinished(item, oldItem)
}

private fun setTag(view: View) {
val clickable = view.isClickable
view.setTag(R.id.tag_id, clickable)
view.isClickable = false
iteratorView(view, ::setTag)
}

private fun restoreTag(view: View) {
val clickable = view.getTag(R.id.tag_id)
view.isClickable = clickable as? Boolean ?: false
iteratorView(view, ::restoreTag)
}

private fun iteratorView(view: View, action: ((View) -> Unit)) {
if (view is ViewGroup) {
view.children.forEach(action)
}
}
}

这种方法能快速地应用到需要使用的地方,不需要对业务 viewHolder 进行额外的修改,但是失去了可定制性。我认为在使用上跟方法 3 是不相上下的。

示例demo

我把上面的问题与解决做成了一个 demo 项目:ItemAnimatorBlockClick

在页面底部点击 Default 按钮可以显示原本有问题的页面,点击一个 Check 按钮后,对应列表项将会开始替换动画(动画时间被延长到 1.5s 方便观察);在动画过程中再次点击同一个按钮,将会看到一个 error 的 log 输出,因为传入的状态与数据中存有的状态不符,并且按钮动画消失,马上转变回原有的样子。

Click 按钮可以显示使用方法 3 修复的页面,重复上述操作会发现在动画过程中,重复的点击事件不会被响应,也不会看到 error 的 log 输出,直到动画播放完成后才能正确响应下一次点击。

Tag 按钮可以显示使用方法 4 修复的页面,其表现与前一个页面没有区别。

后文

在项目中,大部分页面都直接调用了 adapter.notifyDataSetChanged 方法,直接刷新整个页面的数据。这样做确实有其便利性,不用考虑在什么地方具体发生了怎样的更新、不用担心动画执行的时间里发生问题。然而尽可能地使用到系统原生提供的工具和动画支持,也是提高用户体验的一个方向,在遇到问题 -> 解决问题的过程中,自己也能得到学习提高的机会。