
1. 写在动手之前LayoutManager到底在RecyclerView里扮演什么角色做Android开发的朋友应该都有体会RecyclerView这个控件你用了三年五年可能每天都在跟LayoutManager打交道但真正动手写一个自定义LayoutManager的人少之又少。大部分人停留在LinearLayoutManager、GridLayoutManager、StaggeredGridLayoutManager这三个官方实现上偶遇到特殊需求就是网上搜一圈看有没有现成的库。这个现象我能理解毕竟自定义LayoutManager的门槛确实比自定义View高了一个量级它牵扯到RecyclerView的缓存机制、测量流程、滑动机制、复用机制等一整套东西没有一定积累很容易被绕进去。但我想说的是一旦你掌握了自定义LayoutManager的写法你对RecyclerView的理解会进入一个完全不同的层次包括最近很多人在做的recyclerview条目曝光埋点、recyclerview嵌套点击事件有时候无反应这类问题如果你懂LayoutManager底层的layoutChunk和onLayoutChildren是怎么跑的很多问题一眼就能看到根源。1.1 从一次真实需求说起先聊个实际场景。我前段时间做一个信息流项目产品给了一个需求首页要有一个横向滚动的卡片列表卡片宽度要随手势滑动逐渐放大中间项、缩小两侧项类似Cover Flow效果同时还要在滑动停止时上报当前曝光卡的曝光埋点。这个需求用官方三个LayoutManager都做不了LinearLayoutManager做不到视觉差效果GridLayoutManager和StaggeredGridLayoutManager更不用提。唯一的选择就是自己写一个LayoutManager。再比如项目里还碰到了recyclerview嵌套点击事件有时候无反应的问题ListView嵌套RecyclerView本来就够头疼了加上Item里还有可点击的按钮整个点击事件分发链路特别容易出问题。排查到最后发现很多情况下问题就出在LayoutManager对Item的测量和布局结果上——Item的可见区域只有1像素或者Item被上一个View的translationX效果覆盖了你点击感觉没反应其实是压根没点到那个Item。这两个问题叠加在一起让我下定决心把自定义LayoutManager彻底搞明白。这篇文章就基于我实际写过的几个LayoutManager把核心原理和实操细节完整梳理一遍希望能帮到正在这个坑里的朋友。1.2 为什么默认的三个LayoutManager不够用先明确概念。LayoutManager在RecyclerView里的职责很纯粹决定一个ItemView放在屏幕的哪个位置、多大尺寸、什么时候应该被回收或复用。官方提供的三个实现覆盖了最高频的使用场景线性列表纵向/横向、网格列表、瀑布流列表。但实际项目里总有不老实的需求比如旋转木马式的卡片叠加效果环形布局、扇形布局、表格布局混合布局前几项是一张Banner后面是普通列表支持拖动排序的复杂自定义布局需要精确控制Item绘制层级和视觉差效果这些需求在官方LayoutManager上要么实现不了要么需要大量hack。与其用ItemDecoration、ItemAnimator这些方案去绕不如直接撸一个自定义LayoutManager从根上解决问题。当然这也意味着你要投入更多时间去理解RecyclerView的底层机制。1.3 自定义LayoutManager需要重写的核心方法概览这里先把要重写的核心方法列个清单后面逐一展开讲class CustomLayoutManager : RecyclerView.LayoutManager() { override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) override fun canScrollVertically(): Boolean override fun canScrollHorizontally(): Boolean override fun scrollVerticallyBy(dy: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int override fun onSaveInstanceState(): Parcelable override fun onRestoreInstanceState(state: Parcelable?) }看着不多但每个方法背后的逻辑都不简单。onLayoutChildren是所有逻辑的入口layoutChunk虽然这个方法是自己写的内部方法而非重写方法是布局的核心单元scrollVerticallyBy/scrollHorizontallyBy控制滑动和子View的回收复用generateDefaultLayoutParams决定每个Item的LayoutParams类型。把这几个方法吃透自定义LayoutManager基本上就拿下了一大半。2. 核心原理拆解onLayoutChildren与layoutChunk的完整逻辑2.1 从RecyclerView的measure到layout数据是怎么流动的要理解自定义LayoutManager先得理解RecyclerView的onMeasure和onLayout是怎么跟LayoutManager协作的。整体流程是这样的RecyclerView在onMeasure阶段会调用LayoutManager.onMeasure方法默认实现里会调用RecyclerView.getDefaultLayoutParams生成一个占满全屏的LayoutParams然后调用View.MeasureSpec相关的工具方法确定RecyclerView自身的宽高。接下来在onLayout阶段RecyclerView会调用LayoutManager.onLayoutChildren开始布局子View。onLayoutChildren是核心中的核心官方注释是Layout all relevant child views from the given Recycler。这句话的意思是把当前应该显示的所有子View从Recycler中取出来放到屏幕上正确的位置。这个方法不是只在初始化时调用一次在数据刷新、RecyclerView尺寸变化、requestLayout等场景下都会被调用。所以这里面的逻辑必须足够健壮能够在任意时刻恢复正确的布局状态。很多初学者写onLayoutChildren的时候习惯第一行先removeAllViews()把所有子View干掉再重新布局这个做法在自定义LayoutManager里是行不通的或者说会带来严重的性能问题。因为RecyclerView的复用机制依赖于ViewHolder的缓存状态你把View直接remove掉缓存就被破坏了后面全走createViewHolder性能直接拉垮。正确做法是通过detachAndScrapAttachedViews(recycler)把当前所有子View临时分离到一个scrap集合里然后重新计算布局能复用的就直接复用。我来用一个生活化的类比解释这个过程你把桌上的一排书子View先拿起来放到旁边的托盘scrap集合里桌面重新规划重新layout规划好了从托盘里把合适的书放回桌面重新attach托盘里用不上的书放回书架回收进RecyclerPool。这样反复调整布局的时候书本身不用重新买不需要重新create效率一下就上来了。2.2 onLayoutChildren布局的入口来看一个最简化的onLayoutChildren实现这个例子是横向布局、从左往右排列override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) { if (itemCount 0) { removeAndRecycleAllViews(recycler) return } if (state.isPreLayout) return // 先把所有子View分离到scrap集合 detachAndScrapAttachedViews(recycler) var left 0 var top 0 val parentWidth width - paddingRight - paddingLeft for (i in 0 until itemCount) { val child recycler.getViewForPosition(i) addView(child) measureChildWithMargins(child, 0, 0) val childWidth getDecoratedMeasuredWidth(child) val childHeight getDecoratedMeasuredHeight(child) layoutDecoratedWithMargins(child, left, top, left childWidth, top childHeight) left childWidth if (left parentWidth) { break // 超出屏幕宽度就停止布局这是线性布局的典型逻辑 } } }这段代码的逻辑很清晰遍历所有数据项逐个创建/获取View测量布局直到坐标超出屏幕范围为止。实际项目里的LayoutManager当然比这个复杂得多你可能还需要考虑offset的计算、锚点定位、缓存回收等逻辑但核心思想就是这个。这里需要注意几个细节第一getViewForPosition(i)内部会先检查这个position对应的ViewHolder是否在scrap集合里如果不在再从RecyclerViewPool里取Pool里也没有就走createViewHolder。所以在onLayoutChildren开头执行detachAndScrapAttachedViews非常关键不然你后面getViewForPosition重新取回之前已经显示的Item时系统得重新查找摄像头效率极低。第二measureChildWithMargins和getDecoratedMeasuredWidth这两个方法配套使用。measureChildWithMargins会考虑父View的ItemDecoration和子View的LayoutParams的margin而getDecoratedMeasuredWidth返回的也是加上装饰之后的尺寸。如果你直接拿child.measuredWidth会把ItemDecoration的宽度漏掉布局就会错乱。第三layoutDecoratedWithMargins是强制要求使用的方法不要直接调用child.layout()。因为layoutDecoratedWithMargins会自动把ItemDecoration的外边距算进去保证装饰层比如分割线的位置正确。2.3 layoutChunk真正把一个Item放到屏幕上的过程如果onLayoutChildren是整体布局的指挥者那layoutChunk就是在一个方向上放置一个Item的具体执行者。线性LayoutManager的官方源码里有个layoutChunk方法它负责填充一个块这个块就是在当前滚动位置下一个要布局的Item。我自己写LayoutManager的时候会把这个逻辑提取成内部方法结构大概是private fun layoutChunk(recycler: RecyclerView.Recycler, state: RecyclerView.State, layoutState: LayoutState): View? { val child recycler.getViewForPosition(layoutState.currentPosition) addView(child) measureChildWithMargins(child, 0, 0) val childWidth getDecoratedMeasuredWidth(child) val childHeight getDecoratedMeasuredHeight(child) layoutDecoratedWithMargins(child, layoutState.left, layoutState.top, layoutState.left childWidth, layoutState.top childHeight) return child }LayoutState是我自己定义的一个简单的数据类保存当前布局的起始坐标、方向、position等状态。实际写的时候layoutChunk还需要处理View的添加方向是往前加还是往后加以及回收逻辑但核心就是上面这几行。为什么要单独抽一个layoutChunk方法因为onLayoutChildren和scrollVerticallyBy或scrollHorizontallyBy这两个方法虽然入口不同但做的事情高度重合——都是把一个Item摆到某个位置。滚动的时候你只是把锚点坐标来回移动然后调用layoutChunk来填充新出现的Item。抽出来代码就能复用不然你会写两遍几乎一模一样的布局逻辑维护起来很痛苦。2.4 状态保存与恢复onSaveInstanceState与onRestoreInstanceState还有两个很容易被忽略的方法onSaveInstanceState和onRestoreInstanceState。默认的LayoutManager这两个方法都是空实现也就是说默认情况下你滑动到第50个位置旋转屏幕后RecyclerView会回到第0个位置。这对一个正经App来说是不能接受的。override fun onSaveInstanceState(): Parcelable { val state SavedState() state.firstVisiblePosition findFirstVisibleItemPosition() state.offset getOffsetOfFirstVisibleItem() return state } override fun onRestoreInstanceState(state: Parcelable?) { if (state is SavedState) { savedState state requestLayout() } }注意onRestoreInstanceState里调用requestLayout而不是直接在这里布局。因为状态恢复的时机可能早于RecyclerView完成measure/layout直接在这里布局容易拿到错误的宽高值。更好的做法是把保存的状态暂时存到一个成员变量里等onLayoutChildren执行的时候再取出来恢复。有些文章在介绍自定义LayoutManager时把这两个方法忽略掉我觉得这是不对的。状态恢复是一个LayoutManager能用和好用的分水岭你的App转了屏幕或者被系统杀了进程之后能不能回到原来的位置直接影响用户体感。3. 从零手写一个支持横向滑动的简单LayoutManager3.1 第一步定义布局参数和构造函数下面进入实战环节。我带着你完整写一个支持横向滑动、Item等宽的LayoutManager然后在这个基础上逐步加上视觉差效果和曝光埋点。首先定义构造参数。为了让代码更灵活我一般会支持设置item的宽度占比相对RecyclerView宽度和item之间的间距。class HorizontalPagerLayoutManager( private val itemWidthRatio: Float 1.0f, private val itemGap: Int 0 ) : RecyclerView.LayoutManager() { private var firstVisiblePosition 0 private var firstVisibleItemLeft 0 private var totalScrollOffset 0 }firstVisiblePosition记录当前屏幕最左边显示的Item的positionfirstVisibleItemLeft记录这个Item的left坐标totalScrollOffset记录已滚动的总距离。这三个变量是整个LayoutManager的状态中枢布局、滑动、埋点上报全靠它们。构造函数里的itemWidthRatio会在后面测量时用到它决定每个Item的宽度是RecyclerView宽度的多少倍。itemWidthRatio 1.0f时就是普通的整页翻页效果0.8f时左右能露出相邻Item的边缘配合视觉差就能实现Cover Flow效果。3.2 第二步实现onLayoutChildren布局的核心逻辑在onLayoutChildren里。因为页面是横向滑动的我们需要从左往右布局Item直到填满RecyclerView的宽度。override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) { if (itemCount 0) { detachAndScrapAttachedViews(recycler) return } if (state.isPreLayout) return // 保存旧的滚动状态后面要恢复 val scrollOffset if (childCount 0) getTotalScrollOffset() else 0 detachAndScrapAttachedViews(recycler) val parentWidth width - paddingLeft - paddingRight val itemWidth (parentWidth * itemWidthRatio).toInt() var left paddingLeft scrollOffset var top paddingTop for (i in 0 until itemCount) { val child recycler.getViewForPosition(i) addView(child) // 关键根据itemWidthRatio测量Item宽度 val widthSpec View.MeasureSpec.makeMeasureSpec(itemWidth, View.MeasureSpec.EXACTLY) val heightSpec View.MeasureSpec.makeMeasureSpec( height - paddingTop - paddingBottom, View.MeasureSpec.EXACTLY) child.measure(widthSpec, heightSpec) val childWidth child.measuredWidth val childHeight child.measuredHeight layoutDecorated(child, left, top, left childWidth, top childHeight) left childWidth itemGap if (left parentWidth) { break } } }这里有个值得展开的细节为什么不用measureChildWithMargins而是手动measure我在这段代码里直接构造MeasureSpec来测量子View。原因是当itemWidthRatio不是1.0时子View的宽度不是由父容器宽度决定的而是由我们自己控制的。measureChildWithMargins内部会基于父容器的MeasureSpec和子View的LayoutParams来测量不方便做这种自定义比例控制。当然你也可以在generateDefaultLayoutParams里设置width itemWidth然后用measureChildWithMargins两种方式都可以。但我更推荐手动measure的方式因为它对MeasureSpec的控制更精细方便后面做视差效果时微调Item尺寸。还有一点layoutDecorated和layoutDecoratedWithMargins在这个场景下效果一样因为Item之间通过itemGap控制间距而不是用margin。但如果你使用ItemDecoration来画分割线建议统一用layoutDecoratedWithMargins避免装饰层位置错乱。3.3 第三步实现scrollHorizontallyBy支持滑动实现横向滑动需要重写三个方法canScrollHorizontally返回truescrollHorizontallyBy处理滑动逻辑。override fun canScrollHorizontally(): Boolean true override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int { if (childCount 0) return 0 // 计算实际可滚动的范围 val maxScroll getMaxScroll() var delta dx // 边界控制 if (totalScrollOffset delta 0) { delta -totalScrollOffset } else if (totalScrollOffset delta maxScroll) { delta maxScroll - totalScrollOffset } if (delta 0) return 0 totalScrollOffset delta firstVisibleItemLeft delta // 移动所有子View for (i in 0 until childCount) { val child getChildAt(i) ?: continue child.offsetLeftAndRight(delta) } // 回收屏幕外的View填充新进入屏幕的View recycleAndFillChildren(recycler, state) return delta }getMaxScroll的计算逻辑是private fun getMaxScroll(): Int { val parentWidth width - paddingLeft - paddingRight val itemWidth (parentWidth * itemWidthRatio).toInt() val totalWidth itemCount * (itemWidth itemGap) - itemGap return (totalWidth - parentWidth).coerceAtLeast(0) }recycleAndFillChildren是滑动时最重要、也最容易写错的方法。它的职责有两个把滑出屏幕的View回收到RecyclerPool里把新滑入屏幕的Item从Recycler里取出来布局到正确位置。private fun recycleAndFillChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) { // 从后往前回收滑出屏幕的View for (i in childCount - 1 downTo 0) { val child getChildAt(i) ?: continue if (getDecoratedRight(child) 0 || getDecoratedLeft(child) width) { removeAndRecycleView(child, recycler) } } // 从前往后填充新进入的Item var lastPosition firstVisiblePosition if (childCount 0) { lastPosition getPosition(getChildAt(childCount - 1)!!) } var nextLeft if (childCount 0) getDecoratedRight(getChildAt(childCount - 1)!!) itemGap else 0 var position lastPosition 1 while (position itemCount nextLeft width) { val child recycler.getViewForPosition(position) addView(child) val widthSpec View.MeasureSpec.makeMeasureSpec( (width - paddingLeft - paddingRight) * itemWidthRatio.toInt(), View.MeasureSpec.EXACTLY) val heightSpec View.MeasureSpec.makeMeasureSpec( height - paddingTop - paddingBottom, View.MeasureSpec.EXACTLY) child.measure(widthSpec, heightSpec) layoutDecorated(child, nextLeft, paddingTop, nextLeft child.measuredWidth, paddingTop child.measuredHeight) nextLeft child.measuredWidth itemGap position } }这段代码里的回收条件getDecoratedRight(child) 0和getDecoratedLeft(child) width是均匀的临界判断View的右边界都滑出屏幕左边了小于0或者左边界超出了屏幕右边大于width才认为它完全不可见可以回收。这个临界值如果设得不对会导致可见Item被误回收引发闪屏或空白。提示边界回收判断一定要用getDecoratedRight/getDecoratedLeft而不是child.right/child.left。因为getDecorated*系列方法会包含ItemDecoration的偏移量直接拿View的坐标会把装饰层算漏。3.4 第四步处理RecyclerView的复用缓存细节写到这里估计有经验的朋友已经发现问题了上面这段recycleAndFillChildren每次滚动只处理屏幕最右边的填充那从左边滑出屏幕的Item被回收后如果用户反向滑动之前的Item怎么恢复答案是靠RecyclerView的RecycledViewPool。当Item被removeAndRecycleView回收后它的ViewHolder不会立即销毁而是进入RecycledViewPool默认每个类型的缓存上限是5个。当用户反向滑动recycler.getViewForPosition(position)请求这个position的ViewHolder时会先查scrap集合查不到再去Pool里按viewType取。所以正确地实现removeAndRecycleView反向滑动时就能命中缓存不需要重新createViewHolder。但Pool缓存按viewType区分而且默认上限5个。如果你的列表有大量不同类型的Item比如混合布局可能缓存命中率不高性能会受影响。这时候可以在RecyclerView上调用setRecycledViewPool自定义Pool的大小或者使用setViewCacheExtension做更精细的缓存控制。另外还有个细节recycler.getViewForPosition(position)拿到的不一定是新View有可能是之前滑出屏幕但还没进Pool的View这些View会被detachAndScrapAttachedViews分离到scrap集合。如果你在onLayoutChildren开头没有做detach这些View还在RecyclerView的children列表里你再addView同一个View会导致崩溃或者布局异常。这个坑我踩过血泪教训。3.5 完整代码与关键参数说明把上面的代码拼起来就是这个自定义横向LayoutManager的完整可用版本。我整理几个关键参数的含义方便你调整参数含义推荐值itemWidthRatioItem宽度占RecyclerView宽度的比例1.0f整页翻页0.8f左右露出相邻Item便于预览itemGapItem之间的间距像素值0~16dp之间太大会浪费屏幕空间firstVisiblePosition屏幕最左边Item的position内部维护用于埋点和状态恢复totalScrollOffset已滚动的总距离内部维护用于边界判定和状态恢复注意itemWidthRatio和itemGap两个参数不是独立的设计它们共同决定相邻Item的重叠/间距效果。当itemWidthRatio 1.0f时左右会露出相邻Item的边缘这时候配合ItemDecoration可以画阴影、圆角等效果视觉上非常出彩。4. 进阶打造支持点击穿透与曝光统计的LayoutManager4.1 曝光埋点怎么判断Item真正可见写埋点之前先明确一个概念可见有两种定义。一种是getChildAt(i)能拿到的Item都算可见但这是错的因为LayoutManager可能把超出屏幕的View也保留在children里比如为了动画需要或者缓存策略。另一种是Item在屏幕内的可见区域超过一定阈值比如30%、50%才算曝光这是业务侧更常要求的定义。正确的写法基于getDecoratedLeft/getDecoratedTop/getDecoratedRight/getDecoratedBottom计算可见面积fun getVisiblePercent(child: View): Float { val left maxOf(getDecoratedLeft(child), 0) val top maxOf(getDecoratedTop(child), 0) val right minOf(getDecoratedRight(child), width) val bottom minOf(getDecoratedBottom(child), height) val visibleArea (right - left).toFloat() * (bottom - top).toFloat() val totalArea (getDecoratedRight(child) - getDecoratedLeft(child)).toFloat() * (getDecoratedBottom(child) - getDecoratedTop(child)).toFloat() return if (totalArea 0f) 0f else visibleArea / totalArea }有了这个方法埋点触发逻辑就很清晰了在scrollHorizontallyBy里每次滚动结束后遍历所有child计算可见百分比如果超过了业务阈值比如50%且之前没上报过就触发曝光。这里有个细节是否要处理曝光去重我的建议是LayoutManager只负责计算可见比例把是否上报、上报几次的逻辑通过回调抛给上层。因为不同业务对曝光的定义不一样有的要求一次有的要求多次比如进入屏幕一次算一次曝光。LayoutManager做太多业务判断反而失去通用性。4.2 嵌套点击事件无响应的根因分析与解决方案标题里提到了brave recyclerview嵌套点击事件有时候无反应的问题这个我在项目里也遇到过。先说结论绝大多数情况下这个问题的根子不在点击事件本身而在LayoutManager对Item的布局和触摸区域的设置上。最常见的根因有这几个第一Item的层叠顺序问题。比如我用translationX或者translationZ给卡片做了层叠效果后面的卡片有一部分区域被前面的卡片覆盖。用户点击被覆盖的区域系统把事件分发给了最上面的View看起来就是点击没反应——实际上你点击的压根不是你想点的那个Item而是覆盖在上面的那个Item的空白区域。这种问题靠调LayoutManager的绘制顺序和触摸区域能解决要确保addView(child)的顺序跟视觉上的层级一致必要时在onLayoutChildren里手动child.bringToFront()。第二Item的点击区域超过实际测量范围。有些自定义View在onDraw里画的内容超出了measuredWidth/measuredHeight比如画了阴影、光晕、拖尾效果。这时候LayoutManager认为Item的边界在A区域但视觉上内容跑到B区域了用户点B区域自然没反应。排查方法在LayoutManager的onLayoutChildren里临时给每个child加个背景色一眼就能看出实际可点击的区域。如果发现视觉内容和边界对不上就要检查自定义View的onDraw或者LayoutParams的设置。第三RecyclerView手势冲突。嵌套场景下内层RecyclerView能竖向滑动外层RecyclerView也能竖向滑动手指放上去系统不知道谁该响应。手机上是这样分发的内层RecyclerView的onInterceptTouchEvent先拿到事件如果它判断自己需要滑动就会拦截掉外层就收不到事件但如果内层的onTouchEvent返回false比如竖向列表里手指做了横向移动事件就会回传给外层。这个过程中的requestDisallowInterceptTouchEvent很容易被误调用导致外层被禁用了拦截内外层争抢事件最终表现出来就是点击偶尔无反应。我当时的解决方案是在外层RecyclerView的onInterceptTouchEvent里做方向判断只有水平方向滑动超过一定阈值才拦截事件垂直方向放行。这个逻辑放到LayoutManager里也可以做在scrollHorizontallyBy的入口处判断dx和dy的相对大小返回0表示不消费这次滚动。4.3 支持锚点定位与滚动到指定位置进阶的另一个功能需求是滚动到指定Item。官方LinearLayoutManager有scrollToPositionWithOffset自定义LayoutManager也必须自己实现。实现思路不复杂把目标position对应的坐标算出来然后调用scrollHorizontallyBy来移动。fun scrollToPositionWithOffset(position: Int, offset: Int) { if (position 0 || position itemCount) return val itemWidth (width - paddingLeft - paddingRight) * itemWidthRatio.toInt() val targetLeft paddingLeft position * (itemWidth itemGap) offset val delta paddingLeft - targetLeft scrollHorizontallyBy(delta, recyclerForScroll, stateForScroll) }这里有个问题scrollHorizontallyBy需要的recycler和state参数在外部拿不到怎么办可以通过onAttachedToRecyclerView保存RecyclerView引用然后在方法里构造一个假的Recycler和State吗不行。RecyclerView内部没有公开的构造器可以直接创建Recycler实例。实际的解决办法是用RecyclerView.scrollBy或者RecyclerView.smoothScrollBy这两个方法内部会触发LayoutManager的滚动逻辑fun smoothScrollToPosition(recyclerView: RecyclerView, state: RecyclerView.State?, position: Int) { val itemWidth (width - paddingLeft - paddingRight) * itemWidthRatio.toInt() val targetX position * (itemWidth itemGap) recyclerView.smoothScrollBy(targetX - totalScrollOffset, 0) }这个方法里直接计算目标Item的坐标x然后调用RecyclerView.smoothScrollBy让RecyclerView自己触发LayoutManager的scrollHorizontallyBy这样就避免了直接构Recycler和State的尴尬。5. 常见问题排查与性能优化实录5.1 滑动卡顿高频日志与measure/layout次数优化自定义LayoutManager最常见的性能问题就是滑动卡顿原因是布局过程中做了太多不必要的measure和layout。典型的低效写法是在scrollHorizontallyBy里对每个仍可见的Item都重新measure。我见过有些同学图省事滚一下就调用child.measure重新测量所有子View滑动起来帧率直接掉到20以下。正确的思路是滚动过程中已经布局好的View只需要改变坐标用offsetLeftAndRight/offsetTopAndBottom不需要重新measure只有新进入屏幕的View才需要measure。如果觉得recycleAndFillChildren里手动measure太麻烦可以考虑只在onLayoutChildren里做measure滑动的recycleAndFillChildren里直接调用layoutDecoratedWithMargins因为此时Item的尺寸已经确定了。另一个性能杀手是日志输出。在scrollHorizontallyBy里打了Log平时在自己测试机上可能感觉不明显但在中低端机器上Log本身耗时就可能拖垮帧率。排查问题可以打印上线前务必去掉。5.2 数据更新后布局错乱notifyDataSetChanged与局部刷新自定义LayoutManager在数据更新的时候也很容易出错尤其是不小心重写了onItemsChanged或者对state.isPreLayout处理不当。先解释isPreLayout。当使用DefaultItemAnimator时RecyclerView会执行动画前的一个预布局阶段pre-layout此时会调用一次onLayoutChildren并且state.isPreLayout true。在预布局阶段RecyclerView需要让LayoutManager把所有相关的Item都布局出来即使是那些即将被删除的Item这样才能为动画计算起始位置。很多自定义LayoutManager在预布局阶段没有做好处理就出现了Item跳动、闪烁、错乱的问题。一个保险的做法是在onLayoutChildren开头判断state.isPreLayout如果是预布局就简单地把所有Item按原始位置布局在正式布局阶段isPreLayout false再执行完整的回收复用逻辑。还有个细节notifyDataSetChanged会导致整个RecyclerView重新布局但detachAndScrapAttachedViews会保留所有ViewHolder如果布局逻辑能正确处理已有的child效率是很高的。最忌讳的是在notifyDataSetChanged回调里清空所有数据再重新创建LayoutManager那样会丢失滚动位置和缓存性能也差。5.3 内存抖动与ViewHolder复用异常内存抖动在自定义LayoutManager场景下通常不是因为布局逻辑本身而是因为测量或者布局过程中创建了太多临时对象。比如在getViewForPosition之后马上addView如果中间有异常分支没有走到addView这个ViewHolder就会被丢在一边无法进入Pool造成内存泄漏。ViewHolder复用异常还可能出现一个问题同一个ViewHolder被多次addView。原因是recycler.getViewForPosition(position)在ViewHolder已经removed但没有recycled的情况下再次获取同一个position时可能会返回同一个ViewHolder。避免方法是在onLayoutChildren开头严格执行detachAndScrapAttachedViews(recycler)保证后续getViewForPosition拿到的都是可用的ViewHolder。内存优化方面recycleAndFillChildren里尽量少创建新的数据结构。比如用for (i in childCount - 1 downTo 0)遍历的时候不要每次循环都new一个Rect来存储坐标直接复用成员变量或者用原始类型的坐标对比。这些小细节在数据量大的时候差距很明显。5.4 实战速查表常见异常与解决方案现象常见原因解决方案程序崩溃IllegalArgumentException: Invalid view holder同一个ViewHolder被多次addViewonLayoutChildren开头严格detachAndScrapAttachedViews滑动后出现空白区域recycleAndFillChildren填充逻辑不完整检查填充循环的终止条件和nextLeft计算Item闪烁/跳动isPreLayout处理不当在onLayoutChildren里显式判断isPreLayout旋转屏幕后位置丢失没实现onSaveInstanceState保存firstVisiblePosition和offset反向滑动时All Views removed缓存回收策略错误检查回收边界判断不要过早回收点击事件无反应Item可视区域与测量区域不一致给子View加临时背景色排查边界滑动卡顿布局过程中频繁measure所有子View滚动时只measure新进入的Item5.5 真机调试经验分享最后分享一个我自己常用的调试技巧在onLayoutChildren里临时打个断点Visualizer里查看每个child的left、top、right、bottom以及对应的position。对比数据可以发现很多隐藏问题比如坐标重叠、间距不对称、装饰层偏移等。另外一个很实用的调试方式是利用Android Studio的Layout Inspector在滑动过程中截取布局层级看看每个Item的实际位置和尺寸。Layout Inspector能看到RecyclerView内部的所有View树结构比自己瞎猜强太多。调试的时候建议开启Animation的Window animation scale和Transition animation scale为0.5x或关闭不然每次操作都被动画干扰看不到真实状态。结尾写到这里自定义LayoutManager的核心内容基本覆盖全了。回想我在这个系列前面几篇文章里说过的RecyclerView是三件套里最值得花时间研究的控件而LayoutManager又是RecyclerView里最值得啃的硬骨头。这个论断放到今天依然成立。不管是做曝光埋点、嵌套滑动冲突还是自定义炫酷布局底层逻辑最后都会汇聚到LayoutManager的这几个方法里。我自己踩过最大的坑就是刚开始写的时候过于关注怎么让Item出现在正确的位置而忽略了怎么让Item高效地出现。结果功能是跑通了性能一塌糊涂。后来把detachAndScrapAttachedViews、removeAndRecycleView的语义彻底搞明白代码才算是真正能用。建议你写的时候也先把这几个方法和Recycler的缓存机制吃透再动手去码布局代码会少走很多弯路。这篇文章属于自定义控件三部曲视图篇的第六篇也是RecyclerView系列的第三篇。下一篇我打算写写RecyclerView的ItemAnimator——动画这块又是另一个深坑等我把坑踩完了再来分享。