深入 Android RecyclerView 自定义 LayoutManager 全链路:从布局算法到动画协同的视口管理引擎
项目中接了个需求:实现一个首项居中放大、两侧渐隐的横向滑动控件。LinearLayoutManager 的 PagerSnapHelper 能处理居中,但缩放效果只能在外层套 ViewPager2 后用 PageTransformer 做——两层嵌套加上滑动冲突,帧率直接崩了。
当时的选择是自己写一个 LayoutManager。这促使我把 RecyclerView 的布局引擎完整走了一遍。
LayoutManager 的布局算法:Fill 机制
RecyclerView 的核心设计是把数据与布局解耦。Adapter 负责数据绑定,LayoutManager 负责在屏幕上摆放 Item,两者通过 Recycler 衔接。
LayoutManager 最关键的入口是 onLayoutChildren(),它在首次布局和数据变更时调用。大多数自定义实现的核心逻辑都在这个方法里:回收不再可见的 View,将新 View 填充到视口内。
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
detachAndScrapAttachedViews(recycler);
int offset = 0;
for (int i = 0; i < getItemCount(); i++) {
View child = recycler.getViewForPosition(i);
addView(child);
measureChildWithMargins(child, 0, 0);
int width = getDecoratedMeasuredWidth(child);
int height = getDecoratedMeasuredHeight(child);
layoutDecorated(child, offset, 0, offset + width, height);
offset += width;
}
}
这段代码把每个 Item 从 Recycler 取出、测量、按横排摆放。简单直接,但没有回收逻辑——Item 一多,OOM 是早晚的事。
RecyclerView 的做法是 Fill 机制:只布局视口内和边缘的几个 Item,超出视口的 detach 或 remove 掉。「detach」不等于「销毁」——detach 的 View 进入 mAttachedScrap 缓存,复用时零开销。真正被 remove 的 View 则掉入 mCachedViews(上限 2 个)或 RecycledViewPool(按 ViewType 分组复用)。
缓存分层:Scrap、Cache、Pool 三级漏斗
理解缓存层级是手写 LayoutManager 的基本功,直接决定了滑动流畅度。
Recycler.getViewForPosition() 的查找顺序:
- mAttachedScrap:被 detach 但未移除的 View,匹配 position 直接复用,无需重新绑定
- mCachedViews:最近 remove 的 View(默认 2 个),位置匹配则直接复用
- mViewCacheExtension:开发者自定义缓存,几乎没人用
- RecycledViewPool:按 ViewType 分组的 SparseArray<ArrayList
>,需要重新调用 onBindViewHolder - onCreateViewHolder:兜底创建
我踩过的坑:onLayoutChildren 中调用 detachAndScrapAttachedViews(recycler) 时,所有 scrapped view 的 position 被标记为 NO_POSITION。紧接着在循环中调用 getViewForPosition(i),Scrap 缓存命中不了,结果走 Pool 或重建。
修复方式是 getScrapList() 手动遍历匹配,或者信任 Recycler 的内部查找——调用 getViewForPosition 时,Recycler 内部会优先从 Scrap 中按 position 查找,前提是你没重写 supportsPredictiveItemAnimations() 返回 false 导致 Scrap 被跳过。
视口编排:脱离 Fill 的布局控制
回到那个需求——首项居中放大。仅靠 fill 机制不够,还需要控制每个 Item 的相对位置和缩放比例。
思路是在 onLayoutChildren 中计算 anchor(锚点)位置,然后用 scrollHorizontallyBy 精确控制偏移:
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
detachAndScrapAttachedViews(recycler);
int startX = getPaddingLeft();
int centerY = getHeight() / 2;
// 首个 item 居中偏移
int offset = getWidth() / 2 - itemWidth / 2;
for (int i = 0; i < getItemCount(); i++) {
View child = recycler.getViewForPosition(i);
addView(child);
measureChildWithMargins(child, 0, 0);
int left = startX + offset;
int top = centerY - getDecoratedMeasuredHeight(child) / 2;
int right = left + getDecoratedMeasuredWidth(child);
int bottom = top + getDecoratedMeasuredHeight(child);
layoutDecorated(child, left, top, right, bottom);
// 根据距中心的距离计算缩放
float distanceFromCenter = Math.abs(left + itemWidth / 2f - getWidth() / 2f);
float scale = 1f - Math.min(distanceFromCenter / maxDistance, 0.4f);
child.setScaleX(scale);
child.setScaleY(scale);
offset += itemWidth + itemSpacing;
}
}
scale 的计算放在 onLayoutChildren 里只是初始状态,滑动时缩放必须跟着变化——这就需要滚动状态机介入。
滚动状态机与航位推算
RecyclerView 的滚动分三个阶段,由 OnScrollListener 暴露:
SCROLL_STATE_IDLE:静止SCROLL_STATE_DRAGGING:手指拖拽中SCROLL_STATE_SETTLING:惯性滑动或 snap 动画中
scrollHorizontallyBy(int dx, Recycler recycler, State state) 的返回值决定了 RecyclerView 是否继续投喂触摸事件。返回的值小于 dx,说明已经滚到底。
@Override
public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
int consumed = 0;
// 回收左侧超出视口的 View
while (dx > 0 && getChildCount() > 0 && getDecoratedRight(getChildAt(0)) - dx < 0) {
removeAndRecycleView(getChildAt(0), recycler);
}
// 回收右侧超出视口的 View
while (dx < 0 && getChildCount() > 0 && getDecoratedLeft(getChildAt(getChildCount() - 1)) - dx > getWidth()) {
removeAndRecycleView(getChildAt(getChildCount() - 1), recycler);
}
consumed = Math.min(Math.abs(dx), maxHorizontalScroll());
offsetChildrenHorizontal(-consumed * Integer.signum(dx));
fillViews(recycler, state);
// 更新每个 Item 的 scale
for (int i = 0; i < getChildCount(); i++) {
updateChildScale(getChildAt(i));
}
return consumed;
}
fillViews 在回收之后立即从两端填充新 View,保证视口不出现空白。这个「回收-填充」模式实际上就是航位推算(dead reckoning)——预判滚动方向,提前回收不可见端、填充可见端。
还有一个容易忽略的点:canScrollHorizontally() 必须返回 true,否则 RecyclerView 不会把触摸事件交给 LayoutManager。
动画协同:Predictive Animation 的取舍
RecyclerView 的 ItemAnimator 支持预测性动画(predictive animation),在数据变更时动画显示 Item 从旧位置移动到新位置。这要求 LayoutManager 在 onLayoutChildren 的预布局(pre-layout)阶段计算旧位置。
预布局通过 State.isPreLayout() 判断。如果你的自定义 LayoutManager 不处理这个模式,ItemAnimator 的动画直接失效。
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
if (state.isPreLayout()) {
// 按旧数据布局,不考虑动画后的位置
layoutWithOldState(recycler);
} else {
layoutNormal(recycler);
}
}
不打算支持预布局动画的话,可以直接关掉:
@Override
public boolean supportsPredictiveItemAnimations() {
return false;
}
代价是数据集变化时没有过渡动画。对于大多数自定义布局场景,这个取舍很划算——特别是滑动列表,非列表布局的 ItemAnimator 动画反而容易出视觉 bug。
调试三板斧与性能红线
手写 LayoutManager 是 RecyclerView 使用中最容易翻车的环节。三个我反复用到的排查方向:
View 泄漏:开启 StrictMode,滑动后检查 Activity 是否正常释放。典型问题是 removeAndRecycleView 没调够,ViewHolder 既不在屏幕上也没回池。
过度测量:在 measureChildWithMargins 前后打日志。每帧测量超过 10 次就有问题——通常是 fill 逻辑有误,反复回收和重建同一个 position。
帧率监控:用 systrace 抓一帧,看 onLayoutChildren 耗时。超过 8ms 必须优化,120Hz 设备的帧预算只有约 8ms 给 measure + layout。
一条容易被忽视的性能红线:scrollHorizontallyBy 中不要加载 Bitmap 或做 I/O。这个方法在滑动时每帧调用,任何耗时操作都直接掉帧。数据加载交给 Adapter,LayoutManager 只负责将已绑定的 View 放到正确位置。
LayoutManager 的核心职责就是编排——不生产 View,只决策它们的生死停留。理解了这个边界,缓存、滚动、动画各自该干什么就不会再搞混。