阅读 715

RecyclerView.ViewCacheExtension 使用及踩坑

这是我参与11月更文挑战的第15天,活动详情查看:2021最后一次更文挑战

前言

最近遇到一个需求,需求实现上并不复杂,大概长这个样:
复制代码

image.png

基本上就是一个RecyclerView 嵌套多个子 RecyclerView ,有横向的,也有竖向的。RecyclerView 实现多类型布局有各种各样的实现方式,这里就不多说了。

本来很开心的实现完了,在测试中确遇到了非常严重的性能问题,也就了本篇文章的诞生。具体的讲,嵌套的横向滑动的RecyclerView 没有任何问题,嵌套的竖向RecyclerView 在上下滑动时却遇到了非常严重的性能问题,表现为在从自上向下滑动到显示竖向的RecyclerView 时,在条目比较多时会感受到非常明显的卡顿。

那为啥竖向的条目为啥还要嵌套一个RecyclerView,直接显示在RecyclerView 中就好了呀,嗯我也想这样子实现。都说设计有三宝,卡片、阴影、圆角,这里就卡到了卡片问题上,设计需求中竖向的RecyclerView外带一个卡片样式,这就要求须用一个卡片样式的布局包一下竖向的条目(尝试使用ItemDecoration 尝试自己绘制,遇到各种问题,遂放弃)。因为这个竖向嵌套的RecyclerView 作为主RecyclerView的一个Item,在显示到屏幕的过程中所有子Item都需要完整的测量,布局和绘制,如果嵌套的竖向RecyclerView中Item过多,这些Item即使没有显示在主RecyclerView中,也需要经过完整的测量和布局,进而计算出竖向嵌套的RecyclerView 的尺寸,这就时导致卡顿的根源。

既然知道了卡顿的原因,要怎么解决呢,在与设计PK 改方案无果后(其实主要是ios实现了),开始默默调研起解决方案,首先肯定是要从RecyclerView的缓存入手,首先尝试简单的解决方案。

尝试使用简单的优化方案

    //竖向嵌套RecyclerView,加快测量
    recyclerView.setHasFixedSize(true)
    //给定足够的缓存数,减少竖向recyclerView 中item 的重建
    recyclerView.recycledViewPool.setMaxRecycledViews(viewType, 20)
复制代码

应用简单方案后,性能仍然不满足需求,继续啃源码查调研方案,在RecycledViewPool 中没有看到可以介入的逻辑,看到了ViewCacheExtension可以拦截Recycler的获取视图的逻辑。也调研了网上的使用方案,发现基本没有使用RecyclerView.ViewCacheExtension的记录,遂开始踩坑。

尝试使用ViewCacheExtension介入RecyclerView缓存

实现一个ViewCacheExtension

简单了解后,ViewCacheExtension会介入RecyclerView 的缓存逻辑,Recycler 会早于RecyclerViewPoll 在 ViewCacheExtension 查找是否有对应视图,这就提供给我们时机介入缓存逻辑。 实现一个自己ViewCacheExtension很简单,只需要复写一个getViewForPositionAndType方法在合适的时机返回自己管理的View就好了。

class SingleCacheEx(private val interestedType: IntArray) : RecyclerView.ViewCacheExtension() {
    override fun getViewForPositionAndType(
        recycler: RecyclerView.Recycler,
        position: Int,
        viewType: Int
    ): View? {
        //返回自己管理的视图
    }
}
复制代码

如何介入到RecyclerView的缓存

1.接入RecyclerView

//RecyclerView 接入
val types = intArrayOf(TYPE_ONE,TYPE_TWO)
val singleCacheEx = SingleCacheEx(types)
//接入ViewCacheExtension
recyclerView.setViewCacheExtension(singleTypeCacheEx)
//同时设置RecycledViewPool中已有ViewCacheExtension接管的Type最大缓存数为 0
val poll = recyclerView.recycledViewPool
for(type in types) poll.setMaxRecycledViews(type,0)
复制代码

2.介入缓存 从源码中我们看到 ViewCacheExtension#getViewForPositionAndType 需要返回一个view 给RecyclerView,那么这个View从哪里来呢?

既然在上面我们设置了特定类型的viewHolder不会在 recycledViewPool 缓存,那么在 Adapter 的 onCreateViewHolder中介入就是一种可行的方案

  override fun onCreateViewHolder(container: ViewGroup, viewType: Int): VH {
      val item = LayoutInflater.from(container.context)
          .inflate(R.layout.card_module_item_layout_main_page, container, false)
      return VH(item).apply {
          //讲viewHolder 保存到 singleCacheEx 中
          singleCacheEx.onCreateViewHolder(viewType, this)
      }
  }
复制代码

将其保存到SingleCacheEx 的 map 字段中,这样子在Recycler 调用 getViewForPositionAndType 获取视图时,我们就可以查找缓存的View返回给其使用。主动管理了缓存,就需要承担内存泄露的风险,我们也需要在和合适的时机清空缓存,方式Context 泄露,因此还需要实现相应的clear逻辑

  class SingleCacheEx(private val interestedType: IntArray) : RecyclerView.ViewCacheExtension() {

      private val singleVHMap = mutableMapOf<Int, RecyclerView.ViewHolder>()

      override fun getViewForPositionAndType(
          recycler: RecyclerView.Recycler,
          position: Int,
          viewType: Int
      ): View? {
          if (viewType !in interestedType)
              return null
          val vh = singleVHMap[viewType] ?: return null
          return if (isStateSafe(recycler, vh)) {
              vh.itemView
          } else {
              singleVHMap.remove(viewType)
              null
          }
      }

      fun onCreateViewHolder(viewType: Int, vh: RecyclerView.ViewHolder) {
          if (viewType in interestedType) {
              singleVHMap[viewType] = vh
          }
      }

      private fun isStateSafe(
          recycler: RecyclerView.Recycler,
          viewHolder: RecyclerView.ViewHolder
      ) :Boolean{
          return viewHolder.itemView.parent == null
      }

      fun clear() {
          if (singleVHMap.isNotEmpty()) {
              singleVHMap.clear()
          }
      }

      fun clearType(type: Int) {
          singleVHMap.remove(type)
      }
  }
复制代码

遇到的一些坑

本来文章到这里就可以结束了,但是作为一个多端App,在适配平板和折叠屏时又遇到了一些新的坑。

  1. OnConfigurationChange 适配的坑

如果缓存的视图不可见时,屏幕发生了OnConfigurationChange事件,这时缓存的View脱离了视图树,无法收到这个事件,也就无法响应相关改变

getViewForPositionAndType 获取到view 后并不会走onBindViewHolder的逻辑,RecyclerView 认为你这个View 不是Dirty的,不需要重新绑定。

兜兜转转一圈,想到我们可以在 OnConfigurationChange 发生后主动给SingleCacheEx缓存的但目前没有在布局树中的View设置一个tag,在重新添加到布局树中时检查这个tag即可。

需要监听 OnConfigurationChange 事件的ViewHolder 实现 ConfigurationChangeAware接口

class ViewHolder(item):RecyclerView.ViewHolder(item):OnConfigurationChange{

      fun notifyConfigurationChange(){
        //post 到AttachInfo.mHandler中,这样子才会在attach后执行
        post {
            // do configuration change
        }
      }
}
复制代码

SingleTypeCacheEx 中修改的方法如下

class SingleTypeCacheEx(private val interestedType: IntArray) : RecyclerView.ViewCacheExtension() {

    interface ConfigurationChangeAware{
        fun notifyConfigurationChange()
    }

    override fun getViewForPositionAndType(
        recycler: RecyclerView.Recycler,
        position: Int,
        viewType: Int
    ): View? {
        if (viewType !in interestedType)
            return null
        val vh = singleVHMap[viewType] ?: return null
        return if (isStateSafe(recycler, vh)) {
            dispatchLazyConfigurationChangeIfNeeded(vh)
            vh.itemView
        } else {
            singleVHMap.remove(viewType)
            null
        }
    }

    private fun dispatchLazyConfigurationChangeIfNeeded(vh: RecyclerView.ViewHolder) {
        if (isConfigurationChanged(vh)) {
            setConfigurationChanged(vh, false)
            if (vh is ConfigurationChangeAware){
                vh.notifyConfigurationChange()
            }
        }
    }

    fun dispatchOnConfiguration() {
        for ((_, vh) in singleVHMap) {
            val isDetached = vh.itemView.parent == null
            if (isDetached) {
                setConfigurationChanged(vh, true)
            }
        }
    }

    private fun isConfigurationChanged(vh: RecyclerView.ViewHolder): Boolean {
        return vh.itemView.getTag(R.id.single_cache_extension_configuration_change_flag) as? Boolean == true
    }

    private fun setConfigurationChanged(vh: RecyclerView.ViewHolder, value:Boolean) {
        vh.itemView.setTag(R.id.single_cache_extension_configuration_change_flag, value)
    }

}
复制代码
  1. ItemDecoration 的坑 RecyclerView#LayoutParams 中有一个字段标志来支持是否调用ItemDecoration#getItemOffsets更新Offset,但是在我们手动管理缓存时Offset 并不总是会刷新,这个解决方案也很多,比如可以在getViewForPositionAndType方式反射mInsetsDirty设置为true 在attach 到布局后就会自动刷新;或者仿照方案一在attach后手动设置一下 ItemDecoration 触发更新 offset
public static class LayoutParams extends android.view.ViewGroup.MarginLayoutParams {
    boolean mInsetsDirty = true;
    ...
 }
复制代码

总结

使用ViewCacheExtension还是比较危险的,对内存也会造成一定的压力,在使用时需要合理考虑实现成本与收益。

Happy Ending.

文章分类
Android
文章标签