RecyclerView缓存之ViewCacheExtension

场景

最近在开发产品的过程中,有这样一个场景:页面包含一个竖向列表,当点击特定的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
        }
    }

在返回已缓存的视图之前需要做相关的检查:

  1. 通过对集合进行查询,我们检查对应的view holder类型是否已启用缓存。
  2. 是否这个holder的item id 和当前指向的位置相同。这是为了确保在我们的adapter已经被新的数据更新了但是我们的缓存Map还持有旧的view holder的场景不会发生。这是重要的,因为在数据集更新后,新的视图类型被分配到这个位置上,但是,我们仍然为这个位置返回旧的view holder,这将导致仍显示旧的视图。

3.确保在数据集变更的同时缓存的视图也会被更新

为了达到当数据集有变化,我们就能更新缓存Map的目的,我们需要利用AdapterDataObserver 观察数据的变化。这样当有任何通知到来我们会得到回调。

我们可以将数据集变更调用主要分为两组来处理:

  1. 使用notifyItemRangeRemoved()移除的缓存项。
  2. 我们在notifyItemRangeMoved()或notifyDataSetChanged()之后移除的view holder。

为了简化这个数据集变化导致的分组,我们继承AdapterDataObserver类来实现一个抽象类。

这里包含两个回调方法

  1. 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()
                }
            }
        }
  1. 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()
        }
    }

}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,616评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,020评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,078评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,040评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,154评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,265评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,298评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,072评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,491评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,795评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,970评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,654评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,272评论 3 318
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,985评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,223评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,815评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,852评论 2 351

推荐阅读更多精彩内容