策略模式应用 | 每当为 RecyclerView 新增类型时就很抓狂

8,025 阅读14分钟

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?
)
  1. 定义实体类
  2. 定义与实体类绑定的ViewHolder
  3. 定义与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)

其中ImageProxyImage是和上面提到的TextProxyText类似的策略及数据类型。

单类型多匹配

有时候服务器返回的列表数据中有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 系列文章目录如下:

  1. RecyclerView 缓存机制 | 如何复用表项?

  2. RecyclerView 缓存机制 | 回收些什么?

  3. RecyclerView 缓存机制 | 回收到哪去?

  4. RecyclerView缓存机制 | scrap view 的生命周期

  5. 读源码长知识 | 更好的RecyclerView点击监听器

  6. 代理模式应用 | 每当为 RecyclerView 新增类型时就很抓狂

  7. 更好的 RecyclerView 表项子控件点击监听器

  8. 更高效地刷新 RecyclerView | DiffUtil二次封装

  9. 换一个思路,超简单的RecyclerView预加载

  10. RecyclerView 动画原理 | 换个姿势看源码(pre-layout)

  11. RecyclerView 动画原理 | pre-layout,post-layout 与 scrap 缓存的关系

  12. RecyclerView 动画原理 | 如何存储并应用动画属性值?

  13. RecyclerView 面试题 | 列表滚动时,表项是如何被填充或回收的?

  14. RecyclerView 面试题 | 哪些情况下表项会被回收到缓存池?

  15. RecyclerView 性能优化 | 把加载表项耗时减半 (一)

  16. RecyclerView 性能优化 | 把加载表项耗时减半 (二)

  17. RecyclerView 性能优化 | 把加载表项耗时减半 (三)

  18. RecyclerView 的滚动是怎么实现的?(一)| 解锁阅读源码新姿势

  19. RecyclerView 的滚动时怎么实现的?(二)| Fling

  20. RecyclerView 刷新列表数据的 notifyDataSetChanged() 为什么是昂贵的?