背景
假設有這樣的需求,我們需要在 RecyclerView 的每個 item 中都通過定時器切換圖片來持續(xù)播放一個動畫,比如通過每秒切換一張電量不同的電池圖片來實現(xiàn)類似充電時的動畫效果逸月,這個需求看起來好像很簡單惋嚎,但是如果在 RecyclerView 的每個 item 中都需要實現(xiàn)這樣的動畫镀赌,由于 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 中定義一個定時器虏缸,通過點擊按鈕來控制定時器的開關,當定時器開啟時嫩实,每秒切換一張圖片刽辙,模擬充電的效果,然后看一下運行效果
我們發(fā)現(xiàn)舶赔,點擊第一個 item 的按鈕之后扫倡,動畫開始播放了,然后上滑竟纳,發(fā)現(xiàn)第 10 個 item 也在播放動畫撵溃, 出現(xiàn)這種情況的原因是什么呢,下面我們分析一下锥累。
如圖所示缘挑,假設屏幕的大小剛好夠顯示 5 個 viewholder,那么當設置 adapter 的時候桶略,系統(tǒng)會立即創(chuàng)建 5 個 viewholder 用于顯示前 5 條數(shù)據(jù)语淘,然后我們向上滑,這時候系統(tǒng)并不會再次創(chuàng)建 viewholder际歼,而是把上面移除屏幕的 viewholder 重新拿到下面來使用惶翻,這就是 RecyclerView 的復用機制。
如圖鹅心,上滑一個 item 的距離時吕粗,item5 移入屏幕,這時候并不是重新創(chuàng)建一個 viewholder旭愧,而是把之前顯示 item0 數(shù)據(jù)的 viewholder0 直接拿到下面來顯示 item5颅筋。事實上,總共創(chuàng)建的 viewholder 數(shù)量比屏幕顯示的最大 item 數(shù)量要多一點输枯,就是說议泵,這里其實 item5 還是會新創(chuàng)建 viewholder 的,可能后面的 item6 或者 item7 甚至更大才會重用 viewholder0桃熄,這里為了方便畫圖先口,就這么解釋了,大家理解意思就好了。
那么根據(jù)上面測試的結果池充,我們可以推斷桩引,當 item10 移入屏幕的時候,它是復用了本來用來顯示 item0 的 viewholder0收夸, 而 viewholder0 在之前的操作中打開了動畫,所以item10 也會播放動畫血崭。
那么改如何解決這樣的問題呢卧惜?
刷新 item 列表
最簡單的方法就是在定義一個圖片資源的數(shù)組,用于存放 item 的圖片夹纫,在定時器中需要切換圖片的時候咽瓷,直接改變數(shù)組中的值,然后刷新對應位置的 item舰讹,adapter 的代碼如下
class Sample2Adapter(context: Context): SampleAdapter<Sample2Adapter.ViewHolder>() {
private val mContext = context
// 用于顯示對應 item 位置的圖片
@DrawableRes
private val drawables = IntArray(100)
// 定時器數(shù)組茅姜,每個 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 都能保持自己的動畫播放狀態(tài),必須為每個 item 都設置一個定時器锄开,用于記錄其對應 item 的動畫播放狀態(tài)素标,可以看一下運行效果
這種方法非常簡單粗暴,原理也很簡單萍悴,直接通過改變 item 中圖片資源的值头遭,然后刷新
item,而不用關心 item 是存放在哪個 viewholder 中癣诱,但是每秒鐘都要刷新 item计维,如果同時開起多個 item 的定時器,那么每秒鐘都要刷新多個 item撕予,這無疑會有巨大的性能消耗鲫惶。
同步播放狀態(tài)
有一種比較高效的方法是在滑動過程中,及時把當前位置 item 的動畫播放狀態(tài)同步到 viewholder 中嗅蔬,然后 viewholder 中根據(jù)播放狀態(tài)來確定是否要播放動畫剑按,代碼如下
class Sample3Adapter(context: Context): SampleAdapter<Sample3Adapter.ViewHolder>() {
private val mContext = context
// 布爾類型數(shù)組,用于記錄每個 item 的播放狀態(tài)
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 的播放狀態(tài)同步給 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 {
// 創(chuàng)建 viewholder 的時候立即開啟定時器
val d = Observable.interval(0, 1, TimeUnit.SECONDS)
.subscribeOn(Schedulers.computation())
.filter { playing } // 根據(jù)開關狀態(tài)確定是否播放動畫
.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 創(chuàng)建的時候直接開啟定時器澜术,但是定時器有個開關艺蝴,在滑動過程中,把每個 item 的播放狀態(tài)實時的同步到 viewholder 中來控制開關鸟废,從而控制定時器是否要播放動畫猜敢。
分析一下滑動的過程,首先 item0 在 viewholer0 中顯示,點擊 item0 中的按鈕時缩擂,打開了它的播放開關鼠冕,這時候 viewholder0 開始播放動畫
然后滑動到下面的時候,item10 在 viewholder0 中顯示胯盯,但是在滑動的過程中懈费,把 item10 的播放開關同步到 viewholder0 中了,所以這時 viewholder0 沒有播放動畫然后再次點擊 viewholder0 中的按鈕時博脑,開啟了 item10 的動畫播放開關憎乙,同時把播放狀態(tài)同步到 viewholder0 中,viewholder0 繼續(xù)播放動畫
然后回到 item0叉趣,再次把 item0 的播放狀態(tài)同步到 viewholder0 中泞边,動畫仍然在播放,然后關閉 item0 的動畫疗杉,同時把播放狀態(tài)同步到 viewholder0 中阵谚,所以動畫停止播放
再次下滑,由于 item10 的動畫還在播放中烟具,并在滑動過程中把播放狀態(tài)同步到 viewholder0 中了梢什,所以 viewholer0 中又開始播放動畫
同步播放進度
同步播放狀態(tài)的方法雖然很高效,但是有一定的限制净赴,就是我們在上下滑動的過程中只能同步每個 item 的播放狀態(tài)绳矩,是播放中還是未播放,但是無法同步播放的進度玖翅。假設現(xiàn)在每個 item 中不是要切換圖片翼馆,而是有一個 ProgressBar,類似于那種下載進度條金度,ProgressBar 是一直在動的应媚,然后在滑動過程中還需要隨時還原每個 item 中的進度,那應該如何實現(xiàn)呢猜极?
首先肯定是每個 item 都需要一個定時器和控制開關中姜,分別用于記錄自己的進度和控制動畫是否播放,然后在可以 viewholder 中保存上一次存放在該 viewholder 的 item 的位置跟伏,然后在滑動過程中丢胚,關閉上一次的位置的開關(因為這時候它已經(jīng)滑出屏幕了),再開啟當前位置的開關受扳,代碼如下
class Sample4Adapter(context: Context): SampleAdapter<Sample4Adapter.ViewHolder>() {
private val mContext = context
// 定時器數(shù)組携龟,每個 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 的播放進度勘高,如果不做任何處理峡蟋,就會發(fā)現(xiàn)有多個定時器更新進度的效果坟桅,所以我們記錄 viewholer 中上一次存放的 item,然后當 item 滑出屏幕時蕊蝗,關閉它的更新 ui 的開關(這里只是不更新ui仅乓,定時器仍然在發(fā)送數(shù)據(jù)),只開啟當前顯示在 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 的定時器已經(jīng)走到了 15%矿辽,把它的進度同步到 viewholder0 中丹允,所以 viewholder0 顯示的進度是 15%,并且跟隨 item0 的定時器繼續(xù)走
接著關閉 item0 的定時器袋倔,然后下滑到 item10雕蔽,當 item10 再次顯示在 viewholder0 中時,它的開關被重新開啟宾娜,此時 item10 的定時器走到了 10%批狐,再把它的進度同步到 viewholder0中,所以 viewholder0 顯示的進度是 10%前塔,并且跟隨 item10 的定時器繼續(xù)走