如果你手里正好有一个这样的需求页面不需要复杂多变的列表逻辑数据也就五六条到十几条每条 item 长什么样还不一样但这个页面必须支持下拉刷新——你会怎么做我遇到这个场景的时候第一反应并不是上 RecyclerView。倒不是不会写而是那点数据量实在没必要为了一个 Adapter 来回倒腾。更关键的是每一条 item 的布局差异都很大硬用 RecyclerView 的多 type 反而把自己绕晕。于是我的选择是SwipeRefreshLayout 包一层 ScrollViewScrollView 里放一个 LinearLayout再通过 addView 把列表项一条一条挂上去整体用 SwipeRefreshLayout 实现下拉刷新。这套组合在 Android 开发里属于比较“复古”的玩法但它简单、可控、代码量少尤其适合轻量页面快速上线。当然简单不等于没坑。这篇内容就是我从一个真实项目里把整个方案的实现思路、源码级触发原理、踩坑记录和优化方式完整整理出来的结果希望能给正打算用同样思路做页面的你省点时间。1. 这种“手工列表”到底在什么场景下值得用先别急着抄代码。你需要先弄清楚一个前提ScrollView LinearLayout addView 这种“手工列表”它的适用边界到底是什么。很多人一看到 ScrollView 就先摇头觉得性能不行、不够现代。这个判断在大多数场景下是对的但有一种场景它反而是最优解。1.1 为什么不用 RecyclerViewRecyclerView 是 Android 列表的绝对主流这一点没有任何疑问。但它的优势是有前提的数据量大、item 结构可复用、需要滚动性能优化。一旦你的场景变成“数据量很小、每条 item 长得完全不一样”RecyclerView 的优势就发挥不出来了反而会带来额外成本。举一个我之前做过的真实需求一个保险产品的详情页从上到下依次是产品介绍卡片、保障责任列表、理赔说明、常见问题、底线免责声明。整个页面一共 9 个区块每个区块的布局完全不同有些区块里面还要嵌套一个小列表。数据量撑死十几条。如果用 RecyclerView 做你要做至少 6 种 ViewType写对应的 ViewHolder、Adapter 里的类型分发逻辑、item 间距处理还得小心嵌套滚动的冲突。一套搞下来几百行代码起步。但用 ScrollView LinearLayout就是一个普通布局文件代码里按顺序 addView 进去就完事了。为了更直观我把两种方案的差异拉一个对比表对比维度RecyclerViewScrollView LinearLayout数据量 20 条以内需要写 Adapter、ViewHolder直接 addView代码量少一半item 类型多且互不相同需要多 ViewType 分发天然支持各写各的布局滚动嵌套需要处理 NestedScrolling普通 View 滚动无冲突页面结构固定需要一个 item 一个 type每个区块独立 View直观性能回收复用适合大数据量全部挂载超过 30 条会吃力快速原型验证搭建成本高半小时出效果所以我的结论是数据量在 20 条以内、item 结构不重复、页面整体是一个长滚动页这三个条件同时满足时ScrollView 方案比 RecyclerView 更合适。反过来只要你的数据可能增长到 50 条以上或者 item 结构可以复用那就老老实实用 RecyclerView别跟性能过不去。1.2 手工挂载子 View 的三种做法ScrollView 里“放列表”实际操作层面有三种常见姿势取决于你的列表项是静态的还是动态的。第一种全部写在 XML 里。适合页面结构完全固定、不需要根据接口数据增删的情况。比如一个“关于我们”页面三个区块永远不变直接在布局里摆好就行。第二种代码里动态 addView。这是我要重点讲的。LinearLayout 作为容器根据接口返回的数据循环创建 item View然后 addView 挂上去。这样接口返回多少条页面就显示多少条。第三种用 NestedScrollView 代替 ScrollView。这是更好的替代方案。NestedScrollView 是 Android 5.0 之后官方推出的增强版 ScrollView专门解决嵌套滑动问题。SwipeRefreshLayout 对它的支持也比对 ScrollView 更平滑。如果你是从零开始写建议直接用 NestedScrollView后面我会解释原因。2. 搭建基础骨架布局、初始化、刷新回调搞清楚了适用场景接下来就是实操环节。这套方案的核心骨架分三层外层 SwipeRefreshLayout 负责下拉刷新中层 ScrollView 负责滚动内层 LinearLayout 作为列表容器。三层各司其职。2.1 布局 XML 的写法与关键属性直接看布局文件?xml version1.0 encodingutf-8? androidx.swiperefreshlayout.widget.SwipeRefreshLayout android:idid/refreshLayout android:layout_widthmatch_parent android:layout_heightmatch_parent ScrollView android:idid/scrollView android:layout_widthmatch_parent android:layout_heightmatch_parent android:fillViewporttrue LinearLayout android:idid/listContainer android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationvertical / /ScrollView /androidx.swiperefreshlayout.widget.SwipeRefreshLayout这里有几个细节我必须强调一下。第一SwipeRefreshLayout 的直接子 View 只能有一个。这是它的硬性限制。如果往里面放两个并列的 View不是报错就是行为异常。所以 ScrollView 必须是 SwipeRefreshLayout 的唯一子 View。第二ScrollView 的 android:fillViewporttrue 一定要加。这个属性的作用是把 ScrollView 的高度撑满整个屏幕。不加的话当内容不足一屏时ScrollView 的高度会收缩成内容的高度SwipeRefreshLayout 的布局高度在视觉上就不完整某些国产 ROM 上甚至会影响下拉手势的响应区域。第三内层 LinearLayout 的高度用 wrap_content。它的职责是包裹所有 item View高度根据内容自适应。这里不能写 match_parent否则会撑满 ScrollView导致 item 下方出现大片空白而且会让“是否滚动到顶部”的判断出现问题。注意如果你还没有引入 androidx.swiperefreshlayout 依赖记得在 build.gradle 里加上implementation androidx.swiperefreshlayout:swiperefreshlayout:1.1.02.2 动态创建 item 并模拟网络加载布局文件搞定之后核心代码就是动态添加 item View。这里我用一个模拟网络请求的例子来说明完整链路。先定义一个简单的数据类data class ItemData( val id: Int, val title: String, val desc: String )然后写一个模拟网络请求的方法。这里用 Handler 延迟 1.5 秒模拟网络延迟实际项目中替换成你的 Retrofit/RxJava 请求即可private fun fetchData(callback: (ListItemData) - Unit) { Handler(Looper.getMainLooper()).postDelayed({ val data mutableListOfItemData() for (i in 1..10) { data.add(ItemData(i, 标题 $i, 这是第 $i 条数据的描述内容)) } callback(data) }, 1500L) }接下来是需要重点关注的 addView 环节。很多人在这里掉坑动态创建的 item View 如果不手动指定 LayoutParams默认按 wrap_content 处理一个两个看不出来多了就会有问题。正确姿势是创建 item View 后显式设置 LayoutParamsprivate fun createItemView(data: ItemData): View { val itemLayout LinearLayout(context).apply { orientation LinearLayout.VERTICAL setPadding(dp2px(16f), dp2px(12f), dp2px(16f), dp2px(12f)) } val titleView TextView(context).apply { text data.title textSize 16f setTextColor(Color.parseColor(#333333)) } val descView TextView(context).apply { text data.desc textSize 13f setTextColor(Color.parseColor(#888888)) setPadding(0, dp2px(4f), 0, 0) } itemLayout.addView(titleView) itemLayout.addView(descView) val params LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ) params.setMargins(0, 0, 0, dp2px(8f)) itemLayout.layoutParams params return itemLayout }这里我把 item 的宽度固定为 MATCH_PARENT高度 wrap_content并给每个 item 设置底部间距避免密密麻麻贴在一起。实际项目中你完全可以把 item 做成一个独立的 XML 布局用 LayoutInflater 加载这样代码会更好维护private fun createItemView(data: ItemData): View { val itemView LayoutInflater.from(context) .inflate(R.layout.item_list, listContainer, false) itemView.findViewByIdTextView(R.id.tv_title).text data.title itemView.findViewByIdTextView(R.id.tv_desc).text data.desc return itemView }注意这里 inflate 的第三个参数传的是listContainer而不是null。传 parent 进去可以让 LayoutInflater 按照父容器的布局规则生成正确的 LayoutParams这是很多新手容易忽略的细节。2.3 完整刷新流程从 onRefresh 到页面重建数据加载和 item 创建都备好了接下来把它们串成一个完整的刷新流程。SwipeRefreshLayout 的下拉刷新回调是setOnRefreshListener在里面触发数据加载数据回来之后再重建列表、收起刷新动画class MainActivity : AppCompatActivity() { private lateinit var refreshLayout: SwipeRefreshLayout private lateinit var scrollView: ScrollView private lateinit var listContainer: LinearLayout override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) refreshLayout findViewById(R.id.refreshLayout) scrollView findViewById(R.id.scrollView) listContainer findViewById(R.id.listContainer) refreshLayout.setOnRefreshListener { loadData() } // 首次进入页面自动触发一次加载 refreshLayout.isRefreshing true loadData() } private fun loadData() { fetchData { result - // 1. 清空旧的 item listContainer.removeAllViews() // 2. 重新添加新的 item result.forEach { item - listContainer.addView(createItemView(item)) } // 3. 数据渲染完成后再收起刷新动画 refreshLayout.isRefreshing false } } }这套流程看起来简单但有几个执行顺序的讲究。removeAllViews 必须在 addView 之前不然每次刷新都会在旧的 item 后面追加新的 item页面内容会无限增长。isRefreshing false 必须放在数据渲染完成之后不能放在 fetchData 返回之前。很多人习惯把 isRefreshingfalse 放在网络请求的 finally 块里结果数据还没渲染完转圈先停了页面闪一下新的内容才开始渲染视觉上很难受。setRefreshing(false) 还必须在主线程调用。如果你的网络请求回调是在子线程直接调用会触发 CalledFromWrongThreadException。上面我用 Handler(Looper.getMainLooper()) 模拟请求回调天然在主线程。实际项目中如果你用的是 Retrofit RxJava记得在 AndroidSchedulers.mainThread() 中订阅或者用协程的 Dispatchers.Main 确保回到主线程再操作 UI。到这里一个基本能跑的下拉刷新列表就已经完成了。但事情没这么简单。我在真实项目里把这段代码跑起来之后遇到了一个让人挠头的问题在某些情况下下拉刷新就是不出转圈动画。这就要从 SwipeRefreshLayout 的触发原理说起了。3. 下拉刷新触发的关键细节为什么转圈不出来SwipeRefreshLayout 作为一个成熟的组件它的触发机制是有明确逻辑的。搞清楚这个逻辑你才能理解为什么有时下拉没反应以及怎么去排查。3.1 触发条件只有滚动容器在顶部才能触发SwipeRefreshLayout 处理触摸事件时会先判断当前子 View 是否处于可滚动区域的顶部。只有在顶部它才会接管触摸事件让下拉手势变成刷新动画。如果子 View 不在顶部比如你正在列表中间的位置下拉手势会被子 View 消费掉表现为子 View 继续向上滚动而不是触发刷新。这个判断为什么这么设计因为下拉刷新的交互约定是在列表顶部继续下拉才表示“我要重新加载数据”。如果你在列表中间下拉也触发刷新那用户想往上滚动的时候就会误触刷新体验会非常糟糕。所以当你发现下拉刷新不出圈时第一反应应该是我的滚动容器到底在不在顶部3.2 ScrollView 的“顶部”判定getScrollY 的边界情况SwipeRefreshLayout 内部通过canChildScrollUp()方法判断子 View 是否还能向上滚动。如果不能向上滚动了说明已经到达顶部此时才允许下拉。对于 ScrollView这个判断最终落到ViewCompat.canScrollVertically(target, -1)上。方向参数 -1 表示“向上滚动”。如果返回 false说明 ScrollView 再也无法向上滚动了即处于顶部。这里有一个关键细节当 ScrollView 的内容不足一屏时getScrollY 始终为 0canScrollVertically 始终返回 false。这意味着两个结论第一内容不足一屏时理论上更容易触发下拉刷新因为整个 ScrollView 永远处于“顶部状态”。第二如果内容超过一屏你必须先把 ScrollView 滚回顶部然后才能下拉。这是符合预期的行为。但我实际测试中发现一个问题当 ScrollView 的内容刚好等于或略大于一屏高度时下拉刷新会出现一点“顿挫感”。手指往下拖的时候SwipeRefreshLayout 的转圈没有第一时间跟随手指出现而是要等 ScrollView 先滚回顶部才突然弹出来。这是因为 ScrollView 自身先消费了 move 事件直到它滚到顶部之后SwipeRefreshLayout 才拦截到事件。这个问题在 NestedScrollView 上会好很多因为 NestedScrollView 实现了嵌套滚动机制SwipeRefreshLayout 能在滚动事件分发阶段更早地感知到“子 View 已经到顶了”。3.3 从源码看 onInterceptTouchEvent 和 canChildScrollUp为了加深理解直接看一下 SwipeRefreshLayout 的核心源码逻辑。它的触摸判断主要在这几个方法里Override public boolean onInterceptTouchEvent(MotionEvent ev) { // ... 部分代码省略 switch (action) { case MotionEvent.ACTION_DOWN: // 记录手指按下时是否在顶部 mInitialDownY ev.getY(); mInitialMotionY mInitialDownY; mActivePointerId ev.getPointerId(0); mIsBeingDragged false; mCurrRatio 0; if (mInitialDownY - mInitialDownY 0) { // 简化示意 return false; } break; case MotionEvent.ACTION_MOVE: // 关键判断是否处于可以刷新的位置 if (!canChildScrollUp() !mIsBeingDragged) { // 子 View 不能向上滚动已到顶部 // 且手指正在向下滑动 if (ev.getY() - mInitialMotionY mTouchSlop) { mIsBeingDragged true; parent.requestDisallowInterceptTouchEvent(true); } } break; } return mIsBeingDragged; }而核心方法 canChildScrollUpprivate boolean canChildScrollUp() { if (mTarget null) { return false; } return ViewCompat.canScrollVertically(mTarget, -1); }关键就是最后这行。canScrollVertically(mTarget, -1)内部进一步调用View.canScrollVertically(direction)对于 ScrollView 来说这行判断最终返回的是getScrollY() 0简化后的理解。当你理解了这层机制排查“为什么下拉不触发”就有了明确的思路先确认子 View 是否在顶部再确认事件有没有被子 View 消费再确认 onInterceptTouchEvent 是否进入了下拉分支。我下面要讲的几个真实踩坑案例都是围绕着这几个环节展开的。4. 实测中容易踩的坑与调试链路理论说完了进入正题。这一节是我在实际项目中踩过的坑的完整记录每条都付出了真金白银的时间成本。我把排查过程和最终结论都写出来你可以直接对照排查。4.1 内容不满一屏时下拉无反应的排查过程这是最诡异的一个问题。我的页面当时只有三条数据内容远远不满一屏。按照我刚才的分析内容不足一屏时 getScrollY 恒为 0应该一直处于“可下拉”的状态。但实测结果是手指从列表区域往下拖SwipeRefreshLayout 没有反应从 ScrollView 之外的空白区域往下拖倒可以触发刷新。这个问题我排查了快两个小时过程记录下来。第一步检查 XML 结构。SwipeRefreshLayout 只包了一个 ScrollViewScrollView 里只放了一个 LinearLayout结构没问题。第二步在 SwipeRefreshLayout 的 onInterceptTouchEvent 里打日志发现 ACTION_DOWN 事件每次都走到了canChildScrollUp()的判断。于是我又在canChildScrollUp()前后打印了返回值发现返回的是 false也就是说判断结果确实是“可以下拉”。那为什么手指在列表区域拖动时没有触发第三步检查 OnTouchListener。我没有给 ScrollView 设置任何 OnTouchListener排除拦截嫌疑。第四步把注意力放在 ScrollView 本身。这时候我突然意识到一个问题ScrollView 里的子 View 高度被设置成了 match_parent。我加载 item 的根布局使用的是LayoutInflater.from(context).inflate(R.layout.item_list, listContainer, false)但 item 的根布局 XML 里被我写了一个占满全屏的 FrameLayoutFrameLayout 里的内容并没有把高度撑起来导致 LinearLayout 容器被 FrameLayout 撑成了全屏高度。这一步带来的连锁反应是ScrollView 认为自己的内容区域高度等于屏幕高度虽然视觉上内容只有三条 item但布局层面积已经是全屏了。在某些系统版本的 ViewCompat 实现里这个状态可能导致 canChildScrollUp 的判定出现边界偏差加上 ScrollView 自身的滚动逻辑又认为“没有内容可滚”事件处理上出现了矛盾。最终解决方案是把 item 根布局的高度从 match_parent 改成 wrap_content同时在 inflate 时明确指定 LayoutParams。改完之后下拉刷新立刻恢复正常从列表区域任意位置下拉都稳稳触发。4.2 动态添加子 View 后 item 高度和宽度异常的坑另一个常见的坑是动态 addView 进去的 item 显示出来宽度不对或者高度塌陷。我之前遇到过的情况是item 里有个 ImageView 加载一张网络图片图片加载完成后ImageView 的实际高度和我在代码里声明的 wrap_content 不一致导致 item 布局被拉伸页面出现一大块空白。排查过程比较曲折最终定位到两个原因。第一个原因ImageView 没有设置 scaleType 和 adjustViewBounds。图片加载进来后默认按原始尺寸绘制把 item 撑得很大。解决办法是在 XML 里设置android:scaleTypecenterCrop和android:adjustViewBoundstrue或者直接给 ImageView 写死宽高。第二个原因addView 时没有传 LayoutParams。如果你直接在代码里 new 一个 View 然后 addView 进去系统会给你一个默认 LayoutParams宽度和高度都是 wrap_content。问题是这个 wrap_content 的默认行为有时候不是你想要的效果特别是设置的 LayoutParams 类型和实际容器不匹配时会显示异常。正确的做法是val layoutParams LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ) listContainer.addView(itemView, layoutParams)添加 item 的话我还建议在 item 之间加一个分割线或者 margin不然多个 item 堆一起视觉上像一大坨也容易让用户误以为内容没有更新。可以用一个通用的方法private fun addItemWithDivider(itemView: View, marginBottom: Int) { val layoutParams LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ) layoutParams.setMargins(0, 0, 0, marginBottom) listContainer.addView(itemView, layoutParams) }4.3 刷新完成时 setRefreshing(false) 时机不对这个坑属于典型的“逻辑顺序”问题代码不会报错但视觉效果很怪。我遇到过两种表现。第一种转圈闪一下就没。原因是把isRefreshing false放在了一个提前回调的地方比如把 fetchData 的回调结果先缓存了然后认为数据已经加载完成直接收起转圈。但此时 item View 还没来得及创建和 addView用户看到的效果就是转圈突然消失页面空白过了几百毫秒内容刷一下出现。第二种转圈永远不消失。原因是数据加载过程中抛了异常没有进入回调isRefreshing 一直没有被置为 false。这种情况更隐蔽因为项目默认把异常吞掉了只打了一行日志UI 上就一直卡在刷新的状态。排查这个问题我建议你把“刷新完成”看作一个状态机流转而不是一个简单的回调private fun loadData() { refreshLayout.isRefreshing true viewModel.fetchData( onSuccess { result - renderList(result) completeRefresh() }, onError { error - showErrorToast(error) completeRefresh() } ) } private fun completeRefresh() { // 确保在 UI 线程 refreshLayout.isRefreshing false }核心原则无论成功还是失败都必须有最终收起转圈的出口。最稳妥的办法是在网络请求的最外层包裹 try/finally确保万无一失。4.4 特殊子 View 引发的连带崩溃除了逻辑问题还有一个容易在“手工列表”中踩的连带坑item 里有 ImageView 需要加载本地图片或者 content:// URI 的图片时会触发 FileUriExposedException 崩溃。这个问题的根因是 Android 7.0 之后对 file:// URI 的限制。如果你手动拼接了一个content://com.tencent.wework.fileprovider/external_path/...之类的 URI 并直接传给 ImageView如果你没有正确配置 FileProvider 和对应路径规则就会在页面加载时崩溃。解决方案有两个路径路径一使用成熟的图片加载库。比如 Glide、Coil它们内部已经处理好了 URI 转换、缓存和管理。用 Coil 的代码简洁得感人imageView.load(imageUrl)路径二自己配置 FileProvider。如果确实需要手动处理需要在 AndroidManifest.xml 里声明 FileProvider并在 xml 里配置 paths 规则。这个方案不在本文展开只提醒一句凡是通过外部传入的 URI不要直接丢给 ImageView 做 decode。用 Glide 或 Coil 可以省掉一大批这种莫名其妙的崩溃。你在 Android Studio 里调试这类问题的时候布局层级看着头大可以打开 Layout Inspector 直接看视图树。ScrollView 的层级本身不深但动态 addView 进去的 item 数量多了之后视图树会比较长用 Layout Inspector 能快速定位到具体是哪个 item 的哪个控件尺寸异常。5. 进阶让“手工列表”更接近真实 App 体验基础功能跑通之后如果你愿意再多做一步这个“手工列表”的体验可以再上一个台阶。下面这几个方向是我在项目里实际验证过的。5.1 空数据、加载失败与正常状态的切换真实项目里接口不一定每次都有数据。什么都不显示直接给用户一个空白页面体验很糟糕。所以我在列表容器外面额外准备了两个布局空数据布局和加载失败布局。思路很简单在 loadData 的结果里分情况处理。private fun renderList(result: ListItemData) { emptyView.visibility View.GONE errorView.visibility View.GONE listContainer.visibility View.VISIBLE if (result.isEmpty()) { listContainer.visibility View.GONE emptyView.visibility View.VISIBLE return } listContainer.removeAllViews() result.forEach { item - listContainer.addView(createItemView(item)) } }加载失败同理展示错误提示和一个“重试”按钮。这个重试按钮的点击事件重新调用 loadData就完成了“失败 - 重试 - 成功”的闭环。有个细节空数据布局和错误布局最好放在 SwipeRefreshLayout 的里面。这样即使用户在空数据状态下也能通过下拉刷新重新发起请求。如果把它们放在 SwipeRefreshLayout 外面下拉手势会被空布局拦截刷新就无效了。5.2 与“上拉加载更多”联动ScrollView 配合 SwipeRefreshLayout 做下拉刷新没问题但如果你还想要“上拉加载更多”那就需要自己监听滚动了。用 NestedScrollView 的话可以这样监听nestedScrollView.setOnScrollChangeListener { _, _, scrollY, _, _ - // 判断是否滚动到底部 if (scrollY nestedScrollView.getChildAt(0).height - nestedScrollView.height) { if (hasMore) { loadMore() } } }这个判断的原理是子 View 的总高度减去 ScrollView 的可视高度等于最大滚动距离scrollY 达到这个值就说明已经滚到底部了。但我要提醒一句ScrollView 方案做上拉加载更多有个天然短板——没有“加载中”的脚部状态展示。RecyclerView 可以通过 addFooterView 或者 Adapter 的 getItemViewType 来展示一个“正在加载”的底部条但 ScrollView 需要你自己额外 addView 一个 loading 的脚部布局加载完成后再 remove。不是不能做但代码量会涨不少。所以我还是那句话如果业务明确需要分页加载数据量可能持续增长尽早切换成 RecyclerView。下拉刷新 上拉加载更多这种组合从来不是 ScrollView 的强项。5.3 自定义刷新动画的配色与行为SwipeRefreshLayout 默认的转圈是主题色如果你想跟 App 的品牌色一致或者觉得默认的白色背景太突兀可以在初始化时设置refreshLayout.setColorSchemeResources( R.color.refresh_primary, R.color.refresh_accent ) refreshLayout.setProgressBackgroundColorSchemeResource(R.color.white) refreshLayout.setDistanceToTriggerSync(dp2px(80f))setColorSchemeResources 设置的是转圈的颜色序列可以在下拉过程中依次变化。setProgressBackgroundColorSchemeResource 设置的是转圈背后的圆形背景色。如果 App 页面底色是深色把背景设成半透明或者白色转圈才看得清。还有一个很多人不知道的参数setDistanceToTriggerSync。它控制的是下拉多少距离才触发刷新。默认值是 64dp如果你觉得默认下拉距离太长或者太短可以通过这个接口调整。我一般会在内容不满一屏的页面上把它调小一点比如 48dp让用户稍微拉一点就能触发。如果你对默认的进度条样式不满意SwipeRefreshLayout 还支持通过setProgressViewOffset调整转圈的初始偏移量让它在页面顶部显示还是稍微靠下一点。这些参数没有标准答案我建议按照你的页面头部布局实际调一调观感最舒服就行。最后再分享一个细节如果这个页面本身是一个 Fragment不要把 SwipeRefreshLayout 的初始化逻辑放在 onCreateView 之前。等 Fragment 的 View 创建完成后再去 findViewById 和 setOnRefreshListener否则大概率拿到一个 null然后一脸懵。我在实际项目中就是让这种对手工列表又爱又恨的状态持续了一整年。爱的是它真的快几个文件就搞定一个页面恨的是每次数据量超了预期都得回头做技术债重构。建议你也像我一样在心里给这个方案划一条明确的数据量红线——超过 30 条就果断换 RecyclerView。在这个范围内SwipeRefreshLayout ScrollView 作为一个轻量列表的快速方案用起来还是很舒服的。