背景

假设要实现下面的效果图:

高效复用:RecyclerView内部嵌套横向列表时的优化技巧

如图所示,首先这是一个多样式的滑动列表(截图里只列举了其中的3 种样式),整体外部使用 RecyclerView 来实现没什么疑问。那么截图第3个ItemView 中箭头指向的横向标签列表如何实现呢?

实现思路

我们对上述问题进行一个抽象,本质上就是两个列表:外部是纵向列表,内部有一个横向列表。如下:

高效复用:RecyclerView内部嵌套横向列表时的优化技巧

外部纵向列表关键代码实现如下:

//RecyclerView.Adapter
open class BaseAdapter<T : Any>(private val vhFactory: IVHFactory) :
    RecyclerView.Adapter<BaseVHolder<T>>() {
    private val models = mutableListOf<T>()
    override fun getItemViewType(position: Int): Int {
        val model = models[position]
        if (model is IMultiType) return model.getItemViewType()
        return super.getItemViewType(position)
    }
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseVHolder<T> {
        //在这里创建ViewHolder
        return vhFactory.getVH(parent.context, parent, viewType) as BaseVHolder<T>
    }
    override fun onBindViewHolder(holder: BaseVHolder<T>, position: Int) {
        //在这里绑定数据
        holder.onBindViewHolder(models[position], position)
    }
    override fun getItemCount(): Int = models.size
    fun submitList(newList: List<T>) {
        //传入新旧数据进行比对
        val diffUtil = ChatDiffUtil(models, newList)
        //经过比对得到差异结果
        val diffResult = DiffUtil.calculateDiff(diffUtil)
        //NOTE:注意这里要重新设置Adapter中的数据
        models.clear()
        models.addAll(newList)
        //将数据传给adapter,最终通过adapter.notifyItemXXX更新数据
        diffResult.dispatchUpdatesTo(this)
    }
}
//工厂模式,用于生产BaseVHolder
interface IVHFactory {
    fun getVH(context: Context, parent: ViewGroup, viewType: Int): BaseVHolder<*>
}
  • onCreateViewHolder()用于创建 ViewHolder对象。它会在每次需要一个新的 ItemView 时被调用,并返回一个包含了 ItemViewViewHolder 对象。
  • onBindViewHolder()则负责将数据与指定位置上的ItemView视图进行关联,在滚动列表时会多次调用此函数来更新显示内容。
class ChatVHolderFactory : IVHFactory {
    companion object {
        const val TYPE_ASK_TXT = 1 //type1
        const val TYPE_REPLY_TXT = 2 //type2
        const val TYPE_REPLY_SPAN = 3 //type3
    }
    override fun getVH(context: Context, parent: ViewGroup, viewType: Int): BaseVHolder<*> {
        return when (viewType) {
            TYPE_ASK_TXT -> ChatAskHolder(context, parent)
            TYPE_REPLY_TXT -> ChatReplyTxHolder(context, parent)
            TYPE_REPLY_SPAN -> ChatReplyImgTextHolder(context, parent)
            else -> throw IllegalStateException("unSupport type")
        }
    }
}
class ChatGptActivity : AppCompatActivity() {
    private val mRv: RecyclerView by id(R.id.rv_view)
    private val chatAdapter by lazy { BaseAdapter<MessageModel>(ChatVHolderFactory()) }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_layout_rv)
        setRvInfo()
    }
    private fun setRvInfo() {
        val list = mutableListOf<MessageModel>()
        list.add(MessageModel(content = "天气预报", type = ChatVHolderFactory.TYPE_ASK_TXT))
        list.add(MessageModel(content = "天气情况如下:", type = ChatVHolderFactory.TYPE_REPLY_TXT))
        list.add(MessageModel(type = ChatVHolderFactory.TYPE_REPLY_SPAN))
        for (i in 0..20) {
            list.add(MessageModel(content = "天气预报", type = ChatVHolderFactory.TYPE_ASK_TXT))
        }
        chatAdapter.submitList(list)
        mRv.layoutManager = LinearLayoutManager(this)
        mRv.adapter = chatAdapter
    }

上述代码对多类型列表场景下做了个简单的封装,不再过多解释。

重点看第3个ItemView内部的横向列表如何实现。其中横向标签列表个数有两种情况:

  • case1:标签列表个数是固定的;
  • case2:标签列表个数是不固定的(数据由服务端下发),如果不固定,那么列表应该是在Adapter#onBindViewHolder中得到数据之后动态创建的。

针对不同情况得到下面几种可能的实现方式。

方式一

标签列表直接使用固定个数的TextView控件实现,可以满足 case1的场景,什么也不用想,就是干!

高效复用:RecyclerView内部嵌套横向列表时的优化技巧

使用起来也很方便,因为不涉及动态创建,所以上下滑动时也不会有频繁创建子View的问题,但这种实现方式是有缺点的:

  • 需要创建多个TextView对象并且需要给每个对象引用一一赋值
  • 不够灵活,当标签列表的数量不固定时,这种方式就无能为力了。

方式二

使用一个 LinearLayoutViewGroup 来动态添加每个标签子View,关键代码如下:

  private val labels = mutableListOf<CardItemModel>().apply {
        add(CardItemModel().apply { sceneName = "标签1" })
        add(CardItemModel().apply { sceneName = "标签2" })
        add(CardItemModel().apply { sceneName = "标签3" })
        add(CardItemModel().apply { sceneName = "标签4" })
    }
  private val llLabel: LinearLayoutCompat = bind(R.id.ll_label)
  llLabel.removeAllViews()
  llLabel.weightSum = 1F
  labels.forEachIndexed { index, it ->
     val itemView = LayoutInflater.from(context).inflate(R.layout.chat_reply_language_change_item, null)
     val tv: TextView = itemView.findViewById(R.id.tv_language)
     tv.text = it.sceneName
     //添加标签子View
     log("方式2:LinearLayout.addView $index")
     llLabel.addView(itemView, LinearLayoutCompat.LayoutParams(
     0, ViewGroup.LayoutParams.WRAP_CONTENT, 1 / labels.size.toFloat()).apply { 
     if (index != labels.lastIndex) marginEnd = 10.dp2px() })
  }

方式三

内部横向标签列表也使用RecyclerView来实现。注意使用细节,我们要使用DiffUtil来更新数据,这样做的优点是可以利用 RecyclerView 的复用机制和 DiffUtil 提高性能。关键代码如下:

//声明了BaseViewHolder,方便后面直接使用
//BaseViewHolder
abstract class BaseVHolder<T>(context: Context, parent: ViewGroup, resource: Int) :
    RecyclerView.ViewHolder(LayoutInflater.from(context).inflate(resource, parent, false)) {
    fun onBindViewHolder(item: T, position: Int) {
        onBindView(item, position)
    }
    abstract fun onBindView(item: T, position: Int)
    protected fun <V : View> bind(id: Int): V {
        return itemView.findViewById(id)
    }
}

使用它:

//ViewHolder
class LabelItemHolder(
        context: Context,
        parent: ViewGroup,
        layoutId: Int = R.layout.chat_reply_language_change_item,
    ) : BaseVHolder<CardItemModel>(context, parent, layoutId) {
        private val sceneName = bind<TextView>(R.id.tv_language)
        override fun onBindView(item: CardItemModel, position: Int) {
            log("方式3:onBindViewHolder: $position")
            sceneName.text = item.sceneName
        }
    }
//声明Adapter
private val labelAdapter by lazy {
        BaseAdapter<CardItemModel>(object : IVHFactory{
            override fun getVH(context: Context, parent: ViewGroup, viewType: Int): BaseVHolder<*> {
                log("方式3:onCreateViewHolder")
                return LabelItemHolder(context, parent)
            }
        })
    }
private val labels = mutableListOf<CardItemModel>().apply {
        add(CardItemModel().apply { sceneName = "标签1" })
        add(CardItemModel().apply { sceneName = "标签2" })
        add(CardItemModel().apply { sceneName = "标签3" })
        add(CardItemModel().apply { sceneName = "标签4" })
    }
//在外部Adapter中的onBindViewHolder()里刷新列表数据   
labelAdapter.submitList(labels) 

性能对比

高效复用:RecyclerView内部嵌套横向列表时的优化技巧

上述截图是利用方式2、方式3实现的UI效果,方式1由于不够灵活,就不再看了。下面来对比下方式2、方式3的性能,当第一次打开页面时,日志输出如下:

E/Tag: 外部Rv---> onBindViewHolder(): 2
E/Tag: 方式2:LinearLayout.addView 0
E/Tag: 方式2:LinearLayout.addView 1
E/Tag: 方式2:LinearLayout.addView 2
E/Tag: 方式2:LinearLayout.addView 3
E/Tag: 方式3:onCreateViewHolder
E/Tag: 方式3:onBindViewHolder: 0
E/Tag: 方式3:onCreateViewHolder
E/Tag: 方式3:onBindViewHolder: 1
E/Tag: 方式3:onCreateViewHolder
E/Tag: 方式3:onBindViewHolder: 2
E/Tag: 方式3:onCreateViewHolder
E/Tag: 方式3:onBindViewHolder: 3

因为是第一次创建,方式2中通过 LinearLayout#addView 添加各个标签子View,而方式3中通过RecyclerView.Adapter 中的 onCreateViewHolder、onBindViewHolder来创建,假设列表够长,继续往下滑动然后再滑动回来,此时日志如下:

E/Tag: 外部Rv---> onBindViewHolder(): 2
E/Tag: 方式2:LinearLayout.addView 0
E/Tag: 方式2:LinearLayout.addView 1
E/Tag: 方式2:LinearLayout.addView 2
E/Tag: 方式2:LinearLayout.addView 3

可以看到列表再次滑动到原位置时,方式2每次还会重新创建标签子View,而方式3却不会再重新创建了,这是因为方式3通过DiffUtil再次设置数据时,会进行数据对比,如果数据没有发生变化,那么什么都不会做。而我们在第一次创建View的时候,已经给每个子View设置了数据,所以此时数据展示的依然是正确的。

这里开始有个疑问,为什么上下滑动列表并返回原位置时,方式3没有重新设置数据也能正确显示呢? 我们知道RecyclerView是通过RecyclerView.Recycler缓存的ViewHolder,当尝试获取ViewHolder中的itemView时,会调用下面的方法:

//RecyclerView.Recycler
@NonNull
 public View getViewForPosition(int position) {
      return getViewForPosition(position, false);
 }
 View getViewForPosition(int position, boolean dryRun) {
     return tryGetViewHolderForPositionByDeadline(position, dryRun, FOREVER_NS).itemView;
}

当上下滑动时,我们的 ViewHolder 会逐级进行缓存,假设最后存到了 mRecyclerPool中,此时ItemView因为在第一次创建时设置了数据,所以会把绑定的数据一块存入ViewHolder中。因此再次滑动到原 position 时,虽然没有设置数据,但是会从缓存池中获取数据并正确显示。

这里可以把ViewHolder看成是一个普通的对象,缓存时不仅缓存了ItemView,如果之前设置过数据,会一并进行缓存

高效复用:RecyclerView内部嵌套横向列表时的优化技巧
详细介绍参见:Android | 深入理解RecyclerView缓存机制

总结

对于RecyclerView内部某个ItemView嵌套横向列表,通常考虑下面几种方式:

  • 直接创建多个固定的子View:这种方式不够灵活扩展性差,且在动态创建子View时就无能为力了;
  • 通过ViewGroup方式动态的创建各个子View:这种方式本身不能缓存子View,所以每次上下滑动时都会重新创建子View,虽然能实现我们想要的效果,但是性能并不是最优的;
  • 通过RecyclerView创建内部列表并使用 DiffUtil 进行数据对比和更新操作:数据变化时更新,否则什么都不做。这样做可以最大限度地利用 RecyclerView 的复用机制和缓存优势,在数据变化时进行精准刷新并提高整体渲染效率。所以此种方式是最优解。

示例地址

完整代码示例参见:ChatGptActivity示例