SwiftUI 列表页反复请求,我是怎么定位到状态设计问题的
SwiftUI 列表页反复请求,我是怎么定位到状态设计问题的
SwiftUI 项目里最容易被低估的一类问题,不是布局,也不是动画,而是“状态放错位置”。
我遇到过一个非常典型的线上问题:资讯列表页滚动、切后台、切 Tab、返回详情,再回来之后,请求会反复发,列表偶尔闪一下,偶尔顺序错乱。接口本身没报错,但流量比预期高了一倍多。
最后查下来,不是网络层有 bug,也不是服务端重复下发,而是页面状态和生命周期设计有问题。
场景很常见
页面结构大致是这样:
- 首页有多个 Tab
- 每个 Tab 对应一个列表
- 列表支持下拉刷新和分页
- 点进详情后再返回列表
最初实现很“顺手”:
struct FeedPage: View {
@State private var viewModel = FeedViewModel()
var body: some View {
List(viewModel.items) { item in
NavigationLink(item.title) {
DetailPage(id: item.id)
}
}
.task {
await viewModel.loadFirstPage()
}
}
}
代码不复杂,但问题就在这里。
为什么会重复请求
很多人会把 .task 理解成“页面首次出现时执行一次”。这在简单场景下近似成立,但在真实项目里并不稳。
原因 1:View 重建不等于你以为的“还是那个页面”
SwiftUI 的 View 是值类型。只要上层状态变化、Tab 容器重绘、筛选条件变化,FeedPage 就可能被重新构造。只要 .task 重新绑定,异步任务就会再跑一次。
你以为只是“回来看看原页面”,对 SwiftUI 来说可能是“生成了一个新视图”。
原因 2:@State 适合轻状态,不适合承载复杂页面模型
@State 放一个简单 Bool、String 没问题,但复杂列表页通常有:
- 当前页码
- 是否首屏加载完成
- 是否正在翻页
- 当前筛选条件
- 当前请求任务
这些状态如果散在 View 本身,页面生命周期稍微复杂一点,就很容易失控。
原因 3:异步结果返回顺序未必和发起顺序一致
最麻烦的情况是重复请求不仅浪费流量,还会把旧结果写回页面。
比如用户快速切换筛选条件:
- 请求 A:全部
- 请求 B:未读
如果 B 先回来,A 后回来,而你没有做请求版本控制,最终列表会被旧数据覆盖。
这类问题在弱网和接口偶发抖动时特别明显。
我最后怎么收敛的
核心思路有三条:
- 页面模型外提,不让
View自己管太多业务状态 - 请求有且只有一个入口
- 旧请求返回时不能污染新状态
1. 用 @StateObject 托管页面级 ViewModel
如果这是一个页面级模型,而不是局部表单状态,优先用 @StateObject。
@MainActor
final class FeedViewModel: ObservableObject {
@Published private(set) var items: [FeedItem] = []
@Published private(set) var isLoading = false
@Published private(set) var isLoaded = false
@Published private(set) var filter: FeedFilter = .all
private var currentTask: Task<Void, Never>?
private var requestVersion: Int = 0
func loadIfNeeded() {
guard !isLoaded else { return }
reload()
}
func reload() {
requestVersion += 1
let version = requestVersion
currentTask?.cancel()
currentTask = Task {
await fetch(version: version, reset: true)
}
}
private func fetch(version: Int, reset: Bool) async {
guard !isLoading else { return }
isLoading = true
defer { isLoading = false }
do {
let result = try await api.fetchFeed(filter: filter)
guard !Task.isCancelled, version == requestVersion else { return }
if reset {
items = result.items
} else {
items.append(contentsOf: result.items)
}
isLoaded = true
} catch is CancellationError {
} catch {
guard version == requestVersion else { return }
}
}
}
页面里这样接:
struct FeedPage: View {
@StateObject private var viewModel = FeedViewModel()
var body: some View {
List(viewModel.items) { item in
Text(item.title)
}
.task {
viewModel.loadIfNeeded()
}
}
}
这一步解决的是“页面重建导致模型跟着重置”的问题。
2. 不要让 .task 和 .onAppear 同时管加载
很多项目里会出现这种代码:
.task { await viewModel.reload() }
.onAppear { viewModel.trackExpose() }
后来需求变多,又有人在 onAppear 里偷偷加一句:
.onAppear {
viewModel.reload()
}
然后请求翻倍,没人知道是谁加的。
我的原则很简单:
- 数据加载只保留一个入口
onAppear只做曝光、埋点、UI 副作用
请求发起统一收敛到 loadIfNeeded() 或 reload(),不要多个生命周期钩子同时碰数据。
3. 对筛选切换做任务取消和版本控制
只取消任务还不够,因为很多网络库底层取消不一定及时。保险做法是“取消 + 版本号双保险”。
func updateFilter(_ newFilter: FeedFilter) {
guard filter != newFilter else { return }
filter = newFilter
reload()
}
真正写状态前校验:
guard version == requestVersion else { return }
这比单纯靠 isCancelled 更稳,因为它能拦住“已经发出但晚到”的旧结果。
分页还有一个坑:底部触发会连发
列表滑到底部时,很多人会在最后一个 cell 的 onAppear 里触发下一页加载:
if item.id == viewModel.items.last?.id {
Task { await viewModel.loadMore() }
}
这在真实滚动里很容易被多次触发,尤其是:
- cell 复用
- 列表回弹
- 页面切前后台恢复
我一般会加两层保护:
func loadMoreIfNeeded(currentItem: FeedItem) {
guard currentItem.id == items.last?.id else { return }
guard !isLoadingMore else { return }
guard hasMore else { return }
loadMore()
}
然后在 UI 里只调这个入口,不直接写请求。
一个更隐蔽的问题:详情页回写导致列表刷新
后面我们还踩过一个坑。详情页支持点赞,点赞成功后会通过共享 Store 回写列表项状态。这个回写会触发列表页重新计算 body,而某些旧代码又把 .task(id: filter) 和局部状态绑在一起,结果点赞一次,列表又发一次请求。
这个问题本质上不是 SwiftUI “神秘”,而是状态边界没划清:
- 哪些状态用于 UI 更新
- 哪些状态用于请求触发
- 哪些变化不应该重新拉全量数据
如果把它们混在一起,页面就会变成“任何状态变化都可能触发请求”。
我最后定下来的页面约束
这个约束在后面几个列表页里效果很好:
- 页面级数据模型统一由
@StateObject托管。 - 首屏加载只能走
loadIfNeeded()。 - 手动刷新只能走
reload()。 - 筛选切换必须取消旧任务,并带请求版本号。
- 分页只能走
loadMoreIfNeeded(),禁止在 cell 里直接发请求。 - UI 状态和请求触发条件分离,避免任何
@Published变化都误触拉取。
结果
做完这轮收敛后,最明显的变化不是代码更“优雅”,而是问题消失得很具体:
- 首页列表请求量下降了将近 40%
- 详情返回不再重复拉首屏
- 弱网下筛选切换不会再出现旧数据回写
- 分页重复触发基本消失
SwiftUI 这类问题的难点从来不是 API 不会用,而是你必须接受一件事:View 不是页面控制器,别把整个页面生命周期赌在一个 .task 上。
