先聊一个高频得不能再高频的问题:RecyclerView 卡顿、滚动不流畅,几乎每个做过一段时间安卓开发的人都会撞上。明明列表数据也不多,甚至单个 item 也就是几行文字加一张图,可一滑起来就是掉帧,跟吃了德芙的竞品一比,体验瞬间就低了一个档次。我自己见过太多类似的排查场景,有刚入门的新手把图片加载、布局嵌套全堆在onBindViewHolder里,也有做了几年的老手在嵌套列表、动画开关这些细节上翻车。所以这篇内容不是给你背 API 文档,而是把 RecyclerView 卡顿的常见原因、定位思路和能直接落地的优化方案捋一遍,你手头正好有这类问题的话,按这个顺序排查,大概率能解决一大半。
先说个总体判断:RecyclerView 本身不背锅,卡顿的本质是主线程负担过重。列表滑动时,每一帧都要求在 16ms 内完成输入事件处理、动画更新、布局、绘制这一整套流程。任何一环超时,系统就会跳过一帧,视觉上就是卡顿。RecyclerView 只是把 item 的创建、绑定、布局这些动作集中暴露出来,真正拖后腿的,往往是你的写法、布局结构、数据加载方式,以及和 ListView 时期遗留下来的坏习惯。搞清楚这一点,后面的所有优化就都有了靶心。
详细拆解放在下面,从原理定位到布局、绑定、复用机制,再到高频问题实录,每一部分都是可以直接照着改的实操内容。
1. 先把卡顿问题拆明白:原理与定位
1.1 卡顿的本质:主线程调度不过来
安卓的 UI 渲染依赖垂直同步信号,每个周期就是屏幕刷新一帧的间隔,常见的 60Hz 屏幕约 16.6ms,120Hz 屏幕约 8.3ms。你的代码、系统框架的测量布局绘制、渲染线程的任务,都得在这个时限内完成。如果某个任务超时,Choreographer 就会跳过这一帧的绘制,掉帧就发生了。连续掉帧、或者单帧耗时超过一两百毫秒,用户体感就是明显的顿挫、迟滞。
RecyclerView 场景里,主线程上的主要开销集中在三块:
- Item 布局的 measure 与 layout:布局层级越深、嵌套越多,遍历子 View 的计算量就越大。
- ViewHolder 的绑定(bind):如果你在
onBindViewHolder里做数据解析、图片解码、格式化、甚至读写 SharedPreferences,这些都会直接占掉宝贵的帧时间。 - 列表数据更新的通知与重排:
notifyDataSetChanged()会把整个列表标记为数据变更,导致所有可见 item 全部重新绑定,不仅浪费,还可能引发局部闪烁。
理解了这三块,定位方向就清晰了:卡顿发生时,到底是 hit 在布局、绑定还是刷新逻辑上。接下来就看工具怎么说,而不是靠猜。
提示:千万不要一卡顿就去改 RecyclerView 的源码或者引入黑科技库,绝大多数问题都在业务代码层级。先把耗时点找出来,再动手。
1.2 我用得最多的两个定位工具
自己在实际排查中,最常用的两个工具组合是系统自带的Profile GPU Rendering(开发者选项里的“硬件加速渲染分析”)和Systrace / Perfetto。
Profile GPU Rendering 会把每一帧的绘制耗实用彩色柱状图展示出来,直接叠加在屏幕上。打开方式:开发者选项 → 硬件加速渲染 → 在屏幕上显示分析。柱子的颜色有讲究:
- 蓝色代表测量和布局时间
- 红色代表绘制时间(执行 View 的 draw 方法)
- 橙色代表渲染线程的合成时间
滑动列表时盯住柱子的高度,如果蓝色部分很高,问题多半在布局层级;红色部分高,多半是绘制过程,比如过度绘制或复杂图形;柱状图整体基线偏高,指向的是主线程上其他任务占用了太多时间。
如果屏幕上还不能判定精确方法,就用 Systrace 抓一段滑动期间的 trace。老工具systrace.py可能已经不大好用了,现在推荐直接抓 Perfetto trace,然后去 Perfetto UI 里分析。RecyclerView 的关键跟踪点包括RV CreateView、RV BindView、RV Layout,这几个 slice 的耗时能直接反映 ViewHolder 创建、绑定、布局阶段的真实开销。它们正是在手机上很难肉眼区分、但优化起来收益最大的目标。
Systrace 里有一个很隐蔽的点:如果
RV BindView耗时不长但整体帧耗时依然很高,注意看主线程的Choreographer#doFrame前后有没有其他高耗时任务,比如频繁的 GC、主线程 I/O、Binder 调用。单线程的主线程只要被占住,卡顿就会呈现为“间歇性猛卡”,而不是持续掉帧。
2. 布局是头号嫌疑人:列表项 UI 层优化
2.1 减少布局嵌套,从源头压掉 measure 耗时
布局优化在 RecyclerView 场景里优先级非常高,原因很直接:列表 item 是反复复用的,一张 item 布局每一次出现在屏幕上都要经历 measure 和 layout。层级越多,遍历子 View 的时间就越长,而且这个成本是每个 item 每次滑入屏幕都要付的。
我见过一个典型例子:item 外层是LinearLayout横向排列,里面嵌了三个子布局,其中一个内部又是四层嵌套,最里面还有一个FrameLayout套TextView。这个列表滑起来简直像放 PPT,一帧一帧地跳。用 Layout Inspector 或者直接看布局文件的层级树,会发现视觉上只需要一个圆形头像、两行文本、右侧一个操作按钮,结果堆了四层容器。
优化的方向很直接:
- 能用
ConstraintLayout做扁平化约束的,就别再用LinearLayout+FrameLayout层层嵌套。ConstraintLayout 在一次测量中就能确定所有子 View 的相对位置和尺寸,代价远低于多层嵌套的测量。 - 关系相对固定的布局,可以用
merge标签直接合并到父容器,减少一层 ViewGroup。 - 不需要同时显示的视图,用
ViewStub延迟加载。注意 ViewStub 一旦 inflate 后就不再参与后续测量,这一点对列表 item 很友好。
实操心得:改布局前后,一定要复测 Profile GPU Rendering 的蓝色柱。我自己优化过一个详情型 item,measure 时间从 8ms 降到 2ms,整条列表滑动时从“肉眼可见不顺”变成“勉强能接受”,再配合其他优化最终才达到流畅。
2.2 过度绘制与 ViewStub 的正确用法
过度绘制指的是同一个像素点被多个 View 重叠绘制,GPU 要反复填充同一片区域的颜色。列表 item 里很容易出现一个背景色上叠了一个白色圆角背景,再叠一层按钮背景,三层绘制就砸在同一个像素上。
开发者选项里的“调试 GPU 过度绘制”,打开后会在屏幕上显示颜色覆盖层:
- 无色:没有过度绘制
- 蓝色:绘制 1 次
- 绿色:绘制 2 次
- 粉色:绘制 3 次
- 红色:绘制 4 次及以上
列表 item 区域出现大片绿色粉红色,就说明背景层次有问题。常见做法是:
- 删掉多余背景,特别是 item 根布局的背景如果和整个页面背景一致,完全可以直接去掉。
- 使用
clipToPadding和适量的padding代替子 View 的外边距背景。 - 自定义绘制中,如果只需要部分区域圆角,可以用
clipPath或者canvas.saveLayer控制范围,但这个偏性能热点,优先把普通层级的背景清理干净再说。
ViewStub 的使用场景主要是那些“只在某条件下出现”的视图,比如列表 item 里的“已售罄”角标、商品标签区。如果用 ViewStub 而不是普通View.GONE,前者不会参与初始布局的测量和绘制,可以减轻一部分首屏压力。但这里有一个务实的提醒:如果你的 item 里超过三分之一的子视图都可能被隐藏,与其用 ViewStub 一个个延迟加载,不如在数据层直接拆分出不同的 itemType,让完全不同的布局各走各的 ViewHolder 创建路径,反而更清晰。
2.3 一个实际案例:五层嵌套压到两层
曾经帮人优化过一个订单列表 item。原始布局是这样:
LinearLayout(垂直,根布局) └─ LinearLayout(水平,顶部店铺信息) ├─ ImageView(店铺图标) └─ LinearLayout(垂直,店铺名+订单状态) └─ LinearLayout(垂直,商品区) └─ LinearLayout(水平) ├─ ImageView(商品图) └─ LinearLayout(垂直,商品名+规格+价格) └─ LinearLayout(水平,底部按钮区)再加上各种 padding、背景,总共快 8 层 View。后来用 ConstraintLayout 重写同一视觉结构,压缩到 2 层:一个根 ConstraintLayout,里面用约束放图标、文本、按钮,不需要中间容器。改完之后嵌套层级从 8 掉到 2,measure 时间肉眼可见地下来一半以上。
这里要提一个大家容易忽略的细节:ConstraintLayout 的约束不能太极端。一个布局里几十个约束链,在复杂情况下其内部算法开销也会上升。列表 item 属于高频场景,尽量保持每屏 item 的总控件数在合理范围,通常一屏 5-8 个 item,每个 item 十几二十个控件问题不大,但如果单个 item 有五六十个控件,那不管什么 LayoutManager 都救不了你,建议拆 itemType。
经验之谈:布局优化是最枯燥、最“肉眼可见但不好量化”的工作,但效果恰恰是最扎实的。别的优化可能依赖于设备、数据量、动画开关,布局层级的优化是实打实地降低每帧成本。
3. 数据绑定阶段最容易被忽视:onBindViewHolder 里的成本控制
3.1 onBindViewHolder 里只做必要的事
很多卡顿问题,查到最后都发现是onBindViewHolder里做了不该做的事。举几个真实的例子:
- 在绑定里用
SimpleDateFormat格式化时间戳,每个 item 都 new 一个实例。 - 在绑定里用
BitmapFactory.decodeStream读了图片文件。 - 在绑定里调用
notifyDataSetChanged(),这会导致整个列表重新绑定,而这次重新绑定又会再次触发绑定里的耗时操作,形成恶性循环。 - 在绑定里做了网络请求,回调回来以后直接刷新 UI。这条在基线上看可能不卡,但网络抖动时下拉刷新等操作会引发大量 item 的异步回调互相竞争,造成局部卡顿。
这些操作的共同点是:它们都发生在主线程,而主线程每一帧只有 16ms 的预算。把任何耗时操作塞进绑定阶段,都会直接压缩帧的剩余时间。
正确的做法是,在数据进入 Adapter 之前完成所有预处理:
- 时间格式化、价格计算、文案拼接,放到集合装载的阶段,或者用协程/线程池预计算后缓存结果。
- 文件、数据库操作移出绑定,改为异步读取后把结果存入内存缓存,绑定阶段只负责从缓存取数据。
- SharedPreferences 的读取次数要克制,可以把参数在列表加载前一次性读入并持有。
如果你在写一个对性能要求比较高的列表,可以把onBindViewHolder看作“只做 setText、setImageResource、setVisibility”的纯赋值方法。这样不仅快,而且排查问题也简单——绑定方法里没有复杂逻辑,自然不会有奇怪 bug。
实操心得:我有一次排查列表卡顿,发现罪魁祸首是
onBindViewHolder里调用了holder.itemView.invalidate()。这个调用看着人畜无害,实际上会让系统在下一帧强制重绘整个 item,连带着所有子 View 都会重新走一遍 draw。这种“强制重绘”在列表滚动场景下极其浪费,删掉之后帧率立刻回升。绑定阶段尽量不要碰requestLayout、invalidate这类触发重绘重排的调用,除非你完全清楚自己在做什么。
3.2 图片加载要裁剪到合适尺寸
图片加载在列表场景里是另一个高频雷区。RecyclerView 配合 Glide 或 Coil 是标准做法,但不是“用了图片库就万事大吉”。最常见的性能问题包括:
- 加载原始大图,例如服务器下发的是 2048 像素宽的商品图,但 item 里的 ImageView 只需要 200 像素,结果解码后内存和 GPU 开销都白花了。
- 没有设置合适的
override,Glide 默认会按 ImageView 尺寸加载,但如果 ImageView 尺寸来源不明,可能在低内存设备上引发频繁 GC。 - 缓存策略没有区分内存缓存和磁盘缓存,或者关闭了缓存,导致每次滑动都要重新加载。
图片加载的正确姿势是:让加载尺寸尽量接近实际显示尺寸,同时让图片库的缓存策略真正生效。
Glide.with(imageView.context) .load(url) .override(300, 300) // 根据实际显示尺寸的两倍以内设置,兼顾清晰度和性能 .centerCrop() .into(imageView)这里的“两倍以内”是一个经验值,主要是为 Retina/高密度屏幕留余量,300 的 override 实际显示 150 的时候,视觉上够清晰,但内存占用远小于加载原图。如果列表 item 里的 ImageView 尺寸是固定的,还可以用setImageResource加载本地 drawable,省去网络成本。
还有一个容易忽视的问题:图片库的请求要避免在滚动时全量发起。Glide/Coil 本身有生命周期感知和滚动监听优化,但要确保你没有在某些封装里强制preload所有数据,也别在onBindViewHolder里额外写一个线程池去加载图片,那样反而会打乱图片库自己的优先级队列。
避坑提醒:用
File或Uri加载本地图片时,注意图片旋转信息(EXIF)。如果没正确处理旋转角度,ImageVIew 会显示得不正常,于是有人可能在绑定里反复读取 EXIF 或做额外的 Bitmap 旋转,直接毁掉帧表现。正确做法是用 Coil/Glide 自带的变换或者一次性提前处理好,而不是在绑定阶段现场转。
3.3 用 DiffUtil 替换全量刷新
数据更新是另一个隐蔽的卡顿源。业务场景里常见的刷新方式是adapter.notifyDataSetChanged(),这个调用会把整个列表标记为“数据变了”,然后 RecyclerView 会让所有可见 item 全部重新绑定,即便其中 99% 的数据根本没变。数据量小的时候没感觉,数据量到几百上千,或者 item 绑定本身有一定成本时,这种全量刷新就会造成明显卡顿和闪烁。
推荐使用ListAdapter配合DiffUtil来做数据更新:
class MyAdapter : ListAdapter<ItemBean, MyViewHolder>(DiffCallback()) { class DiffCallback : DiffUtil.ItemCallback<ItemBean>() { override fun areItemsTheSame(oldItem: ItemBean, newItem: ItemBean): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame(oldItem: ItemBean, newItem: ItemBean): Boolean { return oldItem == newItem } } }使用submitList()之后,DiffUtil 在后台线程计算差异,然后只通知 RecyclerView 增删改对应的位置。这样做有更小的开销,也避免了不必要的闪动。
DiffUtil 使用的几个要点:
areItemsTheSame用稳定 ID(如数据库主键),不要用“内容完全相同才返回 true”。两个不同 ID 但内容相同的对象应该是不同的 item。areContentsTheSame判断内容是否变化,如果两个 item 的 ID 相同但内容有变化,需要返回 false,否则 UI 不刷新。- 如果列表里同时存在大量位置变化,DiffUtil 的位移算法会产生额外开销。此时如果列表结构变化很大,可以考虑
notifyDataSetChanged,但前提是 item 绑定很轻量。
另外一个不能忽略的点是onBindViewHolder里要使用getItemId()等稳定 ID 来标识作用域,避免使用position作为异步回调时定位 item 的唯一依据。很多列表页卡顿背后,其实是异步回调频繁触发notifyItemChanged且位置错乱造成的连锁刷新。稳定 ID 配合局部刷新,可以大幅减少这种乱局。
4. 复用机制与滚动细节:让 RecyclerView 跑得更顺
4.1 ViewHolder 复用失效的几个场景
RecyclerView 的复用机制是它高效的核心:滑出屏幕的 ViewHolder 会进入缓存池,新滑入的 item 优先复用同类型的 ViewHolder,而不重新创建。理解这个机制以后,你就可以主动规避一些让复用失效的写法:
- itemViewType 设置不当。如果同一个列表里不同位置返回的 itemViewType 不稳定,比如根据数据里的字符串动态生成 type,那么复用池就会失效,每个 item 都可能新创建 ViewHolder。正确定义方式是把有限的界面形态映射成固定的 int 常量。
- 在 ViewHolder 里动态添加 View。如果在
onCreateViewHolder里只 inflate 基础布局,然后在onBindViewHolder里又addView加子视图,那第一次绑定和之后的复用会产生完全不同的 View 树,缓存的意义就没了。 - ViewHolder 持有昂贵资源。如果你的 ViewHolder 持有监听器、动画对象、甚至 Bitmap,这些资源在回收时会拖慢回收速度,间接让复用池周转效率变低。释放不必要的资源放在
onViewRecycled里做,但这方法在滑动过程中会被频繁调用,所以里面的动作必须轻量,不能做耗时操作。
复用机制还有一个反向作用:item 视图被复用时,之前位置残留的状态可能混淆。比如某些 item 隐藏了某个 View,但复用到另一个需要显示该 View 的位置时,如果绑定里没有重新设置可见性,就会出现错误。因为所有属性都“继承”自上一个位置。这个问题看似是逻辑问题,但也会导致你在绑定阶段额外加一堆判断,然后绑定变慢,又回到性能问题。解决方法是尽量把每个 item 的 UI 状态在绑定里显式设置完备,让绑定方法有个明确且简洁的赋值清单。
补充一个我自己常用的判断方法:如果滑动列表时有明显“卡一下”的瞬间,并且刚好发生在 item 类型变化的位置,比如从普通文本 item 滑到带大图 item 时,多半是 itemType 切换导致创建新 ViewHolder 的成本暴露了。这一类卡顿用 profile 抓
RV CreateView耗时会非常明显,优化方向是让同类 item 尽量集中,或者降低创建 ViewHolder 时的 inflate 与初始化成本。
4.2 预取机制与 setHasFixedSize
RecyclerView 从 25.1.0 版本开始内置了预取(Prefetch)能力。简单理解:LayoutManager 在滑动手势开始并稳定后,会提前一个 item 的间距,利用下一帧的空闲时间提前创建和绑定即将滑入的 ViewHolder,从而减少实际滑动时的耗时。
这个机制绝大部分时候是自动工作的,但有两个前提容易被破坏:
- 布局一定得是非嵌套结构,如果列表被嵌在 ScrollView 里,RecyclerView 无法准确判断预取向,预取会失效。
- 数据加载不能太慢。预取只会提前绑定一个 item,如果你的
onBindViewHolder里还在异步取数据,预取帮不上忙。
如果列表的宽高是固定的,正确做法是设置setHasFixedSize(true)。它的含义是:RecyclerView 知道列表自身的尺寸不会因为内容数量变化而变化,因此可以在notifyItemChanged等局部更新时跳过重新测量自身。这个开关不能乱开,如果你的 item 高度是动态的,比如高度由内容决定且会变化,开了setHasFixedSize(true)可能引发测量错误。只有明确列表宽度与高度固定时才设置。
关于预取配置,LayoutManager有两个值得关注的参数:
setInitialPrefetchItemCount(int):主要用在嵌套 recycle 场景,指当这个列表作为子列表被滑入时,预取多少个 item。子列表在横向滚动场景中效果尤其明显。GapWorker机制:自动运行的,但你可以通过避免在每个 item 都大量requestLayout来保证预取线程不被挤占。
在自定义 LayoutManager 时,预取支持需要实现LayoutManager#supportsPredictiveItemAnimations和组件协调,但普通场景直接用系统自带的 LinearLayoutManager 即可。
4.3 滚动监听与嵌套列表的坑
滚动监听也是一个常见的隐性卡顿来源。很多人为了做“头图缩放”“标题渐变”会在 RecyclerView 上挂addOnScrollListener,然后在onScrolled里频繁更新外部 UI。如果这些更新操作本身很重,比如每次都修改布局参数、触发 requestLayout,主线程就会被大量重复计算占满。
优化方向:
- 在
onScrolled里尽量只做状态判断,把真正耗时的 UI 更新放到computeScroll或属性动画里,用插值驱动。 - 如果只需要监听是否滚动到底部或标题是否消失,可以用标志位判断,尽量少触发外部 View 的更新。
- 避免在滚动监听里频繁 new 对象,不然会触发 GC,而 GC 又会卡住动画。
嵌套 RecyclerView 是另一个大坑:外层竖向列表,item 里嵌一个横向列表,看起来是很自然的设计,但在低端设备上很容易出现滑动一顿一顿的情况。主要原因在于:
- 内层 RecyclerView 有自己的缓存池和预取机制,但它和外层列表的交互会让系统无法准确判断滚动意图。
- 如果内层列表的
layoutManager每次都新建,内层 item 无法复用,缓存池形同虚设。
嵌套列表的正确写法:
- 内层 RecyclerView 使用独立的
RecycledViewPool,多个同类横向列表共享同一个 pool,减少覆盖面大的重复创建。 - 内层 RecyclerView 的高度如果固定,可以设置
setHasFixedSize(true)。 - 尽量避免让内层 RecyclerView 拦截外层滑动手势,通过
NestedScrolling或自定义scroll判断来协调。
还有一种更极端的做法,在需要复用大量横向列表并且条目极多的情况下,可以自定义 LayoutManager 直接在外层绘制内层条目。这个方案代码量大,一般不推荐,除非你已经确认 RecyclerView 嵌套方案确实无法满足性能指标。
注意:嵌套列表内层不要设置
android:nestedScrollingEnabled="false"后一劳永逸,这确实可以让外层滚动更顺,但同时内层就无法滚动了。很多“横向滑不动”的 bug 就是这么来的。正确的做法是让内层保留滚动能力,但在滚动方向上协调处理。
5. 常见问题与排查实录
5.1 高频卡顿场景速查表
下面这个表是我结合多个项目经验整理的,几乎覆盖了日常开发里 RecyclerView 卡顿的大部分原因和对策。当你接到“列表卡顿”的反馈时,可以按表格快速对号入座,避免从零开始排查。
| 典型场景 | 根本原因 | 直接对策 |
|---|---|---|
| 滑动过程中持续掉帧 | 主线程长期处于高负载,比如任务队列被数据解析或 I/O 挤满 | 把耗时任务移出主线程,绑定阶段只做赋值 |
| 首屏加载慢,下滑到中部才卡 | 图片加载过多、原图过大导致内存暴涨 | 用图片库并设置合适的 override 与缓存策略 |
| 滑动到 item 类型切换时明显顿挫 | itemType 切换导致新 ViewHolder 创建成本暴露 | 同类 item 尽量集中,降低 onCreateViewHolder 里的初始化成本 |
| 刷新数据后整个列表闪烁卡顿 | 使用 notifyDataSetChanged 全量刷新 | 改用 DiffUtil 局部刷新 |
| 列表嵌在 ScrollView 里滑动不畅 | 预取机制失效,外部 scroll 破坏了 RecyclerView 的测量策略 | 用 NestedScrollView 代替或改用单层 RecyclerView 加 header/footer |
| 横向嵌套列表滑动顿挫 | 内层没有设置合适的 RecycledViewPool,复用率低 | 共享 RecycledViewPool,设置 setHasFixedSize(true) |
| item 里复杂布局导致测量耗时 | 布局嵌套层级过深 | 用 ConstraintLayout 扁平化布局,删除无意义容器 |
| 滚动时手势被外部父控件抢占 | 事件分发冲突,导致抬起/按下状态误判 | 协调 parent 的 onInterceptTouchEvent,必要时使用 requestDisallowInterceptTouchEvent |
| 低内存设备上频繁 GC 卡顿 | 绑定阶段创建临时对象、图片占内存过多 | 减少绑定中的 new 对象,压缩图片内存占用,开启硬件缓存 |
这个表不是万能药,但它提供了一个完整的排查顺序:先看主线程任务是否过重,再看布局与绑定,最后看刷新与复用。按这个顺序走,九成的列表卡顿都能找到明确方向。
5.2 我踩过的几个真实坑
这里聊几个实际案例,都来自真实项目,供你参考。
第一个坑:静态数据变成动态数据后,列表开始卡。具体的项目里,列表本来是静态写死的,所以 item 布局写得随意,三四个容器嵌套无所谓。后来改成网络数据,图片源从固定 drawable 变为网络图片,卡顿立刻暴露。排查下来发现 adapter 里有个逻辑:每次绑定都会根据“商品状态”重新 setTextColor,而状态值是每次从 SharedPreferences 里读的。读一次 SharedPreferences 在帧时间内本来不至于卡顿,但问题是列表里几十个 item 同时绑定,每个都读一次,主线程被反复唤醒 I/O,帧预算就爆了。后来把状态一次性读入内存,绑定阶段只用内存值,卡顿立刻消失。
第二个坑:动态高度 item 导致的反复测量。有阵子做聊天列表,气泡高度根据文本内容自动扩展,每个 item 的高度不固定,于是我在绑定里对气泡的 TextView 做了动态setMaxWidth和高度计算。这个做法本身没错,但每次滑动时,TextView 的宽高都要重新计算,而且因为高度不固定,LayoutManager 无法利用 pre-layout 信息,频繁触发 requestLayout。后来改用固定最大宽度 + 合理换行策略,让测量成本集中在首屏,而非每次滚动都要重新走一遍测量流程,问题才缓解。
第三个坑:横向 RecyclerView 嵌套纵向列表时,内层很多 item 无法复用。原因是横向列表每次重置时我都 new 了一个 LinearLayoutManager 和 RecycledViewPool,导致内层 holder 创建开销极高。改成把 innerLayoutManager 和 pool 作为外层 item 的共享对象之后,横向滑动明显顺了。别看这个改动小,低端机上差距非常大。
第四个坑:使用了自定义 ItemAnimator 但没控制动画成本。做列表删除/新增动画时,自定义了默认的 DefaultItemAnimator 以外的动画效果,比如让每个 item 平移加淡入,一百个 item 同时执行时,动画系统在主线程上的开销直接拉满。后来把冗长动画改成短促的 alpha 动画,并在滚动状态下用recyclerView.setItemAnimator(null)暂时关闭效果,滚动结束后再恢复。这个技巧在很多需要“滑动性能优先”的场景都适用,关键点是判断滚动态,而不是一上来就永久关掉动画。
最后一个提醒:卡顿问题不是一次优化就永远解决的。布局调整、数据量增大、低端机新增,都可能导致问题复发。建议在关键版本的发布前,用 Profile GPU Rendering 和 Perfetto 跑一遍核心列表路径,设置一个简单的帧率指标,比如滑动时掉帧率低于 5%,有问题尽早发现。
根据我个人的经验,RecyclerView 的卡顿排查和优化,最怕的就是“上来就改代码”。摸清原理、工具定位、按数据绑定、布局、复用、刷新这个顺序逐步推进,往往几行改动就有质的提升。流畅的列表体验是用户对一个 App 最直观的感知之一,值得你多花那点时间。