200 路硬件状态高频上报不卡顿:RecyclerView 增量刷新 + 滑动暂停实战
场景:智能柜管理页用 RecyclerView(4 列网格)展示 200 个箱门卡片,每个卡片要显示开关门状态、在位传感器状态、占用人、上架信息等。硬件通过 TCP 高频推送 200 门 + 200 传感器的状态变化。原实现是"每收到一个事件就全量查库 + notifyDataSetChanged()",页面卡到无法滑动。本文记录完整的优化方案:三级去重漏斗 → 事件带定位参数 → 单行增量刷新 → 滑动暂停与脏标记合并 → 失败降级兜底。
一、问题分析:刷新链路每一环都是浪费
原链路:硬件事件 → EventBus → ViewModel loadData() 全量查库(含排序、分组、关联查询)→ Activity notifyDataSetChanged() 全量重绑。
一次传感器状态变化,只为更新一个卡片上的一行字,却付出了"全表查询 + 400 个卡片重绑"的成本。而且传感器上报非常频繁(门开关、在位变化、定时轮询回复),卡顿是必然的。
二、前提改造:让事件自带"谁变了"
增量刷新的前提是接收方能精确定位变化的那一条。给事件类加上定位参数(DoorChangeEvent.kt):
class DoorChangeEvent(
val m3id: String = "",
val pindex: String = "",
val index: String = ""
)
注意默认值 "" 的用意:兼容旧的无参事件——收到 m3id 为空的事件就降级走全量刷新,老代码路径零迁移成本。
事件发送侧(MainViewModel.handleDoorOpened/handleDoorClosed)只在状态实际变化时才发事件:
val changed = handleDoorEventInternal(host, cabinetAddress, doorAddress, true)
if (changed) {
EventBusUtil.post(DoorChangeEvent(getM3Id(host), cabinetAddress, doorAddress))
}
handleDoorEventInternal 内部如果发现 DB 里的值和新值相同,直接 return false——这是数据层的去重。
三、三级去重漏斗:让 UI 只收到"真变化"
在事件到达 UI 之前,实际上有三层过滤逐级收敛:
- 硬件层:
lastReportedHardwareStates记录每个门上次上报的状态,重复上报直接丢弃,不入队; - 队列层:同一门的消息进入专属队列,消费者协程一次性取出整批,按消息类型只保留每类最后一条(状态类消息天然可合并,10 条"门开着"只需要处理最后一条):
val typeLastIndex = mutableMapOf<String, Int>()
batch.forEachIndexed { index, msg -> typeLastIndex[msg.type] = index }
val merged = batch.groupBy { it.type }.map { (_, msgs) -> msgs.last() }
.sortedBy { typeLastIndex[it.type] ?: 0 }
- 数据层:写入 DB 前比对,值未变则不发事件(见上一节)。
200 路传感器的状态风暴经过这三层,到达页面层时只剩下真正需要刷新的变化。
四、ViewModel:内存快照 + 单行增量更新
ViewModel 维护两份内存快照(DoorManageViewModel.kt:48-63),全量加载成功后回填:
val singleItemChanged = MutableLiveData<Pair<Int, Map<String, M2SDetailDbBean>?>>()
private var currentCabinetList: ArrayList<M2SDetailDbBean> = ArrayList()
private var currentPositionMap: Map<String, M2SDetailDbBean> = emptyMap()
收到事件后,只查变化的那一条 DB 记录,更新快照,通知对应 position(DoorManageViewModel.kt:777-799):
@Subscribe(threadMode = ThreadMode.MAIN)
fun onDoorChanged(event: DoorChangeEvent) {
if (event.m3id.isEmpty()) { // 兼容旧事件,降级全量
refreshDebounced()
return
}
incrementalUpdateCabinet(event.m3id, event.pindex, event.index)
}
private fun incrementalUpdateCabinet(m3id: String, pindex: String, index: String) {
if (currentCabinetList.isEmpty()) return
launch({
val fresh = LitePal.where(
"m3id = ? and pindex = ? and index = ?", m3id, pindex, index
).findFirst(M2SDetailDbBean::class.java) ?: return@launch -1 to null
val position = currentCabinetList.indexOfFirst {
it.m3id == m3id && it.pindex == pindex && it.index == index
}
if (position >= 0) currentCabinetList[position] = fresh
position to null
}, { (position, _) ->
if (position >= 0) singleItemChanged.value = position to null
}, {
refreshDebounced() // 增量失败 → 降级全量
})
}
一个容易踩的坑:异构数据的反查映射。 在位传感器和箱门是两条独立的 DB 记录,传感器事件变化要刷新的是它对应的箱门卡片。所以传感器的增量更新要多走一步:通过 getRelativeIndex() 把传感器 index 映射回箱门 index,再定位卡片 position(DoorManageViewModel.kt:804-833)。
五、Activity:滑动暂停 + 脏标记合并刷新
即使单条刷新很轻,滑动过程中刷新仍会打断滚动的流畅性(重绑触发 measure/layout)。方案是滑动期间只记账、不执行(DoorManageActivity.kt:83-113):
private var isScrolling = false
private val pendingDirtyPositions = mutableSetOf<Int>()
mViewBind.rv.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
when (newState) {
RecyclerView.SCROLL_STATE_DRAGGING,
RecyclerView.SCROLL_STATE_SETTLING -> isScrolling = true
RecyclerView.SCROLL_STATE_IDLE -> {
isScrolling = false
if (pendingDirtyPositions.isNotEmpty()) {
val sorted = pendingDirtyPositions.sorted()
pendingDirtyPositions.clear()
// 合并连续的 position,减少 notify 次数
var start = sorted.first()
var end = start
for (i in 1 until sorted.size) {
if (sorted[i] == end + 1) {
end = sorted[i]
} else {
mAdapter.notifyItemRangeChanged(start, end - start + 1)
start = sorted[i]; end = start
}
}
mAdapter.notifyItemRangeChanged(start, end - start + 1)
}
}
}
}
})
增量通知的观察处(DoorManageActivity.kt:212-220):
mViewModel.singleItemChanged.observe(this) { (position, positionMap) ->
if (position < 0) return@observe
val item = mViewModel.getCabinetItemAt(position) ?: return@observe
if (isScrolling) {
pendingDirtyPositions.add(position) // 滑动中:记账
} else {
mAdapter.updateCabinetItem(position, item, positionMap) // 非滑动:立即刷新
}
}
亮点在 IDLE 时的"连续区间合并":滑动停止后如果积累了 20 个脏 item,排序后把相邻 position 合并成区间调 notifyItemRangeChanged,20 个分散的脏 item 可能只需要 3~4 次 notify——这是对刷新风暴的二次压缩。
Adapter 侧的单条刷新非常简单(DoorManageAdapter.kt:61-69):
fun updateCabinetItem(position: Int, item: M2SDetailDbBean,
newPositionMap: Map<String, M2SDetailDbBean>? = null) {
if (position < 0 || position >= data.size) return
data[position] = item
newPositionMap?.let { positionMap = it }
notifyItemChanged(position)
}
这里配套的一个设计:卡片绑定需要的关联数据(在位状态 map、上架 map、人员 map)外挂在 Adapter 的独立 Map 上,convert() 按 key 查表。所以单条刷新只需替换 data[position] 和一个 map 引用,无需重建任何关联数据。
六、失败降级:快路径可失败,慢路径兜底
增量路径的任何异常(DB 查询失败、position 找不到、旧格式事件)统一落到 refreshDebounced():
private fun refreshDebounced() {
refreshHandler.removeCallbacks(refreshRunnable)
refreshHandler.postDelayed(refreshRunnable, 500L) // 500ms 防抖后全量刷新
}
降级路径本身也带防抖,避免异常风暴时退化成原来的"每事件全量刷新"。正确性由慢路径保证,性能由快路径提供——这是增量刷新方案里最重要的一条设计原则。
七、配套微优化
同一页面还有几个小但有效的点(DoorManageActivity.kt:76-81):
itemAnimator = null:关掉默认的变更动画,高频刷新不闪烁;setItemViewCacheSize(24):增大缓存,减少滑动时的重新创建;setHasFixedSize(true):item 高度固定,跳过一次 measure;- 箱门点击 500ms 防抖,防连点重复开门。
八、完整数据流
锁控板 TCP 推送(200门+200传感器)
└─ MainViewModel.onMessageReceived
├─ 硬件层去重:重复状态上报直接丢弃
├─ 队列层合并:同门消息批量取尽,同类型只留最后一条
└─ 数据层去重:DB 值未变不发事件
└─ EventBusUtil.post(DoorChangeEvent(m3id, pindex, index))
└─ DoorManageViewModel(IO 线程只查 1 条 DB,更新内存快照)
├─ 失败 → refreshDebounced() 500ms 防抖全量刷新(降级兜底)
└─ singleItemChanged.value = position
└─ DoorManageActivity
├─ 滑动中 → pendingDirtyPositions 记账
│ └─ 滑动停止 → 排序 + 合并连续区间 → notifyItemRangeChanged
└─ 非滑动 → adapter.updateCabinetItem → notifyItemChanged
九、总结
这套方案可以提炼为五条通用经验:
- 事件要带定位参数,并用默认值兼容旧事件格式,降级零成本;
- 去重要分层做:硬件层、队列层、数据层各挡一道,UI 层压力最小;
- 增量刷新 = 单行查库 + 内存快照定位 + 单条 notify,复杂度从 O(全表) 降到 O(1);
- 滑动期间只记账不刷新,停止后合并连续区间批量补刷;
- 永远留好降级路径:增量失败的任何异常都落回防抖全量刷新,快路径可以失败,正确性不能丢。
最后坦诚一点:全量路径(进页面、手动刷新、上下架操作后)依然保留原样——增量优化只覆盖"高频事件刷新"这一条路径。这是合理的投入取舍:把优化预算花在频率最高的链路上。