深入 Android RecyclerView ItemDecoration 全链路
接手过一个聊天应用的时间轴需求:消息列表左侧要画一条竖线串联时间节点。第一反应是改 Item 布局,结果竖线在快速滑动时断裂闪烁——每个 Item 独立绘制,无法保证线条连续性。换用 ItemDecoration 统一在 Canvas 上绘制后,问题立刻消失。
ItemDecoration 不只是加分割线,它是依附于 RecyclerView 视口的装饰引擎。 粘性头部、时间轴、分组标签这类复合 UI,都得走它的绘制链路。
onDraw 与 onDrawOver 的分层绘制
RecyclerView 的 draw() 方法里有三处关键调用:
// RecyclerView.draw() 简化逻辑
public void draw(Canvas c) {
super.draw(c); // 1. 绘制自身背景
for (ItemDecoration dec : mItemDecorations) {
dec.onDraw(c, this); // 2. 装饰层(底层)
}
// ... 绘制所有 Item View
for (ItemDecoration dec : mItemDecorations) {
dec.onDrawOver(c, this);// 3. 装饰层(顶层)
}
}
时序很明确:onDraw 在 Item 之前执行,onDrawOver 在 Item 之后。 画在 onDraw 里的内容会被 Item 遮盖,画在 onDrawOver 里的内容会遮盖 Item。
普通分割线应该画在 onDraw 中——它出现在两个 item 的间隙里,被 item 边界自然裁剪。粘性头部则必须用 onDrawOver,因为它要悬浮在所有 item 之上。
两者拿到的都是全屏 Canvas。RecyclerView 在调用前会裁剪到 padding 区域,但在 onDrawOver 中你依然可以突破这个边界。个人习惯在绘制前手动 save() + clipRect(),绘制后 restore(),避免嵌套布局时污染外部。
getItemOffsets:被低估的布局入口
很多人只写 onDraw,忽略了 getItemOffsets。它才是 ItemDecoration 接入测量阶段的关键:
override fun getItemOffsets(
outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State
) {
outRect.set(0, 0, 0, dividerHeight) // 为 item 底部预留分割线空间
}
outRect 给 item 套了一层”额外间距”。它增大的是 item 之间的间隙,不是 item 本身。 LayoutManager 在 layoutDecoratedWithMargins() 时自动累加这些偏移量。
踩过的一个坑:GridLayoutManager 中均匀设置 outRect,导致首列和末列到边缘的距离不一致。应该用 span 信息动态分配:
val spanIndex = (view.layoutParams as GridLayoutManager.LayoutParams).spanIndex
val spanCount = (parent.layoutManager as GridLayoutManager).spanCount
outRect.left = spacing * spanIndex / spanCount
outRect.right = spacing * (spanCount - 1 - spanIndex) / spanCount
网格布局中,左间距分配给靠左的列,右间距分配给靠右的列,这样视觉上各列间距均匀。
粘性头部的工程实现
粘性头部(Sticky Header)的核心思路:监听滚动位置,在 onDrawOver 中判断屏幕顶部的第一个可见 item 属于哪个分组,据此绘制对应的头部。
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: State) {
val firstPos = (parent.layoutManager as LinearLayoutManager)
.findFirstVisibleItemPosition()
if (firstPos == RecyclerView.NO_POSITION) return
val groupId = getGroupId(firstPos)
var headerTop = parent.paddingTop.toFloat()
// 推动式吸顶:下一个头部上推时,当前头部跟随移动
val nextHeaderPos = findNextGroupFirstPos(firstPos, groupId)
if (nextHeaderPos != RecyclerView.NO_POSITION) {
val nextChild = parent.findViewHolderForAdapterPosition(nextHeaderPos)?.itemView
nextChild?.let {
if (it.top < headerHeight) {
headerTop = it.top - headerHeight
}
}
}
c.drawRect(0f, headerTop, parent.width.toFloat(), headerTop + headerHeight, bgPaint)
c.drawText(groupTitle, textX, headerTop + textBaseY, textPaint)
}
推动式吸顶的逻辑:下一组 header 顶上来时,当前 header 被”推”出去而非直接消失。实现方式就是上面代码里的 it.top - headerHeight 偏移。如果直接切换显示/隐藏,视觉上会有跳变,不如这种推动效果顺畅。
点击事件需要额外处理。ItemDecoration 画出来的内容不在视图树里,必须通过 RecyclerView 的 addOnItemTouchListener 手动分发:
parent.addOnItemTouchListener(object : RecyclerView.SimpleOnItemTouchListener() {
override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
if (e.y < stickyHeaderHeight && e.action == MotionEvent.ACTION_UP) {
handleStickyHeaderClick()
return true
}
return false
}
})
时间轴装饰:连续线条的绘制策略
时间轴装饰的挑战在于:竖线必须在 item 之间无缝衔接。 用 ItemDecoration 的 onDraw 天然适合,所有 item 的线条都在同一个 Canvas 上绘制:
override fun onDraw(c: Canvas, parent: RecyclerView, state: State) {
val childCount = parent.childCount
for (i in 0 until childCount) {
val child = parent.getChildAt(i)
val pos = parent.getChildAdapterPosition(child)
val cy = child.top + child.height / 2f
// 时间节点圆点
c.drawCircle(timelineX + nodeRadius, cy, nodeRadius, circlePaint)
// 连接线:从上一节点底部到当前节点顶部
if (pos > 0) {
val prevChild = parent.getChildAt(i - 1)
val prevCy = prevChild.top + prevChild.height / 2f
c.drawLine(timelineX + nodeRadius, prevCy + nodeRadius,
timelineX + nodeRadius, cy - nodeRadius, linePaint)
}
}
}
这段代码有一个前提:屏幕可见的 item 是连续的。 如果中间有被回收的空白区间,线条会断开。更稳健的做法是让每个 item 自己画一段竖线(理解为”线段片段”),通过 getItemOffsets 和对齐逻辑在视觉上拼成一条。
我当时的思路是:在 getItemOffsets 里给左侧预留 60dp 时间轴区域,Adapter 侧不再管竖线,全部交给 ItemDecoration。这样即使 item 内容高度差异很大,竖线依然连续。唯一需要特殊处理的是首尾 item——第一个不画上方线段,最后一个不画下方线段。
性能敏感点
ItemDecoration 的 onDraw/onDrawOver 在每次 RecyclerView 重绘时都触发——滚动、数据更新、动画播放,无一例外。几个容易踩的坑:
对象创建:Paint、Path、Rect 在构造时初始化,永远别在 onDraw 里 new。见过有人在 onDrawOver 里每次 new Paint(),列表帧率从 60 掉到 40。
Canvas 裁剪:clipRect 有开销。如果绘制范围固定在左侧 60dp 内,用 canvas.translate() 做坐标偏移比 clipRect 高效。
条件跳过量绘:粘性头部只在分组变化时才需重绘。缓存上一个 groupId:
private var lastGroupId = -1
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: State) {
val groupId = getVisibleGroupId(parent)
if (groupId == lastGroupId) return // 同一分组,跳过
lastGroupId = groupId
// 执行绘制...
}
多 Decoration 的顺序:按 addItemDecoration() 的顺序依次回调。同时用分割线 + 粘性头部时,确保粘性头部后添加——它在 onDrawOver 里,需要盖在 onDraw 的分割线上面。
设计思路
ItemDecoration 三种能力对应三层设计:
getItemOffsets→ 空间分配:为装饰预留物理空间,介入布局测量onDraw→ 背景装饰:绘制在 item 之下,如分割线、时间轴线onDrawOver→ 前景覆盖:绘制在 item 之上,如粘性头部、浮动标签
遇到复杂装饰需求时,先判断它属于哪一层,再选对应方法。大部分场景 onDraw 够用,需要悬浮效果才上 onDrawOver。
ItemDecoration 本质是无状态的——它不持有数据,只根据 RecyclerView 当前状态做绘制决策。保持这种无状态性,逻辑更清晰,出 bug 的概率更低。如果确实需要缓存计算结果(比如分组位置映射表),把数据放在 Adapter 中通过回调传入,别在 Decoration 里维护可变状态。