SwiftUI 数据流最容易被写成一张 property wrapper 速查表:局部值用 @State,父子双向传递用 @Binding,引用模型曾经用 @StateObject。这样的记忆在小 Demo 中够用,却无法回答工程里真正困难的问题:模型应该活多久?谁有资格替换它?一个字段改变时,为什么远处的视图也执行了 body?编辑器为什么拿不到 $model.title

更可靠的起点不是 wrapper,而是三个问题:谁拥有状态,谁读取状态,谁被允许写入状态。Observation 只是让这些关系更精确;它不会替你决定所有权,也不会自动把混乱的共享状态变成良好架构。本文面向已经会写 SwiftUI、正在从 ObservableObject 迁移或准备建立中大型应用数据流的开发者。前置知识是值类型、引用类型、View 的值语义和基本 Swift Concurrency。

版本边界:稳定能力与 Beta 必须分开

Apple 明确说明,SwiftUI 对 Observation 的支持从 iOS 17、iPadOS 17、macOS 14、tvOS 17 与 watchOS 10 开始;@Observable 由宏在编译期生成观察能力,而不是靠手工遵循空协议获得。这些是当前稳定平台能力,可从 Managing model data in your appObservation 迁移指南 交叉确认。

本文代码以 Xcode 26.6、Swift 6.3 的稳定工具链语义为基线。五段精确代码均映射到 examples/swiftui/Sources/WebSunSwiftUIExamples/ObservationExamples.swift,通过 macOS Swift Package 与通用 iOS Simulator 编译;其中模型行为、选择和 Binding 转换还由 CanonicalSnippetTests 执行。这些证据不包含目标设备性能 trace。Apple 的 SwiftUI updates 还列出了 Xcode 27 中 @State 由宏实现等变化;截至本文核验日,Xcode 27 仍是 Beta,因此下文不依赖该行为,也不把它当作已发布契约。这个区分很重要:源码写法可能相同,编译器展开与边缘语义却仍可能变化。

心智模型:状态不是数据,状态是被管理的身份

普通存储属性只是某次 View 值里的数据。SwiftUI 重新求值父视图后,可以创建新的视图值;如果某个值需要跨这些重建保持连续性,就必须交给 SwiftUI 管理。@State 的核心不是“这个值会刷新界面”,而是这个视图身份拥有一块持久存储。因此,布尔开关、选中项、草稿文本适合放在 @State 中;由视图创建并拥有的 @Observable 引用实例,同样可以由 @State 保持身份。

Observation 改变的是依赖记录粒度。WWDC23 的 Discover Observation in SwiftUI 解释了宏如何让普通属性参与追踪;SwiftUI 在执行 body 时记录实际读取过的 observable 属性。当这些属性之后变更,对应视图失效。没有被这次 body 读取的属性发生变化,不应仅因为它们属于同一个模型就让该视图形成依赖。

Observation 模型属性只连接实际读取它们的视图

图 1:读取建立依赖,写入触发相关节点失效;这是本站原创的 1400×800 程序化 SVG。

嵌套内容框通过单向信号连接而观察透镜只照亮真正依赖变化的节点

图 2(1600 × 900):Observation 数据流的编辑式视觉隐喻,不作为运行时依赖图。OpenAI Image Gen × WEB/SUN,2026-07-16;项目内原创生成资产。

这和 ObservableObject 的典型广播模型不同。objectWillChange 或任意 @Published 属性发出通知时,订阅这个对象的视图会收到对象级变化;Observation 则能按读取过的属性收窄依赖。Apple 的 Demystify SwiftUI performance 将 SwiftUI 更新描述成依赖图上的工作:减少无关依赖通常比在 body 里堆更多条件判断更有价值。不过,“更精确”不等于“免费”;如果根视图读取了整个模型的许多字段,它仍然会自然成为一个宽依赖节点。

一套可执行的所有权规则

先把常见角色压缩为下表。重点不是语法,而是所有权和写权限:

场景 声明方式 谁拥有 子视图能否写
视图自己的瞬时值 @State private var query = "" 当前视图身份 通过显式 Binding 授权
视图创建的 observable 模型 @State private var store = Store() 当前视图身份 传引用或 @Bindable
父级传入的值 let article: Article 父级或上层模型 默认不能
父级值的可写投影 @Binding var selection: ID? 父级 可以,范围由 binding 决定
传入的 observable 模型 let store: Store 创建它的上层 属性可按模型访问控制写
需要 $model.property @Bindable var store: Store 仍由外部拥有 可以生成属性 binding
跨层级共享依赖 @Environment(Store.self) 注入者 可读取;绑定需本地 @Bindable

这里有两个容易混淆的结论。第一,@Bindable 不拥有模型,也不负责它的生命周期;它只是为 Observable 引用提供动态成员 binding。第二,把一个类写成 @Observable 并不意味着所有地方都应该共享它。生命周期仍应尽可能靠近真正拥有它的功能边界。

从一个可测试的模型开始

以下模型把文章检索界面的长期状态放在一个引用对象中,同时把异步任务句柄排除在观察之外。@MainActor 明确 UI 模型的隔离域;这不是 Observation 的强制要求,而是避免多个执行器随意修改界面状态的设计选择。

import Observation
import SwiftUI

@MainActor
@Observable
final class ArticleStore {
    var query = ""
    var selectedID: Article.ID?
    private(set) var articles: [Article] = []
    private(set) var loadState: LoadState = .idle

    @ObservationIgnored
    private var loadTask: Task<Void, Never>?

    func reload(using repository: ArticleRepository) {
        loadTask?.cancel()
        loadState = .loading

        loadTask = Task {
            do {
                let result = try await repository.fetchArticles()
                try Task.checkCancellation()
                articles = result
                loadState = .loaded
            } catch is CancellationError {
                // 新请求接管状态,不把取消展示成错误。
            } catch {
                loadState = .failed(message: error.localizedDescription)
            }
        }
    }
}

这个类型仍然不应该直接创建网络客户端、读取全局单例或决定页面导航。Repository 作为参数进入行为,让测试可以传入确定性的替身。private(set) 表达“视图可以读,但只能通过模型行为改变”;相比把所有属性开放写入,这会让状态转换更容易追踪。任务句柄是实现细节,视图不会根据它绘制,因此使用 @ObservationIgnored 是明确的意图表达。

在组合根创建,在功能边界注入

真正拥有 ArticleStore 的功能根视图用 @State 保存实例,再选择显式参数或 Environment 向下分发。Apple 的模型数据文档建议,用 State 管理由视图实例化的 observable 模型,并可通过类型化 Environment 共享。

struct ArticleFeature: View {
    @State private var store = ArticleStore()
    let repository: ArticleRepository

    var body: some View {
        ArticleList()
            .environment(store)
            .task {
                store.reload(using: repository)
            }
    }
}

struct ArticleList: View {
    @Environment(ArticleStore.self) private var store

    var body: some View {
        @Bindable var store = store

        List(filteredArticles) { article in
            ArticleRow(article: article)
        }
        .searchable(text: $store.query)
    }

    private var filteredArticles: [Article] {
        store.articles.filter { article in
            store.query.isEmpty || article.title.localizedCaseInsensitiveContains(store.query)
        }
    }
}

局部的 @Bindable var store = store 只是为 .searchable 生成 $store.query,不会创建第二份状态。Apple 在 Observable 迁移指南 中展示了同一原则:不需要 binding 的子视图直接接收 observable 引用;需要 binding 的编辑视图才使用 @Bindable

Environment 适合“这个功能树普遍需要”的依赖,但不应该成为隐形服务定位器。一个只在两层之间使用的模型,用初始化参数通常更清楚,也更容易在 Preview 和测试中构造。类型化 Environment 的另一个风险是漏注入:在架构层应让组合根集中负责注入,并为独立 Preview 显式提供实例,而不是在深层视图里悄悄创建后备模型。

读取位置决定更新边界

Observation 的优化机会不是把所有状态塞进一个巨型 store,而是让读取发生在最接近展示的位置。假设根视图先计算 let snapshot = store.articles.map(...),再把整个快照传给许多子视图,那么根视图已经读取了 articles,它会在数组改变时重新求值。若只有计数视图需要数量,列表需要条目,就可以分别传入更窄的值,或让各自读取对应属性。

struct ArticleSummary: View {
    let count: Int
    let selectionTitle: String?

    var body: some View {
        LabeledContent("文章", value: count.formatted())
        if let selectionTitle {
            Text(selectionTitle)
        }
    }
}

小值参数让依赖在调用点可见,也让子视图不必知道整个 store。反过来,过度拆分会增加胶水代码;应该优先拆出更新频率不同、计算昂贵或可独立复用的区域,而不是为了“最少刷新”把每个 Text 都包装成新类型。WWDC 的性能建议同样强调判断与反馈循环,而不是无证据地机械优化。

派生数据也要谨慎。把 filteredArticles 写成 observable 存储属性,意味着你必须在 queryarticles 任一变化时同步维护它;漏掉一个入口就产生不一致。若计算便宜,使用计算属性保持单一事实来源;若计算昂贵,再把索引、缓存或后台计算放到专门组件,并用真实 Instruments 证据决定。本文不提供虚构的“刷新减少百分比”,因为那必须基于具体层级、设备和数据量测量。

State、Binding 与引用模型的写入边界

Binding 是能力,不是数据仓库。父视图把 $selection 传给子视图,相当于授予子视图读写某一小块状态的权限。它适合控件和短链路编辑,不适合把所有业务行为都暴露成可写属性。复杂操作更适合命名方法,例如 store.select(_:)store.archive(_:);方法可以验证前置条件、合并多字段更新,并留下测试入口。

对引用模型还要区分“修改属性”和“替换实例”。一个子视图拿到 let store: ArticleStore 后可以修改开放的 var,但不能替换父级持有的 store。这通常正是需要的边界。如果子功能必须替换整个实例,应重新审视所有权:它可能才是真正的拥有者,或者父级应该接收一个明确事件,而不是把实例 binding 向下传。

局部瞬时 UI 状态不要为了“统一”塞进共享模型。某行是否展开、输入框是否聚焦、临时动画相位通常只影响一个视图身份,留在局部 @State@FocusState 更稳妥。跨页面需要恢复的筛选条件、当前文档 ID 等才值得提升到 scene 或功能模型。状态提升的标准是多个消费者需要同一个事实来源,而不是“未来可能用到”。

并发与更新批次

Observation 能追踪变化,但不会替你解决线程安全。面向 UI 的 store 标注 @MainActor 后,异步仓库可以在适当隔离域执行,结果回到主 actor 再提交。避免在后台任务逐条 append 大数组并让界面观察到一串中间态;更易推理的方式是先构造结果,然后一次赋值。如果产品确实需要流式呈现,则把“流式”建模成明确状态,并控制批次,而不是偶然泄漏实现细节。

多字段必须一致变化时,用一个意图方法完成:

func select(_ id: Article.ID?) {
    selectedID = id
    if id == nil {
        loadState = .idle
    }
}

这不保证 SwiftUI 只运行一次 body,也不应该据此作性能承诺;它保证的是业务状态不会经由多个任意写入口漂移。是否需要动画事务、是否会合并更新,是另一个层次的问题,将在本系列动画文章中单独处理。

多窗口环境下,作用域比“全局唯一”重要

在单窗口应用里,把模型放到 App 根部与放到首屏根部看起来差别不大;多窗口后,两者代表完全不同的产品语义。账户会话、只读配置可以由多个 scene 共享,而搜索词、导航选择、正在编辑的草稿通常属于一个窗口。若后者被 App 级单例持有,一个窗口的操作会改变另一个窗口,Observation 只是更快地暴露这个错误。

因此创建位置应匹配恢复位置:需要随 scene 独立存在的模型,在 scene 内容根创建并注入;需要随某个 sheet 生命周期存在的模型,在 sheet 功能根创建;真正跨窗口共享的服务才由 App 组合根提供。共享服务也不必直接成为 observable UI 模型,可以通过协议向各 scene 的 store 提供数据,让每个窗口拥有自己的展示状态。

Environment 的作用域沿视图树传播,而不是“进程全局”。这正适合声明功能边界:外层注入账户服务,scene 根注入窗口 store,子功能再显式接收必要依赖。Preview 应复刻同样层级;若一个视图在 Preview 中总是因为缺少 Environment 崩溃,通常说明它的依赖没有在组件接口或测试夹具中被清楚表达。

自定义 Binding 是边界适配器,不是副作用入口

有时控件所需类型与模型不同,例如 UI 编辑空字符串,而模型使用可选值。可以用 Binding(get:set:) 做小范围转换与同步校验,但 setter 应保持快速、确定:

let title = Binding(
    get: { store.draftTitle ?? "" },
    set: { store.draftTitle = $0.isEmpty ? nil : $0 }
)

不要在 setter 中发网络请求、启动不可取消任务或偷偷改变导航。控件可能频繁写入,SwiftUI 也不承诺 setter 只调用一次。提交、保存、删除仍应通过命名事件进入模型。这样 Binding 只完成表示层适配,业务副作用仍有清晰入口、取消规则和错误状态。

从 ObservableObject 渐进迁移

Apple 明确支持渐进迁移:应用可以暂时混用 ObservableObject@Observable 模型,不必一次性重写全树。推荐顺序是:先迁移叶子模型并移除 @Published;再把创建者从 @StateObject 改为 @State;把 @EnvironmentObject 改为类型化 @Environment;最后只在真正需要 projected binding 的位置加入 @Bindable。每一步都检查视图实际读取了哪些属性。

迁移时最危险的是把旧 wrapper 名称机械替换后就结束。旧代码可能依赖对象级广播:某个视图没有直接读取字段,却因为 objectWillChange 而顺便刷新。Observation 会暴露这类隐含耦合。正确修复不是手动发送更多通知,而是让视图读取真正依赖的状态,或把派生关系变成模型的明确属性。

如果最低系统版本早于 iOS 17 / macOS 14,Observation 的 SwiftUI 集成不能直接作为唯一实现。此时可以继续使用稳定的 ObservableObject 路径,或在模块边界提供不同适配层;不要仅凭编译器支持宏就假设旧系统拥有相同行为。

常见失败模式

  1. body 中创建模型。 let store = ArticleStore() 会随求值产生新实例,异步任务、选择和草稿都可能失去连续身份。
  2. 所有页面共用一个 AppStore。 任何根级读取都会扩大依赖,功能生命周期也无法独立释放。共享服务和界面状态应分层。
  3. @Bindable 当所有者。 它只生成 binding;实例仍必须由 @State、上层对象或其他明确容器持有。
  4. 在计算属性里读取无关 observable 字段。 即使最终分支没显示那个值,执行期间的读取也可能建立依赖。先缩小计算入口。
  5. 用全局单例逃避注入。 Observation 可以追踪单例读取,但依赖会变得隐形,Preview、测试和多窗口隔离都更困难。
  6. 声称 Observation 自动提升性能。 它提供更细的依赖能力;实际收益仍取决于读取位置、视图身份和每次更新成本,必须用 Instruments 验证。

验证清单

源代码评审时,可以逐个状态回答:谁创建它、谁销毁它、谁能替换它、哪些视图读取哪些字段、写入是否经过业务方法、是否需要跨 scene 恢复。本仓库的自动证据已覆盖正文模型的可编译性与部分非视觉状态转换;完整产品还应增加取消语义和用户操作形成正确 binding/展示结果的视图测试。性能问题应在稳定工具链和目标设备上,用 SwiftUI Instruments 的更新原因与时长定位,而不是靠 print 次数推断渲染成本。

最终原则很朴素:@State 表达拥有,普通参数表达依赖,Binding/@Bindable 表达有限写权限,Environment 表达功能树共享。Observation 让“读取了什么”成为更新边界,但架构质量仍来自你是否把所有权放在正确层级。当三个问题都能被代码直接回答时,数据流才真正可维护。

Sources