尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Android纵向滑动页面实战:三容器选型与避坑指南
简介这是一份面向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 个 item100 个 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 会全量测量内部所有 ViewRecyclerView 在非嵌套父容器里边界判断也会失真最终得到的是一个又卡又难维护的页面。三者之间ScrollView 负责静态页RecyclerView 负责数据列表NestedScrollView 负责混合页分工明确。3. 用 RecyclerView 实现纵向滑动页面的最小可运行工程从布局到 Adapter不管你最终选哪种容器先把 RecyclerView 的最小工程跑通后续调手感、查嵌套才会有据可依。以下代码是我每次新开列表页都会先搭的骨架在 Android 新建空工程里直接替换相关文件就能运行。这套代码的核心只有三件事布局里放一个 RecyclerView代码里给它设置 LayoutManager 和 Adapter然后监听滚动事件做分页。3.1 布局文件与初始化三行关键代码让页面上下滑动先写布局。activity_main.xml?xml version1.0 encodingutf-8? FrameLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent androidx.recyclerview.widget.RecyclerView android:idid/recycler_view android:layout_widthmatch_parent android:layout_heightmatch_parent android:clipToPaddingfalse android:paddingBottom16dp / /FrameLayoutRecyclerView 直接用 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 findViewByIdRecyclerView(R.id.recycler_view) // 关键点 1必须设置 LayoutManager方向为 VERTICAL 才是上下滑动 recyclerView.layoutManager LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) // 关键点 2数据通过 Adapter 交给 RecyclerView recyclerView.adapter SimpleAdapter(generateData()) } private fun generateData(): ListString { 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 version1.0 encodingutf-8? TextView xmlns:androidhttp://schemas.android.com/apk/res/android android:idid/tv_title android:layout_widthmatch_parent android:layout_height60dp android:gravitycenter_vertical android:paddingHorizontal16dp android:textSize15sp /列表项高度固定为 60dp。固定高度有两个直接好处RecyclerView 可以快速判断 item 是否进入或离开屏幕布局阶段不需要二次测量。如果高度是 wrap_contentTextView 内容一旦变化整行重新参与测量列表项一多累计出来的成本会让滑动明显变重。SimpleAdapter 的完整写法class SimpleAdapter(private val items: ListString) : RecyclerView.AdapterSimpleAdapter.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 换成 ListAdapterclass SimpleListAdapter : ListAdapterString, SimpleListAdapter.VH( object : DiffUtil.ItemCallbackString() { 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 { // 按屏幕密度换算最大速度density3 时约 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:idid/recycler_view android:layout_widthmatch_parent android:layout_heightmatch_parent android:overScrollModenever /设成 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 计算完成并应用之后执行此时列表已经 readyscrollToPosition 才有意义。注意这个回调只保证提交完成不保证滑动动画结束如果要做平滑滚动到恢复位置改用 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 之间的选择不是看哪个代码少而是看这三件事的答案坑就少了一半。希望帮到你。本文还有配套的精品资源点击获取
RELATED

相关推荐

低附着路面半挂汽车列车折叠失稳仿真与差动制动控制

低附着路面半挂汽车列车折叠失稳仿真与差动制动控制

我自己真正开始较真“半挂汽车列车横向稳定性控制”这个问题,是因为冬天在服务区门口亲眼看了一场折叠事故。一辆满载的半挂列车在匝道口的冰面上稍微带了点方向,挂车就开始左右甩,车头几乎被拽着转了180度,场面很吓人。从那以后&…

📅 2026/10/13 4:17:14
OMA-VMD自动寻优:告别手动调参的变分模态分解实战

OMA-VMD自动寻优:告别手动调参的变分模态分解实战

变分模态分解(VMD)这个算法,懂的人不少,但真正把它用好的人不多。十次上手有八次都卡在同一个地方:惩罚系数 alpha 和分解模态数 K 到底怎么定。K 拍脑袋给了个 5,alpha 随手填了 2000,结果分解…

📅 2026/10/13 4:17:14
包管理器三剑客:npm、Yarn、pnpm底层原理与工程实践对比

包管理器三剑客:npm、Yarn、pnpm底层原理与工程实践对比

1. 先想清楚一件事:包管理器到底在管什么做前端这些年,几乎每个新项目开工第一步就是npm install,可真正追问一句“包管理器是怎么工作的”,很多人其实答不上来。早年间我也只是把它当“下载依赖的神奇按钮”,直到后来…

📅 2026/10/13 4:17:14
MORE NEWS

更多资讯

📰

STM32 CAN总线实战:从协议原理到配置收发

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

多模态Agent如何规划UI测试路径:从感知融合到动态决策的工程实践

1. 多模态 Agent 做 UI 测试,到底难在哪UI 测试这件事,做过的人都懂——写起来不难,维护起来要命。一个按钮挪了位置、一个弹窗改了文案、一个加载动画多转了半秒,传统基于选择器的自动化脚本就可能集体飘红。而多模态 Agent 的出…

📰

多步任务总在细节崩盘?用技能编排让智能体真正稳定

最初让我意识到"工具调用"和"技能编排"是两码事的,是一次很普通的实测:我在一个demo里给智能体接入了邮件发送、日历查询和文档搜索三个工具,模型只凭从三个工具拿到的零散结果,接连搞砸了一个"帮用户确…

📰

Agent技能系统实战:为大模型装上可插拔的“手”和“脚”

这两年但凡碰过AI应用开发的,应该都绕不开一个词——Agent(智能体)。模型本身只是“脑子”,真正让它干活的,是那一套技能系统,也就是项目标题里说的“agent-skills”。不少团队早期做Agent产品时都有一个共…

📰

AnyPS5:跨平台文件处理与格式转换的轻量化解决方案

1. 项目缘起与核心定位AnyPS5 这个名字第一次出现在我视野里的时候,我正被一堆跨平台文件处理的需求折腾得够呛。团队里有人用 Windows 做设计,有人用 macOS 写代码,还有人习惯在 Linux 环境下跑批处理脚本,每次交付物在三个系统之…

📰

UCINET数据导入实战:格式规范、Excel整理与高频排错

1. 先搞清楚UCINET到底在等什么数据很多人在UCINET上栽的第一个跟头,不是不会跑算法,而是数据根本喂不进去。明明Excel里填得工工整整,一点Import却报错,或者导进去之后变成一团乱麻,节点对不上、值全错位。这些问题的…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬