App 界面愈发复杂,元素越来越多,就算手机屏幕再大也无法在一屏中展示所有内容。将不同类型的元素组织成 RecyclerView
就可以超越屏幕的限制。常用的RecyclerView
在使用时有诸多痛点,比如“如何处理表项及其子控件点击事件?”、“如何在为列表新增类型时不抓狂?”、“如何低成本地刷新列表?”、“如何预加载下一屏数据?”等等。这一篇尝试让扩展列表数据类型变得简单。
单类型列表
项目刚开始时,新闻列表还不复杂,就是单纯的Feed流,左边图片,右边标题的那种,对应的Adapter
实现也一样简单:
// 新闻适配器
class NewsAdapter : RecyclerView.Adapter<NewsViewHolder>() {
// 新闻列表
var news: List<News>? = null
set(value) {
field = value
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NewsViewHolder {
// 构建新闻列表表项
val itemView = ...//省略构建细节
return NewsViewHolder(itemView)
}
override fun getItemCount(): Int {
return news?.size ?: 0
}
override fun onBindViewHolder(holder: NewsViewHolder, position: Int) {
news?.getOrNull(position)?.let { holder.bind(it) }
}
}
// 新闻ViewHolder
class NewsViewHolder(itemView: View):RecyclerView.ViewHolder(itemView) {
fun bind(news:News){
// 将新闻填充入新闻表项视图
itemView.apply {... // 省略绑定细节}
}
}
// 新闻实体类
data class News(
@SerializedName("image") var image: String?,
@SerializedName("title") var title: String?
)
- 定义实体类
- 定义与实体类绑定的
ViewHolder
- 定义与
ViewHolder
绑定的Adapter
构建 RecyclerView 时,大多遵循这样的步骤。
带空视图的列表
展示网络内容的列表通常会带有一个空视图,以在网络请求失败时提醒用户。
刚才定义的NewsAdapter
已经和NewsViewHolder
耦合,不能满足新增列表空视图的需求,重构如下:
// 列表适配器基类
abstract class BaseRecyclerViewAdapter<T> : RecyclerView.Adapter<BaseViewHolder?> {
companion object {
const val TYPE_EMPTY_VIEW = -1 // 空视图
const val TYPE_CONTENT = -2 // 非空视图
}
// 列表数据
protected var datas: MutableList<T>? = null
// 空视图
private var emptyView:View? = null
// 当前列表状态(空或非空)
private var currentType = 0
// 监听列表数据变化,及时更新列表是否是空状态
private inner class DataObserver : RecyclerView.AdapterDataObserver() {
override fun onChanged() {
// 根据列表数据长度更新当前列表状态
currentType = if (datas != null && datas.size != 0) {
TYPE_CONTENT
} else {
TYPE_EMPTY_VIEW
}
}
}
// 供子类实现以定义如何构建表项
protected abstract fun createHolder(parent: ViewGroup?, viewType: Int, inflater: LayoutInflater?): BaseViewHolder
// 供子类实现以定义如何将数据绑定到表项视图
protected abstract fun bindHolder(holder: BaseViewHolder?, position: Int)
// 供子类定义真实表项长度
protected abstract val count: Int
// 供子类定义真实表项类型
protected abstract fun getViewType(position: Int): Int
constructor(context: Context) {
this.context = context
// 监听列表数据变化
registerAdapterDataObserver(DataObserver())
}
fun setData(datas: MutableList<T>?) {
this.datas = datas
notifyDataSetChanged()
}
// 注入空视图
fun setEmptyView(emptyView: View) {
this.emptyView = emptyView
}
// 创建表项视图
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
val viewHolder: BaseViewHolder
when (viewType) {
TYPE_EMPTY_VIEW -> { viewHolder = BaseViewHolder(emptyView) }
// 非空表项构建逻辑延迟到子类实现
else -> viewHolder = createHolder(parent, viewType)
}
return viewHolder
}
// 为表项视图绑定数据
override fun onBindViewHolder(holder: BaseViewHolder?, position: Int) {
val viewType = getItemViewType(position)
when (viewType) {
// 空视图不需要绑定
TYPE_EMPTY_VIEW -> {}
// 非空视图绑定逻辑延迟到子类实现
else -> bindHolder(holder, position)
}
}
override fun getItemCount(): Int {
// 如果列表为空,则列表长度为1(听着就很别扭),否则返回列表实际长度
return if (currentType == TYPE_EMPTY_VIEW) { 1 } else count
}
override fun getItemViewType(position: Int): Int {
// 如果列表为空,则返回空列表类型,否则返回真实列表表项类型
return if (datas == null || datas.size == 0) {
TYPE_EMPTY_VIEW
} else {
getViewType(position)
}
}
}
// ViewHolder基类
public class BaseViewHolder extends RecyclerView.ViewHolder {
public BaseViewHolder(View itemView) { super(itemView); }
}
抽象了一个基类BaseAdapter
,它分别在onCreateViewHolder()
、onBindViewHolder()
、getItemCount()
、getItemViewType()
这四个方法中通过if-else
增加了空视图逻辑分支,子类需要实现与之对应的四个抽象方法以定义业务表项,并可以通过setEmptyView()
将空视图注入。
BaseAdapter
还引入了ViewHolder基类
,使得构造 Adapter 时不再需要和一个具体的ViewHolder
绑定,这为扩展列表数据类型提供了方便。
但这个方案有一点“拧巴”,从getItemCount()
的实现就可以看出:
override fun getItemCount(): Int {
// 如果列表为空,则列表长度为1(听着就很别扭),否则返回列表实际长度
return if (currentType == TYPE_EMPTY_VIEW) { 1 } else count
}
拧巴的点在于:明明空视图也是列表的一种表项,但却把对它的处理“特殊化”。而且通过if-else
实现的特殊化处理是没有扩展性的!假设需要为列表新增 header 和 footer,那还得修改基类BaseAdapter
,为其增加更多的if-else
分支,需求一变就得修改基类,那这个基类着实有点“鸡肋”。
伪多类型列表
随着版本的迭代,需要在列表顶部插入 Banner,并和新闻一起滚动。
虽然BaseAdapter
对扩展不太友好,但还能凑合着用(毕竟它处理了空视图逻辑)。这一次的新需求可以通过在子类中新增if-else
来扩展:
// 新闻适配器
class NewsAdapter : BaseRecyclerViewAdapter<News>() {
val TYPE_NEWS = 1
val TYPE_BANNER = 2
// 通过 if-else 为列表新增类型
override fun createHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
if (viewType == TYPE_NEWS){
return createNewsViewHolder(parent)
}else {
return createBannerViewHolder(parent)
}
}
// 通过 if-else 为不同表项绑定数据
override fun bindHolder(holder: BaseViewHolder, position: Int) {
val viewType = getViewType(position)
if (viewType == TYPE_NEWS){
(holder as? NewsViewHolder)?.bind(datas[position])
}else {
(holder as? BannerViewHolder)?.bind(datas[position])
}
}
override val count: Int
protected get() = if (datas == null) 0 else datas.size
// 构建 Banner 表项
private fun createBannerViewHolder(parent: ViewGroup): BaseViewHolder {
val itemView = ... // 省略构建细节
return BannerViewHolder(itemView)
}
// 构建新闻表项
private fun createNewsViewHolder(parent: ViewGroup): BaseViewHolder {
val itemView = ... // 省略构建细节
return NewsViewHolder(itemView)
}
}
// 新闻ViewHolder
class NewsViewHolder(itemView: View): BaseViewHolder(itemView) {
fun bind(news:News){ ... // 省略绑定细节 }
}
// Banner ViewHolder
class BannerViewHolder(itemView: View): BaseViewHolder(itemView) {
fun bind(news:News){ ...// 省略绑定细节 }
}
// 新闻实体类(新增了banner字段)
data class News(
@SerializedName("image") var image: String?,
@SerializedName("title") var title: String?,
var banners: List<Banner>?
)
// Banner实体类(从有别于新闻接口的另一个接口返回)
data class Banner(
@SerializedName("jumpUrl") var jumpUrl: String?,
@SerializedName("imageUrl") var imageUrl: String?
)
如果项目中的RecyclerView
是这样的话,每次为它新增类型之时,即是你抓狂之时。
-
因为
NewsAdapter
和具体的News
实体类耦合,所以新增的数据类型只能是News
的成员,虽然从业务上讲,列表新增了一种新类型,但代码中将两种类型揉成了一种(伪多类型)。新增 n 个类型,News
类就会增加 n 个成员,而且每一种表项只会使用到其中的某个字段,其余字段对它来说都是冗余。 -
因为不同类型表项是通过
if-else
来区别,所以每新增一个类型,就得修改NewsAdapter
类。对既有类的修改是危险的,因为它可能已经被多人补丁而变得“坑坑洼洼”,到处是不可言喻的“潜规则”,只要一不留心就可能“引爆地雷”。
抓狂只是前戏,莫名其妙的 bug 蜂拥而至才是高潮。细心的你一定发现了,通过这种方式为列表新增类型会破坏基类中空视图的逻辑:
abstract class BaseRecyclerViewAdapter<T> : RecyclerView.Adapter<BaseViewHolder?> {
// 监听列表数据变化,及时更新列表是否是空状态
private inner class DataObserver : RecyclerView.AdapterDataObserver() {
override fun onChanged() {
// 如果列表没有数据,则加载空视图,否则加载业务数据
currentType = if (datas != null && datas.size != 0) {
TYPE_CONTENT
} else {
TYPE_EMPTY_VIEW
}
}
}
}
当 banner 接口返回数据而新闻接口返回空时,此时列表长度不为 0,所以BaseAdapter
不会展示空视图。产品期望没有新闻时,新闻区域的空视图,当 banner和新闻都没有时,展示整个列表的空视图。继续重构基类?(真想删掉这个基类)
类型无关适配器
现有Adapter
难扩展,一扩展就出 bug 的原因是适配器和具体数据类型耦合。
有没有可能设计一种和具体类型无关的适配器?就像这样:
class VarietyAdapter(
var dataList: MutableList<Any> = mutableListOf()
) : RecyclerView.Adapter<ViewHolder>() { }
// 构建一个包含三种数据类型的列表,它们分别展示一条新闻、一个Banner,一张图片
val adapter = VarietyAdapter().apply {
dataList = mutableListOf (
News(),
Banner(),
"https:xxx"
)
}
VarietyAdapter
的声明避开了所有和类型相关的信息:
- 原本
RecyclerView.Adapter
的子类必须声明一个具体的ViewHolder
类型,这里直接使用了RecyclerView.ViewHolder
基类。 - 原本
RecyclerView.Adapter
中的datas
必须是一个存放具体数据类型的列表,这里直接使用了所有非空类型的基类Any
。
为VarietyAdapter
添加数据的时候,将不同类型的数据揉搓在一个列表中。
一个新的Adapter
通常用实现下面这三个方法:
class VarietyAdapter(
var datas: MutableList<Any> = mutableListOf()
):RecyclerView.Adapter<RecyclerView.ViewHolder>() {
// 构建表项布局
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {}
// 填充表项内容
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {}
// 获取表项数量
override fun getItemCount(): Int {}
}
其中onCreateViewHolder()
和onBindViewHolder()
的实现会和表项的数据类型绑定。
构建表项和填充表项这两个抽象的动作,不会随着业务变化而变化的,但构建什么表项和怎么填充表项是两个具体的动作,会随着业务的变化而变化。
之前犯的错误就是由Adapter
亲自处理“具体动作”,导致难以扩展,并且会使Adapter
随业务的变化而变。
是不是可以把“具体动作”抽离出Adapter
,交由其他角色处理,而Adapter
只和抽象打交道?这样就可以改进Adapter
的扩展性。
// 和 Adapter 类似的一组策略
abstract class Proxy<T, VH : RecyclerView.ViewHolder> {
// 构建表项
abstract fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder
// 填充表项
abstract fun onBindViewHolder(holder: VH, data: T, index: Int, action: ((Any?) -> Unit)? = null)
// 填充表项(局部刷新)
open fun onBindViewHolder(holder: VH, data: T, index: Int, action: ((Any?) -> Unit)? = null, payloads: MutableList<Any>) {
onBindViewHolder(holder, data, index, action)
}
}
声明一组策略,它看上去和RecyclerView.Adapter
没什么两样,几乎拥有相同的接口,目的是为了把原本RecyclerView.Adapter
做的事情,由它来代理。但它并没有直接继承RecyclerView.Adapter
,即它不拥有完整的RecyclerView.Adapter
的功能,所以不能称为代理模式,而应该是策略模式。
策略类定义了两个类型参数,第一个T
表示表项对应数据的类型,第二个VH
表示表项ViewHolder
的类型。
策略类是抽象的,每一个它的实例代表着一种类型的表项,并和一种数据类型对应。
一个策略类的实例通常长这个样子:
// 文字类表项策略(对应的数据是Text,对应的ViewHolder是TextViewHolder)
class TextProxy : Proxy<Text, TextViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
// 构建表项视图(构建布局 DSL)
val itemView = parent.context.run {
TextView {
layout_id = "tvName"
layout_width = wrap_content
layout_height = wrap_content
textSize = 40f
gravity = gravity_center
textColor = "#ff00ff"
}
}
// 构建表项ViewHolder
return TextViewHolder(itemView)
}
// 绑定表项数据
override fun onBindViewHolder(holder: TextViewHolder, data: Text, index: Int, action: ((Any?) -> Unit)?) {
holder.tvName?text = data.text
}
}
// 与文字类表项对应的“文字数据”
data class Text( var text: String )
// 文字类表项ViewHolder
class TextViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val tvName = itemView.find<TextView>("tvName")
}
构建Proxy
时需要指定与表项对应的数据和ViewHolder
,把他们定义在同一个 Kotlin 文件中,以方便修改。
其中构建布局用到的DSL,可以参考这里。
RecyclerView.Adapter
会同时持有一组数据和若干策略类的实例,它的作用变为根据数据类型将“构建表项”和“填充表项”的 任务分发给对应的策略类。
class VarietyAdapter(
// 策略列表
private var proxyList: MutableList<Proxy<*, *>> = mutableListOf(),
// 数据列表
var dataList: MutableList<Any> = mutableListOf()
) : RecyclerView.Adapter<ViewHolder>() {
// 注入策略
fun <T, VH : ViewHolder> addProxy(proxy: Proxy<T, VH>) {
proxyList.add(proxy)
}
// 移除策略
fun <T, VH : ViewHolder> removeProxy(proxy: Proxy<T, VH>) {
proxyList.remove(proxy)
}
// 将构建表项布局分发给策略
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return proxyList[viewType].onCreateViewHolder(parent, viewType)
}
// 将填充表项分发给策略
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
(proxyList[getItemViewType(position)] as Proxy<Any, ViewHolder>).onBindViewHolder(holder, dataList[position], position, action)
}
// 将填充表项分发给策略(布局刷新)
override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList<Any>) {
(proxyList[getItemViewType(position)] as Proxy<Any, ViewHolder>).onBindViewHolder( holder, dataList[position], position, action, payloads )
}
// 返回数据总量
override fun getItemCount(): Int = dataList.size
// 获取表项类型
override fun getItemViewType(position: Int): Int {
return getProxyIndex(dataList[position])
}
// 获取策略在列表中的索引
private fun getProxyIndex(data: Any): Int = proxyList.indexOfFirst {
// 如果Proxy<T,VH>中的第一个类型参数T和数据的类型相同,则返回对应策略的索引
(it.javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0].toString() == data.javaClass.toString()
}
// 抽象策略类
abstract class Proxy<T, VH : ViewHolder> {
abstract fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder
abstract fun onBindViewHolder(holder: VH, data: T, index: Int, action: ((Any?) -> Unit)? = null)
open fun onBindViewHolder(holder: VH, data: T, index: Int, action: ((Any?) -> Unit)? = null, payloads: MutableList<Any>) {
onBindViewHolder(holder, data, index, action)
}
}
}
VarietyAdapter
将一组策略存储在ArrayList
结构中:var proxyList: MutableList<Proxy<*, *>> = mutableListOf()
,其中将声明Proxy
必须指定的两个类型做了star投影,目的是为了让任何类型都能成为Proxy<*,*>
的子类型。(关于类型投影的详细介绍可以点击这里)
原本返回表项类型的方法getItemViewType()
,现在返回的是策略的索引值,这样在构建和绑定表项时就可以通过该索引在proxyList
中找到与数据对应的策略并委托之。
策略的索引值是通过遍历proxyList
并将每一个策略和数据类型做比较,比较的内容是“策略的第一个类型参数 和 数据类型 是否一致”,如果一致,则表示该策略是指定数据对应的策略。
(proxy.javaClass.genericSuperclass as ParameterizedType)// 获取类型参数列表
.actualTypeArguments[0].toString()// 获取类型参数表列中的第一个类型
对于一个泛型类Proxy<T, VH : ViewHolder>
的实例proxy
上面的表达式返回的是Proxy
的第一个类型参数T
的完整类名。
然后就可以像这样使用VarietyAdapter
了:
// 构建适配器
val varietyAdapter = VarietyAdapter().apply {
// 为Adapter添加两种策略,分别显示文字和图片
addProxy(TextProxy())
addProxy(ImageProxy())
// 构建数据(不同数据类型融合在一个列表中)
dataList = mutableListOf(
Text("item 1"), // 代表文字表项
Image("#00ff00"), //代表图片表项
Text("item 2"),
Text("item 3"),
Image("#88ff00")
)
notifyDataSetChanged()
}
// 将Adapter赋值给RecyclerView
recyclerView?.adapter = varietyAdapter
recyclerView?.layoutManager = LinearLayoutManager(this)
其中ImageProxy
和Image
是和上面提到的TextProxy
和Text
类似的策略及数据类型。
单类型多匹配
有时候服务器返回的列表数据中有type
字段,用于指示客户端应该展示哪种布局,即同一个数据类型对应了多种布局方式,VarietyAdapter
现有的做法不能满足这个需求,因为匹配规则被写死在getProxyIndex()
方法中。
为了扩展匹配规则,新增接口:
// 数据和策略的对应关系
interface DataProxyMap {
// 将数据转换成策略类名
fun toProxy(): String
}
然后让数据类实现这个接口:
data class Text(
var text: String,
var type: Int // 用type指定布局类型
) : VarietyAdapter.DataProxyMap {
override fun toProxy(): String {
return when (type) {
1 -> TextProxy1::class.java.toString() // type为1时对应TextProxy1
2 -> TextProxy2::class.java.toString() // type为2时对应TextProxy2
else -> TextProxy2::class.java.toString()
}
}
}
还得修改下getProxyIndex()
方法:
private fun getProxyIndex(data: Any): Int = proxyList.indexOfFirst {
// 获取策略类中第一个类型参数的类名
val firstTypeParamClassName = (it.javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0].toString()
// 获取策略类名
val proxyClassName = it.javaClass.toString()
// 首要匹配条件:策略类第一个类型参数和数据类型相同
firstTypeParamClassName == data.javaClass.toString()
// 次要匹配条件:数据类自定义匹配策略名和当前策略名相同
&& (data as? DataProxyMap)?.toProxy() ?: proxyClassName == proxyClassName
}
预告
下一篇会在VarietyAdapter
基础上进行扩展,让刷新列表更加高效。
Talk is cheap, show me the code
推荐阅读
RecyclerView 系列文章目录如下: