SwiftUI 中的高级列表与分页

发布: (2025年12月19日 GMT+8 08:07)
5 min read
原文: Dev.to

Source: Dev.to

列表看起来很简单——直到你尝试构建真实的动态。

然后你会遇到以下问题:

  • 无限滚动卡顿
  • 行重复
  • 分页触发过于频繁
  • 滚动位置跳动
  • 重行导致性能下降
  • 异步竞争
  • 到处都是加载状态
  • 离线 + 分页冲突

本文展示了 生产环境的 SwiftUI 应用 如何处理列表和分页——干净、快速、可预测且可扩展。

🧠 核心原则

  • 增量加载数据
  • 永不阻塞滚动
  • 保持身份稳定
  • 避免重复请求
  • 优雅地处理错误
  • 支持离线数据
  • 在大数据集下保持流畅

🧱 1. 正确的列表架构

Separate concerns:

View

ViewModel (pagination state)

Service (fetch pages)

ViewModel 拥有分页逻辑——而不是视图。

📦 2. 分页状态模型

@Observable
class FeedViewModel {
    var items: [Post] = []
    var isLoading = false
    var hasMore = true
    var page = 0
}

这使得分页:

  • 可预测的
  • 易于调试的
  • 可测试的

🔄 3. 安全获取页面

@MainActor
func loadNextPage() async {
    guard !isLoading, hasMore else { return }

    isLoading = true
    defer { isLoading = false }

    do {
        let response = try await service.fetch(page: page)
        items.append(contentsOf: response.items)
        hasMore = response.hasMore
        page += 1
    } catch {
        errors.present(map(error))
    }
}

关键规则

  • 防止重复调用
  • 在主 actor 上更新状态
  • 追加,永不替换
  • 明确跟踪 hasMore

📜 4. 从视图触发分页(安全)

List {
    ForEach(items) { item in
        RowView(item: item)
            .onAppear {
                if item == items.last {
                    Task { await viewModel.loadNextPage() }
                }
            }
    }

    if viewModel.isLoading {
        ProgressView()
            .frame(maxWidth: .infinity)
    }
}

这段代码:

  • 支持动态行高
  • 在旋转时仍能正常工作
  • 避免 GeometryReader 陷阱

🆔 5. 身份不可协商

错误的身份会导致列表性能下降。

ForEach(items, id: \.id) { ... }

规则

  • ID 必须保持稳定
  • 切勿在行内生成 UUID
  • 切勿使用数组索引
  • 切勿修改 ID

错误的身份会导致:

  • 行重复
  • 动画卡顿
  • 滚动跳动
  • 行之间的状态泄漏

⚠️ 6. 避免在行内部进行繁重工作

绝不这样做:

RowView(item: item)
    .task {
        await expensiveWork()
    }

而是

  • 在 ViewModel 中预先计算
  • 缓存结果
  • 将轻量级数据传入行

行应该是:

  • 轻量的
  • 纯粹的
  • 渲染快速

🧊 7. 骨架屏 & 占位行

if items.isEmpty && isLoading {
    ForEach(0..<5) { _ in
        SkeletonRow()
    }
}

这:

  • 保持布局
  • 感觉更快
  • 避免 UI 跳动

📶 8. 离线 + 分页

当离线时:

  • 加载缓存页面
  • 禁用分页
  • 保持滚动流畅

示例:

if !network.isOnline {
    hasMore = false
}

恢复在线后自动重试。

🔁 9. 下拉刷新而不重置所有内容

.refreshable {
    page = 0
    hasMore = true
    items.removeAll()
    await loadNextPage()
}

避免:

  • 重建 ViewModel
  • 重置身份
  • 不必要地清除滚动状态

⚖️ 10. Performance Rules for Large Lists

  • ✅ 使用 List 处理大型数据集
  • ✅ 在 ScrollView 中使用 LazyVStack 进行自定义布局
  • ✅ 避免在行中使用 GeometryReader
  • ✅ 保持行结构浅层
  • ✅ 积极缓存图像
  • ✅ 避免对每行进行环境更新
  • ✅ 避免嵌套列表

SwiftUI 列表在正确使用时性能极快。

🧪 11. 测试分页逻辑

因为逻辑位于 ViewModel 中:

func test_pagination_appends() async {
    let vm = FeedViewModel(service: MockService())
    await vm.loadNextPage()
    await vm.loadNextPage()

    XCTAssertEqual(vm.items.count, 40)
}

不需要 UI,也不需要滚动模拟——纯逻辑测试。

🧠 心智模型速查表

自问:

  • 分页状态的所有者是谁?
  • 会出现重复请求吗?
  • 身份是否稳定?
  • 行是否轻量?
  • 离线会破坏它吗?

如果所有答案都清晰 → 你的列表将具备可扩展性。

🚀 最后思考

高级列表并不是关于巧妙的技巧。它们涉及:

  • 明确的所有权
  • 稳定的标识
  • 可预测的分页
  • 最小的行工作量
  • 干净的异步处理

做好这些,你的 SwiftUI 列表将感觉原生、快速且坚如磐石——即使拥有数万行。

Back to Blog

相关文章

阅读更多 »

SwiftUI 数据流与单向架构

SwiftUI 看起来很简单——直到数据开始双向流动。那时你会看到:- UI 意外更新 - 状态从多个地方改变 - 异步…

介绍 Marlin

!封面图片用于《Introducing Marlin》 https://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3....