场景
最近在开发产品的过程中,有这样一个场景:页面包含一个竖向列表,当点击特定的item时会在其中展示建议视图。这些建议视图从服务器获取并展示在一个横向滑动的列表中。我们还是如常使用RecyclerView去做。
技术规划
现在让我们深入到技术细节,获取建议并展示的逻辑是在一个单独的view holder中处理,其内部包含它自己的RecyclerView去展示横向的列表。在onBindView中,这个view holder执行获取数据和在RecyclerView中展示的逻辑。我们不能在不知道用户点击具体哪个item就去提前获取数据,而且我们也不能把数据保存到父级adapter中,就结构层面来说我们想要保证这些view holder逻辑是分离的。
出现的问题
当我们每次滑动列表到这个view holder显示在屏幕中时,onBind会被调用并且我们不得不重新获取数据并配置新的adapter然后展示到UI上。
解决方案
如果我们能禁止这个建议view holder每次都调用onBind就没问题了 。为了达到这个目的,我们需要做两件事。首先,我们需要告诉RecyclerView始终重用同一个view holder,同一位置不再调用onBind.其次,相同的view Type在不同的位置上不能使用这个view holder。
简而言之,我们需要一个可以缓存特定类型视图的解决方案。
不太妥帖的方案
1.我们可以增加RecyclerView的缓存数量。但是,这样的话我们就必须把缓存数量设置成跟整个列表的数量一致,这是因为RecyclerView只能缓存我们可见区域内的视图,所以如果你的view holder远离可见区域它将不会被缓存,直到缓存大小等于整个列表。
但是,这种方案对内存是不友好的,当你将整个列表都缓存起来的话,使用RecyclerView也就没有意义了。
2.其次,有人会建议你去将特定的view holder定义成不可复用。
holder.setIsRecyclable(false);
或是
recyclerView.getRecycledViewPool()
.setMaxRecycledViews(TARGET_VIEW_HOLDER, POOL_CAPACITY);
//即设置 POOL_CAPACITY 为 0.
它将帮助你实现第二点即对相同的类型且不同位置不去使用view holder,不会将它移动到RecyclerViewPool。但是,它不会在所有情况下都满足我们的目的,因为,当我们将目标view holder从可见区域移开时,RecyclerView仍然会从cache中清除它。它只是没有移到RecyclerViewPool中。
进阶方案
RecyclerView提供了一个回调方法:
ViewCacheExtension()
RecyclerView在检查了它自身的cache或scrap view之后以及在将其移动到RecyclerViewPool之前会调用它的方法 getViewForPositionAndType 。这是一个抽象类,你可以实现它并返回特定位置的视图。
val viewCacheExtension = object : ViewCacheExtension() {
override fun getViewForPositionAndType(
recycler: Recycler,
position: Int,
type: Int
): View? {
}
}
我们可以利用它来缓存特定的视图类型。
让我们看看如何使用它来达到我们的目的,并创建一个通用的解决方案,使它允许为特定的视图类型启用缓存。
1.选取特定的view holder去缓存
我们需要一个存储view holder的Map用来保存这些已经缓存的视图。我们还需要存储需要启用缓存的视图holder类型,这里我们使用Set。
val cachedItems: MutableMap<Long, ViewHolder> = HashMap()
//这里的Key是唯一的用来标识特定位置的item
val cachedViewHolderTypes: MutableSet<Int> = HashSet()
现在填充已经创建的视图的最佳时机是:
onViewDetachedFromWindow()
这是因为视图已经被创建并且已经被绑定了一次,在它被回收之前,我们将它保存到我们的Map中:
//一旦视图划出屏幕便将其保存起来
override fun onViewDetachedFromWindow(holder: ViewHolder) {
if (cachedViewHolderTypes.contains(holder.itemViewType)) {
val pos = holder.bindingAdapterPosition
Timber.d("onViewDetached called for position : $pos")
if (pos > -1) {
holder.setIsRecyclable(false)
cachedItems[getCachedItemId(pos)] = holder
}
}
super.onViewDetachedFromWindow(holder)
}
这里,我们存储的view holder与其Item Id相关联,即RecyclerView的getItemId(int pos)方法。
可能你也注意到了我们将这个holder置为可被回收,否则相同的holder将被我们重用到其他的位置上,因为一旦划出屏幕RecyclerView便将其移动到RecyclerView Pool中。
2.在onBindView调用之前返回已缓存的view holder
对此,我们像这样使用ViewCacheExtension:
//返回特定位置已缓存的view holder
private val viewCacheExtension = object : ViewCacheExtension() {
override fun getViewForPositionAndType(
recycler: Recycler,
position: Int,
type: Int
): View? {
val cachedItemId = getItemId(position)
if (cachedViewHolderTypes.contains(type) && cachedItemId != NO_CACHED_ITEM_ID
&& cachedItems.containsKey(cachedItemId)
) {
Timber.d("Returning view from custom cache for position:$position")
return cachedItems[cachedItemId]?.itemView
}
return null
}
}
在返回已缓存的视图之前需要做相关的检查:
- 通过对集合进行查询,我们检查对应的view holder类型是否已启用缓存。
- 是否这个holder的item id 和当前指向的位置相同。这是为了确保在我们的adapter已经被新的数据更新了但是我们的缓存Map还持有旧的view holder的场景不会发生。这是重要的,因为在数据集更新后,新的视图类型被分配到这个位置上,但是,我们仍然为这个位置返回旧的view holder,这将导致仍显示旧的视图。
3.确保在数据集变更的同时缓存的视图也会被更新
为了达到当数据集有变化,我们就能更新缓存Map的目的,我们需要利用AdapterDataObserver 观察数据的变化。这样当有任何通知到来我们会得到回调。
我们可以将数据集变更调用主要分为两组来处理:
- 使用notifyItemRangeRemoved()移除的缓存项。
- 我们在notifyItemRangeMoved()或notifyDataSetChanged()之后移除的view holder。
为了简化这个数据集变化导致的分组,我们继承AdapterDataObserver类来实现一个抽象类。
这里包含两个回调方法
- onChanged() : 我们观察任何数据集的变化除了数据被移除这个操作外。所以,我们只是检查,如果我们的缓存项是否仍然存在于新的集合中。
如果缓存项不在新的集合中,这个观察器将负责删除它们。
override fun onChanged() {
Timber.d("items added or moved")
if (cachedItems.isEmpty())
return
val iterator: MutableIterator<String> = cachedItems.keys.iterator()
val newItemsIds = mutableSetOf<String>()
for (newIndex in 0 until itemCount) {
val itemId = getItemId(newIndex)
newItemsIds.add(itemId)
}
//移除新集合中没有的缓存项
while (iterator.hasNext()) {
val cachedItemId = iterator.next()
val holder = cachedItems[cachedItemId]
if (holder?.bindingAdapterPosition == -1 || !newItemsIds.contains(cachedItemId)) {
holder?.itemView?.let { view ->
if (view.isAttachedToWindow || view.parent != null) {
recyclerView?.removeView(view)
view.visibility = View.GONE
}
}
iterator.remove()
}
}
}
- onItemRangeRemoved(positionStart: Int, itemCount: Int) :这里,我们观察数据的删除操作。
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
Timber.d("On Item range removed called for adapter observer")
if (cachedItems.isEmpty())
return
val iterator: MutableIterator<String> = cachedItems.keys.iterator()
while (iterator.hasNext()) {
val cachedItemId = iterator.next()
val index = cachedItems[cachedItemId]?.bindingAdapterPosition ?: -1
if (index != -1 && positionStart <= index && index < positionStart + itemCount) {
Timber.d("Removing view from adapter observer for position:$index")
val holder = cachedItems[cachedItemId]
holder?.itemView?.let { view ->
if (view.isAttachedToWindow || view.parent != null) {
recyclerView?.removeView(view)
view.visibility = View.GONE
}
}
iterator.remove()
}
}
}
4.在RecyclerView中注册这些回调方法
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
registerAdapterDataObserver(adapterDataObserver)
recyclerView.setViewCacheExtension(viewCacheExtension)
}
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
unregisterAdapterDataObserver(adapterDataObserver)
super.onDetachedFromRecyclerView(recyclerView)
}
5.一个简化的方法去为特定的view holder类型启动缓存
fun enableCacheForViewHolderType(type: Int) {
cachedViewHolderTypes.add(type)
}
到这里我们已经准备好了,我们自己实现的为RecyclerView特定view holder类型进行缓存的逻辑,只需要在onCreateViewHolder ()加入一行代码即可。
enableCacheForViewHolderType(viewType);
现在,在被声明view holder类型的onBindView方法只会执行一次。
完整代码:
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.*
import timber.log.Timber
import java.util.HashSet
import java.util.concurrent.ConcurrentHashMap
/**
* Created by Sumeet on 24,January,2022
This class helps you to return same view holder once bind , without rebinding again
Just sent the required type for enabling cache using enableCacheForViewHolderType and overriding getItemId()
*/
abstract class BaseAdapterWithCaching : RecyclerView.Adapter<ViewHolder>() {
private val cachedItems: MutableMap<String, ViewHolder> = ConcurrentHashMap()
private val cachedViewHolderTypes: MutableSet<Int> = HashSet()
companion object {
const val NO_CACHED_ITEM_ID = "NO_CACHED_ITEM_ID"
}
//Return cached view holder for particular position
private val viewCacheExtension = object : ViewCacheExtension() {
override fun getViewForPositionAndType(
recycler: Recycler,
position: Int,
type: Int
): View? {
val cachedItemId = getItemId(position)
if (cachedViewHolderTypes.contains(type) && cachedItemId != NO_CACHED_ITEM_ID
&& cachedItems.containsKey(cachedItemId)
) {
Timber.d("Returning view from custom cache for position:$position")
return cachedItems[cachedItemId]?.itemView
}
return null
}
}
fun enableCacheForViewHolderType(type: Int) {
cachedViewHolderTypes.add(type)
}
protected fun isCacheEnabledForViewHolder(type: Int): Boolean =
cachedViewHolderTypes.contains(type)
private val adapterDataObserver: DataChangeObserver = object :
DataChangeObserver() { //This observers will take care of removing the cached items if they are not in new set
override fun onChanged() {
Timber.d("items added or moved")
if (cachedItems.isEmpty())
return
val iterator: MutableIterator<String> = cachedItems.keys.iterator()
val newItemsIds = mutableSetOf<String>()
for (newIndex in 0 until itemCount) {
val itemId = getItemId(newIndex)
newItemsIds.add(itemId)
}
//Remove cached items which are not in new set
while (iterator.hasNext()) {
val cachedItemId = iterator.next()
val holder = cachedItems[cachedItemId]
if (holder?.bindingAdapterPosition == -1 || !newItemsIds.contains(cachedItemId)) {
holder?.itemView?.let { view ->
if (view.isAttachedToWindow || view.parent != null) {
recyclerView?.removeView(view)
view.visibility = View.GONE
}
}
iterator.remove()
}
}
}
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
Timber.d("On Item range removed called for adapter observer")
if (cachedItems.isEmpty())
return
val iterator: MutableIterator<String> = cachedItems.keys.iterator()
while (iterator.hasNext()) {
val cachedItemId = iterator.next()
val index = cachedItems[cachedItemId]?.bindingAdapterPosition ?: -1
if (index != -1 && positionStart <= index && index < positionStart + itemCount) {
Timber.d("Removing view from adapter observer for position:$index")
val holder = cachedItems[cachedItemId]
holder?.itemView?.let { view ->
if (view.isAttachedToWindow || view.parent != null) {
recyclerView?.removeView(view)
view.visibility = View.GONE
}
}
iterator.remove()
}
}
}
}
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
registerAdapterDataObserver(adapterDataObserver)
recyclerView.setViewCacheExtension(viewCacheExtension)
}
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
Timber.d("onDetached recycler view called ")
unregisterAdapterDataObserver(adapterDataObserver)
super.onDetachedFromRecyclerView(recyclerView)
}
//Saving the cached item once it goes out of screen
override fun onViewDetachedFromWindow(holder: ViewHolder) {
if (cachedViewHolderTypes.contains(holder.itemViewType)) {
val pos = holder.bindingAdapterPosition
Timber.d("onViewDetached called for position : $pos")
if (pos > -1) {
holder.setIsRecyclable(false)
cachedItems[getItemId(pos)] = holder
}
}
super.onViewDetachedFromWindow(holder)
}
private abstract class DataChangeObserver : AdapterDataObserver() {
abstract override fun onChanged()
override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
onChanged()
}
override fun onItemRangeChanged(
positionStart: Int, itemCount: Int,
payload: Any?
) {
onChanged()
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
onChanged()
}
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
onChanged()
}
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
onChanged()
}
}
}