背景
假设有这样的需求,我们需要在 RecyclerView 的每个 item 中都通过定时器切换图片来持续播放一个动画,比如通过每秒切换一张电量不同的电池图片来实现类似充电时的动画效果,这个需求看起来好像很简单,但是如果在 RecyclerView 的每个 item 中都需要实现这样的动画,由于 RecyclerView 的复用机制,就会导致错乱的问题。
RecyclerView 的重用机制
我们可以尝试一下,按照正常的思路,RecyclerView 的 Adapter 代码如下:
class Sample1Adapter(context: Context): SampleAdapter<Sample1Adapter.ViewHolder>() {
private val mContext = context
override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder {
return ViewHolder(LayoutInflater.from(mContext).inflate(R.layout.item_sample_1, parent, false))
}
override fun onBindViewHolder(holder: ViewHolder?, position: Int) {
holder?.textView?.text = position.toString()
}
override fun getItemCount(): Int { return 100 }
inner class ViewHolder(itemView: View?) : RecyclerView.ViewHolder(itemView) {
val button = itemView?.findViewById<Button>(R.id.button)
val imageView = itemView?.findViewById<ImageView>(R.id.image_view)
val textView = itemView?.findViewById<TextView>(R.id.text_view)
private var disposable: Disposable? = null
init {
button?.setOnClickListener {
if (disposable == null) {
disposable = Observable.interval(0, 1, TimeUnit.SECONDS)
.subscribeOn(Schedulers.computation())
.map { when((it % 5).toInt()) {
1 -> R.drawable.ic_battery_charging_1
2 -> R.drawable.ic_battery_charging_2
3 -> R.drawable.ic_battery_charging_3
4 -> R.drawable.ic_battery_charging_4
else -> {
R.drawable.ic_battery_charging_0
}
} }
.observeOn(AndroidSchedulers.mainThread())
.subscribe{imageView?.setImageResource(it)}
button.text = "stop"
addDisposable(disposable)
} else {
removeDisposable(disposable)
disposable?.dispose()
disposable = null
imageView?.setImageResource(R.drawable.ic_battery_charging_0)
button.text = "start"
}
}
}
}
}
代码的逻辑还是很简单的,在每个 ViewHolder 中定义一个定时器,通过点击按钮来控制定时器的开关,当定时器开启时,每秒切换一张图片,模拟充电的效果,然后看一下运行效果
我们发现,点击第一个 item 的按钮之后,动画开始播放了,然后上滑,发现第 10 个 item 也在播放动画, 出现这种情况的原因是什么呢,下面我们分析一下。
如图所示,假设屏幕的大小刚好够显示 5 个 viewholder,那么当设置 adapter 的时候,系统会立即创建 5 个 viewholder 用于显示前 5 条数据,然后我们向上滑,这时候系统并不会再次创建 viewholder,而是把上面移除屏幕的 viewholder 重新拿到下面来使用,这就是 RecyclerView 的复用机制。
如图,上滑一个 item 的距离时,item5 移入屏幕,这时候并不是重新创建一个 viewholder,而是把之前显示 item0 数据的 viewholder0 直接拿到下面来显示 item5。事实上,总共创建的 viewholder 数量比屏幕显示的最大 item 数量要多一点,就是说,这里其实 item5 还是会新创建 viewholder 的,可能后面的 item6 或者 item7 甚至更大才会重用 viewholder0,这里为了方便画图,就这么解释了,大家理解意思就好了。
那么根据上面测试的结果,我们可以推断,当 item10 移入屏幕的时候,它是复用了本来用来显示 item0 的 viewholder0, 而 viewholder0 在之前的操作中打开了动画,所以item10 也会播放动画。
那么改如何解决这样的问题呢?
刷新 item 列表
最简单的方法就是在定义一个图片资源的数组,用于存放 item 的图片,在定时器中需要切换图片的时候,直接改变数组中的值,然后刷新对应位置的 item,adapter 的代码如下
class Sample2Adapter(context: Context): SampleAdapter<Sample2Adapter.ViewHolder>() {
private val mContext = context
// 用于显示对应 item 位置的图片
@DrawableRes
private val drawables = IntArray(100)
// 定时器数组,每个 item 都需要一个定时器
private val disposables = arrayOfNulls<Disposable>(100)
init {
for (i in drawables.indices) {
drawables[i] = R.drawable.ic_battery_charging_0
}
}
override fun onBindViewHolder(holder: ViewHolder?, position: Int) {
holder?.textView?.text = position.toString()
holder?.imageView?.setImageResource(drawables[position])
if (disposables[position] == null) holder?.button?.text = "start"
else holder?.button?.text = "stop"
}
override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder {
return ViewHolder(LayoutInflater.from(mContext).inflate(R.layout.item_sample_1, parent, false))
}
override fun getItemCount(): Int {
return 100
}
inner class ViewHolder(itemView: View?): RecyclerView.ViewHolder(itemView) {
val button = itemView?.findViewById<Button>(R.id.button)
val imageView = itemView?.findViewById<ImageView>(R.id.image_view)
val textView = itemView?.findViewById<TextView>(R.id.text_view)
init {
button?.setOnClickListener {
val position = adapterPosition
if (disposables[position] == null) {
// 定时器用于改变对应 item 位置的图片,然后刷新该位置的 item
disposables[position] = Observable.interval(0, 1, TimeUnit.SECONDS)
.subscribeOn(Schedulers.computation())
.map { when((it % 5).toInt()) {
1 -> R.drawable.ic_battery_charging_1
2 -> R.drawable.ic_battery_charging_2
3 -> R.drawable.ic_battery_charging_3
4 -> R.drawable.ic_battery_charging_4
else -> {
R.drawable.ic_battery_charging_0
}
} }
.doOnNext { drawables[position] = it }
.observeOn(AndroidSchedulers.mainThread())
.subscribe { notifyItemChanged(position) }
addDisposable(disposables[position])
} else {
removeDisposable(disposables[position])
disposables[position]?.dispose()
disposables[position] = null
drawables[position] = R.drawable.ic_battery_charging_0
notifyItemChanged(position)
}
}
}
}
}
这里需要注意,为了保证上下滑动过程中,每个 item 都能保持自己的动画播放状态,必须为每个 item 都设置一个定时器,用于记录其对应 item 的动画播放状态,可以看一下运行效果
这种方法非常简单粗暴,原理也很简单,直接通过改变 item 中图片资源的值,然后刷新
item,而不用关心 item 是存放在哪个 viewholder 中,但是每秒钟都要刷新 item,如果同时开起多个 item 的定时器,那么每秒钟都要刷新多个 item,这无疑会有巨大的性能消耗。
同步播放状态
有一种比较高效的方法是在滑动过程中,及时把当前位置 item 的动画播放状态同步到 viewholder 中,然后 viewholder 中根据播放状态来确定是否要播放动画,代码如下
class Sample3Adapter(context: Context): SampleAdapter<Sample3Adapter.ViewHolder>() {
private val mContext = context
// 布尔类型数组,用于记录每个 item 的播放状态
private val flags = BooleanArray(100)
override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder {
return ViewHolder(LayoutInflater.from(mContext).inflate(R.layout.item_sample_1, parent, false))
}
override fun getItemCount(): Int { return 100 }
override fun onBindViewHolder(holder: ViewHolder?, position: Int) {
holder?.textView?.text = position.toString()
// 把当前位置 item 的播放状态同步给 viewholder
holder?.playing = flags[position]
holder?.setStatus()
}
inner class ViewHolder(itemView: View?) : RecyclerView.ViewHolder(itemView) {
val button = itemView?.findViewById<Button>(R.id.button)
val imageView = itemView?.findViewById<ImageView>(R.id.image_view)
val textView = itemView?.findViewById<TextView>(R.id.text_view)
// 是否播放动画的开关
var playing: Boolean = false
init {
// 创建 viewholder 的时候立即开启定时器
val d = Observable.interval(0, 1, TimeUnit.SECONDS)
.subscribeOn(Schedulers.computation())
.filter { playing } // 根据开关状态确定是否播放动画
.map {
when ((it % 5).toInt()) {
1 -> R.drawable.ic_battery_charging_1
2 -> R.drawable.ic_battery_charging_2
3 -> R.drawable.ic_battery_charging_3
4 -> R.drawable.ic_battery_charging_4
else -> R.drawable.ic_battery_charging_0
}
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe { imageView?.setImageResource(it) }
addDisposable(d)
button?.setOnClickListener {
val position = adapterPosition
playing = !playing
flags[position] = playing
setStatus()
}
}
fun setStatus() {
when {
playing -> button?.text = "stop"
else -> {
button?.text = "start"
imageView?.setImageResource(R.drawable.ic_battery_charging_0)
}
}
}
}
}
这里在每个 viewholder 创建的时候直接开启定时器,但是定时器有个开关,在滑动过程中,把每个 item 的播放状态实时的同步到 viewholder 中来控制开关,从而控制定时器是否要播放动画。
分析一下滑动的过程,首先 item0 在 viewholer0 中显示,点击 item0 中的按钮时,打开了它的播放开关,这时候 viewholder0 开始播放动画
然后滑动到下面的时候,item10 在 viewholder0 中显示,但是在滑动的过程中,把 item10 的播放开关同步到 viewholder0 中了,所以这时 viewholder0 没有播放动画然后再次点击 viewholder0 中的按钮时,开启了 item10 的动画播放开关,同时把播放状态同步到 viewholder0 中,viewholder0 继续播放动画
然后回到 item0,再次把 item0 的播放状态同步到 viewholder0 中,动画仍然在播放,然后关闭 item0 的动画,同时把播放状态同步到 viewholder0 中,所以动画停止播放
再次下滑,由于 item10 的动画还在播放中,并在滑动过程中把播放状态同步到 viewholder0 中了,所以 viewholer0 中又开始播放动画
同步播放进度
同步播放状态的方法虽然很高效,但是有一定的限制,就是我们在上下滑动的过程中只能同步每个 item 的播放状态,是播放中还是未播放,但是无法同步播放的进度。假设现在每个 item 中不是要切换图片,而是有一个 ProgressBar,类似于那种下载进度条,ProgressBar 是一直在动的,然后在滑动过程中还需要随时还原每个 item 中的进度,那应该如何实现呢?
首先肯定是每个 item 都需要一个定时器和控制开关,分别用于记录自己的进度和控制动画是否播放,然后在可以 viewholder 中保存上一次存放在该 viewholder 的 item 的位置,然后在滑动过程中,关闭上一次的位置的开关(因为这时候它已经滑出屏幕了),再开启当前位置的开关,代码如下
class Sample4Adapter(context: Context): SampleAdapter<Sample4Adapter.ViewHolder>() {
private val mContext = context
// 定时器数组,每个 item 都需要一个定时器
private val disposables = arrayOfNulls<Disposable>(100)
// 用于记录是否更新 ui 的开关
private val flags = BooleanArray(100)
// 用于记录 item 中 progressBar 的进度
private val progresss = IntArray(100)
override fun getItemCount(): Int { return 100 }
override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder {
return ViewHolder(LayoutInflater.from(mContext).inflate(R.layout.item_sample_2, parent, false))
}
override fun onBindViewHolder(holder: ViewHolder?, position: Int) {
holder?.textView?.text = position.toString()
holder?.progressBar?.progress = progresss[position]
when {
disposables[position] == null -> holder?.button?.text = "start"
else -> holder?.button?.text = "stop"
}
// 关闭上一次位置的开关
if (holder?.lastPosition != -1) {
flags[holder?.lastPosition!!] = false
}
// 开启当前位置的开关
flags[position] = true
holder.lastPosition = position
}
inner class ViewHolder(itemView: View?) : RecyclerView.ViewHolder(itemView) {
val button = itemView?.findViewById<Button>(R.id.button)
val progressBar = itemView?.findViewById<ProgressBar>(R.id.progress_bar)
val textView = itemView?.findViewById<TextView>(R.id.text_view)
// 上一次存放在 viewholder 中的 item 的位置
var lastPosition: Int = -1
init {
button?.setOnClickListener {
val position = adapterPosition
if (disposables[position] == null) {
disposables[position] = Observable.interval(0, 1, TimeUnit.SECONDS)
.subscribeOn(Schedulers.computation())
.filter { it <= 100 && flags[position] }
.map { it.toInt() }
.doOnNext { progresss[position] = it }
.observeOn(AndroidSchedulers.mainThread())
.subscribe { progressBar?.progress = it }
addDisposable(disposables[position])
button.text = "stop"
} else {
disposables[position]?.dispose()
removeDisposable(disposables[position])
disposables[position] = null
progresss[position] = 0
progressBar?.progress = 0
button.text = "start"
}
}
}
}
}
这里的原理其实是在 viewholder 中同时开启了多个定时器,分别用于记录不同 item 的播放进度,如果不做任何处理,就会发现有多个定时器更新进度的效果,所以我们记录 viewholer 中上一次存放的 item,然后当 item 滑出屏幕时,关闭它的更新 ui 的开关(这里只是不更新ui,定时器仍然在发送数据),只开启当前显示在 viewholder 中的 item 的开关
照例分析一下滑动的过程,首先 item0 在 viewholder0 中显示,它的进度是 0%,然后点击按钮,viewholder0 中的进度条开始动,当它走到 5% 的时候,向下滑动
当 item10 显示在 viewholder0 的时候,item0 中的开关被关闭,此时 item10 中的定时器还没有开启,把 item10 的进度同步到 viewholder0 中,所以 viewholder0 显示的是进度为 0%
然后开启 item10 的定时器,viewholder0 的进度开始动了,当它走到 5% 的时候,上滑回到 item0,当 item0 重新显示在 viewholder0 中时,item 10 的开关被关闭,item0 的开关被重新开启,而此时 item0 的定时器已经走到了 15%,把它的进度同步到 viewholder0 中,所以 viewholder0 显示的进度是 15%,并且跟随 item0 的定时器继续走
接着关闭 item0 的定时器,然后下滑到 item10,当 item10 再次显示在 viewholder0 中时,它的开关被重新开启,此时 item10 的定时器走到了 10%,再把它的进度同步到 viewholder0中,所以 viewholder0 显示的进度是 10%,并且跟随 item10 的定时器继续走