
做Android列表需求做到一定阶段几乎每个人都会撞上同一种情况产品甩来一张设计图里面的列表既不是线性、也不是网格而是类似卡片层叠、旋转木马、瀑布错位这种花活。RecyclerView 自带的 LinearLayoutManager、GridLayoutManager、StaggeredGridLayoutManager 都救不了场这时候就得自己动手写一个自定义 LayoutManager。这也是“自定义控件三部曲视图篇”系列里被问得最多、踩坑最多的一篇今天我把 RecyclerView 系列第三篇专门留给 LayoutManager 自定义从原理、手写一个最简实现到卡片层叠和旋转木马的实际案例、曝光埋点接入、嵌套点击事件失灵的问题一次性讲透。这篇文章比较硬核适合已经会用 RecyclerView.Adapter但对测量、布局、滚动回收这些底层机制还比较模糊的开发者。只要你跟完整个思路再照着代码敲一遍你会发现自己对 RecyclerView 的理解会上一个台阶之后再去写各种炫酷列表基本就是套模板的事。1. 先搞清楚 LayoutManager 在 RecyclerView 里到底干了什么很多同学写自定义 LayoutManager 之前先去找网上的代码抄回来能跑但稍微改点需求就崩。根本原因是没理解 LayoutManager 在 RecyclerView 里的定位。这一节把原理补齐后面写代码才有底。1.1 别把 LayoutManager 当成“布局文件的另一种写法”LayoutManager 这个命名很容易让人误解以为它跟 XML 里的 LinearLayout、FrameLayout 是一类东西。其实在 RecyclerView 的框架里LayoutManager 承担的是“子 View 摆放策略”这个角色它不负责绘制也不负责处理触摸事件它只回答四个问题每个子 View 该多大对应测量逻辑。每个子 View 该放在哪对应布局逻辑。用户滑动时列表内容怎么跟着动对应滚动逻辑。滑出去的 View 怎么回收、新滑进来的 View 怎么复用对应缓存逻辑。你可以把 LayoutManager 想象成一个图书馆管理员。书item还是那些书ViewHolder管理员决定它们摆在哪个书架、哪个位置书太多的时候把不常用的收进仓库有人要看再从仓库取出来。换一个管理员书架的摆放风格就完全不一样LinearLayoutManager 是把书一本本竖向排开GridLayoutManager 是一排排放自定义 LayoutManager 就是你自己定义一套摆书规则。RecyclerView 本身则像一个图书馆的管理系统它负责发布指令比如“开始布局”“用户滑动了”“我要回收一些书”LayoutManager 负责执行具体的摆放动作。系统不关心你的摆放风格多奇怪只要你在规定的方法里把子 View 的位置算对、尺寸量对、回收逻辑处理好它就能正常工作。1.2 必须一起理解的协作机制测量、布局、滚动、回收RecyclerView 在界面需要更新时会走一大套流程其中和 LayoutManager 强相关的有三个阶段第一阶段是测量。RecyclerView 的 onMeasure 里会把自己的 MeasureSpec 交给 LayoutManagerLayoutManager 可以选择直接使用父容器给的限制也可以自定义尺寸。默认情况下RecyclerView 会要求 LayoutManager 对当前所有子 View 做一次测量然后决定 RecyclerView 自身的大小。这个阶段还有个冷知识如果 LayoutManager 支持自动测量默认开启RecyclerView 的宽高在 match_parent 和 wrap_content 下的行为会完全不一样。第二阶段是布局。RecyclerView 在 onLayout 时会触发 dispatchLayout()内部又拆成三步dispatchLayoutStep1 记录动画前后状态、dispatchLayoutStep2 真正的布局、dispatchLayoutStep3 执行动画。其中 step2 会调用 LayoutManager 的 onLayoutChildren()这就是我们自定义布局的主战场。每次数据变化、RecyclerView 尺寸变化都会重新触发 onLayoutChildren()。第三阶段是滚动。用户手指滑动时RecyclerView 通过 onTouchEvent 里的处理逻辑把滑动距离交给 LayoutManager 的 scrollHorizontallyBy 或 scrollVerticallyBy()LayoutManager 负责真正“移动”子 View。注意这里的“移动”不是直接改变每个 View 的坐标而是调用 offsetChildrenHorizontal/Vertical() 整批平移被移出去的 View 要回收新进入可视区域的 View 要填充。这部分不处理好列表滑动起来就会闪烁、重影、卡顿。缓存回收也贯穿在滚动和布局过程中。LayoutManager 在 onLayoutChildren 时会先把所有子 View 暂存到 scrap 区再按当前数据位置重新取回滚动时越界的 View 会被标记为 recycle后续通过 RecycledViewPool 复用。写自定义 LayoutManager 时最常见的错误就是没有正确调用 detachAndScrapAttachedViews 和 removeAndRecycleView导致 item 状态错乱。1.3 自定义前先列一张方法清单完整自定义 LayoutManager 需要覆写的方法其实不多但每个都很关键先列一张总表方便后面对照方法作用优先级generateDefaultLayoutParams()生成默认的 LayoutParams必写onLayoutChildren()布局子 View 的核心入口必写canScrollVertically() / canScrollHorizontally()声明列表支持哪个方向滚动按需scrollVerticallyBy() / scrollHorizontallyBy()处理具体滚动逻辑按需scrollToPosition()直接跳转到某个位置按需getChildCount() 相关配合方法便于 Adapter 调用建议写记住一个原则能用系统自带 LayoutManager 解决的场景不要去自定义。自定义 LayoutManager 的维护成本不低它涉及测量、布局、滚动、缓存、动画兼容任何一个环节出错表现都是难以定位的诡异问题。只有当现有 LayoutManager 确实无法满足视觉效果时才值得动手。2. 从零手写一个垂直 LayoutManager搞清楚每个方法怎么覆写直接讲花活会把人绕晕先从最朴素的垂直列表写起。这个例子做出来效果跟 LinearLayoutManager 基本一致但代码完全由我们自己控制适合理解流程。写完之后后面再改造成卡片层叠、旋转木马就有清晰思路了。2.1 第一步覆写 generateDefaultLayoutParams 和 onLayoutChildren先把第一屏画出来新建一个类继承 RecyclerView.LayoutManager第一个要覆写的是 generateDefaultLayoutParams()。这个方法负责生成默认的 LayoutParamsRecyclerView 会把它作为每个 item 的布局参数。一般情况下我们直接这样写class SimpleVerticalLayoutManager : RecyclerView.LayoutManager() { override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams { return RecyclerView.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ) } }这里让宽度填满父容器、高度自适应内容。注意如果宽度写成 WRAP_CONTENT很多场景下 item 会塌成 0 宽因为 LayoutManager 在测量子 View 时用的是 RecyclerView 传入的 MeasureSpec不是你直接指定的数值。接着是核心方法 onLayoutChildren()。我先给一个能跑的最简版本再逐步解释。override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) { super.onLayoutChildren(recycler, state) // 1. 先把所有子 View 从 RecyclerView 上解绑并暂存起来 detachAndScrapAttachedViews(recycler) // 2. 计算可用的布局区域注意避开 RecyclerView 自身的 padding val left paddingLeft val top paddingTop val right width - paddingRight val bottom height - paddingBottom // 3. 从上到下摆放 item var currentTop top var currentPosition 0 while (currentTop bottom currentPosition itemCount) { val view recycler.getViewForPosition(currentPosition) addView(view) measureChildWithMargins(view, 0, 0) val childWidth getDecoratedMeasuredWidth(view) val childHeight getDecoratedMeasuredHeight(view) layoutDecoratedWithMargins(view, left, currentTop, left childWidth, currentTop childHeight) currentTop childHeight currentPosition } }这一步做了三件关键事第一调 detachAndScrapAttachedViews(recycler)。每次重新布局前所有已经挂在 RecyclerView 上的子 View 都会被暂存到 scrap 区。这一步不是删除而是暂时解绑后续还能通过位置索引从 recycler 里取回来复用。如果不做这步旧 view 会一直残留在 RecyclerView 中导致布局错乱。第二用 recycler.getViewForPosition(position) 获取 View。这个方法内部会优先从 scrap 区拿拿不到就去 RecycledViewPool 取再没有就调用 Adapter.onCreateViewHolder 新建。我们自己写的 LayoutManager 不要直接调用 mRecyclerView 的 addView 去新建 View所有 View 的获取都走 recycler这是复用机制生效的前提。第三measureChildWithMargins getDecoratedMeasuredWidth/Height layoutDecoratedWithMargins 这套组合。measureChildWithMargins 会把 LayoutParams 的 margins 也算进测量规格layoutDecoratedWithMargins 在最终布局时也会把 margins 算进去保证 item 之间的间距正确。如果只用 layoutDecoratedmargins 会被吃掉视觉上所有 item 会挤在一起。写到这里第一屏已经能显示出来了。但你会发现列表不会跟着手滑动因为我们还没声明支持滚动。2.2 第二步让列表滚起来——canScrollVertically 与 scrollVerticallyByRecyclerView 在接收触摸滑动事件后会先问 LayoutManager 能不能滚动。所以第一步先告诉它我们支持垂直滚动override fun canScrollVertically(): Boolean true然后是滚动处理的核心方法override fun scrollVerticallyBy(dy: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int { // 1. 先平移当前所有子 View val travel dy offsetChildrenVertical(-travel) // 2. 填充滚动后新进入可视区域的 item fill(recycler, state) // 3. 回收滚出可视区域的 item return travel }这里的 dy 是 RecyclerView 希望我们移动的距离正数代表内容向上滚动手指向上滑负数代表内容向下滚动。offsetChildrenVertical(-travel) 的意思是让所有子 View 反向移动。举个例子手指上滑 dy10内容应该往上移动 10 像素所以调用 offsetChildrenVertical(-10)。注意方向别搞反了我第一次写的时候就是反着来滑起来整个列表像黏在手指上一样。fill 方法和回收逻辑是自定义 LayoutManager 里最容易出问题的部分。fill 要做的是检查 RecyclerView 的四个边界看哪个方向缺 item 了缺多少补多少。private fun fill(recycler: RecyclerView.Recycler, state: RecyclerView.State) { // 先根据现有子 View 算出应该从哪个位置继续填充 val topEdge paddingTop val bottomEdge height - paddingBottom // 情况一顶部区域空出来了向前填充 var firstChild getChildAt(0) while (firstChild ! null getDecoratedTop(firstChild) topEdge) { val previousPosition getPosition(firstChild) - 1 if (previousPosition 0) break val view recycler.getViewForPosition(previousPosition) addView(view, 0) // 插到列表最前面 measureChildWithMargins(view, 0, 0) val childHeight getDecoratedMeasuredHeight(view) val childLeft paddingLeft val childRight paddingLeft getDecoratedMeasuredWidth(view) val childBottom getDecoratedTop(firstChild) val childTop childBottom - childHeight layoutDecoratedWithMargins(view, childLeft, childTop, childRight, childBottom) firstChild getChildAt(0) } // 情况二底部区域空出来了向后填充 var lastChild getChildAt(childCount - 1) while (lastChild ! null getDecoratedBottom(lastChild) bottomEdge) { val nextPosition getPosition(lastChild) 1 if (nextPosition itemCount) break val view recycler.getViewForPosition(nextPosition) addView(view) measureChildWithMargins(view, 0, 0) val childTop getDecoratedBottom(lastChild) val childLeft paddingLeft val childRight paddingLeft getDecoratedMeasuredWidth(view) val childBottom childTop getDecoratedMeasuredHeight(view) layoutDecoratedWithMargins(view, childLeft, childTop, childRight, childBottom) lastChild getChildAt(childCount - 1) } }这里我强调一个细节item 数量很小时这种按需填充的方式其实就能工作。但如果数据量大反复在 fill 里调用 getViewForPosition 可能会因为缓存策略没做好而卡顿这个坑我放到第五节专门讲。2.3 第三步边界、复用和布局回收的完善滚动时还有一个关键问题滚出屏幕的 item 如果不回收子 View 会越积越多内存和绘制开销都会爆炸。所以在 scrollVerticallyBy 里必须检查越界 View 并回收// 回收底部越界的 item repeat(childCount) { val child getChildAt(it) ?: returnrepeat if (getDecoratedBottom(child) paddingTop) { removeAndRecycleView(child, recycler) } } // 回收顶部越界的 item repeat(childCount) { val child getChildAt(it) ?: returnrepeat if (getDecoratedTop(child) height - paddingBottom) { removeAndRecycleView(child, recycler) } }注意这里的边界判断用的是 paddingTop 和 height - paddingBottom不是 0 和 height因为 RecyclerView 可能设置了 paddingitem 在 padding 区域内就该被认为不可见了。完整的边界处理还需要考虑滑动到最底部时的“消费距离”计算。现在的实现里scrollVerticallyBy 无论到没到边界都会返回 dy这会导致 RecyclerView 认为已经消费了全部手势距离但列表实际已经没有内容可滑了用户继续上滑时列表会跟手然后松手回弹体验很怪。正确的做法是计算真实可滑动距离超过边界时只返回实际能滑的距离。这一块可以用一个简单办法找到最后一个子 View 的底部再加上剩余 item 的总高度和可视区域底部比较。不过真实项目中我更推荐直接用 LinearLayoutManager 的源码思路去处理边界这里不展开毕竟本次重点是理解机制。到这里一个可用的垂直 LayoutManager 已经写完了。你可以把它直接用在一个 RecyclerView 上效果跟 LinearLayoutManager 大差不差。有了这个基础下面就能上花活了。3. 上手两个能炫技的自定义 LayoutManager理解了最基础的自定义流程接下来就是发挥想象力的时候。我挑两个比较经典、又适合讲原理的案例卡片层叠和旋转木马。这两个效果用 LinearLayoutManager 都做不出来但原理其实跟上面的垂直列表几乎一样只是摆放坐标和每帧状态计算不同。3.1 卡片层叠 LayoutManager让列表变成一副副叠放的卡片这个效果在很多金融 App 的“我的卡包”、电商 App 的促销卡片里见过列表像一叠扑克牌顶部一张是完全展开的下面的卡牌依次向下偏移、缩小、变暗滑动时卡片一张张“翻开”。实现思路是从 onLayoutChildren 开始以当前可见的第一个数据位置为基准依次计算每张卡片的偏移量和缩放比例。核心代码我简化成下面这样class StackLayoutManager : RecyclerView.LayoutManager() { private var firstVisiblePosition 0 private var offsetY 0 // 滑动的偏移量用于驱动动画 override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams { return RecyclerView.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) } override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) { detachAndScrapAttachedViews(recycler) if (itemCount 0) return val baseLeft paddingLeft val baseTop paddingTop val baseRight width - paddingRight val baseBottom height - paddingBottom val cardWidth baseRight - baseLeft val cardHeight baseBottom - baseTop // 计算第一张卡片的位置 val firstView recycler.getViewForPosition(firstVisiblePosition) addView(firstView) measureChildWithMargins(firstView, 0, 0) layoutDecoratedWithMargins(firstView, baseLeft, baseTop, baseRight, baseBottom) // 后续卡片依次向下错开并缩放 var currentTop baseTop cardHeight / 4 for (i in 1 until visibleCount) { val position firstVisiblePosition i if (position itemCount) break val view recycler.getViewForPosition(position) addView(view) measureChildWithMargins(view, 0, 0) layoutDecoratedWithMargins(view, baseLeft, currentTop, baseRight, currentTop cardHeight) // 缩放和透明度可以在这里设置 val scale 1f - i * 0.1f view.scaleX scale view.scaleY scale view.alpha 1f - i * 0.15f currentTop cardHeight / 6 } } override fun scrollVerticallyBy( dy: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State ): Int { offsetY dy // 根据 offsetY 判断什么时候切换 firstVisiblePosition val threshold height / 5 var consume dy while (offsetY threshold firstVisiblePosition itemCount - 1) { offsetY - threshold firstVisiblePosition consume dy // 这里简化了实际消费距离的计算 onLayoutChildren(recycler, state) } while (offsetY 0 firstVisiblePosition 0) { offsetY threshold firstVisiblePosition-- consume dy onLayoutChildren(recycler, state) } return consume } }这段代码里最关键的是 threshold它决定用户滑动多少像素后切到下一张卡片。这个值可以根据视觉效果调整卡片切换太灵敏就调大切换太迟钝就调小。为了使滑动过程有“跟手”的感觉更精细的做法是把 offsetY 映射给当前卡片的 translationY让所有卡片在切换动画期间有一个平滑的位移动画。这种自定义 LayoutManager 的优点是逻辑集中在 onLayoutChildren滚动时通过重新布局实现动画代码容易理解。缺点是频繁调用 onLayoutChildren 性能一般真实项目里如果卡片数量多、item 布局复杂建议用 Decoration 或者 ItemAnimator 去配合或者用属性动画配合一次布局完成。3.2 旋转木马 LayoutManager把线性列表弯成一条弧线另一个很常见的自定义效果是旋转木马比如浏览器的标签页切换、视频推荐的横向画廊。它和垂直列表最大的区别是每个 item 的 y 坐标不再线性递增而是根据它离中心位置的距离计算出一个弧线路径位置同时附带缩放和透明度变化。实现思路依然是在 onLayoutChildren 里进行坐标变换class CarouselLayoutManager(private val orientation: Int HORIZONTAL) : RecyclerView.LayoutManager() { private var offset 0 override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams { return RecyclerView.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT ) } override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) { detachAndScrapAttachedViews(recycler) if (itemCount 0) return for (i in 0 until itemCount) { val view recycler.getViewForPosition(i) addView(view) measureChildWithMargins(view, 0, 0) val childWidth getDecoratedMeasuredWidth(view) val childHeight getDecoratedMeasuredHeight(view) // 根据当前位置计算一个相对“中心”的偏移量 val centerOffset i offset // 这里的 offset 随滚动变化 val maxOffset itemCount / 2 val ratio centerOffset.toFloat() / maxOffset.coerceAtLeast(1) // 横向位置中心对齐纵向按照正弦曲线偏移 val centerX (width - childWidth) / 2f val baseY (height - childHeight) / 2f val arcOffset sin(ratio * PI / 2).toFloat() * height / 6 // 缩放比例离中心越远越小 val scale 1f - abs(ratio) * 0.2f layoutDecoratedWithMargins(view, centerX.roundToInt(), (baseY - arcOffset).roundToInt(), (centerX childWidth).roundToInt(), (baseY - arcOffset childHeight).roundToInt()) view.scaleX scale view.scaleY scale } } override fun canScrollHorizontally(): Boolean true override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int { offset dx / 50 // 通过除一个系数控制滚动速度 onLayoutChildren(recycler, state) return dx } }这个例子我把横向滚动简化成了“来回移动中心偏移量”每个 item 都一直存在没有做回收。实际上当 item 数量很多时这种做法会导致所有 item 常驻内存非常不推荐。真正的生产级旋转木马应该仍然只布局可见范围然后用位置关系计算弧线坐标。这里为了讲清视觉产生原理故意保留了简化写法大家学习时可以先用这种方式观察效果再逐步加入回收和复用。参加开源项目或者面试时如果被问到“你做过最复杂的自定义控件”这类自带视觉效果的 LayoutManager 是很加分的案例。但面试官通常会接着问数据量上千还这样搞行不行所以回收复用原理一定要吃透不然答不上来反而减分。4. 从自定义 LayoutManager 延伸出去的两个高频需求写自定义 LayoutManager 往往不是终点真正的业务需求往往落在“这个列表要曝光埋点”“为什么列表内层控件点不了”这类问题上。这两个问题我在实际项目里遇到过无数次这里展开说说。4.1 条目曝光埋点自己算可见范围才能埋得准App 里做埋点几乎是标配RecyclerView 的条目曝光埋点也是面试常客。系统里的 LinearLayoutManager 暴露了 findFirstVisibleItemPosition() 和 findLastVisibleItemPosition()用起来很方便。但这两个方法返回的是“部分可见”的 item 位置如果产品要求“露出 50% 才算曝光”就得自己写了。自定义 LayoutManager 场景下曝光埋点逻辑会更自由因为我们自己控制 item 的摆放坐标完全可以自己算出每个 item 的可见比例。通用做法是给 RecyclerView 添加 OnScrollListener在 onScrolled 回调里判断当前可见的 item 集合然后上报recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { super.onScrolled(recyclerView, dx, dy) val layoutManager recyclerView.layoutManager ?: return val first layoutManager.findFirstVisibleItemPosition() val last layoutManager.findLastVisibleItemPosition() if (first -1 || last -1) return for (position in first..last) { val view layoutManager.findViewByPosition(position) ?: continue val top view.top val bottom view.bottom val parentHeight recyclerView.height val visibleHeight minOf(bottom, parentHeight) - maxOf(top, 0) val totalHeight bottom - top val visibleRatio if (totalHeight 0) 0f else visibleHeight.toFloat() / totalHeight if (visibleRatio 0.5f !exposureSet.contains(position)) { exposureSet.add(position) reportExposure(position) } } } })这套逻辑有几个坑需要注意不要每次 onScrolled 都上报必须用 Set 记录已曝光的 position否则每次滚动都会重复上报。滑动太快时可能跳过中间位置可以通过 first 到 last 的遍历来兜底。item 高度不固定时findLastVisibleItemPosition 的返回结果不一定准确因为它可能只看到“露出 1 像素”的 item如果你用的是自定义 LayoutManagerfindViewByPosition 方法必须自己写稳健否则会出现 visibleRatio 算出来是负数的情况。另外一个经验是曝光埋点最好配合“页面切换”事件一起上报比如列表从一个不可见的 Fragment 切回可见时也要重新判断曝光不然用户停在列表页不滑动永远不会触发 onScrolled曝光数据就丢了。4.2 嵌套点击事件失灵的真相与解法“RecyclerView 嵌套 RecyclerView 时点击事件有时候无反应”这是社区里出现频率极高的问题。我在自定义 LayoutManager 之后也遇到过而且更隐蔽。先解释为什么会失灵第一种场景是 RecyclerView 嵌在 ScrollView/NestedScrollView 里。外层 ScrollView 在拦截竖直滑动事件时会调用 requestDisallowInterceptTouchEvent(false) 把自己设为事件拦截者内层 RecyclerView 的 Click 事件在下发时被外层容器干扰导致 item 的 onTouch 行为不稳定。再加上 RecyclerView 默认是支持嵌套滚动的它也在抢事件两个滚动容器相互消耗最终点击事件要么延迟要么丢失。第二种场景是自定义 LayoutManager 里对子 View 做触摸区域变换比如旋转、缩放、层叠。你看到的卡片位置经过了 scaleX/scaleY 变换但 View 的 hitTest 用的还是自身原始边界点击区域和视觉上看到的卡片区域对不上自然“点了没反应”。针对第一种场景最推荐的解法是能不用 ScrollView 包 RecyclerView 就别用。RecyclerView 本身就支持滚动外层套 ScrollView 纯粹是多个爹管一个娃性能还差。如果是因为业务需要外层整体滑动就只保留最外层一个滚动容器内层 RecyclerView 设置 setNestedScrollingEnabled(false) 并让高度自适应内容把滚动权彻底交给外层这样点击事件就不会被内部 RecyclerView 的滚动拦截影响。针对第二种场景自定义 LayoutManager 在设置 scaleX/scaleY 之后要重写子 View 的 touch 判定区域或者直接对 itemView 做 Invalidate 和 clickable 设置。比如层叠卡片场景可以给顶部的卡片设置 elevation 和 clickabletrue让它在 z 轴上层优先接收点击同时给下面的卡片设置 clickablefalse避免遮挡区域抢事件。如果你抽到的项目里历史代码已经用了 ScrollView 包 RecyclerView又不能大改一个补救办法是给内层 RecyclerView 设置recyclerView.isNestedScrollingEnabled false recyclerView.isFocusable false再配合每个 item 根布局设置 descendantFocusability 为 blocksDescendants可以解决大部分点击和焦点问题。这些手段不优雅但能救急。5. 自定义 LayoutManager 最容易踩的几个深坑写自定义 LayoutManager 的过程就是不断遇到诡异 bug 的过程。很多问题在 LinearLayoutManager 里根本不会出现因为官方已经把边界条件处理得很完善。到了自定义阶段所有边界条件都得自己兜底。我把自己踩过最深的三个坑整理出来。5.1 回收复用没处理好卡顿和错乱一起找上门LayoutManager 的性能底线是缓存复用。如果你按本文最开始的写法每次 onLayoutChildren 都把全部子 View detach 掉再从 recycler 里 getViewForPosition这种粗暴写法在少量 item 下没问题但列表拉到几千条时就会明显卡顿。回收复用要理解两个概念scrap 和 recycle。detachAndScrapAttachedViews 把 View 放进了 scrap 区这个区域的 View 还跟 RecyclerView 有关联同一个 position 再次请求时能直接复用速度快。removeAndRecycleView 则把 View 送进 RecycledViewPool里面存储的 ViewHolder 已经和 Adapter 解绑下次任何位置都能复用。实际操作中滚动过程不应该反复 detach 所有 View而是只回收越界的 View填充缺失的 View。能做到这一点性能就已经接近官方 LayoutManager 了。再做进一步优化可以实现 ViewCacheExtension、优化 getViewForPosition 的命中率但这属于高阶优化两三屏数据的普通列表用不上。5.2 MeasureSpec 没搞明白item 尺寸会失控自定义 LayoutManager 测量子 View 时MeasureSpec 的来源很讲究。RecyclerView 传入的宽高 Direction 不同measureChildWithMargins 内部会根据 LayoutParams 的宽高属性生成不同 MeasureSpec。这里有个常见误区如果 LayoutParams 里设置了固定宽度 100dpmeasureChildWithMargins 会把 spec 设成 EXACTLY 100dp但如果 LayoutParams 是 match_parent它可能拿到的是父容器剩余空间的 spec。更隐蔽的是高度方向。垂直滚动的列表通常高度使用 wrap_contentitem 高度由内容决定所以 onMeasure 阶段会走 UNSPECIFIED。这时如果你在 onLayoutChildren 里直接用了 getMeasuredHeight可能拿到 0。正确做法是始终通过 getDecoratedMeasuredHeight(view) 拿测量结果并且在布局前调 measureChildWithMargins。还有一点自定义 LayoutManager 如果要做特殊的 item 宽高比例千万别在 onLayoutChildren 里改 measure 结果应该在 onMeasure 阶段统一处理。否则 RecyclerView 的自动测量机制会认为你的尺寸不准确滚动时出现 item 跳动。5.3 动画过程中的 onLayoutChildren 回调千万别强行填充RecyclerView 的 ItemAnimator 会在数据变化时执行动画动画期间会反复回调 onLayoutChildren。如果在这个阶段你的实现里无条件执行逻辑比如每次都新增 View、每次都重置 offset就会导致动画错乱、item 闪烁。官方推荐做法是在 onLayoutChildren 开头判断 state.isPreLayoutoverride fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) { if (state.isPreLayout) { return } // 正常布局逻辑 }另外要处理 state.didStructureChange()数据真正变化时才重置全部状态否则只做增量布局。这一块我在第一次写 LayoutManager 时完全没意识到结果每次 notifyItemInserted 之后列表整个重排动画效果惨不忍睹。后来去看 LinearLayoutManager 源码发现它对 isPreLayout 和 willChangeAdapter 都有专门分支处理瞬间理解了什么叫“成熟的生产级代码”。还有个小坑onLayoutChildren 不是只在首次布局时调用RecyclerView 尺寸变化也会触发。如果你的 LayoutManager 在计算 item 位置时依赖 RecyclerView 宽高要注意新尺寸下旧 View 的缓存坐标可能不对必要时在 onLayoutChildren 里先 detach 再重新布局。写在最后自定义 LayoutManager 这件事难点不在于某一两个 API 不会用而在于它是 RecyclerView 里最需要“全局视角”的组件。测量、布局、滚动、回收、动画、缓存每一环都咬合在一起。我建议学习时不要直接复制网上的炫酷实现而是像文章里这样先从垂直列表这种最朴素的写法入手把每个回调的触发时机弄明白再逐步加入特效。写坏了大不了回到上一步比一上来就照抄几百行代码要稳得多。另外提一件小事真机上调试自定义 LayoutManager 时多用 Android Studio 的 Layout Inspector 看每一帧的子 View 边界比凭空猜要高效很多。遇到 item 位置不对先看测量值再看布局坐标基本能找到问题。如果屏幕显示错乱优先怀疑回收逻辑其次怀疑动画回调。这两个方向能过滤掉绝大部分诡异现象。