SwiftUI 长列表卡顿没有一个万能修饰器。问题可能来自不稳定 identity、一次状态变化让过多行重新求值、body 内做昂贵工作、图片解码占用主线程,或布局层级本身成本过高。可靠做法是建立可复现实验,用 Instruments 观察更新原因,再一次只改变一个变量。本文不提供脱离设备、数据和构建配置的“提升百分比”。

先固定测量场景

选择支持范围内一台真实设备,使用 Release 或接近发布的配置,准备固定数量与内容的数据。记录冷启动或热启动、滚动路径、图片缓存状态、系统版本和性能工具。重复同一动作多次,保留中位趋势以及明显异常,不把一次顺滑或一次卡顿当结论。

测试数据要能暴露真实结构:长短标题、缺图、不同动态字体和频繁更新项目都应存在。若生产列表只有两百项,就没必要用十万条合成数据优化完全不同的问题;但也不要用十条 demo 宣称长列表已经合格。

身份必须来自领域

SwiftUI 用 identity 判断一个视图是原来的元素、被移动,还是全新元素。每次访问都生成 UUID、用可能重复的显示文本作 id,或在排序后使用数组下标,都会让框架丢失连续性。身份应来自数据库主键或稳定领域标识:

struct FeedItem: Identifiable, Equatable {
    let id: UUID
    let title: String
    let imageURL: URL?
    let isRead: Bool
}

struct FeedView: View {
    let items: [FeedItem]

    var body: some View {
        List(items) { item in
            FeedRow(item: item)
        }
    }
}

不要额外对 FeedRow.id(UUID()) 强迫刷新。那会重置行内状态、动画和复用关系。若身份确实改变,应该是领域含义发生变化,而不是为了绕过数据流问题。

懒容器不等于免费

ListLazyVStack 可以延迟创建不可见内容,但可见行仍会执行布局与渲染。选择取决于交互语义:List 提供平台行行为、选择、滑动操作和无障碍;ScrollViewLazyVStack 提供更自由布局。不要仅因名字有 Lazy 就假定后一种更快。

body 中避免日期解析、Markdown 转换、大图缩放、同步文件读取或全数组筛选。把纯派生数据在输入变化时计算,把图片读取、下载和解码移出主线程,并让任务随行消失取消。缓存要有容量和失效策略;无限字典只是延迟内存问题。

缩小失效范围

顶层可观察对象的一个大属性变化可能让许多行重新评估。把每行真正需要的值作为小型不可变输入传入,避免整份全局 store。不要为了阻止更新盲目添加 EquatableView:先用 SwiftUI Instruments 查看 body 更新原因,确认输入相同且计算昂贵,再考虑显式相等边界。

频繁变化的数据如下载进度或计时器,不应让所有列表项共享同一个刷新脉冲。把变化定位到相应行,降低刷新频率到用户能感知的程度,并在不可见时暂停。搜索输入可以去抖,但选择、删除等直接操作不应为追求吞吐而延迟反馈。

图片与布局需要单独诊断

网络延迟、压缩数据下载、图片解码、缩放和 SwiftUI 布局是不同阶段。为每阶段打 signpost 或使用相应 Instruments,才能知道缓存命中了什么。只缓存原始数据仍可能重复解码;缓存过大的渲染图又会快速消耗内存。根据显示尺寸生成目标图,并在内存警告时释放可重建缓存。

布局方面,深层 GeometryReader、大量 preference 传播、模糊阴影和尺寸互相依赖都可能增加成本。先简化一行并测量,再推广。视觉上相同的替换是否更快必须由目标设备证据决定。

建立回归门槛

把固定数据和滚动动作保留为性能场景,记录首屏可交互、滚动 hitch、主线程工作和峰值内存等可解释指标。不同机器的绝对值不能随意比较,CI 更适合捕捉明显退化趋势,最终体验仍在代表性真机上验收。

结论

优化顺序应该是身份、失效范围、主线程工作、资源管线和布局。每一步都由工具证明,而不是同时替换容器、缓存和数据模型后猜测原因。稳定身份与清楚的数据所有权通常先改善正确性,再带来性能;这比追逐一个“最快列表组件”更能长期保持流畅。