欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 科技 > 能源 > Android-RecyclerView学习总结

Android-RecyclerView学习总结

2025/5/25 1:12:50 来源:https://blog.csdn.net/2301_80329517/article/details/148061725  浏览:    关键词:Android-RecyclerView学习总结

​​面试官​:

“你在项目中有遇到过 RecyclerView 滑动卡顿的情况吗?当时是怎么解决的?”

​(回忆项目场景,自然带入):
“有的!之前我们团队做了一款新闻阅读 App,首页的资讯列表用 RecyclerView 展示图文内容。上线后发现快速滑动时会出现掉帧,尤其在低端手机上,用户体验挺差的。

我们先用 Android Studio 的 Profiler 工具抓了一下性能数据,发现 onBindViewHolder 方法耗时特别长。仔细一看,原来每个 Item 里都直接加载高清大图,而且图片还是从网络请求来的,没做任何压缩。

后来我们做了几个优化:

  1. 图片压缩​:在后台线程把图片缩放到 Item 的显示尺寸(比如 300x300),再加载到 ImageView,主线程压力立马小了很多。
  2. 内存缓存​:用了 Glide 的 LruCache,避免重复解码同一张图。
  3. 布局扁平化​:把原来嵌套三层的 LinearLayout 换成了 ConstraintLayout,测量时间减少了 40%。

改完之后再测,帧率从原来的 30 帧提到了 60 帧,用户反馈也好了很多。不过中间有个坑,Glide 的缓存策略一开始没配置好,导致频繁 GC,后来调整了缓存大小才稳定下来。”

扩充:

要我说,遇到RecyclerView滑动卡顿,可不能头疼医头脚疼医脚,得先**分析卡顿的“元凶”**到底是谁。一般来说,常见的“疑犯”有这么几个:

  • Item布局太复杂:如果每个列表项的布局层级太深,或者里面塞了太多复杂的View,那每次创建和绑定ViewHolder的时候,系统就得吭哧吭哧干半天活,滑动自然就慢了。
  • onBindViewHolder里干了太多“重活”: 这个方法可是RecyclerView的“心脏地带”,它被调用的频率非常高。如果你在这里面做了很多耗时的操作,比如复杂的计算、频繁的I/O读写,甚至是网络请求(这个绝对不行!),那卡顿就是板上钉钉的事了。
  • 图片加载没做好:列表里有图片太常见了,但如果图片太大,或者没有用好图片加载库(比如Glide、Picasso),再或者没有做适当的缓存和图片压缩,那每次滑动加载新图片的时候,就很容易造成界面卡顿。
  • 频繁的notifyDataSetChanged(): 这个方法虽然简单粗暴能刷新整个列表,但效率实在太低了。它会导致RecyclerView重新创建和绑定所有的ViewHolder,哪怕你只是改了一两条数据。滥用它,卡顿自然找上门。
  • 过度绘制(Overdraw): 就是屏幕上的同一个像素点被画了多次。这会给GPU增加额外的负担,尤其是在一些性能不太好的手机上,卡顿就会更明显。

那当时我们是怎么解决的呢?其实就是对症下药,见招拆招:

  • 优化Item布局: 这是首要任务。我们会尽量减少布局层级,用ConstraintLayout来“拍扁”复杂的嵌套。对于一些不常变化的静态部分,可以考虑自定义View来减少测量和布局的开销。如果Item的ViewType比较多,也要确保getItemViewType的逻辑足够高效。

  • onBindViewHolder“减负”: 严格遵守“轻量级”原则!只做数据绑定,把那些耗时的计算、数据处理什么的,尽量异步处理,或者在数据准备阶段就提前搞定。绝对不能在这里面做任何网络请求或者数据库查询。

  • 图片加载“三板斧”

    • 选择合适的图片加载库:像Glide、Picasso这些库都自带了很好的缓存策略和图片处理能力。
    • 图片压缩和缩放: 根据ImageView的大小加载合适尺寸的图片,别用一张超大图去塞一个小小的ImageView。
    • 占位图和加载动画: 在图片加载出来之前,显示一个占位图或者加载动画,能改善用户体验,不至于白屏或者卡顿感那么强。
  • 精准刷新,告别notifyDataSetChanged(): RecyclerView提供了更精细的刷新方法,比如notifyItemChanged()notifyItemInserted()notifyItemRemoved()notifyItemRangeChanged()等等。当数据发生变化时,只告诉RecyclerView哪部分数据变了,让它按需更新,效率会高很多。如果数据量比较大,或者逻辑复杂,可以考虑使用DiffUtil,它能帮你计算出新旧数据集之间的差异,然后进行局部刷新,非常高效。

  • 消灭过度绘制: 这个可以借助Android Studio自带的GPU Overdraw工具来检测。看到一堆深红色的区域,就说明过度绘制比较严重了。解决方法通常是移除不必要的背景(比如父布局已经有背景了,子View就没必要再设背景),或者优化布局结构。

  • ViewHolder的复用: 这个是RecyclerView的核心机制,虽然我们一般不用直接干预,但要确保正确使用了它。简单来说,就是滚动出屏幕的Item的ViewHolder会被回收,然后在新的Item出现时重新利用,避免了频繁创建和销毁对象的开销。

  • 分页加载(Pagination): 如果列表数据非常多,一次性加载所有数据肯定会卡。所以我们会用分页加载的策略,先加载一部分数据,等用户滑到列表底部的时候,再去加载更多数据。


面试官追问​:

“如果列表里有多种类型的 Item(比如文字、图片、视频),怎么保证 RecyclerView 的流畅度?”

​(结合技术细节,口语化解释):
“这个问题我们还真踩过坑!之前做社交 App 的时候,动态列表有十几种样式——文字、九宫格图片、视频、分享链接等等。一开始滑动起来特别卡,尤其快速翻页的时候。

后来发现是因为每种类型的 ViewHolder 都独立缓存,但 RecyclerView 的默认缓存池太小,导致频繁创建新 ViewHolder。我们的解决办法挺直接的:

  • 合并相似类型​:比如把纯文字和带表情的文字合并成一种 ViewHolder,通过数据字段区分样式。
  • 动态计算 ViewType​:比如视频封面加载中和已加载的用同一个 ViewType,但根据数据状态显示不同布局。
  • 扩大缓存池​:recyclerView.recycledViewPool.setMaxRecycledViews(viewType, 10),这样即使突然滑动到历史消息,也能快速复用已有的 ViewHolder。

不过最关键的还是 ​避免在 onBindViewHolder 里做复杂计算。比如视频封面需要根据分辨率计算缩略图尺寸,我们改到后台线程预处理,主线程只负责显示。”

扩充:

“嗯,面试官您好!如果列表里有多种类型的Item,要保证流畅度,我首先会特别关注getItemViewType()这个方法。因为RecyclerView要知道每个位置到底是个啥(是文字还是图片还是视频),才能决定用哪个样式的“坑”(ViewHolder)去填。所以,这个方法一定要快,不能在里面搞太复杂的判断,最好就是能直接从我的数据模型里拿到一个类型标记。

然后呢,针对每一种Item类型,我会给它创建一个专属的ViewHolder。比如,文字的就用TextViewHolder,图片的就用ImageViewHolder,视频的就用VideoViewHolder。这样在onCreateViewHolder()里,我就能根据不同的viewType,清清楚楚地创建对应的ViewHolder,让它们各司其职。

到了onBindViewHolder(),数据绑定的时候,我也会先判断一下这个ViewHolder到底是哪个类型的,然后再把对应的数据填进去。这里面,图片和视频的处理要格外小心。图片加载我会交给Glide或者Picasso这样的库,它们能帮我搞定缓存和图片大小的问题,省心不少。

至于视频,如果只是列表里放个封面图和播放按钮,那还好。但如果想直接在列表里播放,那挑战就大了。我会确保同一时间只有一个视频在播放,而且当视频的Item滑出屏幕的时候,在onViewRecycled()这个回调里面,我一定会把视频播放器给停掉,把资源释放干净,不然内存肯定吃不消,App也容易崩。

总的来说,核心思路就是让RecyclerView能够快速识别不同的Item类型,高效地复用对应类型的ViewHolder,并且特别注意图片、视频这种大资源的加载和及时释放。我会尽量让onBindViewHolder保持轻量,把复杂的活儿都提前处理好或者异步去做。这样,即使Item类型比较多,也能尽可能地保证滑动体验顺畅。”


面试官挑战​:

“假设现在有个需求:一个页面里外层是 ScrollView,内嵌一个 RecyclerView(比如商品详情页的‘猜你喜欢’模块),怎么解决滑动冲突?”

​(用故事化解技术难点):
“这个需求我们做过!刚开始开发的时候,用户反馈说‘猜你喜欢’的区域根本滑不动,手指一划就触发外层 ScrollView 滚动。

我们试了几种方案:

  1. 粗暴解法​:把 RecyclerView 的固定高度设为全部内容高度,这样它自己就不滚动了,完全依赖外层 ScrollView 滚动。但这样如果‘猜你喜欢’有 100 个商品,页面会变得巨长,直接 OOM。
  2. 改用 NestedScrollView​:这是系统提供的支持嵌套滚动的容器,设置 android:fillViewport="true" 后,内层 RecyclerView 可以正常滑动,外层也能联动。但实测在旧机型上还是有卡顿。
  3. 自定义滚动逻辑​:通过 NestedScrollingParentNestedScrollingChild 接口协调滚动优先级。比如当用户手指在 RecyclerView 区域垂直滑动时,优先让 RecyclerView 滚动;滚动到底部后,再触发外层滚动。

最后选了第二种方案,因为开发成本低。不过上线前用 ​云真机测试​ 跑了一遍主流机型,发现华为部分机型有兼容性问题,加了版本判断代码才解决。”

扩充:

“面试官您好,ScrollView里面嵌套RecyclerView确实很容易出现滑动冲突。这主要是因为外层的ScrollView和内层的RecyclerView都想响应用户的垂直滑动事件(如果我们假设RecyclerView是垂直滑动的)。当用户在RecyclerView上滑动时,可能ScrollView把事件抢走了,导致RecyclerView滑不动;或者反过来,事件全被RecyclerView消费了,导致外层ScrollView在该区域无法滚动。

要解决这个问题,我主要会考虑以下几种方案:

  1. 首选方案:使用 NestedScrollView 替代 ScrollView 这是Google官方推荐的,也是最常见的解决方案。NestedScrollView就是专门为了处理这种嵌套滑动场景而设计的。它可以很好地与支持嵌套滑动的子View(比如RecyclerView,它默认就是支持的)协同工作。

    当RecyclerView滑动到自己的顶部或底部,并且用户还想继续滑动时,NestedScrollView能接管滑动手势,让整个页面继续滚动。反之,当NestedScrollView滑动到RecyclerView区域时,它也能把滑动事件正确地传递给RecyclerView。

    在布局XML里,就像这样替换一下:

    <androidx.core.widget.NestedScrollViewandroid:layout_width="match_parent"android:layout_height="match_parent"android:fillViewport="true"> // 这个属性也很重要,特别是当内部内容不足一屏时<LinearLayoutandroid:layout_width="match_parent"android:layout_height="wrap_content"android:orientation="vertical"><TextViewandroid:layout_width="match_parent"android:layout_height="wrap_content"android:text="商品详情..."/><androidx.recyclerview.widget.RecyclerViewandroid:id="@+id/inner_recycler_view"android:layout_width="match_parent"android:layout_height="wrap_content" /> // 高度通常设置为wrap_content或者固定值</LinearLayout>
    </androidx.core.widget.NestedScrollView>
    

    同时,为了让RecyclerView在NestedScrollView中表现良好,特别是当RecyclerView本身的内容不足以填满分配给它的空间时,或者我们希望RecyclerView完全展开它的所有内容时,我们通常会禁止RecyclerView自身的滚动,让NestedScrollView全权负责滚动。

    在代码中可以这样做:

    RecyclerView innerRecyclerView = findViewById(R.id.inner_recycler_view);
    // 关键:禁止RecyclerView内部滚动,交由NestedScrollView处理
    innerRecyclerView.setNestedScrollingEnabled(false);// 如果RecyclerView是垂直方向且希望它完全展开(不出现内部滚动条)
    // 它的高度在XML中通常设为wrap_content,或者你可以动态计算所有item的总高度来设置。
    // 但要注意,如果item非常多,动态计算并设置为固定高度可能会导致性能问题和内存压力,
    // 这种情况下NestedScrollView + RecyclerView(保持其滚动性)是更好的选择。
    // 对于“猜你喜欢”这种通常item数量可控的模块,setNestedScrollingEnabled(false) 配合 wrap_content 高度是常见的。
    

    简单来说,就是用NestedScrollView这个“更懂事的家长”来管理“有主见的孩子”RecyclerView。

  2. 如果RecyclerView的内容不需要独立滚动,只是作为静态列表展示: 这种情况下,我们其实不希望RecyclerView自己滚动,而是希望它把所有的Item都展示出来,然后由外层的ScrollView来统一滚动。 这时,除了像上面提到的在代码中调用innerRecyclerView.setNestedScrollingEnabled(false);,我们还需要确保RecyclerView的高度能够完全包裹其内容。

    • 如果Item数量不多,可以在XML中给RecyclerView设置android:layout_height="wrap_content"。但要注意,默认的LinearLayoutManager等在wrap_content下可能表现不佳,需要自定义LayoutManager或者在Adapter数据加载完成后动态计算所有Item的总高度,然后设置给RecyclerView。
    • 这种方式的缺点是RecyclerView的ViewHolder复用机制可能会失效或效果减弱,因为所有Item理论上都会被一次性创建出来。所以只适用于Item数量可控的情况。
  3. 更底层的处理方式(通常不推荐,除非特定场景): 涉及到重写父View(比如自定义的ScrollView)的onInterceptTouchEvent方法,根据触摸点的位置和滑动方向,手动判断应该由谁来处理事件。这种方法非常灵活,但逻辑复杂,容易出错,一般不建议实习生同学轻易尝试,除非你对Android的事件分发机制有非常深入的理解。

所以,总结一下,遇到ScrollView嵌套RecyclerView的滑动冲突,我首先会想到用NestedScrollView,并根据具体需求(RecyclerView是否需要独立滚动,Item数量等)调整RecyclerView的nestedScrollingEnabled属性和高度设置。这是目前最主流也相对简单的解决方案。”


面试官陷阱题​:

“DiffUtil 用起来会不会有性能问题?比如数据量特别大的时候。”

​(暴露思考过程,展示深度):
“这要看怎么用了!我们之前有个日程管理 App,每次同步数据时要用 DiffUtil 对比新旧 5000 条日程。一开始在主线程跑 DiffUtil.calculateDiff(),直接 ANR 了。

后来改到后台线程计算差异,再切回主线程 dispatchUpdatesTo,问题就解决了。不过这里有个细节:​DiffUtil 的时间复杂度是 O(N)​,如果数据量真的超大(比如 10 万条),对比耗时可能超过 100ms,连后台线程都会卡。

我们的优化方案是:

  • 分片对比​:比如每次只对比当前屏幕可见的 20 条数据。
  • 增量更新​:后端返回数据时带上版本号,只拉取增量的数据,减少对比量。
  • 替代方案​:对于实时性要求不高的列表,直接用 notifyItemRangeChanged 手动控制刷新范围。

不过现在 Jetpack 的 ​Paging 3 库​ 已经内置了分页和差异对比,能自动处理这些优化,我们现在新项目都用这个方案了。”

扩充:

“面试官您好,关于DiffUtil的性能问题,我是这么理解的:

DiffUtil的核心作用是比较新旧两个数据列表,找出它们之间的差异,然后告诉Adapter用最高效的方式去更新界面(比如哪些是新增的、哪些是删除的、哪些是内容改变了或者位置移动了)。这样做的好处是能实现很棒的局部刷新和动画效果,用户体验比简单粗暴的notifyDataSetChanged()好太多了。

但是,这个“比较”的过程,特别是当数据量非常大的时候,比如成千上万条数据,它确实是需要消耗计算资源的。DiffUtil.calculateDiff()这个方法本身是一个同步的阻塞操作。如果直接在主线程(UI线程)上对一个巨大的列表进行calculateDiff(),那几乎肯定会导致界面卡顿,因为主线程被长时间占用了,没空去响应用户的操作和绘制界面。

所以,解决DiffUtil性能问题的第一个,也是最重要的关键点就是:绝对不能在主线程里直接计算Diff!

那我们该怎么做呢?

  1. DiffUtil.calculateDiff()放到后台线程去执行。 这是标准的做法。我们可以在后台线程完成耗时的比较操作,拿到DiffResult之后,再回到主线程把这个DiffResult交给Adapter去dispatchUpdatesTo()

    // 伪代码示意
    new Thread(new Runnable() {@Overridepublic void run() {final DiffUtil.DiffResult diffResult =DiffUtil.calculateDiff(new MyDiffCallback(oldList, newList));// 切回主线程更新UIrunOnUiThread(new Runnable() {@Overridepublic void run() {myAdapter.setData(newList); // 更新Adapter的数据源diffResult.dispatchUpdatesTo(myAdapter); // 应用Diff结果}});}
    }).start();
    
  2. 使用ListAdapter(强烈推荐!) Jetpack组件库里提供了一个非常棒的ListAdapter。它内部就封装了AsyncListDiffer,可以自动帮我们在后台线程执行DiffUtil的计算,我们只需要提供一个DiffUtil.ItemCallback的实现就可以了,剩下的它都帮你处理好了,非常省心,而且能有效避免性能问题。

    // 定义ItemCallback
    class MyItemCallback extends DiffUtil.ItemCallback<MyDataObject> {@Overridepublic boolean areItemsTheSame(@NonNull MyDataObject oldItem, @NonNull MyDataObject newItem) {// 通常比较唯一IDreturn oldItem.getId() == newItem.getId();}@Overridepublic boolean areContentsTheSame(@NonNull MyDataObject oldItem, @NonNull MyDataObject newItem) {// 比较内容是否有变化return oldItem.equals(newItem); // 或者只比较会影响UI的字段}
    }// Adapter继承ListAdapter
    public class MyListAdapter extends ListAdapter<MyDataObject, MyViewHolder> {public MyListAdapter() {super(new MyItemCallback());}// ... onCreateViewHolder, onBindViewHolder ...// 更新数据时,直接调用submitList,它会自动在后台diffpublic void submitNewList(List<MyDataObject> newList) {submitList(newList);}
    }
    

    用了ListAdapter之后,我们只需要调用adapter.submitList(newList),它内部就会自动处理好后台diff和主线程更新,非常方便。

  3. 优化areItemsTheSame()areContentsTheSame()方法。 这两个方法会被DiffUtil频繁调用。

    • areItemsTheSame():应该尽可能快,通常只是比较一下Item的唯一ID。
    • areContentsTheSame():这里也应该只比较那些真正会影响UI展示的内容。如果一个Item里有很多字段,但只有一两个会变,那就只比较那一两个。
  4. 数据量真的特别特别大怎么办?(比如几十万条这种,虽然在客户端列表里不常见)

    • 首先,思考一下是否真的需要一次性在客户端比较这么多数据。通常这种情况下会配合**分页加载(Paging Library)**来使用。Paging Library本身也会处理数据差异和更新,它和小批量的DiffUtil配合起来效果很好。
    • 如果列表更新非常频繁,可以考虑**节流(throttling)或防抖(debouncing)**策略,避免短时间内过于频繁地计算Diff。

总的来说,DiffUtil本身的设计是很高效的,对于大部分常见的列表更新场景,只要我们正确地将计算过程放到后台线程(最好是使用ListAdapter),并且合理实现ItemCallback里的比较逻辑,它带来的性能开销是完全可以接受的,并且它带来的流畅动画和精确更新的收益远大于这点开销。直接在主线程对大数据量用DiffUtil才会出大问题。


面试官终极问题​:

“如果让你设计一个像抖音那样的全屏视频滑动列表,你会怎么保证流畅度?”

​(展现架构思维):
“抖音的流畅体验背后有很多细节!我们之前做过类似的短视频模块,核心优化点有三个:

  1. 预加载机制​:

    • 当前播放第 N 个视频时,预加载 N+1 和 N-1 的视频资源。
    • RecyclerView.addOnScrollListener 监听滑动方向,提前 500ms 加载下一批数据。
  2. 视图复用​:

    • 每个全屏 Item 的 ViewHolder 都包含视频播放器组件。
    • 滑动时,复用的 ViewHolder 不需要重新初始化播放器,只需替换数据源(比如 ExoPlayerprepare 新 URL)。
  3. 内存控制​:

    • 限制同时缓存的视频数(比如最多缓存 3 个),其他 ViewHolder 的播放器释放资源。
    • WeakReference 缓存解码后的第一帧图片,避免 OOM。

不过最难的还是 ​手势冲突处理。比如用户上下滑动切换视频时,如果横向滑动触发点赞控件,体验会很割裂。我们最后通过自定义 GestureDetector 判断滑动方向,水平滑动超过 45 度才触发点赞,否则执行翻页。”


面试官​:

“RecyclerView 的缓存机制你了解吗?能简单说说它的工作原理吗?”

​(自然带入项目经验):
“RecyclerView 的缓存机制我们项目里优化过好几次,确实是个挺关键的点。比如之前做一款社交 App 的聊天页面,消息列表特别长,快速滑动的时候总感觉有点卡。后来我们仔细研究了一下缓存机制,发现它其实分了几个‘暂存区’,用来回收和复用 ViewHolder。”


面试官追问​:

“哦?具体有哪些‘暂存区’?能举个例子吗?”

​(用生活场景比喻):
“可以想象成快递站的包裹柜——

  1. 临时货架(mAttachedScrap)​​:比如你正在取快递,手头拿着的几个包裹暂时放在身边,等会儿可能还要用。RecyclerView 在布局的时候,会把当前屏幕上的 ViewHolder 先放在这里,方便快速调整位置。
  2. 最近包裹区(mCachedViews)​​:快递员会把最近送到但暂时没人取的快递放在这里,比如你刚扫了一眼的某个消息项滑出屏幕,但可能马上又会滑回来,这时候直接从这拿,不用重新绑数据。
  3. 大仓库(RecycledViewPool)​​:如果快递太多放不下,就会按类型分类存到仓库里。比如所有图片消息的 ViewHolder 放一个区,文本消息放另一个区,下次需要的时候虽然要重新绑数据,但至少不用重新造个新柜子。”

面试官深入​:

“那你们项目里是怎么利用这些机制优化的?”

​(结合实战案例):
“之前聊天页面的图片消息特别多,用户快速滑动时经常出现白屏。我们用 Android Studio 的 Profiler 一查,发现 onCreateViewHolder 耗时特别高,说明 ViewHolder 创建太频繁。
后来我们做了两件事:

  • ​扩大‘最近包裹区’:recyclerView.setItemViewCacheSize(10),让更多滑出屏幕的 ViewHolder 留在 mCachedViews 里,反向滑动时直接复用,省去了重新绑定图片的时间。
  • 共享仓库​:因为 App 里还有个‘动态’页面也用图片消息,我们让两个页面的 RecyclerView 共用同一个 RecycledViewPool,这样滑到‘动态’页时,可以直接复用聊天页缓存过的图片 ViewHolder。”

面试官挑战​:

“如果遇到特别复杂的 Item 布局(比如直播间的弹幕),缓存机制还能有效吗?”

​(暴露问题并给出方案):
“确实会遇到挑战!我们做直播功能的时候,弹幕 Item 包含头像、昵称、消息内容,还有各种动画。一开始快速滚动时,FPS 直接掉到 40 以下。
后来分析发现,问题出在 ​缓存命中率低——因为弹幕类型多(普通弹幕、打赏消息、系统通知),每种类型的 ViewHolder 都被单独缓存,但缓存池默认每个类型只存 5 个。
我们的解决方案:

  1. 合并相似类型​:把打赏消息和系统通知都合并成‘特殊消息’类型,通过数据字段区分样式。
  2. 预加载关键 ViewHolder​:在进入直播间时,提前创建 10 个弹幕 ViewHolder 并缓存,避免高峰时段密集创建。
  3. ​优化 onBindViewHolder:把头像加载改成 Glide 的预加载机制,避免在滚动时主线程解码图片。”

面试官追问​:

“听起来你们对缓存机制理解很深,那如果让你设计一个新的列表控件,会参考 RecyclerView 的缓存设计吗?”

​(展示设计思维):
“肯定会参考它的分层思想!比如最近我们在做一个相机滤镜列表,需要横向滚动展示大量滤镜预览图。
借鉴 RecyclerView 的经验,我们设计了:

  • 预览图缓存池​:保留最近使用过的 5 个滤镜预览 Renderer,避免每次滑动都重新初始化 OpenGL 资源。
  • 动态回收策略​:如果用户 30 秒没滑动,自动释放一半缓存,平衡内存和流畅度。
    不过我们也改了一点——因为滤镜列表是横向的,所以 mCachedViews 改成了优先缓存左右两侧的 ViewHolder,这样快速来回滑动更顺滑。”

面试官​:

“你在项目里用过 RecyclerView 的 DiffUtil 吗?能说说它的作用和你们是怎么用的吗?”

​(自然带入场景):
“当然用过!我们团队做新闻 App 的时候,首页的资讯列表经常需要更新,比如用户下拉刷新或者加载更多。一开始用 notifyDataSetChanged(),结果每次刷新整个列表都会闪一下,体验特别差。后来引入了 DiffUtil,只更新有变化的 Item,流畅多了。

比如有一次,用户点了一篇新闻的‘点赞’按钮,点赞数要从 100 变成 101。用 DiffUtil 的话,它只会刷新这一行,其他没变的新闻标题、图片都不用动,看起来就像瞬间更新了一样,完全没有闪烁。”


面试官追问​:

“听起来不错,那 DiffUtil 具体是怎么判断哪些数据变化的?”

​(比喻化解释):
“可以把它想象成一个‘数据侦探’!它会拿着新旧两份数据清单,挨个对比:

  1. 第一步:找熟人​(areItemsTheSame):比如通过新闻的 ID 判断是不是同一条数据。
  2. 第二步:查细节​(areContentsTheSame):如果 ID 对上了,再检查标题、图片这些内容有没有变化。
  3. 第三步:记小本本​(getChangePayload):如果只是某个小地方变了(比如点赞数),就记下来,告诉 Adapter 只更新这个部分,不用整个重画。”

面试官挑战​:

“那你们在实现的时候有没有踩过什么坑?比如数据量很大的时候会不会卡?”

​(暴露问题并给出方案):
“还真踩过!有一次测试同学扔了个 5000 条数据的列表过来,结果一刷新就 ANR 了。后来发现是因为在主线程跑 DiffUtil.calculateDiff(),计算量太大直接卡死主线程。

我们当时的解决方案:

  1. 扔到后台线程​:用 Kotlin 协程或者 RxJava 在后台计算差异,算完了再切回主线程更新 UI。
  2. 数据分片​:比如每次只对比当前屏幕能看到的 20 条数据,而不是全量 5000 条。
  3. 增量更新​:让后端同学改接口,只返回变化的数据,比如‘新增了 10 条,删了 2 条’,这样 DiffUtil 只要处理 12 条,速度飞快。”

面试官深入​:

“如果遇到数据顺序变化,比如用户拖拽排序,DiffUtil 能自动处理吗?”

​(结合动画效果):
“可以的!比如我们做过一个任务管理 App,用户长按拖拽调整任务顺序。DiffUtil 会识别到位置变化,自动触发 notifyItemMoved,配合 RecyclerView 的默认动画,任务项会‘滑’到新位置,特别丝滑。

不过有个细节:如果数据类的 equals 方法没重写,可能会导致 DiffUtil 误判内容变化,触发不必要的刷新。所以我们强制所有数据类必须实现 equalshashCode,只用 ID 和关键字段做对比。”


面试官陷阱题​:

“有人说用了 DiffUtil 就不需要 notifyItemChanged(position) 了,对吗?”

​(指出误区):
“不完全对!比如有个特殊场景:用户修改了某条数据的某个字段,但这个字段不在 areContentsTheSame 的对比范围内。这时候 DiffUtil 会认为内容没变,跳过刷新。

我们的解决方案:

  • 方案一​:在 areContentsTheSame 里加入这个字段的对比。
  • 方案二​(更灵活):手动调用 notifyItemChanged,但用 Payload 告诉 Adapter 只更新特定控件。比如点赞数变化时,只改数字,不碰标题和图片。”

基础知识扩展: 


RecyclerView 缓存机制

一、缓存层级与核心设计思想

RecyclerView 的缓存机制通过 ​多级缓存池​ 实现高效复用,核心目标是 ​减少 ViewHolder 的重复创建和布局测量,从而提升滚动性能。其缓存层级可分为四个部分:

缓存层级存储内容复用条件生命周期
mAttachedScrap当前屏幕可见的 ViewHolder同位置同类型短暂(仅在布局阶段有效)
mCachedViews近期滑出屏幕的 ViewHolder同位置同类型长期(容量满时淘汰到下一级)
RecycledViewPool按类型分类的 ViewHolder同类型即可复用长期(应用生命周期内有效)
ViewCacheExtension开发者自定义缓存(极少使用)开发者控制自定义

二、各级缓存详解与实战场景
1. mAttachedScrap:临时缓存,用于布局优化
  • 工作原理​:在 onLayoutChildren() 过程中,屏幕可见的 ViewHolder 会被临时存入 mAttachedScrap。当布局完成后,未被复用的 ViewHolder 会回到 mCachedViews 或 RecycledViewPool。
  • 场景案例​:快速来回滑动时,刚滑出的 ViewHolder 可能还在 mAttachedScrap 中,直接复用无需重新绑定数据。
  • 关键代码​:
    // RecyclerView 源码中的处理逻辑
    void layoutChildren() {// 将当前可见的 ViewHolder 存入 mAttachedScrapscrapOrRecycleView(recycler, i, view);// 重新布局时优先从 mAttachedScrap 获取ViewHolder holder = getScrapOrCachedViewForPosition(position);
    }
2. mCachedViews:高频复用缓存(默认容量 2)​
  • 工作原理​:ViewHolder 滑出屏幕后,优先存入 mCachedViews。当用户反向滑动时,直接从 mCachedViews 取出复用(无需 onBindViewHolder)。
  • 优化技巧​:若列表项固定(如消息列表),增大 mCachedViews 容量可提升反向滑动性能。
    recyclerView.setItemViewCacheSize(10); // 增大缓存容量
  • 淘汰策略​:当 mCachedViews 容量满时,最旧的 ViewHolder 会被转移到 RecycledViewPool。
3. RecycledViewPool:跨列表共享的全局缓存
  • 存储结构​:按 viewType 分类,每个类型默认缓存 5 个 ViewHolder。
  • 复用规则​:不同位置、不同 RecyclerView 的同类型 ViewHolder 可复用(需重新绑定数据)。
  • 共享场景​:ViewPager 中多个 RecyclerView 共享同一个 Pool,避免重复创建。
    RecyclerView.RecycledViewPool pool = new RecyclerView.RecycledViewPool();
    recyclerView1.setRecycledViewPool(pool);
    recyclerView2.setRecycledViewPool(pool);
  • 容量调整​:
    pool.setMaxRecycledViews(TYPE_IMAGE, 10); // 增大图片类型缓存
4. ViewCacheExtension:自定义缓存(高级用法)​
  • 使用场景​:需要特殊复用逻辑时(如根据业务状态缓存),但 99% 的项目无需使用。
  • 示例代码​:
    public class CustomCacheExtension extends RecyclerView.ViewCacheExtension {private SparseArray<ViewHolder> mCache = new SparseArray<>();@Overridepublic View getViewForPositionAndType(int position, int type) {return mCache.get(position); // 根据位置返回缓存视图}public void addToCache(int position, ViewHolder holder) {mCache.put(position, holder);}
    }

三、缓存工作流程(以向下滑动为例)​
  1. ViewHolder 滑出屏幕

    • 存入 mCachedViews(若未满) → 复用时不触发 onBindViewHolder
    • 若 mCachedViews 已满,转移到 RecycledViewPool。
  2. 新 ViewHolder 需要显示

    • 优先从 mAttachedScrap 查找(布局阶段)。
    • 若未找到,从 mCachedViews 查找(同位置)。
    • 若未找到,从 RecycledViewPool 获取(同类型)。
    • 若未找到,调用 onCreateViewHolder 创建新实例。
  3. ViewHolder 回收到池中

    • 从 RecycledViewPool 获取的 ViewHolder 必须重新绑定数据(onBindViewHolder)。

四、性能优化实战技巧
1. 提升缓存命中率
  • 预加载布局​:在空闲期预创建 ViewHolder。
    recyclerView.post(() -> {RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();layoutManager.scrollToPosition(preloadPosition); // 触发预加载
    });
  • 避免频繁变更 ViewType​:相同数据尽量使用相同 ViewType。
2. 监控缓存状态
  • 通过 RecyclerView.Recycler 调试​:
    RecyclerView.Recycler recycler = recyclerView.getRecycler();
    int cachedCount = recycler.getCachedViews().size(); // mCachedViews 当前数量
    int poolSize = recycler.getRecycledViewPool().getRecycledViewCount(TYPE_TEXT); // 某类型缓存数
3. 解决常见问题
  • 卡顿问题​:检查 onBindViewHolder 是否耗时,避免主线程操作。
  • 内存泄漏​:在 onViewRecycled() 中释放资源。
    @Override
    public void onViewRecycled(@NonNull ViewHolder holder) {Glide.with(holder.imageView).clear(holder.imageView); // 释放图片资源
    }

五、大厂面试高频问题
问题 1:mCachedViews 和 RecycledViewPool 的区别?​
  • mCachedViews​:按位置缓存,复用无需重新绑定数据,容量小(默认 2)。
  • RecycledViewPool​:按类型缓存,复用需重新绑定数据,容量可跨列表共享。
问题 2:如何实现类似微信聊天列表的流畅滑动?​
  • 优化点​:
    1. 使用 DiffUtil 局部更新,减少 onBindViewHolder 触发次数。
    2. 增大 mCachedViews 容量(setItemViewCacheSize(20))。
    3. 避免在 onBindViewHolder 中加载图片,用 Glidepreload() 预加载。
问题 3:为什么 RecyclerView 比 ListView 更高效?​
  • 缓存机制​:RecyclerView 通过多级缓存和 ViewHolder 模式,减少布局测量和视图创建开销。
  • 布局解耦​:支持横向、网格、瀑布流等布局,避免 ListView 的全局重绘。

DiffUtil

1. DiffUtil 是什么?​

DiffUtil 是 Android 中用于优化 RecyclerView 数据更新的工具。它通过智能对比新旧数据集,精确计算出哪些数据项发生了改变(增、删、改、移动),从而触发局部刷新,避免无脑调用 notifyDataSetChanged() 导致整个列表重绘。


2. 为什么需要 DiffUtil?​
  • 传统方法的弊端​:
    使用 notifyDataSetChanged() 会强制刷新整个列表,即使只有一项数据变化,所有 Item 都会重新执行 onBindViewHolder,导致性能浪费(如卡顿、闪烁)。

  • DiffUtil 的优势​:

    • 仅更新变化的 Item,减少 UI 操作次数。
    • 自动处理移动动画(如数据项位置交换时的平滑过渡)。
    • 支持局部更新(仅刷新变化的控件,如点赞数)。

3. 核心原理:DiffUtil.Callback

要使用 DiffUtil,需实现 DiffUtil.Callback 抽象类,定义四个关键方法:

方法作用
getOldListSize()返回旧数据集的长度。
getNewListSize()返回新数据集的长度。
areItemsTheSame(oldPos, newPos)判断新旧位置的数据项是否代表同一对象(通常通过唯一 ID 比较)。
areContentsTheSame(oldPos, newPos)判断同一对象的数据内容是否变化(如标题、图片是否修改)。
getChangePayload()(可选)返回变化的“载荷”(如仅标题变化),用于更细粒度的局部更新。

4. 使用步骤
步骤 1:实现 DiffUtil.Callback
class MyDiffCallback(private val oldList: List<Item>,private val newList: List<Item>
) : DiffUtil.Callback() {override fun getOldListSize() = oldList.sizeoverride fun getNewListSize() = newList.sizeoverride fun areItemsTheSame(oldPos: Int, newPos: Int): Boolean {// 通过唯一 ID 判断是否是同一项return oldList[oldPos].id == newList[newPos].id}override fun areContentsTheSame(oldPos: Int, newPos: Int): Boolean {// 判断内容是否一致(需重写数据类的 equals())return oldList[oldPos] == newList[newPos]}// 可选:返回变化的部分数据override fun getChangePayload(oldPos: Int, newPos: Int): Any? {val oldItem = oldList[oldPos]val newItem = newList[newPos]return if (oldItem.title != newItem.title) "UPDATE_TITLE" else null}
}
步骤 2:在后台线程计算差异
// 在协程或异步任务中执行
GlobalScope.launch(Dispatchers.Default) {val diffResult = DiffUtil.calculateDiff(MyDiffCallback(oldList, newList))withContext(Dispatchers.Main) {// 先更新数据源,再应用变更adapter.updateData(newList)diffResult.dispatchUpdatesTo(adapter)}
}
步骤 3:Adapter 处理局部更新
override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: List<Any>) {if (payloads.isEmpty()) {// 全量更新holder.bind(dataList[position])} else {// 局部更新(如仅更新标题)payloads.forEach { payload ->if (payload == "UPDATE_TITLE") {holder.titleView.text = dataList[position].title}}}
}
步骤 4:启用稳定 ID(关键!)​
class MyAdapter : RecyclerView.Adapter<ViewHolder>() {init {setHasStableIds(true) // 必须启用}override fun getItemId(position: Int): Long {return dataList[position].id // 返回唯一 ID}
}

5. 常见错误与避坑指南
  1. 未在后台线程计算差异​:

    • 问题​:大数据集下 DiffUtil.calculateDiff() 会阻塞主线程,导致卡顿。
    • 解决​:始终在后台线程执行计算,通过协程或 AsyncTask 切回主线程更新。
  2. areItemsTheSame 实现错误​:

    • 错误示例​:直接比较对象引用(oldItem == newItem),而非唯一 ID。
    • 解决​:确保比较的是业务唯一标识(如数据库主键)。
  3. 未设置 setHasStableIds(true)​​:

    • 问题​:RecyclerView 无法正确匹配新旧项,导致动画异常或数据错乱。
    • 解决​:在 Adapter 初始化时调用 setHasStableIds(true),并正确实现 getItemId()
  4. 数据更新顺序错误​:

    • 错误流程​:先调用 diffResult.dispatchUpdatesTo(adapter),再更新数据源。
    • 正确顺序​:先更新 Adapter 的数据源,再应用差异。

6. 性能优化技巧
  • 合理设计数据类​:
    重写 equals()hashCode(),确保 areContentsTheSame 能正确判断内容变化。

  • 使用 Payload 局部更新​:
    对于部分变化的项(如点赞数),通过 getChangePayload 返回变化字段,减少 onBindViewHolder 的计算量。

  • 分页加载大数据集​:
    避免一次性对比数万条数据,采用分页加载减少单次计算量。

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com

热搜词