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 放一个简单 BoolString 没问题,但复杂列表页通常有:

  • 当前页码
  • 是否首屏加载完成
  • 是否正在翻页
  • 当前筛选条件
  • 当前请求任务

这些状态如果散在 View 本身,页面生命周期稍微复杂一点,就很容易失控。

原因 3:异步结果返回顺序未必和发起顺序一致

最麻烦的情况是重复请求不仅浪费流量,还会把旧结果写回页面。

比如用户快速切换筛选条件:

  1. 请求 A:全部
  2. 请求 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 更新
  • 哪些状态用于请求触发
  • 哪些变化不应该重新拉全量数据

如果把它们混在一起,页面就会变成“任何状态变化都可能触发请求”。

我最后定下来的页面约束

这个约束在后面几个列表页里效果很好:

  1. 页面级数据模型统一由 @StateObject 托管。
  2. 首屏加载只能走 loadIfNeeded()
  3. 手动刷新只能走 reload()
  4. 筛选切换必须取消旧任务,并带请求版本号。
  5. 分页只能走 loadMoreIfNeeded(),禁止在 cell 里直接发请求。
  6. UI 状态和请求触发条件分离,避免任何 @Published 变化都误触拉取。

结果

做完这轮收敛后,最明显的变化不是代码更“优雅”,而是问题消失得很具体:

  • 首页列表请求量下降了将近 40%
  • 详情返回不再重复拉首屏
  • 弱网下筛选切换不会再出现旧数据回写
  • 分页重复触发基本消失

SwiftUI 这类问题的难点从来不是 API 不会用,而是你必须接受一件事:View 不是页面控制器,别把整个页面生命周期赌在一个 .task 上。

最后更新 3/31/2026, 11:42:15 PM