SwiftUI 状态 API 的差异不只是语法,而是所有权契约:谁创建状态、谁决定生命周期、谁可以写入、哪些读取会建立更新依赖。
@State保存 View Identity 关联的局部状态,@Binding借用外部读写通道,Environment 沿子树注入作用域依赖;Observation 与旧版ObservableObject又有不同的跟踪和所有权工具。选错包装器的后果通常不是立即编译失败,而是状态重置、重复实例、更新范围失控或异步旧结果覆盖新状态。
一、本文解决什么问题
SwiftUI 工程中经常遇到这些问题:
@State的值究竟存在哪里,为什么 View Struct 重建后仍能保留?@Binding是否复制状态,Child 能否长期持有它?@Environment适合传 Theme,是否也适合传 Feature Model?- Observation 如何知道某个 View 读取了哪些属性?
@Observable与ObservableObject/@Published有什么版本和机制差异?- iOS 17+ 中为什么
@State可以持有@ObservableReference? @StateObject与@ObservedObject谁负责创建对象?- Parent 更新时,Child 内
@State(initialValue:)为什么不会跟着重置? - 什么是 Single Source of Truth,缓存一份派生数组是否算重复状态?
- Derived State 应在
body计算、Model 中计算,还是另行存储? - Sheet、Navigation、
ForEachIdentity 变化时 State 为什么重建? - 搜索词快速变化、页面退出、刷新重试时,如何防止旧请求覆盖新结果?
这些问题的共同主线是:先确定状态的业务生命周期和唯一 Owner,再选择与该生命周期匹配的存储、借用和观察机制。
本文同时覆盖两代主流模型:
- iOS 17+/macOS 14+:Observation Framework、
@Observable、@Bindable与@Environment(Type.self); - iOS 13+:Combine-based
ObservableObject、@Published、@StateObject(iOS 14+)、@ObservedObject和@EnvironmentObject。
示例在 2026-07-31 使用 Xcode 26.1.1、Apple Swift 6.2.1 与 iOS Simulator SDK 验证。SwiftUI/Observation 内部 Graph、Registrar 和失效调度是实现细节;文章只依赖公开 API 语义。部署目标、Swift Language Mode、Actor Isolation 和跨平台行为应以当前 SDK 文档及编译器诊断为准。
核心结论
- 状态选择应先回答 Owner、Lifetime、Mutation Authority、Persistence 和 Sharing Scope,再选择 Property Wrapper;不能从“页面要刷新”反推随便加一个可观察对象。
@State是与 View Identity 关联的局部持久存储。它适合 View 自己拥有的小型 Value State;iOS 17+ 也可拥有@ObservableReference,但对象仍应服务于该 View Identity 的生命周期。@State的初始值只在该身份首次建立存储时生效。Parent 后续传入新初值不会覆盖已有 State;需要同步必须明确来源和重置语义。@Binding不拥有数据,而是外部状态的读写投影。Child 应把它用于编辑 Owner 的状态,不能把 Binding 当独立缓存或事实来源。@Environment从当前 View Tree Scope 读取值。它适合系统环境和跨层级作用域依赖,但过度使用会隐藏依赖、扩大更新范围并让 Preview/Test 难以构造。- Observation 以可观察属性访问建立依赖:View 在求值期间读取的属性变化时,相关内容才需要更新。精确边界取决于实际读取路径,不能理解为“对象任意字段改变就整棵 App 重建”。
@Observable是 Observation Macro,不要求每个属性加@Published。它不自动决定对象 Owner、Actor、持久化或线程安全;这些仍需显式设计。- 旧模型中
@StateObject表示 View 创建并拥有ObservableObject,@ObservedObject表示 View 观察外部传入对象。两者不能仅按“哪个更不容易刷新”选择。 - Single Source of Truth 要求同一业务事实有一个规范 Owner。Binding、Environment、View Projection 和 Derived State 都应从它派生,而不是各自双向同步副本。
- Derived State 能从当前源状态确定地计算时,优先不存储。昂贵派生可以缓存,但缓存 Key、失效条件和 Owner 必须明确。
- State Ownership 应与业务生命周期一致:Control-local、Screen、Navigation Flow、Scene、Account、Document 和 App Scope 不同。把 App Scope 数据放进 Row
@State必然过短,把临时输入放进 Global Store 又会过长。 - Identity 改变会结束旧 State Storage。条件分支、
.id、不稳定ForEachID 和 Navigation Replacement 都可能触发有意或意外重建。 - 异步任务必须同时治理取消、旧结果、重复触发、错误和 Owner 生命周期。
.task(id:)提供取消信号,但 Repository/Model 必须协作响应,并用 Revision/Request ID 防止不可取消工作晚到覆盖。
二、状态设计的五个问题
在写 Property Wrapper 前先回答:
| 状态示例 | 典型 Owner | 生命周期 | 建议表达 |
|---|---|---|---|
| Toggle 是否展开 | 当前组件 | View Identity | @State |
| Text Field 编辑订单备注 | Order Screen/Editor | Screen/Document | @Binding 或 Feature Model |
| Locale、Dismiss Action | Environment Scope | Container/Scene | @Environment |
| 搜索结果加载状态 | Search Feature | Screen/Query Scope | @Observable Model / ObservableObject |
| 登录 Session | Account/App Coordinator | Account/App Scope | 上层 Store + Environment Injection |
| 服务端订单状态 | Repository/Store | 业务与持久化 Scope | Domain Store,不是 Cell @State |
状态不是离 UI 越近越好,也不是越集中越好。Owner 应位于所有读写者的最小共同生命周期边界。
三、@State:View Identity 关联的局部所有权
struct ExpandableSection: View {
@State private var isExpanded = false
var body: some View {
VStack(alignment: .leading) {
Button(isExpanded ? "Collapse" : "Expand") {
isExpanded.toggle()
}
if isExpanded {
DetailContent()
}
}
}
}ExpandableSection Value 可以重建,但同一 View Identity 会重新连接到对应 State Storage。private 表达该状态由组件拥有,外部不能把它当输入修改。
3.1 适合 @State 的内容
- 展开/折叠、选中 Tab、局部动画阶段;
- 未提交的 Control-local 输入;
- 与 View Identity 同生命周期的临时 Sheet/Alert Route;
- iOS 17+ 中由该 View 创建并拥有的
@ObservableModel; - 可丢失且不需要跨进程/长期恢复的 UI 状态。
不适合:
- 服务端或数据库的规范事实副本;
- 多页面共享 Session;
- 必须跨 View Identity 变化持续的上传任务;
- 需要 App 重启恢复但未持久化的数据;
- 从其他状态直接可计算出的重复派生值。
3.2 State 初始化只发生在身份建立时
struct CounterEditor: View {
let initialCount: Int
@State private var count: Int
init(initialCount: Int) {
self.initialCount = initialCount
_count = State(initialValue: initialCount)
}
var body: some View {
Stepper("Count: \(count)", value: $count)
}
}Parent 把 initialCount 从 1 改为 5 时,如果 CounterEditor Identity 不变,已有 count 通常继续保留用户编辑值,不会重新用 5 覆盖。这正是“Initial”语义。
若需求是外部值永远为事实来源,应使用 Binding:
struct CounterEditor: View {
@Binding var count: Int
var body: some View {
Stepper("Count: \(count)", value: $count)
}
}若需求是“切换文档时重置草稿”,应让 Identity 与 Document ID 对齐,或显式设计 Draft State Machine,而不是在 onChange 中无条件双向覆盖。
3.3 不要从 State Getter 触发副作用
@State 修改应来自 Action、Task Result 或明确 Event。body 中读取/写入同一 State 会形成更新循环。State 变化也应在正确 Actor 上发生;UIKit/SwiftUI 界面 Model 通常采用 Main Actor Isolation,后台工作返回后再提交结果。
四、@Binding:借用外部状态的读写能力
Parent 拥有状态:
struct ProfileEditor: View {
@State private var displayName = ""
var body: some View {
DisplayNameField(name: $displayName)
}
}
struct DisplayNameField: View {
@Binding var name: String
var body: some View {
TextField("Display name", text: $name)
}
}$displayName 是 Binding<String> 投影。Child 读写的是 Parent State,不创建副本。
4.1 Binding 不等于所有权
Binding 不能保证数据永久存在;Owner Identity 结束后,状态和 Binding 的有效业务上下文也结束。不要把临时 Binding 逃逸到 Long-lived Singleton、异步回调或跨 Feature 存储中。
4.2 自定义 Binding 的边界
let trimmedName = Binding(
get: { model.name },
set: { model.name = $0.trimmingCharacters(in: .whitespacesAndNewlines) }
)自定义 Binding 可做格式适配,但 Setter 应同步、轻量并保持可预测。不要在 Setter 中直接发网络请求或启动不可控 Task:Text Field 每次编辑都会触发写入。副作用应由提交 Action 或 Model Effect 处理。
4.3 不要制造双向同步环
错误模式:Child 同时持有外部 Binding 和本地 State,再用两个 onChange 相互同步。它会带来:
- 更新循环;
- 哪一方最后获胜不清楚;
- Sheet Cancel 时草稿/原值边界模糊;
- 异步校验结果覆盖用户新输入。
如果需要可取消草稿,建立单向协议:打开时从 Source 创建 Draft,编辑只改 Draft,Save 时一次提交,Cancel 丢弃 Draft。
五、@Environment:作用域读取,而非无边界全局状态
系统 Environment 示例:
struct CloseButton: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
Button("Close") {
dismiss()
}
}
}自定义 Value Environment 已在声明式模型模块介绍。Environment 的关键仍是 Scope:最近祖先的 Override 生效,不同 Scene/Presentation 可以拥有不同值。
5.1 iOS 17+ 注入 Observable Model
@Observable
@MainActor
final class SessionModel {
var user: User?
}
@main
struct ExampleApp: App {
@State private var session = SessionModel()
var body: some Scene {
WindowGroup {
RootView()
.environment(session)
}
}
}
struct AccountBadge: View {
@Environment(SessionModel.self) private var session
var body: some View {
Text(session.user?.displayName ?? "Guest")
}
}这是 iOS 17+ Observation API。@Environment(SessionModel.self) 没有提供 Default 时,目标树必须注入实例,否则运行时会失败。Preview/Test 要显式注入 Test Model。
需要 Binding 到 Observable 属性时,可在局部建立 @Bindable Projection:
struct AccountEditor: View {
@Environment(SessionModel.self) private var session
var body: some View {
@Bindable var session = session
TextField("Display name", text: $session.draftDisplayName)
}
}示例假设 Model 声明了 draftDisplayName。@Bindable 提供 Bindable Projection,不取得 Model 所有权。
5.2 Environment 使用原则
适合放入:
- 系统环境值和 Action;
- Theme、Locale、Feature Flags 等树状 Scope;
- 多层 Feature 共同需要且 Owner 位于更高层的 Model;
- Preview/Test 可明确替换的 Dependency。
不应放入:
- 仅一个 Child 需要的普通参数;
- 任意 Repository/Service 形成隐藏 Service Locator;
- 没有 Scope 的全 App 可变“杂物箱”;
- 需要明确返回值/错误协议的业务 Command。
六、Observation:按属性访问建立依赖
iOS 17+ Observation 的工程心智模型:
这不是内部实现调用栈,而是公开语义的概念表达:SwiftUI 根据 View 求值期间读取的 Observable 属性建立更新依赖。
6.1 @Observable
import Observation
@Observable
@MainActor
final class SearchModel {
var query = ""
private(set) var results: [SearchResult] = []
private(set) var phase: LoadPhase = .idle
private let service: SearchService
init(service: SearchService) {
self.service = service
}
}Macro 为存储属性生成 Observation 支持,通常不需要 @Published。但它不自动提供:
- Main Actor Isolation;
Sendable;- 网络请求取消;
- 数据持久化;
- 权限控制;
- Single Source of Truth;
- 跨进程同步。
这些仍由类型设计决定。UI Model 常用 @MainActor 让状态提交串行化,但耗时网络、图片、数据库工作不应因此在 Main Actor 同步执行。
6.2 读取范围决定依赖
struct ResultCountView: View {
let model: SearchModel
var body: some View {
Text("\(model.results.count) results")
}
}该 View 读取 results,不直接读取 query/phase。当未读取属性变化时,是否需要更新取决于依赖图,而不是简单的“同一个对象变了”。
如果 Helper 在 Body 求值路径中读取多个属性,这些读取也会建立依赖。排查无效更新应查看真实读取路径,而不是只看 Property Wrapper 声明。
6.3 @ObservationIgnored
不应参与观察的内部字段可使用公开忽略机制,例如 Cache/Task Handle。但忽略后其变化不会自动驱动 UI;若 UI 依赖它,应通过另一个可观察语义属性表达结果。不要为“性能”随意忽略业务状态。
七、@Observable 的所有权组合
iOS 17+ 常见三种角色:
7.1 当前 View 创建并拥有
struct SearchScreen: View {
@State private var model: SearchModel
init(service: SearchService) {
_model = State(initialValue: SearchModel(service: service))
}
var body: some View {
SearchContent(model: model)
}
}Model 与 SearchScreen Identity 关联。该 Identity 重建时 Model 也重建。初始化规则与普通 @State 相同:后续 View Value 重建不会用新 Service 自动替换已有 Model。
7.2 Parent 拥有,Child 读取
struct SearchContent: View {
let model: SearchModel
var body: some View {
Text("\(model.results.count) results")
}
}Observation 能跟踪属性读取,Child 不需要为了“观察”再包一层 @ObservedObject。普通 let 不表示复制 Reference Object,也不表示拥有生命周期。
7.3 Child 需要 Binding Projection
struct SearchField: View {
@Bindable var model: SearchModel
var body: some View {
TextField("Search", text: $model.query)
}
}@Bindable 让 Observable Model 属性可产生 Binding,不负责创建/保留 Model。
八、@StateObject 与 @ObservedObject 的版本背景
在 iOS 13+ Combine-based 模型中:
import Combine
@MainActor
final class LegacySearchModel: ObservableObject {
@Published var query = ""
@Published private(set) var results: [SearchResult] = []
}8.1 @StateObject:View 拥有 Reference Model
@StateObject 自 iOS 14 起可用:
struct LegacySearchScreen: View {
@StateObject private var model: LegacySearchModel
init(service: SearchService) {
_model = StateObject(
wrappedValue: LegacySearchModel(service: service)
)
}
var body: some View {
LegacySearchContent(model: model)
}
}它把 Object 生命周期与 View Identity 关联,避免 Parent 每次重建 View Value 就创建新对象作为当前 Owner。
但要注意:
- 初始创建表达式只服务于首次建立该 State Object;
- Identity 不变时,新 Init 参数不会替换已有 Object;
- 若 Object 必须响应 Service/Document 输入变化,应显式方法更新或改变 Feature Identity;
- 不要在 Init 外先构造昂贵 Object 再传给
StateObject,否则仍可能重复做无用构造; @StateObject不等于 Singleton,对不同 View Identity 会创建不同实例。
8.2 @ObservedObject:观察外部 Owner
struct LegacySearchContent: View {
@ObservedObject var model: LegacySearchModel
var body: some View {
Text("\(model.results.count) results")
}
}Parent/Coordinator 必须强持有 Model。Child @ObservedObject 只订阅 objectWillChange,不承诺跨 Child Identity 创建和保留实例。
错误示例:
struct BrokenView: View {
@ObservedObject var model = LegacySearchModel() // 通常错误的所有权表达
// Parent 重建时可能构造新 Model。
}若 View 创建并拥有它,使用 @StateObject;若外部拥有,要求 Init 注入。
8.3 Observation 与 Combine 模型如何选
| 条件 | 建议 |
|---|---|
| iOS 17+ 新 Feature | 优先评估 @Observable + @State/@Bindable |
| 部署到 iOS 13–16 | ObservableObject + Published/Object Wrappers |
| 已有 Combine Pipeline | 可继续 ObservableObject,不必为语法立即迁移 |
| Framework 跨版本发布 | 以最低部署版本和 Public API 稳定性设计 |
| 渐进迁移 | 在 Feature Boundary 适配,避免同一事实双写两套 Model |
迁移收益和性能要用实际更新范围、代码复杂度与工具测量判断,不能宣称 @Observable 在所有场景必然更快。
九、Single Source of Truth:一个事实,一个规范 Owner
例如 Cart Items 是事实来源:
@Observable
@MainActor
final class CartModel {
var items: [CartItem] = []
var total: Decimal {
items.reduce(0) { $0 + $1.price * Decimal($1.quantity) }
}
}不应再长期存一份可独立修改的 total,否则增删 Item 时容易漏同步。
9.1 单一事实源不等于单一巨大 Store
每个 Scope 可以有自己的 Source:
- Session Store 拥有认证事实;
- Document Store 拥有文档内容;
- Screen Model 拥有分页/加载状态;
- Control
@State拥有临时展开状态。
关键是同一事实不要有多个可写副本,不是把整个 App 放进一个类型。
9.2 Server State 与 UI Draft
编辑表单可以同时存在:
- Server/Database Canonical Record;
- 当前 Editor Draft;
- Validation Result;
- Save Phase。
它们是不同状态,不是重复。Draft 打开时从 Canonical 初始化,编辑期间独立,Save 成功后提交 Canonical,Cancel 丢弃。必须定义远端同时更新时的 Conflict Strategy,而不是用两个 onChange 保持实时双向镜像。
十、Derived State:可计算就不要重复存储
10.1 轻量派生
var filteredResults: [SearchResult] {
guard !query.isEmpty else { return results }
return results.filter { $0.title.localizedCaseInsensitiveContains(query) }
}若数据规模小、计算便宜,直接派生最清晰。
10.2 昂贵派生
不能因“不要存 Derived State”就在每次 Body Evaluation 中对数万条数据排序、格式化或解析。可选择:
- 在 Repository/Database 做查询和排序;
- Model 按 Source Revision + Query 缓存;
- 后台计算后提交不可变结果;
- Incremental Update;
- UI 层只读取已经准备好的 View State。
缓存仍不是新的事实源,它必须能从 Source 重建,并有完整 Cache Key/Invalidation。
10.3 不要把 Layout 结果反写业务事实
Geometry/Preference 测量值若持续写回驱动同一 Layout,可能形成反馈循环。布局派生状态应最小化、去重更新,并与真正业务状态分开;具体机制在“更新与布局”模块展开。
十一、State Ownership:生命周期要匹配
状态应放在需要它的最小公共 Owner:
- Row 展开:Row/列表状态,取决于滚动复用后是否要保留;
- 多页面 Checkout Draft:Navigation Flow Owner;
- 每个 Window 独立 Filter:Scene Owner;
- 当前 Account Session:Account/App Owner;
- 后台下载:Process-level Service,不属于可见 Row;
- 深链路导航 Path:Scene/Flow Owner,并考虑 Restoration。
11.1 状态过低
症状:页面切换丢失、列表滚动后重置、多个 Child 不一致、任务随可见性意外取消。
11.2 状态过高
症状:临时状态跨账号泄漏、多个 Scene 相互覆盖、Store 巨大、无关 View 更新、测试必须构建整套 App Context。
11.3 Ownership 与 Dependency Injection
Owner 创建实例,后代通过 Init、Binding 或 Environment 借用。Dependency Injection 解决“如何获得”,不自动解决“谁拥有”。即使 Environment 能取到 Model,也必须有更高层稳定对象真正创建并保留它。
十二、状态初始化与重建
状态重建通常来自 Identity 结束:
常见触发源:
.id值改变;ForEachID 不稳定/实体被替换;- Conditional Branch 切换;
- Navigation Destination 被 Pop/Replace;
- Parent 结构位置改变;
- Scene/Window 销毁。
12.1 用 Identity 表达正确重置
DocumentEditor(documentID: selectedDocumentID)
.id(selectedDocumentID)如果 Editor-local Draft 必须在切换文档时重新初始化,这是合理的显式重置。若只是文档标题变化,不应改变 ID。
12.2 onChange 同步初值要谨慎
.onChange(of: incomingValue) { _, newValue in
draft = newValue
}这可能覆盖用户尚未保存的编辑。使用前必须定义:
- 用户是否 Dirty;
- 外部更新是否比本地草稿优先;
- 是否提示 Conflict;
- Save/Cancel 后如何合并;
- Callback 在最低 OS 的签名差异。
若整个 Feature 在 Source ID 改变时都应重建,Identity 通常比逐字段同步更清晰。
十三、异步状态竞态
典型搜索竞态:
只有 Cancel 不够:某些底层工作不可立即取消,Completion 仍可能到达。需要取消加请求身份校验。
13.1 Model 中统一治理
@Observable
@MainActor
final class SearchModel {
var query = ""
private(set) var phase: LoadPhase = .idle
private let service: SearchService
@ObservationIgnored private var searchTask: Task<Void, Never>?
@ObservationIgnored private var revision = 0
init(service: SearchService) {
self.service = service
}
func search() {
revision += 1
let requestRevision = revision
let requestQuery = query
searchTask?.cancel()
phase = .loading
searchTask = Task { [weak self, service] in
do {
let results = try await service.search(requestQuery)
try Task.checkCancellation()
guard let self,
self.revision == requestRevision,
self.query == requestQuery else {
return
}
self.phase = .loaded(results)
} catch is CancellationError {
return
} catch {
guard let self, self.revision == requestRevision else { return }
self.phase = .failed(error.localizedDescription)
}
}
}
func cancel() {
revision += 1
searchTask?.cancel()
searchTask = nil
}
}示例以 Main Actor UI Model 为边界。SearchService.search 必须是真正异步且不会同步占用 Main Actor;跨 Actor 的 SearchResult/Error 数据还要满足当前 Swift Concurrency 的 Sendability 约束。
13.2 .task(id:) 与 Model Task 不要双重拥有
两种常见方式:
- View
.task(id: query)直接 Await Model 的load(query:);Task 由 View Identity 管理。 - Model 自己持有 Task,适合需要显式 Retry/Cancel、跨短暂 View 展示持续或合并多个 Trigger。
不要两边都创建独立请求又互不知情。必须明确唯一 Effect Owner。
13.3 错误、取消和空状态要区分
.idle:尚未请求;.loading(previous:):加载中,可能保留旧内容;.loaded([]):成功但为空;.failed(error, previous:):失败,可决定保留旧内容;- Cancellation:通常不展示错误;
- Stale Result:静默丢弃并记录诊断指标。
把所有情况压成 isLoading + items + errorMessage 三个可独立写字段,很容易出现 Loading 与 Error 同时为真等非法组合。Enum State Machine 更能约束状态空间。
13.4 页面消失与长期任务
- 页面可见性相关加载可随
.taskCancel; - 上传/下载若必须离开页面后继续,应由更高层 Service/Background Transfer Owner 管理;
- 进程终止不保证 Deinit/Disappear,重要进度必须持续持久化;
- 重试需要 Idempotency、一致性和 Network Policy,不只是重新调用方法。
十四、常见误区与修复
14.1 错误:所有可变值都放 @State
问题: Server State、Session 和多页面共享数据被复制到局部生命周期,产生多个事实源。
修复: 先确定 Owner;@State 只保存该 View Identity 真正拥有的状态。
14.2 错误:Child 把 Binding 再复制成 State
问题: 外部更新和本地编辑产生双写同步,Save/Cancel 边界不清楚。
修复: 直接编辑 Binding;若需要 Draft,建立显式 Draft Owner 和提交协议。
14.3 错误:@ObservedObject var model = Model()
问题: View Value 重建可能重复创建对象,Owner 语义错误。
修复: 旧模型中 View Owns 使用 @StateObject;外部 Owns 通过 Init 注入 @ObservedObject。iOS 17+ 对 @Observable 使用 @State/普通引用/@Bindable 表达角色。
14.4 错误:传入参数变化会重置 @State
问题: State Initial Value 只在 Identity 首次建立时使用。
修复: 外部是事实来源则用 Binding;实体切换需重置则改变稳定 Identity 或显式状态机处理。
14.5 错误:存储所有 Derived State
问题: Source 更新后容易漏同步,形成不一致组合。
修复: 轻量值即时计算;昂贵派生在明确 Owner 按 Source Revision 缓存,并可重建。
14.6 错误:Environment 放入所有 Service
问题: 依赖隐式、Scope 模糊、测试困难,并可能成为全局 Service Locator。
修复: 局部依赖走 Init;跨层级、作用域明确且可替换的依赖才放 Environment。
14.7 错误:取消 Task 后直接相信旧结果不会到达
问题: Cancel 是协作式,底层 Callback/不可取消工作可能晚到。
修复: 同时使用 Cancellation、Revision/Request ID 和当前 Query/Entity ID 校验。
十五、工程实践:一个可测试的 Feature 状态模型
enum LoadPhase<Value> {
case idle
case loading(previous: Value?)
case loaded(Value)
case failed(message: String, previous: Value?)
}
@Observable
@MainActor
final class ArticleListModel {
private(set) var phase: LoadPhase<[Article]> = .idle
var filter: ArticleFilter = .all
private let repository: ArticleRepository
@ObservationIgnored private var loadTask: Task<Void, Never>?
@ObservationIgnored private var revision = 0
var visibleArticles: [Article] {
let articles: [Article]
switch phase {
case .loaded(let value), .loading(let value?), .failed(_, let value?):
articles = value
default:
articles = []
}
return articles.filter(filter.includes)
}
init(repository: ArticleRepository) {
self.repository = repository
}
func reload() {
revision += 1
let currentRevision = revision
let previous = visibleArticles
phase = .loading(previous: previous)
loadTask?.cancel()
loadTask = Task { [weak self, repository] in
do {
let articles = try await repository.fetchArticles()
try Task.checkCancellation()
guard let self, self.revision == currentRevision else { return }
self.phase = .loaded(articles)
} catch is CancellationError {
return
} catch {
guard let self, self.revision == currentRevision else { return }
self.phase = .failed(
message: error.localizedDescription,
previous: previous
)
}
}
}
func cancel() {
revision += 1
loadTask?.cancel()
loadTask = nil
}
}此设计中:
- Repository 是 Server/Data Source Owner;
- Model 是 Screen State 与 Effect Owner;
phase是合法状态机;visibleArticles是 Derived State;- View 通过 Observable Read 自动建立依赖;
- Revision 阻止旧结果;
- Task Handle 不参与 Observation;
- 是否在页面消失时 Cancel 由 Feature Lifecycle 决定。
15.1 测试矩阵
- 首次加载成功、空结果、失败;
- Refresh 保留 Previous Content;
- 连续两次 Reload,第一次晚到不覆盖;
- Cancel 不展示 Error;
- Filter 变化只改变 Derived Results,不发重复网络请求;
- Model Identity 不变时 State 保留;
- 切换 Account/Document ID 时创建正确新 Owner;
- Preview/Test Environment 缺失依赖能尽早暴露;
- Main Actor 状态提交与 Repository 并发边界符合 Swift 6 诊断;
- Model/Task/Repository 无 Retain Cycle。
十六、性能与更新范围验证
不要用包装器名称推断性能。需要观察:
验证方法:
- 使用 SwiftUI Instruments 查看 View Body 和 Update Cause;
- 用 Time Profiler 区分派生计算、Formatter、Diff、Layout 和图片;
- Signpost 标记 State Mutation、Request Revision 与 Result Commit;
- 在 Release/Profile、目标真机、真实数据规模上测量;
- 比较拆分 Model/Environment 前后的 Body Evaluation 与帧表现;
- 确认优化没有引入重复事实源和缓存失效 Bug。
常见治理方向:
- View 只读取真正需要的 Observable 属性;
- 粗粒度 Environment Model 按 Feature Scope 拆分;
- 昂贵 Derived State 移出热 Body Path 并按 Revision 缓存;
- 高频进度更新按展示预算 Coalesce,但不编造统一时间窗口;
- Background Work 不同步占用 Main Actor;
- 无效更新定位将在下一篇“更新与布局”继续展开。
十七、总结
SwiftUI 状态 API 首先表达所有权。@State 属于当前 View Identity,@Binding 借用外部写入能力,@Environment 从当前子树 Scope 读取依赖。iOS 17+ Observation 通过属性访问建立依赖,@Observable 负责可观察性,@State、普通引用和 @Bindable 分别表达拥有、读取和绑定投影;旧版 ObservableObject 路线中,@StateObject 拥有、@ObservedObject 借用观察。
Single Source of Truth 不是一个全局巨大 Store,而是每项业务事实只有一个规范 Owner。能够从 Source 计算的值优先作为 Derived State;需要缓存时,Cache Key 和失效条件必须完整。状态生命周期应匹配 Component、Screen、Flow、Scene、Account 或 App Scope,Identity 改变则意味着旧存储结束并按初值重建。
异步数据流必须处理取消、晚到、重复触发、错误和生命周期。.task(id:) 或 Model-owned Task 都可以成立,但 Effect Owner 必须唯一;Cancellation 之外还需 Revision/Request ID 校验。
真正需要记住的是:先决定谁拥有事实,再决定谁借用和观察;初值不是持续同步,Binding 不是副本,Observation 不是线程安全,Cancel 也不是结果绝不会到达。
问答复盘
Q1:@State 为什么不会随 View Struct 重建而自动丢失?
答: State Storage 与 SwiftUI View Identity 关联,而不是存放在短生命周期 Struct 实例里。Identity 延续时,新 View Value 会重新连接到已有存储。
Q2:Parent 修改 initialCount 后,Child 的 @State 为什么不更新?
答: State(initialValue:) 只在该 Identity 首次建立存储时使用。外部值若应持续作为事实来源,应改用 Binding;若切换实体需要重置,应改变正确的 Identity。
Q3:@Binding 是否拥有被绑定的值?
答: 不拥有。它是对外部状态的读写投影,生命周期和正确性依赖真正 Owner。Child 不应把 Binding 当长期存储或跨 Feature 逃逸。
Q4:@Observable 是否自动让 Model 线程安全?
答: 不会。Macro 提供 Observation 支持,不决定 Actor Isolation、Sendability 或并发同步。UI Model 通常显式使用 Main Actor,后台工作通过异步边界返回结果。
Q5:iOS 17+ 的 @State Reference Model 与旧版 @StateObject 有何关系?
答: 前者用于拥有 @Observable Model,后者用于拥有 Combine-based ObservableObject。它们服务于不同 Observation 世代,不能脱离部署版本和 Model 协议互换。
Q6:@StateObject 与 @ObservedObject 的边界是什么?
答: @StateObject 表示当前 View Identity 创建并拥有对象;@ObservedObject 观察外部 Owner 传入对象。选择依据是所有权,而不是哪个“刷新更少”。
Q7:Single Source of Truth 是否意味着整个 App 只能有一个 Store?
答: 不是。Session、Document、Screen 和 Control 可各有符合 Scope 的 Owner;要求只是同一业务事实不要存在多个可独立写入的副本。
Q8:Derived State 绝对不能缓存吗?
答: 可以缓存昂贵派生,但缓存必须由 Source Revision、Query、Locale 等完整输入决定,能失效且能重建。缓存不能变成第二个可独立修改的事实来源。
Q9:调用 Task.cancel() 后为什么还要检查 Revision?
答: Cancellation 是协作式,底层不可取消工作或竞态 Completion 仍可能晚到。Revision/Request ID 确保只有当前请求可以提交状态。
Q10:搜索功能应使用 .task(id: query) 还是 Model 持有 Task?
答: 两者都可。任务只服务当前 View Identity 时可用 .task(id:);需要显式 Retry、合并 Trigger 或跨短暂展示持续时可由 Model 持有。关键是只有一个 Effect Owner,并处理取消和旧结果。
延伸知识
- SwiftUI Dependency Tracking、Body Evaluation 与 Transaction
- Observation
withObservationTracking和非 SwiftUI 场景 @Bindable、Bindable Projection 与表单架构@EnvironmentObject缺失注入和多实例 Scope- Swift Concurrency Main Actor、Sendable 与 UI Model
- Navigation State、Scene State 与 State Restoration
- Repository、Cache、Offline-first 与 Server State 一致性
- Reducer/State Machine 架构在 SwiftUI 中的适用边界