☰
Android纵向滑动页面实战:三容器选型与避坑指南
2026/10/13 4:17:06 网站建设 项目流程

简介:这是一份面向Android初中级开发者的纵向滑动页面实现资源,围绕ViewPager详细讲解如何构建上下滑动、翻页并显示页码的交互效果。压缩包内共61个文件,主要包括Java源码、XML布局与资源文件、编译生成的class与dex、android-support-v4.jar依赖库,以及可直接安装运行的APK调试包,整体大小约1.31MB,工程结构清晰,便于对照学习或二次开发。资源提供完整的FlingPage示例工程,演示了自定义适配器管理多个Fragment页面、手势滑动检测、页码指示器联动等关键环节,有助于理解Android滚动机制和ViewPager扩展原理。目前已有3778人学习下载,对于希望快速掌握纵向滑动页面实现的开发者,是一份轻量而实用的参考资料。

1. 纵向滑动页面:不只是 ScrollView 和 RecyclerView 的二选一

做 Android 纵向滑动页面(上下滑动效果)时,最常见的一厢情愿是:用 ScrollView 把整页包起来就完事。某开发者曾经拿 ScrollView 装了 200 条商品卡片,首屏加载卡成两秒,用户直接打低分。这个标题背后真正要解决的不是“能不能滑”,而是滚动容器怎么选:数据量多大、要不要嵌套、惯性手感怎么调,三个问题决定你该用 ScrollView、RecyclerView 还是 NestedScrollView。下面先从选型说起,再给你一份最小可运行工程,然后把惯性、回弹这些手感参数讲透,最后把嵌套滚动和性能相关的四个高频坑摊开。新手可以照着搭第一版,熟手可以直接查边界参数和排错思路。

2. 先把纵向滑动的三种实现方式摆清楚:选型决定后面踩多少坑

纵向滑动效果的本质是:手指在屏幕上移动,触摸事件不断上报竖直方向的位移,滚动容器把位移换算成内容的 scrollY 偏移,屏幕按偏移重绘。真正难得不是这个机制,而是不同容器在测量、复用和嵌套滚动上的差异。我接到“页面要上下滑”的需求,永远先问三个问题:数据有多少条?页面是单一列表还是多个模块拼成?会不会套在别的滚动容器里?答案直接指向下面三种方案中的一种。

2.1 ScrollView:适合固定内容页,但全量加载是硬伤

ScrollView 本质上是一个竖直方向的 FrameLayout,当子 View 的高度超过屏幕时,它会通过 scrollTo / scrollBy 移动可视窗口。因为使用门槛低,很多初学者把它当成页面根布局只能放 LinearLayout 的替代品,看到内容超出屏幕就包一层 ScrollView,这习惯在内容固定时没问题。

但 ScrollView 没有复用机制。布局阶段,内部所有子 View 都会一次性 inflate、measure 和 layout,哪怕屏幕只显示 8 个 item,100 个 item 也全部创建出来。数据量一大,首屏测量时间成倍增长;滑动时主线程还要处理整棵 View 树的重新布局,卡顿几乎必然发生。

所以我对 ScrollView 的使用边界是:内容固定、总量在几十项以内且没有重复的列表结构。典型场景是用户协议、活动说明、个人资料编辑页。它适合装 TextView、ImageView、EditText 这类静态控件,不适合装重复 item。这个判断很重要,因为 ScrollView 换 RecyclerView 不是简单的容器替换,而是整套数据结构要往 Adapter 迁移,选错方案后再改成本很高。

另一个容易被忽略的点是,ScrollView 默认不支持嵌套滚动。如果外层还有一个父级滚动容器,子 ScrollView 滚到边界后父容器不会自动接管,需要自己处理触摸事件。这就是为什么 NestedScrollView 在混合布局里更受欢迎。

2.2 RecyclerView:长列表的正解,但 LayoutManager 决定了纵向滑动方式

RecyclerView 解决的核心问题是 View 复用,以及把“布局”这件事交给 LayoutManager。它不会一次性创建所有 item,而是按可见区域创建 ViewHolder,滑出屏幕的 item 被回收后直接给新进入屏幕的 item 复用。数据量几十条到上千条,RecyclerView 都能维持稳定帧率,这是它成为信息流首选的根本原因。

实现纵向滑动效果,关键在于 LinearLayoutManager 的方向。LinearLayoutManager.VERTICAL 表示内容从上往下排、手指上下滑动时列表上下滚动;LinearLayoutManager.HORIZONTAL 则是左右滑动。很多初始化代码忘了 setLayoutManager,直接 setAdapter,运行时报错“No layout manager attached”。这个报错信息很直白,但每周都有新手踩一次。

LayoutManager 家族里还有 GridLayoutManager 和 StaggeredGridLayoutManager,它们同样支持纵向方向,但影响的是 item 的排列方式,不影响“上下滑动”这件事。很多人误以为换成 GridLayoutManager 列表就变横向滑动了,其实滑动方向仍然由 orientation 参数决定。如果只是普通上下滑列表,LinearLayoutManager 就够用。

RecyclerView 的选型理由还有一层:它天然实现了 NestedScrollingChild 接口。开启嵌套滚动后,子列表可以把手势滚动的“剩余距离”交给父容器消费,这为混合布局提供了可能。但它默认的嵌套行为也是双刃剑,如果父容器没有实现 NestedScrollingParent,内层会觉得“没人接收剩余距离”,滚动效果变得很奇怪。套进 NestedScrollView 时,必须记得把内层的嵌套滚动手动关掉。

就我的习惯而言,凡是数据来自接口、数量不固定、后期只增不减的列表,一律用 RecyclerView。头部和底部不需要额外容器,用 Adapter 的多 ViewType 就能解决。把整个页面塞进 ScrollView 再往里面装一个 RecyclerView,是最不推荐的组合:ScrollView 全量测量子 View 的同时,RecyclerView 无法准确感知滚动边界,列表重用机制也被破坏。

2.3 NestedScrollView + 嵌套滚动机制:混合布局的首选

真实项目里的纵向滑动页面大多不是单一列表,而是首页那种结构:顶部轮播、中间分类入口、下面商品流、再下面是为你推荐。全部塞进 RecyclerView 的多个 ViewType 里不是不行,但 adapter 会越来越臃肿,一个页面几十种 item,每加一种模块都要改 getItemViewType,维护成本很高。

常见做法是外层用 NestedScrollView,里面按业务模块放几个子布局,异步加载的商品模块用 RecyclerView,同时关掉它的嵌套滚动。这样整个页面被当成一张长图,手指上下滑动时,NestedScrollView 统一消费所有位移,内层列表只是“看起来可滚动”的展示模块,实际不会自己消费事件。

NestedScrollView 与 ScrollView 最大的区别在于它实现了 NestedScrollingParent 接口。嵌套滚动的流程是:子 View 先消费一部分滚动距离,没消费完的余量交给父容器;父容器可以继续消费,也可以反弹回子 View。这个“先子后父再回传”的协商机制,让多个滚动容器可以同时响应同一次手势,而不是互相抢占事件。

但嵌套滚动有代价。NestedScrollView 必须知道所有子 View 的准确高度,才能计算自身的总高度。内部 RecyclerView 因此会被强制测量完整内容,Item 数量越大,测量和缓存的开销越高。我的边界是:混合模块总共 3 到 5 个、内层列表数量在几百条以内时用 NestedScrollView;一旦某一列数据过千,我会反过来把页面整体改成单 RecyclerView 多 ViewType,性能会好得多。还有一个细节:把 RecyclerView 放进 NestedScrollView 时,RecyclerView 的高度必须设置成 wrap_content,否则它只会占一行高度,后面的模块会被挤到屏幕外。

三种方案放在一起对比会更直观:

容器数据量边界View 复用嵌套滚动最适合的场景
ScrollView几十项以内无默认不支持静态内容页、协议页
RecyclerView + LinearLayoutManager几百到上千项稳定有作为 Child 参与商品流、消息流
NestedScrollView总高度可控内部模块各自决定作为 Parent 参与混合首页、详情页

补充一句:不要试图用“ScrollView 包 RecyclerView”省事,那是两种缺点的叠加。ScrollView 会全量测量内部所有 View,RecyclerView 在非嵌套父容器里边界判断也会失真,最终得到的是一个又卡又难维护的页面。三者之间,ScrollView 负责静态页,RecyclerView 负责数据列表,NestedScrollView 负责混合页,分工明确。

3. 用 RecyclerView 实现纵向滑动页面的最小可运行工程:从布局到 Adapter

不管你最终选哪种容器,先把 RecyclerView 的最小工程跑通,后续调手感、查嵌套才会有据可依。以下代码是我每次新开列表页都会先搭的骨架,在 Android 新建空工程里直接替换相关文件就能运行。这套代码的核心只有三件事:布局里放一个 RecyclerView,代码里给它设置 LayoutManager 和 Adapter,然后监听滚动事件做分页。

3.1 布局文件与初始化:三行关键代码让页面上下滑动

先写布局。activity_main.xml:

<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/recycler_view" android:layout_width="match_parent" android:layout_height="match_parent" android:clipToPadding="false" android:paddingBottom="16dp" /> </FrameLayout>

RecyclerView 直接用 match_parent 撑满页面。这里的 paddingBottom 是给列表底部留出的空间,clipToPadding 设为 false 表示内容可以滑入 padding 区域。很多列表最后一条被底部导航栏或悬浮按钮遮住,多半就是没配合这两项:padding 生效了,但 clipToPadding 默认 true,内容滚到 padding 区域会被裁掉。

MainActivity.kt 里做初始化:

class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val recyclerView = findViewById<RecyclerView>(R.id.recycler_view) // 关键点 1:必须设置 LayoutManager,方向为 VERTICAL 才是上下滑动 recyclerView.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) // 关键点 2:数据通过 Adapter 交给 RecyclerView recyclerView.adapter = SimpleAdapter(generateData()) } private fun generateData(): List<String> { return (0 until 50).map { "第 $it 条数据" } } }

两行关键设置:layoutManager 和 adapter。忘记设 layoutManager 时,RecyclerView 运行时直接抛异常“No layout manager attached”。第三个参数传 false 表示列表从顶部往下排,如果传 true,列表会从底部开始排列,行为类似聊天记录,普通信息流必须用 false。LayoutManager 的方向参数是纵向滑动的总开关,这里错了后面全白搭。

3.2 Adapter 与 ViewHolder:列表项如何被复用和绑定

接下来是 Adapter。item_text.xml 先定义一个最简单的列表项:

<?xml version="1.0" encoding="utf-8"?> <TextView xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/tv_title" android:layout_width="match_parent" android:layout_height="60dp" android:gravity="center_vertical" android:paddingHorizontal="16dp" android:textSize="15sp" />

列表项高度固定为 60dp。固定高度有两个直接好处:RecyclerView 可以快速判断 item 是否进入或离开屏幕;布局阶段不需要二次测量。如果高度是 wrap_content,TextView 内容一旦变化,整行重新参与测量,列表项一多,累计出来的成本会让滑动明显变重。

SimpleAdapter 的完整写法:

class SimpleAdapter(private val items: List<String>) : RecyclerView.Adapter<SimpleAdapter.VH>() { class VH(itemView: View) : RecyclerView.ViewHolder(itemView) { val tvTitle: TextView = itemView.findViewById(R.id.tv_title) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { val view = LayoutInflater.from(parent.context) .inflate(R.layout.item_text, parent, false) return VH(view) } override fun onBindViewHolder(holder: VH, position: Int) { holder.tvTitle.text = items[position] } override fun getItemCount(): Int = items.size }

onCreateViewHolder 只在需要新的 ViewHolder 时调用;onBindViewHolder 把 position 对应的数据绑定到 View 上。第一屏 8 到 10 个 item 只会创建 8 到 10 个 ViewHolder,滑出屏幕的 item 被回收,滑回旧数据时直接复用,不再重新 inflate。这就是纵向滑动页面不卡的核心原因:创建 View 的成本被复用机制摊薄了。

如果想再进一步,把 Adapter 换成 ListAdapter:

class SimpleListAdapter : ListAdapter<String, SimpleListAdapter.VH>( object : DiffUtil.ItemCallback<String>() { override fun areItemsTheSame(oldItem: String, newItem: String): Boolean = oldItem == newItem override fun areContentsTheSame(oldItem: String, newItem: String): Boolean = oldItem == newItem } ) { // onCreateViewHolder / onBindViewHolder 与 SimpleAdapter 一致 // 差别是 onBindViewHolder 里改用 getItem(position) 取数据 }

ListAdapter 的优势在数据刷新时体现:notifyDataSetChanged 会重建所有可见行,而 ListAdapter 通过 DiffUtil 比较新旧差异,只更新真正变化的行。数据源来自接口刷新时,这个差别直接影响上下滑动过程中是否跳屏。

3.3 滑动到底自动加载更多:OnScrollListener 的常规写法

纵向滑动页面的常见交互是滑到底部加载下一页。下面这段监听是我项目里一直沿用的模板:

recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { super.onScrolled(recyclerView, dx, dy) val layoutManager = recyclerView.layoutManager as LinearLayoutManager // 最后一个可见 item 的 position,注意 position 从 0 开始 val lastVisible = layoutManager.findLastVisibleItemPosition() // 当前 Adapter 里的总条数 val totalCount = layoutManager.itemCount // 剩余不足 3 条时就触发加载,threshold 一般取 2~5 if (totalCount - lastVisible <= 3) { loadMore() } } })

两个方法容易混淆。findLastVisibleItemPosition 返回的 position 从 0 开始,屏幕上只露出 1px 也算可见;如果希望“完全可见才触发”,用 findLastCompletelyVisibleItemPosition。threshold 设成 3,表示距离底部还剩 3 条时就开始请求,用来抵消网络延迟。但 onScrolled 每一帧都会回调,所以 loadMore 里必须做防抖,否则一次滑动会重复触发好几次分页请求:

private var isLoading = false private fun loadMore() { if (isLoading) return isLoading = true // 发起网络请求,回调成功或失败后置回 false viewModel.loadNextPage() }

还有一个更稳的做法:在 Adapter 里加 footer 的 ViewType,加载中显示“加载中”,加载完成移除 footer。这样 onScrolled 判断的是 footer 是否进入屏幕,而不是把数据条数当作滚动边界,避免误判用户真的滚到了底部。这个结构在数据条数不规则时比纯数字判断靠谱得多。

4. 把上下滑动效果调到顺手:惯性、阻尼、回弹与滚动监听的参数设置

滚动效果“顺不顺手”是很主观的事,但代码层面值得调的就几个点:滑动状态监听、惯性速度、边缘回弹。下面按我的调试顺序展开。先把监听逻辑做对,再调速度,最后处理边界,这样每一步都能立刻看出效果差异。

4.1 滚动监听:OnScrollListener 与 OnTouchListener 怎么配合

OnScrollListener 有两个回调:onScrollStateChanged 与 onScrolled。前者在滚动状态切换时触发一次,后者在每一帧滚动位置变化时触发。滚动状态有三种:SCROLL_STATE_IDLE 停止、SCROLL_STATE_DRAGGING 手指按住拖动、SCROLL_STATE_SETTLING 松手后惯性滚动。

埋点、收起标题栏这类操作要放在 onScrollStateChanged 里,并且判断 IDLE 状态:

recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { if (newState == RecyclerView.SCROLL_STATE_IDLE) { // 滚动完全停止,适合做埋点、校准位置 val pos = (recyclerView.layoutManager as LinearLayoutManager) .findFirstVisibleItemPosition() reportPosition(pos) } } })

注意 IDLE 不仅包含惯性停止,还包含用户按住不动。如果要做“惯性滑完才上报”,需要自己记录上一次状态:从 SETTLING 转入 IDLE 才算真正滑完。

onScrolled 是高频回调,每一帧都来,不要在里做任何复杂操作。我见过同事在 onScrolled 里 findViewById、甚至触发整张图片加载,结果列表滑动掉帧掉到 30 以下。滑动过程中的 UI 变化只应该用 translationY、alpha 这类轻量属性,重逻辑扔到 onScrollStateChanged 或等滚动停止再做。

OnTouchListener 和 OnScrollListener 的关系也常被搞混。RecyclerView 的 onTouchEvent 会在手指移动时自己处理滚动;OnTouchListener 如果返回 true,等于拦截掉全部触摸事件,RecyclerView 就滑不动了。所以日常只应该用它做“观察”,不消费事件。真想干预手势,得覆写 RecyclerView 的 onInterceptTouchEvent。

4.2 控制惯性滑动:fling 速度与 RecyclerView 的调速

手指快速一滑后松开,页面继续飞出一段距离,这段惯性由 RecyclerView 内部的 OverScroller 计算。减速曲线由平台内置,不同系统版本略有差异,没有暴露“阻尼系数”这种参数。但有一个入口能调:fling(velocityX, velocityY)。它在 GestureDetector 计算完手指离开时的速度后被调用,我们覆写它,把速度钳制在合理范围内:

class LimitedFlingRecyclerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : RecyclerView(context, attrs, defStyleAttr) { override fun fling(velocityX: Int, velocityY: Int): Boolean { // 按屏幕密度换算最大速度:density=3 时约 18000 px/s val maxVelocity = (6000f * resources.displayMetrics.density).toInt() val cappedY = velocityY.coerceIn(-maxVelocity, maxVelocity) return super.fling(velocityX, cappedY) } }

这里有个关键点:fling 的入参来自 VelocityTracker 的 computeCurrentVelocity,单位是 px/s。同一根手指在不同屏幕密度上滑同样的距离,px/s 数值完全不同,所以必须按 density 换算。6000 乘以密度,在 3x 屏上是 18000 px/s,对应中等偏快的滑动;想要更平淡的手感可以降到 4000,做长图文阅读场景通常不建议超过 7000。

另一个常见诉求是“更快停下来”。覆写 fling 时把传入速度整体缩小即可,但注意 OverScroller 的减速是曲线变化,不是线性的,速度缩小一半不等于距离缩小一半。想精确控制停在哪,需要把速度换算成距离,这放到第 6 章专门说。

4.3 边缘回弹与越界拖拽:overScrollMode 和自定义回弹

默认情况下,RecyclerView 滚到顶部或底部继续拖,会出现边缘光晕效果(glow)。这个效果在 Android 2.3 之后默认开启,由 overScrollMode 控制:

<androidx.recyclerview.widget.RecyclerView android:id="@+id/recycler_view" android:layout_width="match_parent" android:layout_height="match_parent" android:overScrollMode="never" />

设成 never 就去掉了光晕。如果想要 iOS 式回弹,即拖过头再松手弹回去,需要自己处理。我一般用覆写 onTouchEvent 的方式:到顶后手指继续下拉,把多余的位移累积成一个 offset,作用到 translationY;松手时再复位。核心骨架如下:

class SpringRecyclerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : RecyclerView(context, attrs) { private var extraOffset = 0f private var lastRawY = 0f private val maxOverScroll = 100f * resources.displayMetrics.density override fun onTouchEvent(e: MotionEvent): Boolean { when (e.actionMasked) { MotionEvent.ACTION_DOWN -> { lastRawY = e.rawY extraOffset = 0f } MotionEvent.ACTION_MOVE -> { val dy = e.rawY - lastRawY lastRawY = e.rawY // 列表到顶且手指继续下拉时,累积偏移并作用到 View 本身 if (!canScrollVertically(-1) && dy > 0) { extraOffset = (extraOffset + dy * 0.5f).coerceAtMost(maxOverScroll) translationY = extraOffset } } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { // 示意代码直接复位,实际项目用 ValueAnimator 平滑回弹 translationY = 0f extraOffset = 0f } } return super.onTouchEvent(e) } }

代码里把偏移压缩了 0.5 倍,这是回弹手感的关键。直接 1 倍位移会让页面显得“粘手”,0.5 倍更软。这个系数属于玄学范畴,有人喜欢 0.3,有人喜欢 0.6,建议做成构造参数,拿真机一个个试。同时记得把 overScrollMode 设为 never,否则回弹和默认光晕会叠加,视觉上很脏。这个方案只是最简骨架,真实项目一定不要直接 translationY = 0 复位,没有动画的回弹会显得很生硬。

5. 纵向滑动页面的避坑指南:嵌套滚动冲突与滑动卡顿的排查实录

滚动需求做多了之后,最花时间的不是写代码,而是排查“为什么又滑不动”。下面 4 个坑我在多个项目里反复遇到,每一条都按翻车现场、原因、解决办法来讲。前两个是手势冲突,第三个是刷新位置错乱,第四个是性能,基本覆盖了纵向滑动页面的绝大多数线上问题。

5.1 坑一:RecyclerView 嵌套 RecyclerView 时内层滑不动

现象:外层是一个竖向滑动页,每个 item 里又放了一个竖向的小 RecyclerView。真机上手指在 item 里上下滑动,内层列表纹丝不动,整个页面却跟着滚走了。用户会认为这个模块坏了,尽管数据都在。

原因:两个同为 VERTICAL 的 RecyclerView 构成嵌套滚动链。默认情况下,内层会先尝试处理滚动事件,但外层作为 NestedScrollingParent,会在事件分发时先接管拦截权,内层根本没机会消费。尤其当内层多个 item 时,外层会把这些事件统一当成“整个页面滚动”。

解决:如果内层不需要独立滑动,比如它只是展示一排分类标签,直接把内层嵌套滚动关掉:

innerRecyclerView.isNestedScrollingEnabled = false

关掉后内层不再消费任何垂直滚动事件,它只是一个高度为 wrap_content 的“长条形展示模块”,随外层同步滚动。如果内层必须独立滑动,不要用同向 RecyclerView 套。常见改法是把内层做成横向滑动,或者在触摸事件里按滑动角度做方向判断。最省心的方案是把外层换成 NestedScrollView,然后同样把内层嵌套滚动关掉。

5.2 坑二:ViewPager2 与纵向滑动抢手势

现象:一个上下滑动的长页面嵌在 ViewPager2 的某个 Fragment 里。左右翻页正常,但用户上下滑列表时,偶尔会被 ViewPager2 抢走,页面突然左右偏移一下,或者上下滑动被吞掉半拍。

原因:ViewPager2 内部是一个横向的 RecyclerView,理论上和纵向滚动方向不同不会冲突。但用户手指轨迹不可能完全垂直,总会有几像素横向偏移。ViewPager2 的 onInterceptTouchEvent 结合 touchSlop 判定方向,当竖向位移还没超过阈值、横向位移先达到了,事件就被它拦截。

解决:给列表 item 的根布局设置 requestDisallowInterceptTouchEvent(true),并且必须在 ACTION_DOWN 里设置,ACTION_UP 后要重置:

itemView.setOnTouchListener { _, event -> when (event.actionMasked) { MotionEvent.ACTION_DOWN -> parent.requestDisallowInterceptTouchEvent(true) MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> parent.requestDisallowInterceptTouchEvent(false) } false }

这个写法只在“手指已落在列表上”的场景生效,不会影响 ViewPager2 在页面空白区域的左右翻页。需要注意每次 ACTION_DOWN 都要重新申请,否则只生效一次。另一种更彻底的方案是自定义 ViewPager2,在 onInterceptTouchEvent 里判断水平位移是否大于垂直位移,只有水平占优才拦截。

5.3 坑三:notifyDataSetChanged 后列表跳动,滑动位置错乱

现象:用户滑到第 30 条时下拉刷新,数据回来后列表瞬间回到第一条,或者停留在第 12 条。这个现象在老项目里很常见,尤其是那些用 MutableList 直接 addAll 然后 notifyDataSetChanged 的写法。

原因:notifyDataSetChanged 通知 Adapter“全部数据都可能变了”,RecyclerView 从头测量、布局并重新绑定可见区域。它没有能力保持用户位置,因为 DiffUtil 没参与计算,它根本不知道哪些 item 需要保留。结果就是从 position 0 开始恢复。

解决:优先把 Adapter 换成 ListAdapter。DiffUtil 在后台线程计算新旧列表差异,提交后只更新变化的 item,位置保持是自动的。如果暂时不能引入 ListAdapter,需要手动恢复位置:

val lm = recyclerView.layoutManager as LinearLayoutManager val position = lm.findFirstVisibleItemPosition() adapter.submitList(newList) { lm.scrollToPosition(position) }

用 ListAdapter 的 submitList 回调来恢复位置,比直接 post 更可靠。submitList 的 callback 在 diff 计算完成并应用之后执行,此时列表已经 ready,scrollToPosition 才有意义。注意这个回调只保证提交完成,不保证滑动动画结束,如果要做平滑滚动到恢复位置,改用 smoothScrollToPosition 并判断是否需要。

5.4 坑四:滑动丢帧,因为 onScrolled 里做了太重的事

现象:列表滑动时肉眼可见卡顿,打开 Profile GPU Rendering 看柱状图,每一帧都超出基准线一大截,掉帧严重时列表像在“一格一格跳”。

原因:RecyclerView 的滚动动画依赖垂直同步信号逐帧回调,onScrolled 是其中一环。如果在里面 decode 一张 2MB 的图片、调用刷新框架的 finishRefresh、或者打日志,主线程单帧耗时直接超过 16ms。滑得越快,回调越频繁,卡顿越明显。另一个隐形雷是 item 布局嵌套层级过深、权重过多,导致每次复用 ViewHolder 时重新测量成本高。

解决:我排这种问题按顺序看三处。第一,onScrolled 里有没有重操作,有就清空,只留轻量赋值;第二,item 根布局是不是用了过多的 LinearLayout 嵌套和 layout_weight,改成 ConstraintLayout 或固定高度的 FrameLayout;第三,图片是否同步加载,统一走异步加载库并做采样缩略图,不要在主线程 decode。第三点是最常见的掉帧来源,图片列表尤其严重。

这些坑并不难避免,难的是在项目一开始就按“数据量、嵌套关系、滑动频率”做设计。嵌套冲突和卡顿一旦上线,再改结构会牵扯到很多页面逻辑,返工成本很高。

6. 一个纵向滑动页面的进阶技巧:把 fling 速度变成滚动距离的节流阀

第 4 章解决的是“滑太快”,这一节解决“停不准”。有一类需求是用户快速划过列表时,页面希望在某个合理位置停下,而不是被惯性带出去一屏多。比如商品列表里用户想停在某个分类附近,手指一滑,页面直接飞出两屏,体验很怪。技巧是覆写 fling,先用 OverScroller 估算初速度对应的最终距离,再决定是否节流配额:

class SnapRecyclerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : RecyclerView(context, attrs) { override fun fling(velocityX: Int, velocityY: Int): Boolean { // 用系统的 OverScroller 先算:这个初速度最终会滚多远 val scroller = OverScroller(context) scroller.fling(0, 0, 0, velocityY, 0, 0, 0, Int.MAX_VALUE) val finalY = scroller.finalY // 估算距离不到半屏:速度归零,让列表在当前位置立刻停住 if (finalY < height / 2) { return super.fling(velocityX, 0) } // 超过半屏:正常惯性滚动,但继续限制初速度防飘 val maxVelocity = (6000f * resources.displayMetrics.density).toInt() val cappedY = velocityY.coerceIn(-maxVelocity, maxVelocity) return super.fling(velocityX, cappedY) } }

这里的关键是 OverScroller.fling 与 RecyclerView 内部的 OverScroller 使用同一套减速模型,所以用 finalY 估算是可靠的。height / 2 作为“短滑即停”的阈值,可以按需求调整:列表项高时可以放大到 height 的八成,列表项矮时缩小到三分之一。如果估算距离没过半屏,说明用户只是轻扫一下,这种情况下立刻停住比飘一段更跟手。

顺带一提,这里的 minY 和 maxY 参数我填了 0 和 Int.MAX_VALUE,实际项目里应该根据 LayoutManager 的内容高度范围传入,避免估算结果严重超过实际可滚动距离。短滑即停和速度限制可以同时生效,这也是“节流阀”的完整含义:短距离不浪费惯性,长距离不让它失控。

做信息流这类滚动高频场景时,我一般把 fling 节流和 item 层级优化放在同一轮迭代里,前者解决“停不准”,后者解决“滑不动”,是投入产出比最高的两个方向。我现在的习惯是,接到任何页面上下滑需求,先问三件事:数据量多大、有没有嵌套、期望的惯性手感。ScrollView、RecyclerView、NestedScrollView 之间的选择不是看哪个代码少,而是看这三件事的答案,坑就少了一半。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询