2025年11月17日·阅读约1分钟

针对长列表的 SwiftUI 性能调优:实用修复方法

针对长列表的 SwiftUI 性能调优:实用修复,涵盖重渲染、稳定行身份、分页、图片加载与在旧 iPhone 上实现平滑滚动的技巧。

针对长列表的 SwiftUI 性能调优:实用修复方法

在真实 SwiftUI 应用中,“慢列表”是什么样子的

SwiftUI 中的“慢列表”通常不是一个 bug,而是 UI 跟不上手指的时刻。你在滚动时会注意到:列表停顿、掉帧,整体感觉很沉重。

典型表现:

  • 在旧设备上滚动会卡顿
  • 行闪烁或短暂显示错误内容
  • 点击有延迟,滑动操作出现滞后
  • 手机发热、电量消耗比预期快
  • 滚动越久内存使用越高

即便每行看起来“很小”,长列表也会感觉慢,因为成本不只是绘制像素。SwiftUI 仍然要确定每行对应什么、计算布局、解析字体和图片、运行格式化代码,并在数据变化时 diff 更新。如果任何这些工作发生得太频繁,列表就会成为性能热点。

还有一点需要区分:在 SwiftUI 中,“重新渲染”通常指视图的 body 被重新计算。那部分通常很便宜。真正昂贵的是这次重新计算触发的工作:重布局、图片解码、文本测量,或者因为 SwiftUI 认为行的身份发生变化而重建许多行。

想象一个有 2000 条消息的聊天。新消息每秒到达,每行都要格式化时间戳、测量多行文本、加载头像。即便只添加一条,如果状态变动的范围太大,可能会导致很多行重新评估,部分行会重绘。

目标不是微观优化,而是实现平滑滚动、即时点击,以及只触及实际改变行的更新。下面的修复侧重于稳定身份、简化行、减少不必要的更新和受控加载。

三大根因:身份、每行工作量与更新风暴

当 SwiftUI 列表感觉慢时,通常不是“行太多”。而是在滚动时发生了额外工作:重建行、重新计算布局或重复加载图片。

大多数根因可归为三类:

  • 身份不稳定:行没有一致的 id,或者对会变化的值使用 \.self。SwiftUI 无法把旧行与新行匹配,因此重建超出必要的行数。
  • 每行工作过多:在行视图里做日期格式化、过滤、调整图片大小,或在行内执行网络/磁盘工作。
  • 更新风暴:一个变化(输入、定时器、进度)触发频繁状态更新,列表反复刷新。

举例:你有 2000 个订单。每行格式化货币、构建富文本并开始图片请求。与此同时,父视图里有一个每秒更新的“上次同步”定时器。即便订单数据没有变,这个定时器依然可能足以频繁使列表失效,从而导致滚动卡顿。

为什么 ListLazyVStack 感觉不同

List 不只是一个滚动视图。它围绕表格/集合行为和系统优化设计,通常以更低的内存消耗处理大数据集,但对身份与频繁更新更敏感。

ScrollView + LazyVStack 在布局和视觉上给你更多控制,但也更容易不小心引发表级别的额外布局或昂贵更新。在旧设备上,多余的工作更容易暴露出性能问题。

在重写 UI 之前,先进行测量。像稳定 ID、把工作移出行、减少状态抖动这类小修通常能在不改容器的情况下解决问题。

修复行的身份,让 SwiftUI 高效 diff

当长列表卡顿时,身份往往是罪魁祸首。SwiftUI 通过比较 ID 决定哪些行可以重用。如果 ID 改变,SwiftUI 会把行当成新的,丢弃旧的并重建,这会导致随机重渲染、滚动位置丢失或无谓的动画。

最简单的优化:确保每行的 id 稳定并和数据源绑定。

常见错误是在视图里生成身份:

ForEach(items) { item in
  Row(item: item)
    .id(UUID())
}

这会在每次渲染时强制生成新 ID,使得每行每次都被视为“不同”。

优先使用模型中已存在的 ID,比如数据库主键、服务器 ID 或稳定的 slug。如果没有,在创建模型时生成一次——不要在视图内生成。

struct Item: Identifiable {
  let id: Int
  let title: String
}

List(items) { item in
  Row(item: item)
}

注意索引。ForEach(items.indices, id: \.self) 将身份绑定到位置。如果你插入、删除或排序,行会“移动”,SwiftUI 可能把错误的数据复用到错误的视图上。仅在数组永不重排时才使用索引作为身份。

如果使用 id: \.self,确保元素的 Hashable 值在时间上是稳定的。如果哈希在字段更新时改变,行的身份也会改变。对 EquatableHashable 的安全规则:以单一稳定 ID 为基础,而不是基于可编辑属性如 nameisSelected

检查要点:

  • ID 来自数据源(而不是视图内的 UUID()
  • 当行内容改变时 ID 不会改变
  • 身份不应依赖数组位置,除非列表从不重排

通过简化行视图减少重渲染

长列表常常感觉慢,是因为每行在 SwiftUI 重新评估 body 时做了太多事。目标很简单:让每行重建开销很小。

一个常见的隐性成本是把“大”值传进行视图。大型结构体、深层嵌套的模型或昂贵的计算属性会在看似未变的情况下触发额外工作。你可能在不经意间重新构建字符串、解析日期、调整图片大小或产生复杂的布局树。

把昂贵工作移出 body

如果某件事很慢,就不要在行的 body 里反复重做它。数据到达时预计算、在 view model 中缓存,或用小的帮助器进行记忆化(memoize)。

行级别会迅速累加的开销:

  • 每行创建新的 DateFormatterNumberFormatter
  • body 中进行重型字符串格式化(连接、正则、markdown 解析)
  • body 内用 .map.filter 构建派生数组
  • 在视图里读取大块二进制并转换(如解码 JSON)
  • 过多嵌套的布局(大量 Stack 和条件分支)

一个简单的例子:把格式化器做成静态并将预格式化字符串传入行视图。

enum Formatters {
    static let shortDate: DateFormatter = {
        let f = DateFormatter()
        f.dateStyle = .medium
        f.timeStyle = .none
        return f
    }()
}

struct OrderRow: View {
    let title: String
    let dateText: String

    var body: some View {
        HStack {
            Text(title)
            Spacer()
            Text(dateText).foregroundStyle(.secondary)
        }
    }
}

拆分行并在合适时使用 Equatable

如果只有一小部分会变化(比如徽章计数),把它隔离成子视图,这样其余部分可以保持稳定。对于真正基于值的 UI,把子视图做成 Equatable(或用 EquatableView 包装)能帮助 SwiftUI 在输入未变时跳过工作。保持可比较的输入小且具体——不要把整个模型当作可比较的输入。

控制触发整体列表刷新的状态更新

迭代而不背负技术债
连接消息和 AI 集成,无需重写应用也能迭代需求。
构建原型

有时行本身没问题,但有东西不断让 SwiftUI 刷新整个列表。滚动时,即使是小的额外更新也能导致卡顿,尤其是在旧设备上。

一个常见原因是过于频繁地重建模型。如果父视图重建,而你对一个由视图拥有的 view model 使用 @ObservedObject,SwiftUI 可能会重建它、重置订阅,并触发新的发布。如果视图拥有该模型,使用 @StateObject 让它只创建一次并保持稳定。对外注入的对象用 @ObservedObject

另一个安静的性能杀手是发布频率过高。定时器、Combine 管道和进度更新可能每秒触发多次。如果某个被发布的属性影响列表(或挂在一个被屏幕共享的 ObservableObject 上),每一次 tick 都会使列表失效。

示例:有一个搜索字段在每次敲键时更新 query,然后过滤 5000 条项目。如果你立刻过滤,列表会在用户输入时反复 diff。对此给搜索做防抖处理,在短暂停顿后再更新过滤数组。

以下模式通常有帮助:

  • 把快速变化的值放在不驱动列表的对象外(使用更小的对象或局部 @State
  • 对搜索和过滤做防抖,使列表在输入暂停后才更新
  • 避免高频率的定时器发布;降低频率或只在值实际改变时更新
  • 把每行状态保持为本地(例如行内的 @State),而不是用一个全局频繁变化的值
  • 拆分大模型:一个 ObservableObject 负责列表数据,另一个负责屏幕级别的 UI 状态

核心思想很简单:让滚动期间尽量安静。如果没有重要变化,列表不应被要求做额外工作。

选择合适的容器:ListLazyVStack

你选择的容器会影响系统为你完成多少工作。

List 往往是更安全的选择,当你的 UI 看起来像标准表格:文本、图片、滑动操作、选择、分隔线、编辑模式和可访问性元素。它在底层享有苹果多年调优的系统优化,通常能更好地处理大量数据。

当你需要卡片、混合内容区块或特殊头部时,ScrollView + LazyVStack 很适合。"Lazy" 意味着它按需构建行,但它并不在所有情况下都严格等同于 List 的行为。对于非常大的数据集,LazyVStack 可能消耗更多内存,并在旧设备上更早出现卡顿。

一个简单决策规则:

  • 经典表格界面(设置、收件箱、订单、管理列表)用 List
  • 自定义布局和混合内容用 ScrollView + LazyVStack
  • 如果只有表格需求且有上千条,优先尝试 List
  • 需要像素级控制时试 LazyVStack,然后测量内存和帧率

还需注意那些悄悄拖慢滚动的样式。每行效果(阴影、模糊、复杂叠加)会强制额外渲染。若需深度感,把较重的效果应用到小元素(如图标)而不是整行。

具体例子:一个有 5000 行的“订单”屏幕在 List 中通常能保持平滑,因为行被重用。若改用 LazyVStack 并把行做成带大阴影和多个叠加的卡片风格,即便代码看起来干净,你也可能看到卡顿。

分页:既流畅又避免内存激增

把行内工作移出 UI
把格式化和业务规则移到后端,这样设备端的行就能保持轻量。
构建应用

分页能保持长列表快速:渲染更少的行、在内存中持有更少模型,并让 SwiftUI 的 diff 工作变小。

先建立清晰的分页契约:固定页大小(例如 30–60 条)、一个“无更多结果”标志,以及一个只在正在获取时显示的加载行。

常见陷阱是只在最后一行出现时触发下一页加载。那通常太晚,用户会看到停顿。相反,当最后几行之一出现时就开始加载。

下面是一个简单模式:

@State private var items: [Item] = []
@State private var isLoading = false
@State private var reachedEnd = false

func loadNextPageIfNeeded(currentIndex: Int) {
    guard !isLoading, !reachedEnd else { return }
    let threshold = max(items.count - 5, 0)
    guard currentIndex >= threshold else { return }

    isLoading = true
    Task {
        let page = try await api.fetchPage(after: items.last?.id)
        await MainActor.run {
            let newUnique = page.filter { p in !items.contains(where: { $0.id == p.id }) }
            items.append(contentsOf: newUnique)
            reachedEnd = page.isEmpty
            isLoading = false
        }
    }
}

这能避免常见问题,例如重复行(API 结果重叠)、多个 onAppear 调用导致的竞态条件,以及一次加载过多数据。

如果你的列表支持下拉刷新,重置分页状态时要小心(清空 items、重置 reachedEnd、尽可能取消未完成任务)。如果能控制后端,稳定的 ID 和基于游标的分页会让 UI 明显更顺畅。

图片、文本与布局:保持行渲染轻量

把流畅滚动做成产品
创建带真实后端的 iOS 应用,让列表更新在数据增长时保持可预测。
开始构建

长列表之所以常常感觉慢,通常是因为行本身。图片是最常见的罪魁:解码、缩放与绘制可能跟不上滚动速度,尤其在旧设备上。

如果加载远程图片,确保在滚动期间不要在主线程上做重工作。也避免为 44–80pt 的缩略图下载高分辨率资产。

示例:一个带头像的“消息”屏幕。如果每行都下载一个 2000x2000 的图片,缩小它并应用模糊或阴影,列表即便数据模型简单也会卡顿。

让图片处理可预测

高影响的做法:

  • 使用服务器端或预生成的接近展示尺寸的缩略图
  • 在可能的情况下离主线程进行解码和缩放
  • 缓存缩略图,避免快速滚动时重复请求或重复解码
  • 使用与最终尺寸一致的占位视图避免闪烁和布局跳动
  • 避免在行内对图片使用昂贵的修饰(重阴影、遮罩、模糊)

稳定布局以避免抖动

如果行高频繁变化,SwiftUI 可能花更多时间测量而非绘制。尽量保持行可预测:缩略图使用固定框架、统一的行数限制和稳定间距。如果文本会扩展,限制其行数(例如 1–2 行),以免单次更新强制额外测量。

占位符也很重要。一个灰色圆点将来会变成头像,应该占据同样的框架,这样行在滚动中不会重新排版。

如何测量:用 Instruments 找到真实瓶颈

只凭“感觉卡”来做性能工作很容易猜错。Instruments 告诉你哪些代码在主线程运行、快速滚动时有哪些分配,以及是什么导致掉帧。

在真实设备上(如果你支持旧设备就用旧设备)建立基线。做一个可重复操作:打开屏幕、从上往下快速滚动、触发一次加载更多,然后再往回滚。记录最明显的卡顿点、内存峰值,以及 UI 是否保持响应。

三个常用的 Instruments 视图

将它们结合使用:

  • Time Profiler:在滚动时查找主线程峰值。布局、文本测量、JSON 解析和图片解码通常会出现在这里。
  • Allocations:观察快速滚动期间临时对象的激增,通常指向重复格式化、新的富文本或重建每行模型。
  • Core Animation:确认掉帧和长帧时间,帮助区分是渲染压力还是数据工作导致的问题。

找到峰值后,点开调用树并问自己:这是每屏发生一次,还是每行、每次滚动都会发生?第二种情况才会破坏平滑滚动。

为滚动和分页事件添加 signpost

很多应用在滚动期间做额外工作(图片加载、分页、过滤)。signpost 能让你在时间线上看到这些时刻。

import os
let log = OSLog(subsystem: "com.yourapp", category: "list")
os_signpost(.begin, log: log, name: "LoadMore")
// fetch next page
os_signpost(.end, log: log, name: "LoadMore")

在每次改动后重新测试,一次只改一个东西。如果 FPS 提升但 Allocations 变糟,你可能把卡顿换成了内存压力。保留基线记录,只保留那些能在旧设备上把关键指标往正确方向推进的改动。

常见会悄悄杀掉列表性能的错误

从源头减少更新风暴
用可视化业务流程自动化工作流,确保应用逻辑在各处一致。
开始项目

有些问题明显(大图片、巨量数据)。有些只有在数据增长时才会显现,尤其是在旧设备上。

1) 行 ID 不稳定

经典错误是在视图内创建 ID,如在行体内用 id: .self 对引用类型,或用 UUID()。SwiftUI 用身份来 diff 更新。如果 ID 改变,SwiftUI 会把行当成新的并重建它,可能丢弃缓存的布局。

使用来自模型的稳定 ID(数据库主键、服务器 ID 或在创建项时存下的 UUID)。如果没有,请添加一个。

2) 在 onAppear 做重工作

onAppear 的运行频率比很多人预期要高,因为行在滚动时频繁进出屏幕。如果每行在 onAppear 启动图片解码、JSON 解析或数据库查询,你会看到重复峰值。

把重工作移出行。在数据加载时预计算并缓存结果,把 onAppear 保持为触发分页等轻量操作。

3) 把整个列表绑定到行的编辑上

当每行都通过 @Binding 绑定到一个大数组时,小的编辑也会看起来像大改动。这会导致许多行重新评估,有时会刷新整个列表。

优先把不可变值传入行,并用轻量级动作把更改传回(例如“为 id 切换收藏”)。当状态确实属于行时,把它放到行内本地状态。

4) 滚动时做太多动画

动画在列表里代价不菲,因为它们可能触发额外的布局。把 animation(.default, value:) 放在高层(整个列表)或对频繁变化的值做隐式动画,会让滚动感觉粘滞。

保持简单:

  • 把动画限定为实际变化的那一行
  • 快速滚动时避免动画(特别是选中/高亮)
  • 小心对频繁变化的值使用隐式动画
  • 优先选择简单过渡而不是复杂组合效果

真实例子:聊天样式列表中每行在 onAppear 中开始网络请求、用 UUID() 作为 id 并为“已读”状态做动画。这个组合会产生持续的行抖动。修复身份、缓存工作并限制动画通常就能让相同的 UI 立即更流畅。

快速检查清单、一个简易示例和下一步

如果你只能做几件事,就从这里开始:

  • 为每行使用稳定、唯一的 id(不是数组索引,也不是每次生成的 UUID)
  • 保持行工作量小:避免重型格式化、庞大的视图树和在 body 中的昂贵计算属性
  • 控制发布频率:不要让快速变化的状态(定时器、输入、网络进度)使整个列表失效
  • 使用分页并预取,保持内存平稳
  • 在变更前后用 Instruments 测量,这样你不是在猜

想象一个有 20,000 条对话的支持收件箱。每行显示主题、最新消息预览、时间戳、未读徽章和头像。用户可以搜索,新消息在滚动时到达。慢版本通常同时犯了几件事:在每次敲键时重建行、过于频繁测量文本、以及过早地获取大量图片。

一个不需要大规模重构的实用计划:

  • 建基线:用 Instruments(Time Profiler + Core Animation)记录一次短滚动和一次搜索会话。
  • 修复身份:确保模型有真实的 id(来自服务器/数据库),并在 ForEach 中一致使用它。
  • 添加分页:先加载最新的 50–100 条,当用户接近末尾时再加载更多。
  • 优化图片:使用更小的缩略图、缓存结果,并避免在主线程解码。
  • 重新测量:确认布局测量减少、视图更新变少,并在旧设备上帧时间更平稳。

如果你在构建一个完整产品(iOS 应用 + 后端 + Web 管理面板),提前设计数据模型和分页契约也会有帮助。像 AppMaster (appmaster.io) 这样的平合专注于全栈工作流:你可以可视化定义数据与业务逻辑,并生成可部署或自托管的源码。

常见问题

我的 SwiftUI 列表滚动时卡顿,最快的修复是什么?

先修复行的身份(ID)。从模型中使用稳定的 id,不要在视图里生成 ID,因为 ID 变化会迫使 SwiftUI 把行当成全新的视图并重建,导致大量不必要的工作。

SwiftUI 是不是因为“重渲染”太多而变慢?

body 重新计算通常开销很小;真正昂贵的是它触发的工作。重布局、文本测量、图片解码,以及由于不稳定的身份导致重建大量行,才是造成掉帧的常见原因。

我该如何为 `ForEach` 和 `List` 选择稳定的 `id`?

不要在行内使用 UUID() 或在元素可以插入/删除/重排的情况下用数组索引作为身份。优先使用服务器/数据库的 ID,或者在创建模型时存一个不会变的 UUID,这样 ID 在更新间保持不变。

`id: .self` 会让列表性能变差吗?

会的,尤其是当元素的哈希会随着可编辑字段变化而变化时,id: .self 会让 SwiftUI 认为是不同的行。若要使用 Hashable,请基于单一稳定标识符,而不是像 nameisSelected 或派生文本这类会变的属性。

行的 `body` 里我应该避免做哪些事?

把昂贵的工作移出 body。预先格式化日期和数字,不要为每一行创建新的格式化器,避免在视图中用 map/filter 构造大的派生数组;在模型或 view model 里计算一次,然后传入小而就绪的展示值到行视图。

为什么我的 `onAppear` 在长列表里频繁触发?

onAppear 会在行进入与离开屏幕时频繁触发。如果每行在 onAppear 里开始进行图片解码、数据库读取或解析工作,你会看到重复的性能峰值。把重工作移出行,把 onAppear 限制为轻量行为(比如在接近结尾时触发分页)。

什么会造成让滚动感觉卡顿的“更新风暴”?

任何共享给列表的高频发布值都会反复使列表失效,即使行数据并未改变。把定时器、输入状态和进度更新从驱动列表的主要对象里剥离,给搜索做防抖(debounce),并在需要时把大的 ObservableObject 拆成更小的对象。

对于大数据集,我该什么时候用 `List`,什么时候用 `LazyVStack`?

当你的界面像标准表格(文本、图片、滑动操作、选择、分隔线、编辑模式)时,优先使用 List,因为它包含系统多年的优化。需要自定义布局、卡片或混合内容时用 ScrollView + LazyVStack,但要测量内存和帧数,因它更容易无意中触发行级别的额外布局工作。

有什么简单且流畅的分页方式?

不要等到最后一行才触发下一页加载,而是在接近结尾的阈值时开始加载。保持固定页大小(例如 30–60 条),维护 isLoadingreachedEnd 标志,按稳定 ID 去重新数据以防止重复行或多次 diff。

我怎么测量是什么在拖慢我的 SwiftUI 列表?

在真实设备上建立基线,并用 Instruments 查找主线程峰值和分配激增。Time Profiler 找到阻塞滚动的调用,Allocations 揭示行级别的临时对象激增,Core Animation 显示掉帧。三者结合能帮你判断瓶颈是渲染还是数据处理。

容易上手
创造一些 惊人的东西

使用免费计划试用 AppMaster。
准备就绪后,您可以选择合适的订阅。

开始吧