RecyclerView列表中的倒计时实现

一、概述

五一到了,劳动人民万岁!放假还要加班的程序员们,原本的回家或出行计划被迫放弃,他们辛苦了,希望老板们玩的开心。五一过完半年就差不多过去了,离十一也不远了。一年就这两个节点,过完这两个节点一年也到头了。

今天来说一下列表中倒计时的实现,这是我在实际项目用到的方案,不知道那些大厂是怎么实现的,他们一定有很好的方案。我这只是记录下我自己的实现,如果大家知道更好的实现,可以告诉我。

场景是有一个列表,比如购物车列表,item中会出现倒计时,比如秒杀倒计时。列表还支持分页,滑到第一页底部会加载下一页,下一页数据同样会有倒计时出现。效果如下:
RecyclerView列表中的倒计时实现_第1张图片

二、实现方案

与后台约定,接口返回剩余时间的时间戳(还有多久结束或者开始),精确度到毫秒。

定时器使用android sdk里面提供的CountDownTimer。

不是每一个item都开启一个CountDownTimer,因为时间的流逝对每一个item来说都是一样的,从接口请求成功回来起,到后面某个时间,过去多少秒都是一样的。因此一页对应一个CountDownTimer,这一页里面的item共用这个CountDownTimer。CountDownTimer会记录时间的流逝,用item的时间戳减去流逝的时间,为item现在要显示的倒计时时间。

当请求到第一页数据,遍历数据里面有没有需要倒计时的item,如果有则立马开启CountDownTimer。
当请求到下一页数据,同样遍历该页的数据有没有需要倒计时的item,如果有,则新建一个属于该页的CountDownTimer,并开启。

CountDownTimer用SparseArray保存,每一页对应一个CountDownTimer。

每个ViewHolder在onViewAttachedToWindow时注册CountDownTimer的监听,接受倒计时事件,在onViewDetachedFromWindow时移除监听,避免已经移除的View还能接受到倒计时,和ViewHolder复用时出现错乱。

三、实现代码

CountDownTimer

倒计时器,记录时间的流逝,通知监听器。接口请求回来时会创建

class CheapRvCountDownTimer {

    //监听器列表
    private var callbackListenerList: LinkedList<ICountDownListener>? = null

    //倒计时器
    private var countDownTimer: CountDownTimer? = null

    //开启倒计时后(接口请求回来之后开启),已过去多少毫秒
    private var millisTimeAgo = 0L

    fun startCountDown() {
        millisTimeAgo = 0//每次开启倒计时,重置已过去的时间
        //如果之前有倒计时,取消之前的倒计时,并移除监听器
        countDownTimer?.let {
            cancelCountDownAndRemoveListener()
        }
        //使用一个很大的值Long.MAX_VALUE,保证倒计时一直开启
        countDownTimer = object : CountDownTimer(Long.MAX_VALUE, REFRESH_GAP) {
            override fun onTick(millisUntilFinished: Long) {
                millisTimeAgo += REFRESH_GAP
                val iterator = callbackListenerList?.iterator() ?: return
                while (iterator.hasNext()) {
                    val next = iterator.next()
                    val realTime = next.getMillisInFuture() - millisTimeAgo//减去已经过去的时间,为现在的时间
                    if (realTime <= 0) {//时间到了
                        iterator.remove()
                    }
                    //这里canRemoveListener传false,不能删除元素,因为当前在循环中
                    callDayHourMinuteSecond(next.getMillisInFuture(), next, false)
                }
            }

            override fun onFinish() {
            }
        }
        countDownTimer?.start()
    }

    //取消并清除监听器
    fun cancelCountDownAndRemoveListener() {
        countDownTimer?.cancel()
        callbackListenerList?.clear()
    }

    //添加监听器
    fun register(countDownListener: ICountDownListener) {
        if (callbackListenerList == null) callbackListenerList = LinkedList<ICountDownListener>()
        callbackListenerList?.add(countDownListener)
    }

    //取消监听
    fun unregister(countDownListener: ICountDownListener) {
        callbackListenerList?.remove(countDownListener)
    }

	//ViewHolder会在onBindView时调用,用于ViewHolder可见时立马显示倒计时
    fun showCountDownTimer(time: Long, countDownListener: ICountDownListener) {
        callDayHourMinuteSecond(time, countDownListener, true)
    }

	//计算剩余的时间,并调用监听器
    private fun callDayHourMinuteSecond(
        time: Long,
        countDownListener: ICountDownListener,
        canRemoveListener: Boolean
    ) {
        val realTime = time - millisTimeAgo//减去已经过去的时间,为现在的时间
        if (realTime <= 0) {//时间到了
            countDownListener.onCountDownTick(0, 0, 0, 0)
            countDownListener.onCountDownFinish()
            if (canRemoveListener) callbackListenerList?.remove(countDownListener)
        } else {
            getDayHourMinuteSecond(realTime) { d, h, m, s ->
                countDownListener.onCountDownTick(d, h, m, s)
            }
        }
    }
}

ICountDownListener

倒计时监听,ViewHolder实现该接口

interface ICountDownListener {

    /**
     * 倒计时时间戳
     */
    fun getMillisInFuture(): Long

    /**
     * 隔一秒回调一次,倒计时剩余时间
     */
    fun onCountDownTick(day: Long, hour: Long, minute: Long, second: Long)

    /**
     * 倒计时结束
     */
    fun onCountDownFinish()

}

/**
 * 一分钟多少毫秒
 */
const val MINUTE_MILLIS = 1000 * 60

/**
 * 一小时多少毫秒
 */
const val HOUR_MILLIS = MINUTE_MILLIS * 60

/**
 * 一天多少毫秒
 */
const val DAY_MILLIS = HOUR_MILLIS * 24

/**
 * 刷新间隔
 */
const val REFRESH_GAP = 1000L

/**
 * 根据时间戳计算天,时,分,秒
 */
inline fun getDayHourMinuteSecond(mill: Long, callback: (Long, Long, Long, Long) -> Unit) {
    val day = mill / DAY_MILLIS
    val d1 = mill - day * DAY_MILLIS
    val h = d1 / HOUR_MILLIS
    val d2 = d1 - h * HOUR_MILLIS
    val m = d2 / MINUTE_MILLIS
    val s = (d2 - m * MINUTE_MILLIS) / 1000
    callback.invoke(day, h, m, s)
}

IActionListener

Activity、Adapter、ViewHolder之间通信的接口,Activity会实现该接口

interface IActionListener {
    //返回position对应的CountDownTimer
    fun getCountDownTimer(position: Int): CheapRvCountDownTimer?
}

Activity:

我们的页面,请求数据相关回调处理,创建倒计时器

class MainActivity : AppCompatActivity(), IActionListener {

    private val countDownMap = SparseArray<CheapRvCountDownTimer?>()
    private var currentPage = 1//当前页数
    private var hasMore = false//是否还有下一页

    private val viewModel by lazy(LazyThreadSafetyMode.NONE) {
        ViewModelProvider(this, ViewModelProvider.NewInstanceFactory())
            .get(MainViewModel::class.java)
    }
    private val swipeRefreshLayout by lazy(LazyThreadSafetyMode.NONE) {
        findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout)
    }
    private val recyclerView by lazy(LazyThreadSafetyMode.NONE) {
        findViewById<RecyclerView>(R.id.recyclerView)
    }

    private val adapter = RvAdapter(this)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        recyclerView.setHasFixedSize(true)
        recyclerView.itemAnimator = null
        recyclerView.layoutManager = LinearLayoutManager(this)
        recyclerView.adapter = adapter

        viewModel.requestPageData(1)
        swipeRefreshLayout.isRefreshing = true

        initListener()
        initObserve()

        val contentView = findViewById<ViewGroup>(R.id.contentView)
        val iv = ImageView(this)
        iv.setImageResource(R.mipmap.red_packget)
        FloatDragView(iv).attach(contentView, recyclerView) {
            Toast.makeText(this, "click", Toast.LENGTH_SHORT).show()
        }
    }

    private fun initObserve() {
        //因为liveData.observe是在页面显示时才收到回调,如果页面在后台那就不会执行,我们的要求是接口回来之后就立马倒计时,不然倒计时会不准,有延后
        //liveData.observeForever是立马就回调
        viewModel.liveData.observeForever { responseBean ->
            //遍历list,判断item有没有倒计时,有就启动
            var hasCountDown = false
            for (itemBean in responseBean.list) {
                if (itemBean.hasCountDown) {
                    hasCountDown = true
                    break
                }
            }
            if (responseBean.page == 1) {//如果是第一页(下拉刷新或页面第一次加载),关闭所有倒计时
                closeCountDownTimer()
            }
            if (hasCountDown) {//有倒计时
                val countDownTimer = CheapRvCountDownTimer()
                countDownTimer.startCountDown()
                countDownMap.put(responseBean.page, countDownTimer)
            }
        }
        viewModel.liveData.observe(this, Observer { responseBean ->
            swipeRefreshLayout.isRefreshing = false
            currentPage = responseBean.page
            hasMore = currentPage < responseBean.totalPage//是否还有下一页
            if (currentPage == 1) {//第一页,清除旧的数据
                adapter.list.clear()
            }
            val list = responseBean.list
            adapter.list.addAll(list)
            adapter.notifyDataSetChanged()
        })
    }

    private fun initListener() {
        //下拉刷新
        swipeRefreshLayout.setOnRefreshListener {
            viewModel.requestPageData(1)//请求第一页
        }
        //加载下一页
        recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                if (dy != 0 && hasMore && !viewModel.isRequesting) {//有滑动距离,并且有下一页,并且不是在加载中
                    //表示是否能向上滚动,false表示已经滚动到底部
                    val canScroll = recyclerView.canScrollVertically(1)
                    if (!canScroll) {//滑倒最底部,加载下一页
                        viewModel.requestPageData(currentPage + 1)
                    }
                }
            }
        })
    }

    //返回position对应页数的CountDownTimer
    override fun getCountDownTimer(position: Int): CheapRvCountDownTimer? {
        if (position == RecyclerView.NO_POSITION) return null
        //0..19为:第一页,20..39为第二页...
        val page = position / MainViewModel.PAGE_SIZE + 1
        return countDownMap.get(page)
    }

    /**
     * 关闭所有倒计时
     */
    private fun closeCountDownTimer() {
        countDownMap.forEach { _, value ->
            value?.cancelCountDownAndRemoveListener()
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        closeCountDownTimer()
    }
}

ViewModel:

负责请求网络数据,和保存相关数据

class MainViewModel : ViewModel() {

    companion object {
        const val PAGE_SIZE = 20//每一页请求的个数
    }

    //当前请求页的数据
    val liveData = MutableLiveData<ResponseBean>()
	//切成作用域
    private val mainScope = MainScope()
	//是否正在请求
    var isRequesting = false

    //请求第page页的数据
    fun requestPageData(page: Int, size: Int = PAGE_SIZE) {
        mainScope.launch {
            isRequesting = true
            try {
                val list = withContext(Dispatchers.IO) {
                    network(page, size)
                }
                liveData.value = list
            } catch (e: Exception) {
                e.printStackTrace()
            }
            isRequesting = false
        }
    }

    override fun onCleared() {
        mainScope.cancel()
    }

	//发起网络请求
    private fun network(page: Int, size: Int): ResponseBean {
        Thread.sleep(3000)//延时3秒之后返回数据
        val totalPage = 4
        val list = ArrayList<ItemBean>(size)
        for (i in 0 until size) {
            val hasCountDown = i % 2 == 0
            val countDown = (i + 1) * 1000 + 3600000L
            list.add(ItemBean(hasCountDown, countDown))
        }
        return ResponseBean(page, totalPage, list)
    }
}

ResponseBean:

网络请求接口返回的实体

class ResponseBean(val page: Int, val totalPage: Int, val list: List<ItemBean>)

class ItemBean(val hasCountDown: Boolean, val countDown: Long)

Adapter:

创建ViewHolder,并将相关生命周期方法代理到ViewHolder中。

class RvAdapter(private val listener: IActionListener) : RecyclerView.Adapter<RvHolder>() {

    val list: ArrayList<ItemBean> = ArrayList()

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RvHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.rv_item, parent, false)
        return RvHolder(view, listener)
    }

    override fun getItemCount() = list.size

    override fun onBindViewHolder(holder: RvHolder, position: Int) {
        holder.onBindView(list[position])
    }

    override fun onViewAttachedToWindow(holder: RvHolder) {
        holder.onViewAttachedToWindow()
    }

    override fun onViewDetachedFromWindow(holder: RvHolder) {
        holder.onViewDetachedFromWindow()
    }
}

ViewHolder:

实现倒计时监听 ICountDownListener,在生命周期相关方法中处理倒计时相关逻辑。

class RvHolder(view: View, private val listener: IActionListener) : RecyclerView.ViewHolder(view),
    ICountDownListener {

    private val textView: TextView = view.findViewById(R.id.textView)
    private val countDownTv: TextView = view.findViewById(R.id.countDownTv)

    private var countDown = 0L
    private var hasCountDown = false

    fun onBindView(itemBean: ItemBean) {
        hasCountDown = itemBean.hasCountDown
        countDown = itemBean.countDown
        textView.text = itemView.context.getString(R.string.position, adapterPosition)
        if (hasCountDown) {
            countDownTv.visibility = View.VISIBLE
            listener.getCountDownTimer(adapterPosition)?.showCountDownTimer(countDown, this)
        } else {
            countDownTv.visibility = View.INVISIBLE
        }
    }

	//注册倒计时监听
    fun onViewAttachedToWindow() {
        if (hasCountDown) listener.getCountDownTimer(adapterPosition)?.register(this)
    }

	//取消倒计时监听
    fun onViewDetachedFromWindow() {
        if (hasCountDown) listener.getCountDownTimer(adapterPosition)?.unregister(this)
    }

    override fun getMillisInFuture(): Long {
        return countDown
    }

    override fun onCountDownTick(day: Long, hour: Long, minute: Long, second: Long) {
        countDownTv.text = "${getDouble(hour)} : ${getDouble(minute)} : ${getDouble(second)}"
    }

    override fun onCountDownFinish() {//倒计时结束
        countDownTv.visibility = View.INVISIBLE
    }

    private fun getDouble(value: Long): String {
        return if (value < 10) "0$value" else value.toString()
    }
}

四、不足与问题

  1. 因为是从接口返回结果时,开启倒计时,如果网络很慢的话,服务器处理完结果,到手机收到结果时间会变长,从而导致倒计时变慢了。不知道怎么知道这个时间,知道的话可以减去i这个时间;
  2. 计算时间的时候是使用的 / ,会舍去小于1000毫秒的数,也就是999毫秒和1毫秒都是显示0秒;导致比真实的时间快;
  3. 每一页中的item是共用一个CountDownTimer,如果一个item的时间是1900毫秒,正确应该900毫秒之后变成1秒。另一个item是1100毫秒,正确应该100毫秒之后变为1秒。但现在是统一1000毫秒之后变,这样产生误差。
  4. 不支持一个item中有多个倒计时;

你可能感兴趣的:(Kotlin,android,kotlin,android,开发语言)