6-10.【架构设计】SwiftUI 中,为什么“状态粒度”比 UIKit 更重要?举一个状态拆分不当导致频繁重绘或卡顿的例子。

4 阅读2分钟

一句话结论

在 SwiftUI 中,状态变化 = View 失效(invalidated),
状态粒度决定了“失效半径”;粒度越粗,每次状态变化牵连的 View 越多。

UIKit 是“你点哪我重画哪”,
SwiftUI 是“你动了谁的 State,我就重新计算谁的 body”。


一、为什么 SwiftUI 比 UIKit 更在乎“状态粒度”?

UIKit 的模型(命令式)

你决定:
- 更新哪个 label
- 刷新哪一行
- 是否 reload

手动限制影响范围


SwiftUI 的模型(声明式)

State 改变
→ 依赖该 State 的 View 全部失效
→ body 重新计算

SwiftUI 不问你想刷新多少,只按依赖关系算

👉 所以:
状态“挂”在哪个 View 上,比状态本身更重要。


二、一个真实会导致卡顿的错误例子(重点)

场景:一个可滚动列表 + 搜索框

❌ 错误的状态设计(粒度过粗)

struct UserListView: View {
    @State var searchText: String = ""
    @State var users: [User] = []   // ❌ 整个列表状态
    
    var body: some View {
        VStack {
            TextField("Search", text: $searchText)
            List {
                ForEach(filteredUsers) { user in
                    UserRow(user: user)
                }
            }
        }
    }

    var filteredUsers: [User] {
        users.filter { $0.name.contains(searchText) }
    }
}

会发生什么?

每一次输入字符:

searchText 改变
→ UserListView body 重算
→ filteredUsers 重算(O(n))
→ List diff
→ 所有 Row body 重新计算

症状(真实项目表现)

  • 输入时明显卡顿
  • 列表越大越卡
  • CPU 飙升
  • Instruments 看不到明显“热点”

👉 因为你无意中把整个列表挂在了 searchText 的失效半径里


三、为什么这是 SwiftUI 特有的痛点?

因为:

  • body 是纯函数
  • SwiftUI 必须重新算整个依赖子树
  • 它不知道你“其实只想动搜索框”

UIKit 里你可能只是:

reloadRows(at:)

SwiftUI 没这个入口。


四、正确的状态拆分方式(细粒度)

✅ 拆分 1:把搜索状态下沉

struct SearchBar: View {
    @Binding var text: String
}

struct UserListView: View {
    @State var searchText = ""
    let users: [User]

    var body: some View {
        VStack {
            SearchBar(text: $searchText)
            UserList(users: users, searchText: searchText)
        }
    }
}

✅ 拆分 2:列表内部自己持有筛选结果

struct UserList: View {
    let users: [User]
    let searchText: String

    var body: some View {
        List {
            ForEach(filteredUsers) { user in
                UserRow(user: user)
            }
        }
    }

    var filteredUsers: [User] {
        users.filter { $0.name.contains(searchText) }
    }
}

现在:

searchText 改变
→ UserList 失效
→ SearchBar 不受影响

✅ 拆分 3(进阶):Row 级别状态独立

struct UserRow: View {
    let user: User
    @State var isExpanded = false
}
  • 展开某一行
  • 只重绘这一行
  • 列表整体不受影响

五、另一个非常常见的卡顿陷阱(动画)

❌ 错误

struct CardList: View {
    @State var isAnimating = false

    var body: some View {
        ForEach(cards) {
            CardView(card: $0, animate: isAnimating)
        }
    }
}
  • 一个卡片动画
  • 触发全部卡片重绘

✅ 正确

struct CardView: View {
    let card: Card
    @State var isAnimating = false
}

👉 状态放在“最小变化单元”。


六、SwiftUI 的黄金法则(工程级)

State 应该放在“最小需要知道它的 View”上。

你可以把状态想成:

“爆炸半径”

  • 粒度越粗 → 半径越大 → 性能越差
  • 粒度越细 → 重绘越局部 → SwiftUI 才能快

七、状态粒度速查表

症状可能原因
输入卡顿State 挂在父 View
滚动掉帧Row 状态集中在 List
动画不流畅全局 State 驱动动画
小改动引发大刷新State 粒度过粗

最终一句话(压轴)

在 SwiftUI 中,状态不是“数据”,而是“失效范围声明”;
粒度选错,就等于告诉系统:‘每次都重算整个世界。’