前言

平时我们做倒计时有很多种方法实现,也相对比较简单。但是最近碰到一个需求,需要在RecyclerView的item中实现倒计时,一般这种场景在电商的业务上应该也会有。那么我们就不能像开始单个倒计时一样简单做了,还需要考虑到viewholder的复用、性能等问题。

效果

这里可以简单先写个Demo看看效果

RecyclerView 实现Item倒计时效果

功能实现

1. 倒计时功能实现

核心就是开启一个计时器,每秒都更新时间到页面上,这个计时器的实现就很多了,比如直接handler,或者kotlin能用flow去做,或者TimerTask这些也能实现。打个广告,要做精准的倒计时可以看这篇文章juejin.cn/post/714065…

我这里是做Demo演示,为了代码整洁和方便,我就用TimerTask来做。

2. 设计思路

试着想想,你用RecyclerView做倒计时,每个ViewHolder的倒计时时间都不同,难道要在每个ViewHolder中开一个TimerTask来做吗?然后对viewholder的缓存再单独做处理?

我的想法是可以所有Item共用一个倒计时

这个系统有3个重要部分组成:

(1)倒计时的实现,只用一个TimerTask来做一个心跳的效果。每次心跳去更新正在显示的Item页面的倒计时 ,比如你有100个Item,但是显示在屏幕上的只有5个,那我只需要关心这5个Item的时间变动,其他95个没必要做处理。

(2)观察者队列。我的每次心跳都要通知正在显示的Item更新页面,那是不是很明显要通过观察者模式去做。

(3)倒计时时间列表。倒计时也需要用一个列表管理起来,recyclerview的页面显示是根据数据去显示,虽然比如说100个数组只需要5、6个viewholder来复用,但是你的差异数据还是100个数据。

3. 倒计时列表

倒计时列表的实现,我这里是用一个HashMap来实现,因为方便直接获取某个实际Item的当前倒计时的时间。

private val cdMap: HashMap<Long, Long> = HashMap()

我的key假设用一个id来做处理,因为我们的数据结构中基本都会存在id并且id是基础数据类型。

data class RcdItemData(
    var id : Long,   //  id
    var cd : Long    //  总倒计时时间
)

添加倒计时

fun addCountDown(id: Long, totalCd: Long, isCover: Boolean = false) {
    if (cdMap.containsKey(id)) {  
        if (isCover) {  
            cdMap[id] = totalCd  
        }  
    } else {  
        cdMap[id] = totalCd  
    }  
}

这个isCover是一个策略,adapter数据刷新时是否更新倒计时,这里可以先不用管,可以简单看成

fun addCountDown(id: Long, totalCd: Long, isCover: Boolean = false) {
    if (!cdMap.containsKey(id)) {  
        cdMap[id] = totalCd  
    }
}

清除倒计时(比如页面退出时就需要做释放操作)

fun clearCountDown() {
    cdMap.clear()  
}

获取某个Item当前倒计时的时间

fun getCountDownById(id: Long): Long? {
    if (cdMap.containsKey(id)) {  
        return cdMap[id]  
    }  
    return null  
}

更新时间(随心跳更新所有数据)

private fun updateCdByMap() {
    cdMap.forEach { (t, u) ->  
    if (cdMap[t]!! > 0) {  
        cdMap[t] = u - 1  
    }   
    ......
}

这些代码都不难理解,就不过多解释了

4. 观察者数组实现

先创建一个观察者数组

private var viewHolderObservables: ArrayList<OnItemSchedule?> = ArrayList()

然后就是最基础的添加观察者和移除观察者操作

fun addHolderObservable(onItemSchedule: OnItemSchedule?) {
    viewHolderObservables.add(onItemSchedule)  
}  
fun removeHolderObservable(onItemSchedule: OnItemSchedule?) {  
    viewHolderObservables.remove(onItemSchedule)  
}  
fun releaseHolderObservable() {  
    viewHolderObservables.clear()  
}

通知观察者(通知Item倒计时1秒了,可以刷新页面了)

private fun notifyCdFinish() {
    viewHolderObservables.forEach {  
        it?.onCdSchedule()  
    }  
}

5. 倒计时心跳实现

前面说了,我们让所有的Item共用一个倒计时,也是通过一个心跳去更新各自倒计时时间

private var task: TimerTask? = null
private var timer: Timer? = null

开始倒计时

fun startHeartBeat() {
    if (task == null) {  
        timer = Timer()  
        task = object : TimerTask() {  
            override fun run() {  
                updateCdByMap()  
            }  
        }  
        timer?.schedule(task, 1000, 1000) // 每隔 1 秒钟执行一次  
    }  
}

每一秒都会调用updateCdByMap()方法去刷新时间。

private fun updateCdByMap() {
    cdMap.forEach { (t, u) ->  
        if (cdMap[t]!! > 0) {  
            cdMap[t] = u - 1  
        }  
    }  
    // 更改完数据之后通知观察者  
    Handler(Looper.getMainLooper()).post {  
        notifyCdFinish()  
    }  
}

TimerTask会在子线程中进行,所以最后通知观察者的操作需要切到主线程

最后关闭倒计时(页面关闭这些时机调用)

fun closeHeartBeat() {
    task?.cancel()  
    task = null  
    timer = null  
}

6. 整体功能

因为上面是拆开来解释说明,这里再把整个工具的代码合起来可能会比较好管理。

object RecyclerCountDownManager {
    private var task: TimerTask? = null  
    private var timer: Timer? = null  
    // viewHolder观察者  
    private var viewHolderObservables: ArrayList<OnItemSchedule?> = ArrayList()  
    // 倒计时对象数组  
    private val cdMap: HashMap<Long, Long> = HashMap()  
    /**  
    * 添加viewHolder观察  
    */  
    fun addHolderObservable(onItemSchedule: OnItemSchedule?) {  
        viewHolderObservables.add(onItemSchedule)  
    }  
    fun removeHolderObservable(onItemSchedule: OnItemSchedule?) {  
        viewHolderObservables.remove(onItemSchedule)  
    }  
    fun releaseHolderObservable() {  
        viewHolderObservables.clear()  
    }  
    /**  
    * 添加倒计时对象  
    * @param totalCd 总倒计时时间  
    * @param isCover 是否覆盖  
    */  
    fun addCountDown(id: Long, totalCd: Long, isCover: Boolean = false) {  
        if (cdMap.containsKey(id)) {  
            if (isCover) {  
                cdMap[id] = totalCd  
            }  
        } else {  
            cdMap[id] = totalCd  
        }  
    }  
    /**  
    * 清除倒计时  
    */  
    fun clearCountDown() {  
        cdMap.clear()  
    }  
    /**  
    * 根据id获取倒计时  
    */  
    fun getCountDownById(id: Long): Long? {  
        if (cdMap.containsKey(id)) {  
            return cdMap[id]  
        }  
        return null  
    }  
    /**  
    * 开始心跳  
    */  
    fun startHeartBeat() {  
        if (task == null) {  
            timer = Timer()  
            task = object : TimerTask() {  
                override fun run() {  
                    updateCdByMap()  
                }  
            }  
            timer?.schedule(task, 1000, 1000) // 每隔 1 秒钟执行一次  
        }  
    }  
    /**  
    * 更新所有倒计时对象  
    */  
    private fun updateCdByMap() {  
        cdMap.forEach { (t, u) ->  
            if (cdMap[t]!! > 0) {  
                cdMap[t] = u - 1  
            }  
        }  
        // 更改完数据之后通知观察者  
        Handler(Looper.getMainLooper()).post {  
            notifyCdFinish()  
        }  
    }  
    private fun notifyCdFinish() {  
        viewHolderObservables.forEach {  
            it?.onCdSchedule()  
        }  
    }  
    /**  
    * 关闭心跳  
    */  
    fun closeHeartBeat() {  
        task?.cancel()  
        task = null  
        timer = null  
    }  
    /**  
    * 调度通知,一般由ViewHolder实现该接口  
    */  
    interface OnItemSchedule {  
        fun onCdSchedule()  
    }  
}

可以看到代码都整体比较简单,就不用过多说明,就是需要注意一下这个是用一个单例去实现的工具,在页面关闭之后需要手动调用closeHeartBeat()、clearCountDown()、releaseHolderObservable()去释放资源。

调用的地方,Demo的Adapter

class RcdAdapter(var context: Context, var list: List<RcdItemData>) :
    RecyclerView.Adapter<RcdAdapter.RcdViewHolder>() {  
    init {  
        // 因为模式默认选择不覆盖,需要每次添加前先清除  
        RecyclerCountDownManager.clearCountDown()  
        list.forEach {  
            RecyclerCountDownManager.addCountDown(it.id, it.cd)  
        }  
    }  
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RcdViewHolder {  
        val text: TextView = TextView(context)  
        text.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 64)  
        text.gravity = Gravity.CENTER  
        val holder = RcdViewHolder(text)  
        RecyclerCountDownManager.addHolderObservable(holder)  
        return holder  
    }  
    override fun getItemCount(): Int {  
        return list.size  
    }  
    override fun onBindViewHolder(holder: RcdViewHolder, position: Int) {  
        holder.setData(list[position])  
    }  
    class RcdViewHolder(var view: TextView) : RecyclerView.ViewHolder(view),  
        RecyclerCountDownManager.OnItemSchedule {  
        private var mData: RcdItemData? = null  
        fun setData(data: RcdItemData) {  
            mData = data  
        }  
        override fun onCdSchedule() {  
            val cd = mData?.id?.let { RecyclerCountDownManager.getCountDownById(it) }  
            if (cd != null) {  
            // 测试展示分秒  
                view.text = "${String.format("%02d", cd / 60)}:${String.format("%02d", cd % 60)}"  
            }  
        }  
    }  
}

其他都比较基础的adapter的写法,就是viewholder要实现RecyclerCountDownManager.OnItemSchedule来充当观察者,然后拿到列表数据后调用RecyclerCountDownManager.addCountDown(it.id, it.cd)去创建倒计时列表。在onCreateViewHolder中调用RecyclerCountDownManager.addHolderObservable(holder)去添加观察者。最后在onCdSchedule()回调中做倒计时的更新

RecyclerView 实现Item倒计时效果

在页面销毁的时候主动释放内存

RecyclerView 实现Item倒计时效果