一、概述
五一到了,劳动人民万岁!放假还要加班的程序员们,原本的回家或出行计划被迫放弃,他们辛苦了,希望老板们玩的开心。五一过完半年就差不多过去了,离十一也不远了。一年就这两个节点,过完这两个节点一年也到头了。
今天来说一下列表中倒计时的实现,这是我在实际项目用到的方案,不知道那些大厂是怎么实现的,他们一定有很好的方案。我这只是记录下我自己的实现,如果大家知道更好的实现,可以告诉我。
场景是有一个列表,比如购物车列表,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复用时出现错乱。
三、实现代码
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()
}
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()
}
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)
}
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 {
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() {
viewModel.liveData.observeForever { responseBean ->
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) {
val canScroll = recyclerView.canScrollVertically(1)
if (!canScroll) {
viewModel.requestPageData(currentPage + 1)
}
}
}
})
}
override fun getCountDownTimer(position: Int): CheapRvCountDownTimer? {
if (position == RecyclerView.NO_POSITION) return null
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
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)
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()
}
}
四、不足与问题
- 因为是从接口返回结果时,开启倒计时,如果网络很慢的话,服务器处理完结果,到手机收到结果时间会变长,从而导致倒计时变慢了。不知道怎么知道这个时间,知道的话可以减去i这个时间;
- 计算时间的时候是使用的 / ,会舍去小于1000毫秒的数,也就是999毫秒和1毫秒都是显示0秒;导致比真实的时间快;
- 每一页中的item是共用一个CountDownTimer,如果一个item的时间是1900毫秒,正确应该900毫秒之后变成1秒。另一个item是1100毫秒,正确应该100毫秒之后变为1秒。但现在是统一1000毫秒之后变,这样产生误差。
- 不支持一个item中有多个倒计时;
|