带倒计时RecyclerView的设计心路历程

5,055

需求

目前有这样一个需求:

  • 1 需要一个页面,展示多个条目
  • 2 每个条目有独立的倒计时,倒计时结束后就删除此条目
  • 3 每个条目上有删除按钮,点击可以删除该条目
  • 4 列表上的条目类型是多样的

可行性分析

首先肯定是可以做的:

  • 1 用一个RecyclerView来实现
  • 2 每个item里面添加一个倒计时控件,注意倒计时是在item对应的数据里面,不是UI里面
  • 3 添加删除按钮,点击就删除对应的数据,并且停止数据对应的倒计时,同时更新适配器
  • 4 使用getViewType()来实现多个item类型

三流程序员看到这里已经去写代码了...

二流以上程序员接着往下看。

需求分析

首先,第1条没问题。

第2条,需要在item对应的数据里面添加一个倒计时组件,这听着就不对,倒计时组件明明是用来更新UI的,应该是UI持有,现在让数据持有,那不就等价于数据间接持有了UI吗,长生命周期持有短生命周期了,不行。而且,数据多的时候,比如10w条数据,就有10w个倒计时组件,cpu不吃不喝也忙不过来(cpu:wqnmlgb)!这明显属于量变引起质变的问题,为了避免这个问题,我们需要将倒计时组件常量化,也就是: 只有常数个倒计时,从而让倒计时组件的个数,不受数据数量的影响。

那么,我们怎么定义这个常量呢?

我们考虑到倒计时是用来更新UI的,那么屏幕内可见的item有多少个,就创建多少个 倒计时组件 不就行了吗,反正屏幕外的,别人也看不见,所以我们可以让ViewHolder持有倒计时组件,而且正好可以利用RecyclerView对ViewHolder的复用机制。

但是,如果让ViewHolder持有,当ViewHolder滑出屏幕外,就会回收,那么倒计时就终止了,此时就无法触发倒计时结束的删除操作,因为即使在屏幕外,只要触发了倒计时的删除数据,我们屏幕内的数据就会向上滑动一个位置,是可以感知的,所以,如果滑出屏幕后,倒计时终止了,就无法触发删除,那么我们可能等了很久,也没发现屏幕内的数据向上滑动,明显是不对的。

程序是为用户服务的,根据以上分析,我们只站在用户角度来考虑:

  • case1 如果倒计时放在数据内,用户可以感知到删除操作,因为有滑动,但是数据多了明显会感觉到卡顿,因为有很多倒计时
  • case2 如果倒计时放在ViewHolder内,用户无法感知到删除操作,因为滑出屏幕倒计时就终止了,但是数据多了不会感觉到卡顿

此乃死锁,无法解决!那么就需要退一步来改下需求了。既然无法完美解决用户的问题,我们就来改变用户的习惯,我们让:倒计时结束后,不再删除item,只是置灰

为什么这么改呢?因为针对case1,我们没法解决,只能从case2入手,而case2的问题就是: 用户无法感知到删除操作,那我就不删除了,这样你也不用感知了,只置灰即可。

好,第二条解决。

第3条,没啥问题,直接remove(index),然后调用adapter.notifyItemRemoved()完事。

第4条,也没啥问题,可以if-else/switch-case,根据不同的type返回不同的ViewHolder。但是可以写的更好点,就是使用工厂模式

设计

可行性分析和需求分析完了后,我们就开始进行概要设计了

  • 1 我们需要创建个RecyclerView。
  • 2 我们需要在ViewHolder里面添加一个倒计时组件,这里我们使用Handler就足够,并且我们需要在进入屏幕时,开启倒计时,在滑出屏幕后,停止倒计时来省cpu。
  • 3 删除数据就不废话了,这都不会的话,回炉重造吧。
  • 4 使用工厂模式,来根据不同的ViewType创建不同的ViewHolder。

这里面有几点需要注意:

  • 1 ViewHolder进入屏幕会触发onBindViewHolder(),滑出屏幕会触发onViewRecycled()。
  • 2 工厂模式要使用多工厂,这样可以降低耦合,有新的ViewType时,只添加就行,可以做到OCP原则。
  • 3 我们可以提前加载工厂,使用map缓存,就跟工厂模式的实现思想里面最后的源码类似,Android源码也是提前加载工厂的。

好,分析结束,开始撸码。

编码

首先,我们先定义数据实体:

// 注意这里的terminalTime,指的指终止时间,不是时间差,是一个时间值。
// type可以理解为ViewType,当然中间有对应关系的
data class BaseItemBean(val id: Long, var terminalTime: Long, val type: Int)

很简单的一行代码,是个Bean对象,直接上data class即可。

然后,我们来定义两个ViewHolder,因为有相同布局,我们可以直接用继承关系:

// 基础ViewHolder
open inner class BaseVH(itemView: View) : RecyclerView.ViewHolder(itemView) {

    // 展示倒计时
    private val tvTimer = itemView.findViewById<TextView>(R.id.tv_time)
    // 删除按钮
    private val btnDelete = itemView.findViewById<TextView>(R.id.btn_delete)

    init {
        btnDelete.setOnClickListener {
            onItemDeleteClick?.invoke(adapterPosition)
        }
    }

    /**
    * 剩余倒计时
    */
    private var delay = 0L

    private val timerRunnable = Runnable {
        // 这里打印日志,来印证我们只跑了 "屏幕内可展示item数量的 倒计时" 
        Log.d(TAG, "run: ${hashCode()}")
        delay -= 1000
        updateTimerState()
    }

    // 开始倒计时
    private fun startTimer() {
        timerHandler.postDelayed(timerRunnable, 1000)
    }

    // 结束倒计时
    private fun endTimer() {
        timerHandler.removeCallbacks(timerRunnable)
    }

    // 检测倒计时 并 更新状态
    private fun updateTimerState() {
        if (delay <= 0) {
            // 倒计时结束了
            tvTimer.text = "已结束"
            itemView.setBackgroundColor(Color.GRAY)
            endTimer()
        } else {
            // 继续倒计时
            tvTimer.text = "${delay / 1000}S"
            itemView.setBackgroundColor(Color.parseColor("#FFBB86FC"))
            startTimer()
        }
    }

    /**
    * 进入屏幕时: 填充数据,这里声明为open,让子类重写
    */
    open fun display(bean: BaseItemBean) {
        Log.d(TAG, "display: $adapterPosition")

        // 使用 终止时间 - 当前时间,计算倒计时还有多少秒
        delay = bean.terminalTime - System.currentTimeMillis()

        // 检测并更新timer状态
        updateTimerState()
    }

    /**
    * 滑出屏幕时: 移除倒计时
    */
    fun onRecycled() {
        Log.d(TAG, "onRecycled: $adapterPosition")

        // 终止计时
        endTimer()
    }
}

在基础ViewHolder里,我们添加了倒计时套件,并且在进入屏幕时,计算并开始倒计时,滑出屏幕后,就终止倒计时,下次滑入屏幕,重新计算delay时间差,再倒计时。

然后看另一个ViewHolder:

// 继承自BaseViewHolder,因为有公共的倒计时套件
inner class OnSaleVH(itemView: View) : BaseVH(itemView) {
    // 添加了一个名字
    private val tvName = itemView.findViewById<TextView>(R.id.tv_name)

    override fun display(bean: BaseItemBean) {
        super.display(bean)
        // 添加名字
        tvName.text = "${bean.id} 在售"
    }
}

接下来我们来看创建ViewHolder的工厂:

/**
* 定义抽象工厂
*/
abstract class VHFactory {
    abstract fun createVH(context: Context, parent: ViewGroup): BaseVH
}

/**
* BaseViewHolder工厂
*/
inner class BaseVHFactory : VHFactory() {
    override fun createVH(context: Context, parent: ViewGroup): BaseVH {
        return BaseVH(LayoutInflater.from(context).inflate(R.layout.item_base, parent, false))
    }
}

/**
* OnSaleVH工厂
*/
inner class OnSaleVHFactory : VHFactory() {
    override fun createVH(context: Context, parent: ViewGroup): BaseVH {
        return OnSaleVH(LayoutInflater.from(context).inflate(R.layout.item_on_sale, parent, false))
    }
}

很简单,接下来,我们来看Adapter:

class Adapter(private val datas: List<BaseItemBean>) : RecyclerView.Adapter<Adapter.BaseVH>() {

    private val TAG = "Adapter"

    /**
     * 点击item的事件
     */
    var onItemDeleteClick: ((position: Int) -> Unit)? = null

    /**
     * ViewHolder的工厂
     */
    private val vhs = SparseArray<VHFactory>()

    /**
     * 用来执行倒计时
     */
    private val timerHandler = Handler(Looper.getMainLooper())

    /**
     * 初始化工厂
     */
    init {
        vhs.put(ItemType.ITEM_BASE, BaseVHFactory())
        vhs.put(ItemType.ITEM_ON_SALE, OnSaleVHFactory())
    }

    // 直接从工厂map中获取对应的工厂调用createVH()方法即可
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseVH = vhs.get(viewType).createVH(parent.context, parent)

    // 滑入屏幕内调用,直接使用hoder.display()展示数据
    override fun onBindViewHolder(holder: BaseVH, position: Int) = holder.display(datas[position])

    override fun getItemCount(): Int = datas.size

    // ViewHolder滑出屏幕调用,进行回收
    override fun onViewRecycled(holder: BaseVH) = holder.onRecycled()

    /**
     * 根据数据类型返回ViewType
     */
    override fun getItemViewType(position: Int): Int = datas[position].type
}

代码也很easy,就是使用工厂模式来返回不同的ViewHolder。

写代码的心路历程:

  • 1 因为有多个ViewType,肯定有多个ViewHolder,ViewType和ViewHolder是映射关系
  • 2 可以用if-else,可以用switch-case,但是这样扩展性差
  • 3 所以用多工厂来实现
  • 4 这样需要创建工厂,每次onCreateViewHolder()都要创建吗?不行,那就缓存起来。
  • 5 缓存需要知道哪个工厂创建哪个ViewHolder,而ViewHolder和ViewType对应,所以可以让工厂和ViewType对应,那就创建一个Map。
  • 6 ViewType是Integer类型的,那就可以用更加省内存的SparseArray(),原因可以看这里
  • 7 于是,我们就有了上述代码。

我们定义的ViewType(都是int类型的,因为int的匹配速度快):

object ItemType {
    const val ITEM_BASE = 0x001
    const val ITEM_ON_SALE = 0x002
}

接下来我们就可以在Activity中使用了:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.recyclerView.layoutManager = LinearLayoutManager(this)

        // 添加测试数据
        val beans = ArrayList<BaseItemBean>()
        for (i in 0..100) {
            // 计算终止时间,这里都是当前时间 + i乘以10s
            val terminalTime = System.currentTimeMillis() + i * 10_000
            // 这里手动计算了ViewType (i%2)+1
            beans.add(BaseItemBean(i.toLong(), terminalTime, (i % 2) + 1))
        }

        val adapter = Adapter(beans)
        adapter.onItemDeleteClick = { position ->
            // 点击就删除
            beans.removeAt(position)
            adapter.notifyItemRemoved(position)
        }
        binding.recyclerView.adapter = adapter

    }
}

效果如下: