1. 换个角度先理解卡顿是怎么来的
在安卓开发里,RecyclerView 基本是列表页的标配控件,但“不卡则已,一卡就是千军万马”的滚动问题,几乎每个做过复杂列表的开发者都遇到过。滑动掉帧、快速滑动时白屏闪烁、点击无响应、甚至是 App 直接 ANR,这些问题表面看是“手机太旧”“机器性能差”,但实际排查下来,十有八九是代码层面在主线程里做了不该做的事。
RecyclerView 本身是一个窗口化的布局容器,它只负责填充当前屏幕可见范围,外加一点预加载缓冲。也就是说,理论上无论数据有多少条,它同一时刻在视图层只处理一小部分 item。如果你的列表滚动仍然卡顿,根源通常不是 RecyclerView 这个控件本身,而是它内部 item 的创建、绑定、布局、绘制这四步中,某一步或某几步耗掉了太多主线程时间。这个思路一定要先立住,后面所有优化方案本质上都是在给这四步“减负”。
说白了,RecyclerView 的流畅度比拼的不是“谁的数据多”,而是“每一帧时间内能不能完成当前可见 item 的测量、布局、绘制和渲染”。安卓系统每 16ms 输出一帧画面,超过这个时间就会掉帧卡顿。所以我们要做的所有优化,都是围绕“把单帧时间内的工作量压到 16ms 以下”来展开的。
2. 先定位瓶颈再谈优化,不要凭感觉操作
很多开发者遇到卡顿的第一反应是“换个图片加载库”“把 item 布局改简洁点”,这种摸着石头过河的方式效率很低。我自己的习惯是,先用工具把问题量化,再针对性地改代码。
2.1 打开 GPU 渲染分析,先看掉帧发生在哪个环节
安卓开发者选项里有一个“GPU 渲染分析”和“Profile HWUI rendering”,前者可以实时显示柱状图,你也可以通过 adb 命令抓取更精确的数据:
adb shell dumpsys gfxinfo <package_name> framestats这个命令会输出每一帧的耗时明细,包括 Draw、Prepare、Process、Execute 四个阶段的时间。拿到数据后重点关注两个指标:
- 帧耗时超过 16ms 的比例。
- 耗时集中出现在哪个阶段。
如果帧时间都花在 Draw 阶段,说明问题大概率出在视图层级太深或者背景等绘制资源太多;如果耗时集中在 Process 或者 Execute 阶段,通常是与 DisplayList 的更新和渲染指令执行有关。千万不要跳过这一步直接改代码,实测下来,很多看起来是图片加载导致的卡顿,实际根因是背景渐变导致过度绘制。
2.2 用 systrace 抓主线程任务,找到极耗时方法
如果你用的是 Android Studio 自带的 Profiler,也可以直接看 CPU 时间线。但要抓更细的“哪个方法在短时间被频繁执行”,systrace 从上到下看主线程的 slice 会直观得多。操作方式比较繁琐,这里说个简化版:
python systrace.py -a <package_name> -b 4096 -t 10 gfx view sched freq抓完导出 html 后用浏览器打开,看主线程每一帧上下文中是否反复出现某个耗时方法,比如 BitmapFactory.decode、某个自定义 View 的 onDraw、或者是大量的 XML inflate。只要在 trace 里看到这些名字,方向基本就确定了。
2.3 通过 log 输出埋点定位具体 item 类型
如果你的列表有多种 item 类型,最好在 Adapter 的getItemViewType、onCreateViewHolder、onBindViewHolder里各打一条 log,记录当前线程和耗时。代码大概长这样:
@Override public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { long start = System.nanoTime(); View view = LayoutInflater.from(parent.getContext()) .inflate(getLayoutId(viewType), parent, false); long cost = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); Log.d("AdapterPerf", "onCreateViewHolder type=" + viewType + " cost=" + cost + "ms"); return new ViewHolder(view); }不用 log 系统也行,用 Android Studio 的 Method Tracing 一样能看到每个方法的执行时间。这里想强调的核心是:优化工具箱里,诊断工具永远要先于代码修改。跳过诊断直接改东西,容易改错方向,浪费大量时间。
3. 从 RecyclerView 本身做起:先排除控件配置问题
有时候卡顿根本不是业务代码的锅,而是 RecyclerView 的配置不合理。这部分通常被称为“基础参数调优”,操作简单但很有效。
3.1 数据变化时不要先清空再全部刷新
很多代码里是这样写“刷新列表”的:
list.clear(); list.addAll(newList); adapter.notifyDataSetChanged();这种写法会触发所有可见 item 的重新绑定和重新布局,在数据量小、item 简单时问题不大,但数据量大或者 item 布局复杂之后,一次刷新就可能卡掉好几帧。正确姿势是使用 DiffUtil,配合ListAdapter做精准的增量更新。
ListAdapter的用法也比较简单,核心是提供一个比较器:
class ChatAdapter extends ListAdapter<Message, ChatAdapter.ChatViewHolder> { protected ChatAdapter() { super(new DiffUtil.ItemCallback<Message>() { @Override public boolean areItemsTheSame(Message oldItem, Message newItem) { return oldItem.getId() == newItem.getId(); } @Override public boolean areContentsTheSame(Message oldItem, Message newItem) { return oldItem.equals(newItem); } }); } }DiffUtil 在后台线程完成差异对比,主线程只处理最终的变化结果,实测下来,500 条数据级的列表,整个 diff 过程对主线程几乎无感知。RecyclerView 还在adapter.submitList(newList)内部自动处理了notifyItemRangeInserted、notifyItemRemoved等精细操作,配合默认的动画效果,视觉上也比notifyDataSetChanged那种整体重绘好太多。
3.2 setHasFixedSize 到底该不该设
setHasFixedSize(true)这个方法是告诉 RecyclerView:item 的内容变化不会影响列表宽度和高度。设置后它就可以跳过onMeasure阶段的某些计算,提升一些性能。但要注意,如果你的 item 里包含异步加载图片,图片加载前后高度不一致,这时候设置setHasFixedSize(true)是安全的——测量宽高都固定了,只是内容变化;但如果你用wrap_content的 RecyclerView 嵌套在 ScrollView 里,这个时候设置setHasFixedSize(true)会出大问题,item 数量变化时高度不会自动更新。
更严重的坑是:在 ScrollView 里嵌套 RecyclerView。这种场景下 RecyclerView 的高度会被迫测量为全量 item 的总高度,所有 item 都会被一次性创建和绑定,完全摧毁了窗口化机制的省资源特性。凡是遇到这种需求,思路应该是换成NestedScrollView+LinearLayout,或者干脆调整整体布局层级,不要在 ScrollView 里放 RecyclerView 硬解。
3.3 合理设置预加载范围,不是越大越好
很多人误以为增加预加载数量能换来更平滑的滑动,于是用这样的代码:
recyclerView.setItemViewCacheSize(50); linearLayoutManager.setInitialPrefetchItemCount(10);实际上,预加载数量过大会直接把内存和绑定耗时拉高,反而更容易掉帧。默认的缓存机制已经做了很好的平衡:每个 ViewHolder 在 RecyclerViewPool 里缓存一份,加上 itemViewCacheSize 默认两张,足够大多数场景使用。只有当你设计的是复杂的多类型列表,且 item 创建成本非常高时,才值得针对性的增加 pool 容量。比如在横向滑动的卡片列表场景,预加载下一页的收益才会明显大于成本。
4. 静态布局篇:ItemView 层级和嵌套,藏着最容易忽略的性能杀手
聊完 RecyclerView 的外围配置,再看 item 本身。这块是多数性能问题的高发区,也是可以立刻动手改的部分。
4.1 尽力减少“无效布局层级”
你是怎么定义“无效层级”的?我用最简单直观的标准:一个控件如果只是为了把一个内边距或一条背景线包出来,那么它通常可以干掉。RecyclerView 的 item 根布局,最忌讳的写法是:
LinearLayout(vertical) -> LinearLayout(horizontal) -> FrameLayout -> RelativeLayout -> ImageView + TextView深不见底的布局层级意味着更多的测量、布局、绘制时间,而且还会增加过度绘制的风险。通常建议 item 布局层级控制在三层以内。在做代码 Review 时,我习惯用 Android Studio 自带的 Layout Inspector 抓取当前页面的视图树,凡是发现某个嵌套层级只是“包了个圆角背景”或者“加了个 margin”,都会毫不犹豫改成根布局直接上background+inset的方式。
4.2 避免权重布局在滚动列表里频繁计算
在 item 里使用layout_weight,在静态页面上问题不大,但在 RecyclerView 的 onMeasure 阶段,权重计算会额外触发子 View 的二次测量,累计次数多了之后开销明显。如果只是为了让某个子 View 占比固定比例,可以直接用 ConstraintLayout 的match_constraint配合比例约束(app:layout_constraintHeight_ratio="0.5"),实测下来测量耗时能减少将近一半。
4.3 高度固定和状态合并
如果你的 item 高度是固定的,可以在布局根节点写死高度,或者至少把高度的确定方式尽可能简化为固定 dp 值。这样 RecyclerView 在滚动时就不用频繁重新测量高度变化。更进一步,可以重写 Adapter 里的getItemViewType逻辑,把“同一种视图展示不同状态”的 item 合并到一个 viewType 下,避免因为状态碎得太多导致 create 和 bind 的频率飙升。
关于 ItemView 优化,有几件后端也需要注意的事:不要在每个 item 里做 dp -> px 的实时转换,不要每一次 bind 都重新设置 LayoutParams,因为 LayoutParams 的生成和赋值也是耗时操作。尽量把可复用的参数放在 ViewHolder 创建阶段一次性计算好,之后 bind 只是赋值。
4.4 主线程里的取色、解析、测量要不得
在 onBindViewHolder 里调用Color.parseColor、getResources().getDimensionPixelOffset、TextUtils.isEmpty这类操作,每条耗时不长,但把 30 个正在滚动的 item 叠加起来,就足够制造可见掉帧。正确做法是在 ViewHolder 构造函数里提前完成这些静态资源的获取。还有更隐蔽的一个坑:在 onBindViewHolder 里去 new 对象。频繁创建短生命周期对象必然导致 GC 频繁触发,滚动时就会出现周期性的掉帧。
5. 图片加载与异步任务:占大头的卡顿常客
RecyclerView 列表里十个有九个会加载图片,图片加载策略好不好,直接决定了滚动体验的下限。
5.1 压缩采样与尺寸匹配,别拿原图硬怼
最粗暴的写法是直接把网络图片的原始 Bitmap 塞进 ImageView,这在列表场景里是大忌。即使是加载本地图片,也要根据 ImageView 的实际尺寸做采样压缩。
如果你用的是 Glide,可以通过以下方式控制加载尺寸:
Glide.with(imageView) .load(url) .override(200, 200) .placeholder(R.drawable.ic_placeholder) .into(imageView);override可以让 Glide 在解码时直接按目标尺寸采样,避免把超大图片完整解码进内存再等比缩放。这里解释一下为什么这个操作很关键:超大 Bitmap 解码不仅内存开销成倍增加,每一次滚动时的onDraw阶段还需要对 Bitmap 做缩放绘制,这些都会直接占用主线程时间,导致掉帧。
本地图片还可以手动进行采样:
BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; BitmapFactory.decodeFile(path, options); options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight); options.inJustDecodeBounds = false; Bitmap bitmap = BitmapFactory.decodeFile(path, options);inSampleSize的计算逻辑也比较简单——取 2 的幂,使得压缩后的宽高都尽量接近目标尺寸,但不要小于目标尺寸。简单示例如下:
public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) { final int height = options.outHeight; final int width = options.outWidth; int inSampleSize = 1; if (height > reqHeight || width > reqWidth) { final int halfHeight = height / 2; final int halfWidth = width / 2; while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) { inSampleSize *= 2; } } return inSampleSize; }5.2 图片加载库统一配置,别一行一行写
Glide 在 RecyclerView 里默认就有缓存和复用机制,但很多人并没有去做全局配置,导致每个页面都得重复写.placeholder()、.error()、.diskCacheStrategy(),麻烦不说,还容易漏配置。更推荐的方案是在 Application 里统一配置:
@GlideModule public final class MyGlideModule extends AppGlideModule { @Override public void applyOptions(Context context, GlideBuilder builder) { builder.setDefaultRequestOptions( new RequestOptions() .format(DecodeFormat.PREFER_RGB_565) .disallowHardwareConfig() ); } }RGB_565 格式相比 ARGB_8888 每个像素内存占用减半,对于不包含透明通道的图片来说,视觉差异几乎看不出来,但内存和绘制开销降得很明显。不过要注意,如果图片本身包含透明区域,或者你用到了圆角遮罩、阴影这类依赖 Alpha 通道的 UI 效果,用 RGB_565 会出现色块和锯齿,需要改为 ARGB_8888。
5.3 异步任务别往主线程凑
在 onBindViewHolder 里直接Thread.start()或者AsyncTask.execute()都属于基础错误,滚动列表会创建大量短生命周期线程,频繁上下文切换和内存抖动会导致卡顿加剧。正确的做法是使用线程池+Handler 的方式,把耗时任务扔到后台线程,结果通过回调更新到 ViewHolder 上。
实际上,更贴近现代推荐的是 Kotlin 协程 + ViewBinding,能比较优雅地处理生命周期和并发问题。不过无论用哪种方式,核心思想一致:主线程只做 UI 更新,其他一切事情都挪走。
有个经常被忽略的点也要提一下:如果你在 onBindViewHolder 里发起异步任务,那么在 RecyclerView 回收 item 时,这些任务的结果很可能已经过期了,直接赋值会导致数据错乱。需要在 ViewHolder 里做一个简单的 tag 判断,比如:
holder.imageView.setTag(loadId); // 异步返回时判断 tag 是否仍匹配 if (holder.imageView.getTag() == loadId) { holder.imageView.setImageBitmap(bitmap); }6. RecyclerViewPool 与 ViewHolder 复用机制的正确用法
很多开发者知道 ViewHolder 在 RecyclerView 里是复用的,但并不知道复用的细节和边界在哪里。理解这一点之后,一些摩擦感和错位感很重的卡顿问题就能迎刃而解。
6.1 RecyclerViewPool 的默认行为
RecyclerView 内部默认的 RecycledViewPool 持有每个 viewType 的 ViewHolder 缓存,默认每个类型最多缓存 5 个。当 item 滚出屏幕时,ViewHolder 被回收到 pool 中,从而在下次滚回到屏幕时直接复用,不需要再次 inflate。
但如果你用的是多种 item 类型混合的长列表,或者 item 创建成本很高(比如包含复杂布局、自定义绘制),那默认的 5 个缓冲可能不够用,会出现滚回来又得重新创建的情形。这时候可以考虑显式扩大 pool:
RecyclerView.RecycledViewPool sharedPool = recyclerView.getRecycledViewPool(); sharedPool.setMaxRecycledViews(VIEW_TYPE_IMAGE, 10); sharedPool.setMaxRecycledViews(VIEW_TYPE_TEXT, 10);还可以在多个 RecyclerView(比如首页有多个横向列表)之间共享同一个 Pool 实例,提升复用率:
recyclerView.setRecycledViewPool(sharedPool);这种方法在做“猜你喜欢”这类多横滑列表时特别管用,实测下来可以减少大量 ViewHolder 重复创建。
6.2 避免在 onBindViewHolder 里重复 inflate 或者动态加 View
有些兄弟为了做灵活布局,会在 bind 里根据数据动态添加 View 或者通过ViewStub切换显示。这类操作都会导致 item 的结构类变化,迫使 ViewHolder 重新 layout,甚至会导致 View 树重建,卡顿自然就来了。
动态视图看着方便,但你要考虑它带来的测量和布局开销。如果只需要根据数据条件显示或隐藏某个区域,直接在布局文件里预置这个区域,然后 bind 时设置itemView.setVisibility()就好,这样布局结构稳定不变,测量成本可控,唯一的“浪费”是布局文件里多了一小部分不可见视图节点占用的绘制时间。这就需要在“一次创建的耗时时长”和“每次绑定的耗时时长”之间权衡取舍。
6.3 自己管理 ViewHolder 的稳定性能
让 ViewHolder 里的成员变量尽量少改动,比如不要在 bind 时每次创建新 Drawable 或者改变 root layout 的方向、padding。固定值一次性在构造阶段完成初始化,bind 时只更新文本、颜色等轻量内容,这就是性能稳定性的保障。我见过很多卡顿问题就是因为在 bind 阶段调用了setPadding(0, 10, 0, 15)这类操作,每次都触发requestLayout(),一屏 8 个 item 滚下来 layout 被反复挤压。
7. 多类型列表的进阶优化:嵌套多层与多种 viewType 的取舍
现在的业务列表,很多都是多类型混合的复杂列表,比如“header + 运营位 + 横向楼层 + 商品流 + footer”。复杂列表本身是卡顿重灾区,但并不代表不可优化,关键是看如何组织结构。
7.1 用 ConcatAdapter 合并多个 Adapter 的利弊
Android 官方从 RecyclerView 1.2 开始提供了ConcatAdapter,可以把多个 Adapter 串联在一起挂到同一个 RecyclerView 上,这种方式在多团队协作拆分代码时比较友好,每个业务模块各自维护自己的 Adapter。
但在我实际使用体验里,ConcatAdapter 有个不容忽视的问题:它为每个子 Adapter 维护了自己的 ViewHolderPool 和观察者,如果集成的 Adapter 数量很多,嵌套层级会变深,滚动性能反而可能下降。如果你有严格的性能要求,还是推荐用单一的 Adapter 管理所有 viewType,通过getItemViewType()做分发。
7.2 多 viewType 列表的 bind 耗时拆分
对多 viewType 列表,可以把 ViewHolder 拆得更细,比如开头定义接口:
interface Binder<VH extends RecyclerView.ViewHolder> { void bind(VH holder, int position); }然后为不同的 viewType 注册不同的 Binder,在 onBindViewHolder 里直接通过 map 找到对应 Binder 并调用。这样做的好处是:不同 item 类型的绑定逻辑互不干扰,后续做针对性优化的时候也容易定位。这也是我在实践里比较推荐的做法,代码可读性和可优化性会提升一个台阶。
7.3 列表内嵌横向 RecyclerView 导致的双重重叠卡顿
列表里嵌横向 RecyclerView 是常见的“复用陷阱”。如果你没有处理好嵌套 RecyclerView 的 pool 与 item 布局,两个 RecyclerView 滚动时可能发生严重的互相干扰。常见问题包括:
- 竖向列表滚出屏幕时,横向列表 item 的 ViewHolder 没有进入同一个 pool,导致横向滚动回来的 item 重新创建。
- 横向列表在 Vertical RecyclerView 中的测量逻辑被反复触发,产生额外 layout 耗时。
解决思路依然简单明确:给每个横向列表指定setHasFixedSize(true),并可以把自己的 RecycledViewPool 传给子列表。另外要注意,横向列表不要拥有无限滚动的数据源,尽量限制 item 数量在 10~20 个之间,因为横向列表的可视区域有限,无限加载对大多数业务场景都是伪需求。
8. 不要忽视 XML 之外的细节:绘制、内存与动画的拖累
8.1 自定义 View 的 onDraw 里别 new 对象
列表 item 中如果用到自定义 View,一定要检查 onDraw 里有没有 new Paint、new Path、分配数组这类操作。onDraw 每帧都可能被调用多次,如果你在里面频繁 new 对象,GC 频率必然飙升,掉帧只是时间问题。
建议在自定义 View 初始化阶段就把所有绘制资源准备好,onDraw 只做坐标计算和canvas.drawXXX()。如果涉及到 Path 的构建,也可以复用同一个 Path 对象,通过reset()清理后再重新填充。
8.2 setLayerType 使用不当会引发额外的离屏缓冲
有些人在自定义 View 里开硬件加速后,为了省事直接设置:
view.setLayerType(View.LAYER_TYPE_SOFTWARE, null);这会让这个 View 的所有绘制都通过软件渲染,在复杂的 item 里反而会严重拖慢滚动。除非你明确知道自己在处理什么(比如要对一个超大区域做频繁的软件绘制截图),否则不要随便设置这个属性。至于硬件加速的开关,在 Android 5.0 之后默认是全局开启的,应用层一般不需要刻意去动。
8.3 动画与绘制层叠加时的性能权衡
RecyclerView item 自带的添加/移除动画(DefaultItemAnimator)会在动画执行期间维护额外的 ViewHolder 引用,同时对变化的 item 执行属性动画。当动画执行期间存在大量 item 变化,或者 item 本身复杂度很高,动画就可能导致明显的掉帧。如果你发现自己列表在数据刷新时出现卡顿,而 DiffUtil 之后的变化数量又很大,可以考虑简化动画:
((DefaultItemAnimator) recyclerView.getItemAnimator()).setSupportsChangeAnimations(false); android.support.v7.widget.RecyclerView.ItemAnimator animator = recyclerView.getItemAnimator();这里也顺带说明:并非所有列表都需要动画,在数据频繁变化的实时列表(比如行情、日志流、IM 消息)里,动画反而让 UI 有一种“跟不上”的黏滞感。关闭动画能够带来更跟手的体验。
8.4 注意位图内存管理与 Bitmap 复用
很多人查内存问题时只盯堆内存,却忽略了 Bitmap 的 Native 内存。在 Android 8 之前,Bitmap 数据存放在 Java 堆之外的 Native 堆,如果回收不及时,即使 Java 堆看起来没事,内存压力也会传导到整体系统。Glide 等库内部会做 BitmapPool 复用,但如果你在代码里手动解码 Bitmap,最好也使用 BitmapFactory.Options 的inBitmap来完成复用,减少连续分配和释放带来的卡顿。
9. 结合场景设计优化方案:不同场景要开不同的“药方”
很多初学者会问:网上提供了一堆优化手段,到底先用哪个?我的回答是,看场景。下面给出几个最常见的场景药方。
9.1 纯图文信息流
这种场景的核心压力在布局复杂度和图片加载上。推荐组合是:
- ConstraintLayout 一根根节点,布局尽量压到 3 层内。
- 图片统一使用 Glide,开启 RGB_565(无透明场景),做
override。 - 使用 ListAdapter + DiffUtil 更新数据。
- 所有文字尺寸、颜色抽取到资源和常量,bind 阶段不做资源换算。
9.2 聊天类列表
聊天列表的压力点常常集中在:消息频繁插入、图片加载、输入法弹起时布局变化。推荐组合是:
setStackFromEnd(true)+setReverseLayout(true)处理倒序列表。- 用 Message 类型区分 text、image、audio 等多种 viewType,分别缓存。
- 数据插入时用
ListAdapter.submitList做局部更新,避免整个列表刷新。 - 图片缩略图可以做严格的尺寸压缩,避免大图额外解码。
9.3 电商首页多楼层嵌套
这种场景最容易把 RecyclerView 玩坏,因为层级嵌套多、房间类型多、内容杂。推荐组合是:
- 主列表采用单一 Adapter,所有楼层类型用 viewType 区分。
- 每个楼层的内部横向列表设置
setHasFixedSize(true)+ 共享 RecycledViewPool。 - 推荐对运营位和商品卡片做动态预加载,而不是一次性把数据堆进去。
- 底部推荐流采用分页加载,保证主列表数据量不会无限膨胀。
9.4 后台返回时列表状态重置问题
有时候卡顿只在 App 从后台切回前台时出现。这种情况大概率是因为 Activity 重建后 RecyclerView 重新绑定数据,数据量大时绑定开销就凸显了。可以考虑使用onSaveInstanceState保存滚动位置,配合 fragment 的setRetainInstance(true),或者把列表数据和位置用一个轻量级 ViewModel 持有,避免完整重载。
10. 排查卡顿问题的“实操踩坑记录”
这一节写的并不是教科书式解法,而是我在真实项目里踩过的坑和排查路径。可能可以直接套用在你正在处理的 bug 上。
10.1 状态栏背景和 root 背景导致过度绘制
之前做一个配置极高的列表页,滑动倒是挺流畅,但从底部往上滚的时候就掉帧。排查了很久,最后发现是页面背景用了两层叠加:一层 drawable 渐变,一层 windowBackground,触发了很多区域绘制两次。直接在主题里把 windowBackground 去掉,或者把 fragment root 背景置为 null,问题直接消失。这类问题肉眼是看不见的,只能靠打开设置里的“显示过度绘制”来发现。
10.2 item 高度测量期间触发了软键盘相关布局
列表页有搜索框,每次点击输入内容,列表滚动都会停住一会儿。后来发现是整个 Activity 调整了 adjustResize,导致输入法弹出时整个 RecyclerView 要做一次全量重新布局测量,尤其是 RecyclerView 里有大图加载的时候,这一步耗时非常长。解决方案是把输入模式改成 adjustPan,并手动控制输入完成后滚动位置。反正这个坑提醒我:输入法引起的布局变化,跟列表本身的卡顿有时候是同一条因果链。
10.3 子线程更新数据的旧习惯害死人
团队里有人习惯在子线程处理完一批数据后,直接用runOnUiThread调adapter.notifyDataSetChanged(),这种写法在数据量大时也会造成 UI 线程卡顿。一开始以为只是线程切换的问题,后来测量才发现是notifyDataSetChanged()本身对大量 item 重新绑定造成的耗时。自从整套列表逻辑迁移到 ListAdapter + DiffUtil 后,这个问题基本绝迹了。
10.4 滑动冲突:上下左右一起滚的时候掉帧
横向列表嵌套在纵向列表里,所有手势都生效,但滚动时格外卡。检查下来发现,是子横向 RecyclerView 和父纵向 RecyclerView 的手势识别发生了频繁抢占,导致很多帧里在做“判断哪个方向”的无效计算。解决方式很简单,给子列表的onInterceptTouchEvent一个方向判断,避免父容器在横向滑动时做纵向拦截。这块属于滑动冲突问题,额外加下来对滚动流畅度的贡献相当可观。
11. 关于 RecyclerView 卡顿,最后再分享几个容易被忽略的小命令
排查卡顿的时候,有几个 adb 命令和系统设置我几乎每次都会用到。它们虽然影响只在调试阶段,但确实能帮你少做很多无用功。
# 开启 GPU 呈现模式分析,在开发者选项里可查看滚动实时帧耗时 adb shell setprop debug.hwui.profile true # 抓取所有系统和应用的过度绘制信息 adb shell dumpsys activity top # 查看当前 App 的内存占用量和 Bitmap 相关数据 adb shell dumpsys meminfo <package_name>另外,开发者选项里的“动画缩放”三个选项如果全关了,会让 RecyclerView 的部分动画和过渡效果失效,在排查“卡顿是不是动画引发的”时可以先关掉对比测试。等确认原因后,再打开也不迟。
在实际项目里,我总结出来的最终心得是:RecyclerView 卡顿优化不是“做完某个设置就一劳永逸”的事情,而是每次需求迭代时都要留点心的一门长期功课。每新增一种 item 类型、每改动一条 item 布局结构,都应该回头跑一遍 GPU 分析和 Profiler。哪怕只是加了一个圆角背景,也可能在真机低端机型上暴露出可见掉帧。把监控和优化习惯内建到每一次代码提交里,比任何银弹库都管用。