背景
假设要实现下面的效果图:
如图所示,首先这是一个多样式的滑动列表(截图里只列举了其中的3
种样式),整体外部使用 RecyclerView
来实现没什么疑问。那么截图第3个ItemView
中箭头指向的横向标签列表如何实现呢?
实现思路
我们对上述问题进行一个抽象,本质上就是两个列表:外部是纵向列表,内部有一个横向列表。如下:
外部纵向列表关键代码实现如下:
//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
时被调用,并返回一个包含了ItemView
的ViewHolder
对象。 -
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
的场景,什么也不用想,就是干!
使用起来也很方便,因为不涉及动态创建,所以上下滑动时也不会有频繁创建子View
的问题,但这种实现方式是有缺点的:
- 需要创建多个
TextView
对象并且需要给每个对象引用一一赋值 - 不够灵活,当标签列表的数量不固定时,这种方式就无能为力了。
方式二
使用一个 LinearLayout
父 ViewGroup
来动态添加每个标签子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)
性能对比
上述截图是利用方式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
,如果之前设置过数据,会一并进行缓存。
详细介绍参见:Android | 深入理解RecyclerView缓存机制
总结
对于RecyclerView
内部某个ItemView
嵌套横向列表,通常考虑下面几种方式:
-
直接创建多个固定的
子View
:这种方式不够灵活扩展性差,且在动态创建子View
时就无能为力了; -
通过
ViewGroup
方式动态的创建各个子View
:这种方式本身不能缓存子View
,所以每次上下滑动时都会重新创建子View
,虽然能实现我们想要的效果,但是性能并不是最优的; -
通过RecyclerView创建内部列表并使用 DiffUtil 进行数据对比和更新操作:数据变化时更新,否则什么都不做。这样做可以最大限度地利用
RecyclerView
的复用机制和缓存优势,在数据变化时进行精准刷新并提高整体渲染效率。所以此种方式是最优解。
示例地址
完整代码示例参见:ChatGptActivity示例