五一到了,劳动人民万岁!放假还要加班的程序员们,原本的回家或出行计划被迫放弃,他们辛苦了,希望老板们玩的开心。五一过完半年就差不多过去了,离十一也不远了。一年就这两个节点,过完这两个节点一年也到头了。
今天来说一下列表中倒计时的实现,这是我在实际项目用到的方案,不知道那些大厂是怎么实现的,他们一定有很好的方案。我这只是记录下我自己的实现,如果大家知道更好的实现,可以告诉我。
场景是有一个列表,比如购物车列表,item中会出现倒计时,比如秒杀倒计时。列表还支持分页,滑到第一页底部会加载下一页,下一页数据同样会有倒计时出现。效果如下:
与后台约定,接口返回剩余时间的时间戳(还有多久结束或者开始),精确度到毫秒。
定时器使用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复用时出现错乱。
倒计时器,记录时间的流逝,通知监听器。接口请求回来时会创建
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)
}
}
}
}
倒计时监听,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)
}
Activity、Adapter、ViewHolder之间通信的接口,Activity会实现该接口
interface IActionListener {
//返回position对应的CountDownTimer
fun getCountDownTimer(position: Int): CheapRvCountDownTimer?
}
我们的页面,请求数据相关回调处理,创建倒计时器
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()
}
}
负责请求网络数据,和保存相关数据
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)
}
}
网络请求接口返回的实体
class ResponseBean(val page: Int, val totalPage: Int, val list: List<ItemBean>)
class ItemBean(val hasCountDown: Boolean, val countDown: Long)
创建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()
}
}
实现倒计时监听 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()
}
}