@Observable 內部機制:巨集、註冊器,以及 ObservableObject 的根本錯誤
Observation 框架於 iOS 17 與 Swift 5.9 引入,以巨集驅動、逐屬性存取追蹤的系統,取代了基於 Combine 的 ObservableObject 模型1。從呼叫端來看,變化看似微小(只需一個 @Observable 巨集,即可取代 : ObservableObject 加上滿地的 @Published),但執行時行為的差異卻足以影響效能、正確性與遷移路徑。一句話概括這項轉變:未讀取已變更屬性的視圖,不會再因該屬性的變動而重新求值。
本文將框架內部機制與 Apple 官方文件、SE-0395 提案交叉對照2。論述主軸是「巨集實際產生了什麼,以及為何如此」,因為多數團隊採用 @Observable 只看上語法簡潔,卻忽略了更新傳播機制上的結構性轉變——而這正是真正效能提升(以及遷移陷阱)的所在。
TL;DR
@Observable是 Swift 巨集,將類別展開為符合Observable標記協定的型別,並合成一個_$observationRegistrar: ObservationRegistrar實例作為儲存屬性3。- 每個屬性的 getter 包裹
_$observationRegistrar.access(self, keyPath:),setter 則包裹_$observationRegistrar.withMutation(of:keyPath:_:)。註冊器負責追蹤哪些範圍存取了哪些 key path。 - 替換對照表:
class Foo: ObservableObject變為@Observable class Foo;@Published var name變為var name;@StateObject var foo = Foo()變為@State var foo = Foo();@EnvironmentObject變為@Environment(Foo.self);@ObservedObject var foo則直接使用該屬性即可。 @Bindable是新的屬性包裝器,用於為可觀察實例的屬性建立綁定(取代@ObservedObject在綁定情境下的部分用法)。- 遷移陷阱:
@State搭配參考型別的行為,在視圖識別性方面與@StateObject有微妙差異。盲目替換的應用程式,可能在視圖重建時產生令人困惑的初始化行為。
巨集展開
當編譯器看到 @Observable 時,會在型別中加入三項內容進行展開3:
@Observable
class UserProfile {
var name: String = ""
var email: String = ""
var preferences: [String] = []
}
展開後(簡化版)會產生:
class UserProfile: Observable {
@ObservationIgnored private let _$observationRegistrar = ObservationRegistrar()
private var _name: String = ""
var name: String {
get {
access(keyPath: \.name)
return _name
}
set {
withMutation(keyPath: \.name) {
_name = newValue
}
}
}
// ... email 與 preferences 採用相同模式
func access<Member>(keyPath: KeyPath<UserProfile, Member>) {
_$observationRegistrar.access(self, keyPath: keyPath)
}
func withMutation<Member, T>(
keyPath: KeyPath<UserProfile, Member>,
_ mutation: () throws -> T
) rethrows -> T {
try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}
}
三項結構性變化:
註冊器。 一個私有 ObservationRegistrar 實例擁有追蹤狀態。註冊器是模型變動與相依範圍重新求值之間的橋樑。巨集將其標記為 @ObservationIgnored,讓註冊器本身不會被追蹤。
屬性儲存重寫。 每個宣告的儲存屬性,會變成一個私有後備欄位,加上一個計算屬性,其 getter 與 setter 會呼叫到註冊器。編譯器產生的存取器,正是讓逐屬性追蹤得以運作的關鍵。
對 Observable 的遵循。 這是註冊器的 API 所期望的標記協定。該協定沒有任何要求;它只是個遵循檢查,並非介面契約。
註冊器的職責
ObservationRegistrar 做兩件事3:
追蹤存取。 當 withObservationTracking { ... } onChange: { ... }(SwiftUI 用於視圖主體的底層追蹤 API)執行其閉包時,註冊器會記錄被讀取的每一組 (self, keyPath) 配對。這組被存取的路徑集合,就是該範圍的「相依足跡」。
觸發失效。 當屬性發生變動時,註冊器會找出每一個曾存取該特定 keyPath 的範圍,並觸發其 onChange 閉包。未存取該 keyPath 的範圍則不受影響。
與 ObservableObject 的對比正是這項結構性轉變。ObservableObject 的 objectWillChange 發布者會在每次 @Published 變動時觸發,所有訂閱者都會收到通知。SwiftUI 的視圖主體機制利用該發布者得知「有東西變了,該重新求值」。重新求值會針對整個視圖執行;接著 SwiftUI 會計算哪些相依視圖實際發生變化,並只更新那些視圖,但主體重新求值的動作早已發生。改用 @Observable 後,主體重新求值本身就受到把關:若主體未讀取已變更的屬性,就不會重新執行。
以一個擁有三個屬性的 UserProfile 為例,假設某視圖只讀取 name,差異就很實際:@ObservableObject 模型在 email 與 preferences 變動時也會觸發主體重新求值;而 @Observable 模型則不會。在擁有眾多模型與視圖的複雜應用中,累積下來的節省相當可觀。
遷移對照
並列呈現的遷移對照表4:
| ObservableObject | @Observable |
|---|---|
class Foo: ObservableObject |
@Observable class Foo |
@Published var name: String |
var name: String |
@StateObject var foo = Foo() |
@State var foo = Foo() |
@ObservedObject var foo: Foo |
var foo: Foo(若需綁定則使用 @Bindable var foo: Foo) |
@EnvironmentObject var foo: Foo |
@Environment(Foo.self) var foo |
.environmentObject(foo) |
.environment(foo) |
@Bindable 包裝器值得另闢段落說明。它是為 @Observable 實例屬性建立 Binding 的新方式:
@Bindable var profile: UserProfile
TextField("Name", text: $profile.name)
TextField("Email", text: $profile.email)
若沒有 @Bindable,$profile.name 這樣的語法就無法運作,因為 @Observable 型別不會自動提供投影值。加上它之後,每個屬性都會具備綁定形式。當子視圖需要對父層的可觀察模型進行雙向綁定時,使用 @Bindable;若子視圖只負責讀取,則使用普通參考(var profile: UserProfile)即可。
@State 與 @StateObject 的陷阱
最容易在生產環境中引發 bug 的遷移就是這條:@StateObject var foo = Foo() 變為 @State var foo = Foo()。改寫可以通過編譯,但行為會透過一個微妙的機制產生分歧:預設值表達式的求值時機5。
當視圖識別性穩定時,@State 與 @StateObject 都會在 SwiftUI 的視圖重建期間保留實例;兩者依識別性建立索引的後備儲存,都會丟棄父層驅動的重新初始化。差別在於初始化表達式何時執行。
@StateObject 透過 @autoclosure 宣告其參數。Foo() 初始化表達式被包裹起來,只有在 SwiftUI 實際需要建構實例時才會求值。在父層重建、視圖識別性得以保留、現有實例被重複使用的情況下,該表達式根本不會被呼叫。昂貴的初始化器永遠不會觸發。
@State 則沒有 autoclosure 包裹。每當視圖的 init 執行時(在每次父層重建時都會發生,即便視圖識別性得以保留、現有實例仍存於儲存中),Foo() 初始化表達式都會被急切地求值。Foo() 配置動作確實發生;SwiftUI 隨後丟棄新實例,繼續使用已儲存的那個。對於 init() 成本低廉的模型而言,這份浪費的配置幾乎不可見。但對於 init() 昂貴的模型來說(網路請求、大量資料載入、在 init 中啟動的非同步工作),其差異就是「應用能正常運作」與「應用在每次父層重建時 DDoS 自家後端」之間的天壤之別。
防禦性做法:讓模型的 init() 保持低成本,使這個差異無關緊要;或者在應用層級僅初始化一次昂貴的模型,再透過 .environment() 向下傳遞。需要昂貴設定工作的模型,無論由哪個屬性包裝器持有,都不該在 init 中執行該工作;對於 @State 與 @StateObject 兩種情境,惰性初始化或明確的 setup 方法才是正確模式。
用 withObservationTracking 進行明確追蹤
在 SwiftUI 之外,追蹤的基本元件是 withObservationTracking { ... } onChange: { ... }6:
import Observation
let profile = UserProfile()
withObservationTracking {
print("Name: \(profile.name)")
} onChange: {
print("Something we read changed")
}
profile.name = "Alice" // 觸發 onChange
profile.email = "..." // 不會觸發 onChange(我們沒讀取它)
該閉包只執行一次,並記錄每一次可觀察存取。當這些存取所對應的來源屬性發生變動時,onChange 會被觸發恰好一次(它是一次性回呼)。若需重新追蹤,必須再次設定該閉包。這正是 SwiftUI 內部用來追蹤視圖主體相依性的模式;對於非 SwiftUI 程式碼(NSWindowController、Cocoa 應用、命令列工具),withObservationTracking 才是正確的基本元件。
何時 ObservableObject 仍是正確選擇
以下三種情況,ObservableObject 仍有其立足之地:
目標為 iOS 16 及更舊版本的應用。 Observation 框架要求 iOS 17 以上。部署目標較舊的應用必須使用 ObservableObject。一旦部署目標升級至 17 以上,遷移就是安全的。
需要在值圖之外發布通知的模型。 ObservableObject 的 objectWillChange 是 Combine 發布者;若程式碼想透過 Combine 管線訂閱「任何變動」(去抖動、節流、轉換事件流),使用 ObservableObject 即可免費取得;若改用 @Observable,則必須重新打造對應功能。Observation 框架優先考量視圖重新求值的效率,而非任意的發布者訂閱。
遷移成本超過效益的既有程式碼庫。 一個運作良好、未測量出效能問題的 ObservableObject 程式碼庫,從遷移中獲得的收益不足以證明全面審視的合理性。應在已經動到該檔案、或剖析發現熱點時再進行遷移。
對於新程式碼,在 iOS 17 以上的目標上,@Observable 是現代化的預設選擇,遷移路徑也很明確。
此模式對 iOS 26+ 應用的意義
三項要點。
-
新程式碼預設使用
@Observable。 巨集語法簡潔,逐屬性追蹤在常見情境下能提升效能,遷移對照清楚明瞭。iOS 17 以上程式碼庫中的新模型,都應採用@Observable。 -
針對
@StateObject→@State的遷移審視視圖識別性。 這項替換能順利通過編譯,但在具有條件式結構的視圖中,可能產生意料之外的重新初始化。執行昂貴init()工作的模型需要謹慎遷移;不執行的則可放心。 -
謹慎使用
@Bindable。 這是對可觀察模型進行雙向綁定的新模式。在需要變動父層模型的子視圖中採用它;在唯讀視圖中則保留普通參考(var foo: Foo)即可。
完整的 Apple 生態系列:具型別的 App Intents、MCP 伺服器、路由問題、Foundation Models、執行時與工具 LLM 的區別、三大介面、單一資料來源模式、兩個 MCP 伺服器、Apple 開發的 hooks、Live Activities、watchOS 執行時、SwiftUI 內部機制、RealityKit 的空間心智模型、SwiftData 結構紀律、Liquid Glass 模式、多平台出貨、平台矩陣、Vision 框架、Symbol Effects、Core ML 推論、Writing Tools 的 API 採用、Swift Testing、Privacy Manifest、輔助使用作為平台特性、SF Pro 字體系統、visionOS 空間模式、Speech 框架、SwiftData 遷移、tvOS 焦點引擎、我拒絕書寫的主題。中樞頁面位於 Apple 生態系列。若想了解更廣泛的「iOS 結合 AI 代理」相關脈絡,請參閱 iOS Agent Development 指南。
FAQ
Apple 為何要取代 ObservableObject?
兩個原因。第一是效能:ObservableObject 的 objectWillChange 發布者會在每次 @Published 變動時觸發,讓每個相依視圖都進行主體重新求值,無論該視圖是否真的讀取了已變更的屬性。@Observable 的逐屬性追蹤,則以視圖實際存取的屬性為界,把關主體重新求值。第二是語法:逐屬性的 @Published 註解,加上 @StateObject/@ObservedObject/@EnvironmentObject 這套階梯,對於概念上其實只是一件事(「這是可變的共享狀態」)而言過於冗長。@Observable 加上 @State 加上 @Environment 簡短得多。
@Observable 能搭配 struct 使用嗎?
不行。@Observable 需要參考語意,struct 並不符合。該巨集是為了讓類別跨視圖持有可變狀態而設計。若是單一視圖內的值型別狀態,直接搭配值型別使用 @State 即可。
我能在同一個應用中同時使用 @Observable 與 ObservableObject 嗎?
可以。兩者並存無衝突。遷移可以逐檔進行。邊界以型別為單位:一個類別只能擇一,要嘛是 ObservableObject,要嘛是 @Observable,但同一個應用中的不同類別可以採用不同做法。
那些觸發 Combine 管線的 @Published 屬性怎麼辦?
@Observable 並未為個別屬性提供等同於 Combine 發布者的機制。使用 $foo.publisher 模式存取 @Published 屬性的程式碼,在改用 @Observable 後必須以不同方式重新建構訂閱(例如將該屬性包裝在值型別模型中、透過 SwiftUI 的更新週期觀察,或反覆使用 withObservationTracking)。對於大量使用 Combine 的程式碼路徑,遷移確實是真槍實彈的工程任務。
@Observable 如何與 SwiftData 的 @Model 互動?
@Model(SwiftData)型別會自動成為 @Observable。持久化框架在程式碼產生階段就加入了 Observable 遵循,因此 SwiftData 模型與普通 @Observable 型別共用相同的逐屬性追蹤機制。觀察 @Model 型別屬性的視圖,享有同樣細緻的重新求值行為。本系列的 SwiftData 遷移 與 SwiftData 結構紀律 兩篇文章,涵蓋了同一觀察介面的持久化面向。
@ObservationIgnored 用來做什麼?
它能讓某個儲存屬性退出觀察追蹤。巨集通常會將每個儲存屬性重寫為經由註冊器處理;標記為 @ObservationIgnored 的屬性則保留直接儲存,不進行追蹤。適用於不應觸發視圖重新求值的屬性:快取、檔案控點、指標計數器,以及註冊器本身。
參考資料
-
Apple Developer Documentation:Observation framework。涵蓋
Observable協定與@Observable巨集的框架參考文件。可用於 iOS 17+、macOS 14+、Swift 5.9+。 ↩ -
Swift Evolution:SE-0395 Observability。已通過的 Swift 提案,內含設計理念、語意要求,以及註冊器協定契約。 ↩
-
Apple Developer Documentation:
ObservationRegistrar與Observable。巨集所遵循的執行時型別,以及合成存取器所呼叫的註冊器 API。 ↩↩↩ -
Apple Developer Documentation:Migrating from the Observable Object protocol to the Observable macro。Apple 官方遷移指南,涵蓋屬性包裝器對照表與 SwiftUI 整合上的變動。 ↩
-
Apple Developer Documentation:
State與StateObject。兩種屬性包裝器在視圖識別性與重建生命週期方面有文件記載的初始化語意。 ↩ -
Apple Developer Documentation:
withObservationTracking(_:onChange:)。SwiftUI 自動視圖主體追蹤之外所使用的明確追蹤基本元件。 ↩