带倒计时RecyclerView的设计心路历程
需求
目前有这样一个需求:
- 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
}
}
效果如下: