尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

RecyclerView进阶实战:多类型布局、DiffUtil刷新与滚动优化

发布时间:2026/9/30 1:04:40

资讯中心
01
ARTICLE

RecyclerView进阶实战:多类型布局、DiffUtil刷新与滚动优化

RecyclerView进阶实战:多类型布局、DiffUtil刷新与滚动优化
做Android开发这些年凡是跟列表沾边的界面几乎占了我日常需求的一半。早期用ListView为了性能还要手写ViewHolder模式一碰到“一个页面里又要有轮播图、又要有图文卡片、还要有不同类型的Feed流”这种需求Adapter里的类型判断和布局加载代码就乱成一团。后来全面切到RecyclerView才真正体会到这套被称作“高级控件”的列表体系在设计上的合理性。这篇文章就用一个模拟资讯App的首页信息流作为贯穿案例把RecyclerView真正进阶的玩法——多类型Item布局、DiffUtil局部刷新、ItemTouchHelper拖拽排序与滑删、SnapHelper滚动吸附、吸顶装饰以及滚动性能优化——逐个拆开讲清楚每一节都配有可以直接落地的实例代码和我在真实项目中踩过的坑。已经能用RecyclerView写简单列表但面对复杂需求不知道怎么下手的读者可以按顺序把这套东西吃透。1. 先弄懂RecyclerView的设计逻辑为什么三件套强制分工很多人用RecyclerView只是机械地把ListView的写法平移过来写一个Adapter、在onBindViewHolder里填充数据然后就不管了。这样用当然也能跑但根本发挥不出RecyclerView的能力。要理解它得先回到ListView时代的痛点去看。1.1 ListView时代为什么难写复杂列表ListView对应的Adapter是BaseAdapter它的getView方法把“创建Item布局”和“绑定数据”混在一起。初学的时候很多人写过这样的代码Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; if (convertView null) { convertView LayoutInflater.from(mContext).inflate(R.layout.item_text, parent, false); holder new ViewHolder(convertView); convertView.setTag(holder); } else { holder (ViewHolder) convertView.getTag(); } holder.tvTitle.setText(mData.get(position).getTitle()); return convertView; }这段代码的问题是逻辑全部耦合在一个方法里。Item少还行一旦列表有多种不同样式的ItemgetItemViewType里开始出现switchgetView里对应的if/else分支越来越多每个分支都要处理convertView的判断、ViewHolder的复用、数据的绑定代码很快就成了一团浆糊。而且ListView的布局只能纵向排列想横滑还得靠HorizontalScrollView或者自定义容器复用机制也不通用。RecyclerView就是冲着这些问题来的它把列表的职责拆成了几个独立的部分强制你按模块去组织代码。1.2 RecyclerView三件套各管什么RecyclerView的设计核心是职责分离最典型的就是三件套的分工组件职责类比Adapter负责提供数据、创建ViewHolder、把数据绑定到View服务员负责把菜端到桌上LayoutManager负责Item如何摆放横排、竖排、网格还是瀑布流摆桌的人决定每盘菜放哪ItemAnimator负责Item增删改移动时的过渡动画上菜的顺序和摆盘效果ItemDecoration负责Item之间的分割线、间距、吸顶装饰桌上的装饰和隔断这里最容易被忽略的是LayoutManager。以前在ListView里“纵向列表”是被写死的而RecyclerView把“如何排列”这件事完全抽象出来了。同一个Adapter只要换一种LayoutManager就能从纵向列表变成横向列表不用改任何数据逻辑// 纵向列表 recyclerView.layoutManager LinearLayoutManager(this) // 横向列表 recyclerView.layoutManager LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false) // 网格 recyclerView.layoutManager GridLayoutManager(this, 4) // 瀑布流 recyclerView.layoutManager StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL)我在实际项目里用过一个比较极端的场景同一个数据列表用户在“列表模式”和“封面模式”之间切换列表模式用LinearLayoutManager封面模式换成GridLayoutManagerAdapter完全不动只切layoutManager就实现了两种完全不同的浏览形态。这就是三件套分工的好处。1.3 最小的可用实例Adapter LayoutManager ViewHolder一个最基础的RecyclerView长这样这也是后面所有高级功能的地基class ArticleAdapter( private val articles: ListArticle ) : RecyclerView.AdapterArticleAdapter.ArticleViewHolder() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArticleViewHolder { val view LayoutInflater.from(parent.context) .inflate(R.layout.item_article, parent, false) return ArticleViewHolder(view) } override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) { holder.bind(articles[position]) } override fun getItemCount(): Int articles.size class ArticleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { fun bind(article: Article) { itemView.findViewByIdTextView(R.id.tvTitle).text article.title itemView.findViewByIdTextView(R.id.tvSummary).text article.summary } } }注意onCreateViewHolder里inflate时的那第三个参数parent和false。很多人写inflate的时候不传parent或者传了parent但第三个参数传true这会导致Item在addView时重复添加出现诡异的“Item多了一个间距”或“子View重复”的bug。传false的目的是只创建View但不立即添加到parentRecyclerView会在接下来的布局阶段自己决定何时加进去。这个细节我教过很多新人每次都要强调inflate的第三个参数必须写明。2. 多类型Item布局一个列表同时承载Banner、图文和视频多类型布局是RecyclerView实战里最常用、也最容易写乱的高级功能。做资讯类App、电商App、社交App几乎都离不开“一个列表里塞多种Item”的需求。前面说的模拟资讯首页就是一个非常典型的多类型场景。2.1 信息流场景的Item类型设计假设我们要实现这样一个首页列表第0项是一个横滑Banner轮播中间穿插图文卡片标题摘要封面图偶尔出现纯文字快讯还会插入视频卡片如果不用多类型最蠢的做法是把几种内容都塞进一个LinearLayout里然后根据类型隐藏/显示子View。这样做会导致每个Item的布局文件极其臃肿而且RecyclerView的复用机制对“隐藏”的子View并不友好性能差还容易出状态错乱。正确做法是让数据源本身携带类型信息。我们先定义一个数据模型data class FeedItem( val id: String, val type: FeedType, val title: String? null, val summary: String? null, val imageUrl: String? null, val videoUrl: String? null, val bannerImages: ListString? null ) enum class FeedType { BANNER, ARTICLE, TEXT_ONLY, VIDEO }数据源就是一个ListFeedItem每项的type不同。Adapter拿到这个列表通过getItemViewType告诉RecyclerView每一项该用哪种布局。2.2 getItemViewType和onCreateViewHolder如何配合Adapter的核心逻辑如下。这里最关键的一点是RecyclerView会根据getItemViewType返回的值自动把相同类型的ViewHolder放进同一个复用池。也就是说BANNER类型的ViewHolder只会和BANNER类型复用ARTICLE类型只会和ARTICLE类型复用不会互相串。class FeedAdapter( private val items: ListFeedItem ) : RecyclerView.AdapterRecyclerView.ViewHolder() { companion object { private const val TYPE_BANNER 1 private const val TYPE_ARTICLE 2 private const val TYPE_TEXT_ONLY 3 private const val TYPE_VIDEO 4 } override fun getItemViewType(position: Int): Int { return when (items[position].type) { FeedType.BANNER - TYPE_BANNER FeedType.ARTICLE - TYPE_ARTICLE FeedType.TEXT_ONLY - TYPE_TEXT_ONLY FeedType.VIDEO - TYPE_VIDEO else - throw IllegalArgumentException(未知类型: ${items[position].type}) } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return when (viewType) { TYPE_BANNER - BannerViewHolder( LayoutInflater.from(parent.context).inflate(R.layout.item_banner, parent, false) ) TYPE_ARTICLE - ArticleViewHolder( LayoutInflater.from(parent.context).inflate(R.layout.item_article, parent, false) ) TYPE_TEXT_ONLY - TextOnlyViewHolder( LayoutInflater.from(parent.context).inflate(R.layout.item_text_only, parent, false) ) TYPE_VIDEO - VideoViewHolder( LayoutInflater.from(parent.context).inflate(R.layout.item_video, parent, false) ) else - throw IllegalArgumentException(未知 viewType: $viewType) } } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { val item items[position] when (holder) { is BannerViewHolder - holder.bind(item) is ArticleViewHolder - holder.bind(item) is TextOnlyViewHolder - holder.bind(item) is VideoViewHolder - holder.bind(item) } } override fun getItemCount(): Int items.size }每个ViewHolder都持有自己的布局引用各自负责自己的绑定逻辑。这样就算以后要新增一种Item只需要加一个viewType常量、一个ViewHolder类、一个when分支不会破坏原来的代码。2.3 多类型复用与常见的类型冲突坑多类型用起来并不复杂但有几个坑我几乎每次都会被问到这里一次说清楚。第一个坑是viewType的值必须唯一且稳定。有些人图省事直接用position当viewType这是完全错误的。RecyclerView复用池根据viewType分类如果你返回position每一行的viewType都不一样等于完全失去了复用能力滑起来会明显卡顿。另外不建议从0开始定义类型编号避免和系统内部默认值混淆我通常从1开始。第二个坑是onBindViewHolder里的类型判断不要依赖数据源的类型要用holder类型的判断。上面代码里我用when (holder)来判断而不是when (items[position].type)。原因是RecyclerView的ViewHolder实例和position之间并不是一一对应的列表滚动时ViewHolder会在不同position之间复用。如果只用items[position].type判断但实际holder类型不匹配强转时就会崩溃。第三个坑是数据源里必须保证每一项的type和它实际对应的ViewHolder一致。这个听起来像废话但在分页加载、下拉刷新后拼接数据时特别容易出问题。比如服务端返回的数据里某个Item既说自己是ARTICLE又说自己是VIDEO或者type值映射错了就会出现ClassCastException。所以我在项目里提倡用枚举类型做数据源而不是存一个int值枚举在编译期就帮你筛掉了不少写错值的情况。3. 数据刷新做到毫秒级差异更新DiffUtil与ListAdapter实战列表数据刷新是另一个绕不开的话题。很多人写下拉刷新之后第一反应就是adapter.notifyDataSetChanged()。这个方法是全量刷新哪怕只改了一个Item的文字它也要把当前可见区域的所有Item全部重绘一遍还会丢失所有滚动位置和动画状态。在数据量小的时候感觉不明显一旦列表有几百上千条、Item布局又复杂整个界面就像闪了一下白光体验很糟糕。3.1 notifyDataSetChanged为什么不能随便用notifyDataSetChanged还有一个隐藏的问题它会导致ViewHolder全部重新绑定也就是说即使你没有改数据只要调用了这个方法所有可见的Item都会重新走一遍onBindViewHolder。如果你的onBindViewHolder里有图片加载、有网络请求、有耗时计算这一下就会造成明显的掉帧。更让人头疼的是在带输入框的Item列表里比如评论列表、编辑表单notifyDataSetChanged会触发EditText重新绑定导致输入框焦点丢失、已经输入的内容被清空。这种bug特别难排查因为有时候它不是必现的取决于键盘的状态和列表重绘的时机。DiffUtil就是来解决这个问题的。3.2 DiffUtil的差异计算与dispatchUpdatesToDiffUtil的作用是计算两个列表之间的“最小差异”然后只针对变化的部分通知Adapter进行更新。它的核心是两条回调class FeedDiffCallback : DiffUtil.ItemCallbackFeedItem() { // 判断两个对象是否代表同一条数据通常用唯一id override fun areItemsTheSame(oldItem: FeedItem, newItem: FeedItem): Boolean { return oldItem.id newItem.id } // 如果areItemsTheSame返回true进一步判断内容是否有变化 override fun areContentsTheSame(oldItem: FeedItem, newItem: FeedItem): Boolean { return oldItem newItem } }areItemsTheSame判断“是不是同一条数据”用唯一id判断比如文章id。areContentsTheSame判断“同一条数据的内容有没有变”一般直接用data class的equals比较。计算和派发更新的代码也很简单val diffResult DiffUtil.calculateDiff( object : DiffUtil.Callback() { override fun getOldListSize(): Int oldList.size override fun getNewListSize(): Int newList.size override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean oldList[oldItemPosition].id newList[newItemPosition].id override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean oldList[oldItemPosition] newList[newItemPosition] } ) diffResult.dispatchUpdatesTo(adapter)calculateDiff执行完之后dispatchUpdatesTo会根据差异结果决定对Adapter调用notifyItemInserted、notifyItemRemoved、notifyItemChanged还是notifyItemMoved。这些局部通知会触发对应的动画用户看到的列表变化会非常平滑而不是整页闪烁。需要注意DiffUtil的差距计算在数据量大的时候也是有一定耗时的。官方文档建议把计算放到后台线程。实际项目里我遇到300条以内的列表在主线程算差异基本感觉不到但如果是上千条甚至几千条的列表还是建议放到子线程算完结果再切回主线程dispatchUpdatesTo。用协程写大概是这个样子lifecycleScope.launch(Dispatchers.Default) { val result DiffUtil.calculateDiff(oldCallback, true) withContext(Dispatchers.Main) { result.dispatchUpdatesTo(adapter) } }3.3 ListAdapter和submitList的坑如果不想反复写calculateDiff的样板代码可以用官方的ListAdapter。它是RecyclerView.Adapter的一个子类内部已经封装好了AsyncListDiffer你只需要实现DiffUtil.ItemCallback然后调用submitList传入新的数据即可class FeedAdapter : ListAdapterFeedItem, RecyclerView.ViewHolder(FeedDiffCallback()) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { // 同前省略 } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { // 注意这里获取数据的方法是 getItem(position)而不是自己维护的 list val item getItem(position) // 绑定逻辑 } override fun getItemCount(): Int currentList.size }使用ListAdapter最大的坑是submitList的“同引用不更新”陷阱。我见过好几个同事在这上面栽过跟头他们维护了一个mList成员变量拉到新数据后先执行mList.clear()和mList.addAll(newData)然后调用submitList(mList)结果界面纹丝不动。原因是AsyncListDiffer内部会先判断传入的列表和当前列表是不是同一个对象引用如果是同一个对象就直接return不进行任何计算。你对同一个mList对象做了clear和addAll在它看来“还是那个列表”所以就忽略了。解决方案很简单每次更新时给提交一个新列表对象比如submitList(ArrayList(newData))让differ知道数据确实变了。还有一个经验areContentsTheSame的判断依赖数据类是否正确重写了equals/hashCode。Kotlin的data class默认帮我们做了这些所以直接用比较没问题。如果你项目里用的是Java的普通POJO就一定要记得重写equals和hashCode否则就算内容变了DiffUtil也会认为“内容相同”导致UI不更新。这个bug的特点是数据确实刷新了但界面看不到变化排查起来很费劲。4. 拖拽排序与滑动删除ItemTouchHelper的完整玩法拖拽排序和滑动删除是很多App都有的交互尤其是任务列表、收藏夹、歌单这类需要对Item顺序进行管理的列表。RecyclerView官方虽然没有直接提供拖拽的组件但提供了ItemTouchHelper这个强大的工具类它是ViewCompat.OnItemTouchListener的封装能帮你完成拖拽过程中的位移、动画和各种状态回调。4.1 基础配置能拖、能滑、能换位ItemTouchHelper的核心是ItemTouchHelper.Callback最常用的是它的子类SimpleCallback。看一个完整示例val callback object : ItemTouchHelper.SimpleCallback( ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.LEFT ) { override fun onMove( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ): Boolean { val fromPos viewHolder.bindingAdapterPosition val toPos target.bindingAdapterPosition Collections.swap(feedList, fromPos, toPos) adapter.notifyItemMoved(fromPos, toPos) return true } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { val pos viewHolder.bindingAdapterPosition feedList.removeAt(pos) adapter.notifyItemRemoved(pos) } override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { if (actionState ItemTouchHelper.ACTION_STATE_DRAG) { viewHolder?.itemView?.alpha 0.7f } super.onSelectedChanged(viewHolder, actionState) } override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { viewHolder.itemView.alpha 1.0f super.clearView(recyclerView, viewHolder) } } ItemTouchHelper(callback).attachToRecyclerView(recyclerView)onMove发生在拖拽一个Item到另一个Item位置时业务上要做两件事更新数据源里的顺序然后notifyItemMoved让界面跟着动。注意这里我用的是bindingAdapterPosition而不是早期教程里的getAdapterPosition()后者在AndroidX里已经废弃在动画过程中可能返回NO_POSITION导致索引越界。onSwiped是手指把Item往左滑出时触发的回调direction返回具体方向。这里要同步移除数据源里的对应项再notifyItemRemoved。如果不移除数据源等列表滑动起来或者刷新时界面上明明删掉了的Item又会“复活”而且数据位置错位。4.2 数据源同步与动画还原数据源同步是拖拽逻辑里最容易出错的地方。很多人写完onMove后只调用了notifyItemMoved忘了改数据源结果拖拽时界面看起来顺序是对了一松手或者一滑动Item就跳回原来的位置。核心原因就是RecyclerView的动画只是视觉上的真正的数据顺序必须由你自己维护。还有一个细节是remove之后做notifyItemRemoved时如果后面还要恢复这条数据比如实现“撤销删除”功能要把被删除的Item对象先缓存起来等用户点击撤销时再插回原来的位置。但插回时注意位置计算因为删除后列表索引已经变化纯靠下标恢复很容易越界。我做过一个比较稳妥的方案是被删除的数据在对象里带上它原来的position字段恢复时对当前列表做一次二分查找或遍历找到最近的可插入位置再insert。拖拽过程中的视觉效果也值得打磨。默认的拖拽只是Item位置交换不太有“提起来”的层次感。我习惯在onSelectedChanged里给被拖拽的Item加一个带阴影的背景或透明度变化如alpha降到0.7在clearView里还原。这个效果虽然简单但会让拖拽交互的反馈清晰很多。4.3 类型限制与手势冲突处理多类型列表里不是所有Item都能拖拽也不是所有Item都能滑动删除。比如Banner显然不能被拖走广告位的Item也不应该出现删除入口。这个需求可以用SimpleCallback里重写两个方法来实现override fun isLongPressDragEnabled(): Boolean false override fun canDrag(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Boolean { return adapter.getItemViewType(viewHolder.bindingAdapterPosition) FeedAdapter.TYPE_ARTICLE } override fun canSwipe(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Boolean { return adapter.getItemViewType(viewHolder.bindingAdapterPosition) FeedAdapter.TYPE_TEXT_ONLY }如果需要“长按某个特定按钮才触发拖拽”可以先把isLongPressDragEnabled返回false然后在按钮的OnClickListener里调用itemTouchHelper.startDrag(viewHolder)。这是比较常见的交互形式比如列表每一行右边有个“汉堡”图标用户只能按住那个图标拖动防止列表Item整体滑动时误触拖拽。手势冲突这块我踩过一个比较隐蔽的坑当RecyclerView嵌套在ScrollView或者垂直滚动的父容器里时ItemTouchHelper的上下拖拽手势会和父容器的滚动事件冲突导致拖拽完全失灵或者拖到一半父容器开始滚。解决思路是在拖拽状态开始ACTION_STATE_DRAG时请求父容器不要拦截事件拖拽结束后再恢复。如果项目里用的是CoordinatorLayout AppBarLayout还要考虑和其他滚动联动机制的关系。5. 滚动定位与吸顶SnapHelper和ItemDecoration的进阶用法RecyclerView的滚动控制也是高级功能里比较有含金量的一块。很多人只会scrollToPosition但真正到“轮播图要一页一页停”“列表要自动回到某个位置”“分类标题要吸在顶部”这些需求时就得用到更进阶的机制。5.1 smoothScrollToPosition与SnapHelper的定位差异先分清两个基础方法scrollToPosition(pos)立即跳转到指定位置没有动画适合“回到顶部”这种快速操作。smoothScrollToPosition(pos)带有平滑滚动动画适合“滚动到某个Item并让用户感知到过程”的场景。很多情况下我们希望列表滚动停止时自动“吸附”到某个对齐位置。比如横向的Banner列表希望它滑动停下来时自动居中显示当前页或者一个竖向的卡片列表希望它停下来时自动把某个Item对齐到屏幕顶部。这时候就该用SnapHelper。RecyclerView官方提供了两个现成的SnapHelperSnapHelper效果适用场景LinearSnapHelper滑动停止时让Item对齐到RecyclerView的起点或中心不限制翻页横向卡片列表、普通居中吸附PagerSnapHelper和ViewPager一样一页一页翻一次最多滑动一页Banner轮播、分页卡片用起来非常简单val snapHelper PagerSnapHelper() snapHelper.attachToRecyclerView(recyclerView)这一行代码就能把普通的横向RecyclerView变成带分页吸附效果的轮播列表比套ViewPager轻量很多。但SnapHelper有个要注意的地方attach之后如果RecyclerView的LayoutManager被替换SnapHelper会失效需要重新attach。我遇到过开发者在代码里动态切换layoutManager后SnapHelper不生效的bug查了半天发现只是没有重新attach。5.2 吸顶分组效果的实现思路资讯App列表里经常有“今天”“热门”“推荐”这种分组标题要求列表向上滚动时分类标题吸在屏幕顶部直到下一组标题把它顶出去。这个效果的实现方案有很多但最优雅的是自定义ItemDecoration因为ItemDecoration不需要改动Adapter的数据结构也不影响Item的复用和动画。核心思路是在onDrawOver里绘制吸顶的标题视图。流程大概是找到当前可见范围内的第一个Item根据这个Item的分组信息确定当前应该显示哪个标题判断这个分组的最后一个Item是否已经滚出屏幕顶部如果是就绘制下一个分组的标题并让它跟随滚动被“顶起”用代码表达出来大概是这个骨架class StickyHeaderDecoration( private val itemCallback: (position: Int) - StickyData ) : RecyclerView.ItemDecoration() { private var headerRect Rect() override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { super.onDrawOver(c, parent, state) val layoutManager parent.layoutManager as? LinearLayoutManager ?: return val firstVisiblePos layoutManager.findFirstVisibleItemPosition() if (firstVisiblePos RecyclerView.NO_POSITION) return // 获取分组的标题信息 val header itemCallback(firstVisiblePos) // 绘制标题背景和文字 // 关键判断下一个分组是否即将顶起当前标题若是则计算偏移量 } }吸顶标题的具体布局怎么画是一个需要反复调整的细节。绘制时注意Canvas的原点坐标默认是在ItemDecoration区域的顶部所以填背景时要让出状态栏高度文字绘制用Paint.setTextAlign和Paint.getTextBounds配合才能保证文字居中。第一次写吸顶的同事十有八九会掉进“文字绘制到屏幕外面”的坑里。如果不想自己造轮子也可以考虑开源的StickyHeaderDecoration库比如fabioCollini的库不过自己实现一遍对理解RecyclerView的绘制流程帮助很大。5.3 嵌套滚动的处理现在的App里很少只有一个独立列表大多是在一个页面上既有横向滚动又有纵向滚动“外层ScrollView套内层RecyclerView”是经典的反模式。很多新人这么写之后发现外层滑不动内层也没法滚或者内层滚到头之后外层才接手。如果场景允许最推荐的方案是不要用ScrollView套RecyclerView而是用CoordinatorLayout AppBarLayout CollapsingToolbarLayoutNestedScrollView或者直接用RecyclerView的addHeaderView能力把多个区块整合进一个列表。如果项目已经存在嵌套结构必须让RecyclerView启用嵌套滚动能力。默认情况下RecyclerView实现了NestedScrollingChild接口所以它自己是支持嵌套滚动的但需要注意在滚动联动时把它标记为“不消费父容器滚动”recyclerView.isNestedScrollingEnabled false这个方法在AndroidX下相当于告诉父层“我不自己消费滚动事件交给外层处理”。但要注意这个属性一旦设为falseRecyclerView内部的平滑滚动动画smoothScrollToPosition也会受影响可能出现“调用后无法滚到指定位置”的bug。如果你在嵌套场景下设置了isNestedScrollingEnabledfalse又想用smoothScrollToPosition建议改用scrollToPosition或者自己用RecyclerView.SmoothScroller配合手动驱动。综合来看滚动相关的功能能依赖官方组件的就依赖官方组件SnapHelper、LayoutManager、ItemDecoration这些机制本身已经很强自己造轮子反而容易制造新的坑。6. 滚动流畅度保障复用机制详解与卡顿排查思路RecyclerView之所以敢叫“高级控件”底层一个核心原因是它有一个非常高效的复用机制。但复用机制不是自动就能发挥好的很多情况下卡顿恰恰是因为不正确的使用方式破坏了复用。6.1 复用池与setHasFixedSize的工作方式RecyclerView内部有一个Recycler对象负责管理所有Item的回收和复用。当一个Item滑出屏幕时它不会被销毁而是进入Recycler池中。当新的Item滑入屏幕时如果viewType相同就会直接从池里取出那个ViewHolder来复用而不是重新inflate。理解这个机制后就明白为什么setHasFixedSize(true)非常重要。这个方法是告诉RecyclerView列表的尺寸是固定的不会因为Item的内容而变化。在这种情况下RecyclerView可以跳过onMeasure阶段的很多重计算性能提升非常明显。如果你的Item高度是完全确定的建议在初始化时直接加上recyclerView.setHasFixedSize(true)反过来如果Item的内容会动态改高度比如展开/收起就不能设成true否则会出现Item绘制不全或者重叠的问题。还有一个容易被忽视的方法是getRecycledViewPool().setMaxRecycledViews(viewType, maxCount)。RecyclerView对每一种viewType会维护一个缓存池默认的缓存数量是5个。如果列表里某种类型特别多或者Item布局比较复杂可以适当调大这个池子的容量val pool recyclerView.recycledViewPool pool.setMaxRecycledViews(FeedAdapter.TYPE_ARTICLE, 10) pool.setMaxRecycledViews(FeedAdapter.TYPE_VIDEO, 8)这样当快速滑动时更多的ViewHolder可以被缓存下来减少重复创建。注意这个设置是全局的同一个Pool被多个RecyclerView共享时要确保不同列表的viewType不会冲突。6.2 onBindViewHolder里的隐形性能黑洞onBindViewHolder是绑定数据的方法也是性能优化最应该盯紧的地方。常见的问题有在onBindViewHolder里findViewById。如果你的ViewHolder没有在构造函数里把子View存成成员变量而是每次绑定时都findViewById等于放弃了ViewHolder的意义。正确做法是在ViewHolder初始化时把需要用到的子View全部缓存下来bind方法只做数据填充。在onBindViewHolder里做复杂计算、创建对象、解析JSON。这些操作应该提前完成绑定只做赋值。比如列表里的时间戳转换最好在数据进入列表前就把字符串算好而不是每次绑定时都new一个SimpleDateFormat再做format转换。图片加载没有和滚动状态联动。用Glide/Picasso这类图片库时建议监听RecyclerView的滚动状态在快速滑动SCROLL_STATE_DRAGGING或SCROLL_STATE_SETTLING时暂停加载滚动停止后再恢复。这里面Glide有现成的Glide.with(fragment).pauseRequests()和resumeRequests()方法。这个优化在低端机上效果特别明显。Item根布局层级过深。每个Item在measure和layout时都要遍历整棵View树层级越深消耗越大。用ConstraintLayout或者扁平化布局能有效减少节点数量特别是在瀑布流和复杂卡片场景下。另外如果Item的透明度、背景色、阴影等属性不需要动画尽量在xml里写死少放在onBindViewHolder里动态set。因为每次setXxx都会触发View的invalidate请求频繁调用会影响渲染。6.3 一套可复用的卡顿排查路径如果列表已经出现明显的卡顿可以按下面的思路排查打开开发者选项里的“严格模式”和“GPU渲染模式分析”先在宏观上确认是不是列表滚动时掉帧严重。用Systrace抓一段滚动轨迹看掉帧时间段内的主线程在做什么。这个能看到主线程是不是有耗时方法比如布局、绘制或者是某个Java方法调用时间过长。检查onBindViewHolder有没有做耗时操作有没有在滚动过程中频繁触发网络请求、磁盘读写。检查Item图片是否过大加载时有没有做压缩和缓存。很多时候卡顿的根源不是布局而是大图解码。如果列表里有大量的圆角、阴影、模糊确认是否有图层过度叠加的问题可以通过开发者选项的“显示布局边界”辅助判断。我用这套方法解决过一个比较诡异的卡顿列表本身不复杂但每次滚动到第15项左右就会明显卡一下。后来发现是这一项的布局文件里嵌套了一个半透明的背景层这个背景层触发了硬件层重绘而它又是动态设置的。改成在xml里静态写死背景后卡顿就消失了。还有一个容易忽略的问题是RecyclerView和Adapter的数据不同步。比如数据源在子线程更新但调用notifyItemInserted/notifyItemRemoved时没有切回主线程或者notify和实际操作顺序不对会导致RecyclerView在下一帧布局时读到错误数据出现类似“跑完进度条后界面冻结”的卡顿感。这种问题在Log里通常看不到异常排查时需要反复确认所有更新操作是否都发生在主线程并且数据源和notify方法是否严格成对。把上面这些点都做到位一个流畅的RecyclerView基本就稳了。很多时候不是RecyclerView不够快而是它的复用和局部更新能力没有真正用起来。最后再分享一个小经验RecyclerView这个“高级控件”的高级不在于它本身提供了多少现成的功能而在于它给了你一套清晰的扩展骨架——Adapter负责数据、LayoutManager负责布局、ItemDecoration负责绘制、ItemTouchHelper负责手势、ItemAnimator负责动画。当你遇到一个复杂需求的时候不要急着往Adapter里堆逻辑先想清楚这个需求应该由哪个角色去承担然后用它对应的扩展点去实现。我最早踩过的大多数坑基本都是因为把所有逻辑塞在Adapter里结果RecyclerView的天生优势完全发挥不出来。希望这篇文章能帮你少走一些弯路。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。