SwiftUI 列表卡顿的七个常见原因与解决思路

SwiftUI 列表卡顿的七个常见原因与解决思路

年前接了一个历史项目,首页滑动掉帧严重到产品经理说"像在用 Android 2.3"。不是硬件问题,是代码写法太随意。

排查了一圈,发现 SwiftUI 列表卡顿的原因特别集中。这里整理七个最常见的,看看你踩过几个。

先看一段典型的列表写法:

struct FeedList: View {
    let items: [FeedItem]

    var body: some View {
        LazyVStack(spacing: 0) {
            ForEach(items) { item in
                NavigationLink {
                    DetailView(id: item.id)
                } label: {
                    FeedCell(item: item)
                }
                .buttonStyle(.plain)
            }
        }
    }
}

问题在哪?NavigationLink 默认会在点击时立即加载目标视图的 body。在 LazyVStack 里,这意味着当你点击某一行时,SwiftUI 可能已经在准备渲染 DetailView 了——即使 navigation 还没真正发生。

解决方式:用 navigationDestination 代替传统的 NavigationLink:

struct FeedList: View {
    let items: [FeedItem]
    @State private var selectedId: String?

    var body: some View {
        LazyVStack(spacing: 0) {
            ForEach(items) { item in
                FeedCell(item: item)
                    .onTapGesture {
                        selectedId = item.id
                    }
            }
        }
        .navigationDestination(item: $selectedId) { id in
            DetailView(id: id)
        }
    }
}

这样 navigation 的触发和数据传递解耦了,cell 的渲染不会被 navigation 逻辑污染。

问题二:没有实现 id 的稳定关联

ForEach 的 id 稳定性是性能关键:

// 常见错误:用 index 作为 id
ForEach(items.indices, id: \.self) { index in
    FeedCell(item: items[index])
}

// 问题:items 重排或删除时,index 不稳定会导致整个列表重建

正确做法:用业务 ID:

ForEach(items) { item in
    FeedCell(item: item)
}

如果 item 不符合 Identifiable:

ForEach(items, id: \.id) { item in
    FeedCell(item: item)
}

问题三:在 body 里创建新的 ObservableObject

这个错误非常隐蔽:

struct BadListView: View {
    var body: some View {
        List(items) { item in
            // 错误:每次渲染 cell 都创建新的 ViewModel
            CellView(viewModel: CellViewModel(item: item))
        }
    }
}

CellViewModel 是个 ObservableObject,每次 cell 重新渲染都会创建一个新实例,这会导致 SwiftUI 的 diffing 算法失效。

解决:让 cell 自己管理状态,或者用 \.self + 基础类型:

struct GoodCellView: View {
    let item: FeedItem
    @State private var isExpanded = false

    var body: some View {
        VStack(alignment: .leading) {
            Text(item.title)
            if isExpanded {
                Text(item.description)
            }
        }
        .onTapGesture {
            isExpanded.toggle()
        }
    }
}

问题四:复杂视图在 cell 里直接计算

struct FeedCell: View {
    let item: FeedItem

    var body: some View {
        VStack {
            // 每次渲染都重新计算
            Text(item.tags.map { "#\($0)" }.joined(separator: " "))

            // 复杂日期格式化
            Text(formatDate(item.timestamp))

            // 正则匹配
            Text(extractDomain(from: item.url))
        }
    }
}

这些计算在 body 里,每次父视图刷新都会重新执行。解决:用 @State 缓存,或用 equatable 修饰:

struct FeedCell: View {
    let item: FeedItem
    @State private var formattedTags: String = ""
    @State private var formattedDate: String = ""
    @State private var domain: String = ""

    var body: some View {
        VStack {
            Text(formattedTags)
            Text(formattedDate)
            Text(domain)
        }
        .onAppear {
            formattedTags = item.tags.map { "#\($0)" }.joined(separator: " ")
            formattedDate = formatDate(item.timestamp)
            domain = extractDomain(from: item.url)
        }
    }
}

问题五:图片加载阻塞主线程

列表里加载网络图片是最常见的卡顿原因之一:

// 错误:同步加载图片
struct BadImageCell: View {
    let imageURL: URL

    var body: some View {
        if let data = try? Data(contentsOf: imageURL),
           let uiImage = UIImage(data: data) {
            Image(uiImage: uiImage)
                .resizable()
        }
    }
}

正确做法:用异步加载库,或自己实现:

struct AsyncImageCell: View {
    let imageURL: URL
    @State private var image: UIImage?
    @State private var isLoading = true

    var body: some View {
        Group {
            if let image {
                Image(uiImage: image)
                    .resizable()
            } else if isLoading {
                ProgressView()
            } else {
                Color.gray.opacity(0.2)
            }
        }
        .frame(width: 80, height: 80)
        .task {
            image = await loadImage(from: imageURL)
            isLoading = false
        }
    }

    private func loadImage(from url: URL) async -> UIImage? {
        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            return UIImage(data: data)
        } catch {
            return nil
        }
    }
}

问题六:Shadow 和透明背景的叠加

这是很多人忽略的。在 iOS 上,阴影和透明图层会导致离屏渲染:

// 性能杀手
cell.background
    .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2)
    .background(Color.white.opacity(0.9))

解决:减少阴影范围,或用设计手段规避:

// 方案1:只用边框代替阴影
cell.background(
    RoundedRectangle(cornerRadius: 8)
        .stroke(Color.gray.opacity(0.2), lineWidth: 1)
)

// 方案2:把阴影移到单一图层
ZStack {
    RoundedRectangle(cornerRadius: 8)
        .fill(Color.white)
        .shadow(radius: 2)
}

问题七:navigationDestination 导致的 cell 重建

navigationDestination(item:) 用法有个陷阱:

struct BadNavigationList: View {
    @State private var selectedItem: Item?

    var body: some View {
        List(items) { item in
            // 每次 selectedItem 变化,这个闭包都会重新执行
            Button(item.name) {
                selectedItem = item
            }
        }
        .navigationDestination(item: $selectedItem) { item in
            // item 会作为 Binding 传入,但这个闭包本身可能被多次创建
            DetailView(item: item)
        }
    }
}

当你在列表 cell 里直接操作 selectedItem = item 时,navigationDestination 的闭包会因为 item 引用变化而重新求值。解决:用 ID 而不是对象本身:

struct GoodNavigationList: View {
    @State private var selectedId: String?

    var body: some View {
        List(items) { item in
            Button(item.name) {
                selectedId = item.id
            }
        }
        .navigationDestination(item: $selectedId) { id in
            if let item = items.first(where: { $0.id == id }) {
                DetailView(item: item)
            }
        }
    }
}

性能检测工具

用 Instruments 的 Core Animation 模板可以观察到:

  • Display Freq:刷新率,60fps 以下就有掉帧
  • Render Server-[MTLRenderQueueContext flush] 频繁说明 GPU 超负荷
  • Core AnimationCAEAGLLayerCAMetalLayer 的提交频率

另一个实用技巧:在真机设置里打开"显示刷新的区域"(Debug > Color Blended Layers),蓝色表示混合图层,红色表示不透明。列表里蓝色越多越好。

总结

列表性能问题的根源通常就几个:

  1. 状态变化范围太大——cell 里的修改触发了不必要的大范围重建
  2. 计算太重——在 body 里做了太多工作
  3. 资源加载阻塞——图片和数据没有异步处理
  4. 视图层级太深——嵌套的容器视图增加了渲染压力

先从这四个方向排查,大部分卡顿问题都能定位到。

最后更新 4/20/2026, 6:02:32 AM