← Wszystkie wpisy

Wewnętrzne mechanizmy @Observable: makro, rejestrator i to, w czym ObservableObject się mylił

Framework Observation, wprowadzony w iOS 17 i Swift 5.9, zastąpił oparty na Combine model ObservableObject systemem opartym na makrach, który śledzi dostęp do poszczególnych właściwości1. Zmiana wygląda niepozornie w miejscu wywołania (jedno makro @Observable zamiast : ObservableObject plus @Published przy każdej właściwości), niemniej zachowanie w czasie wykonywania jest na tyle inne, że wpływa na wydajność, poprawność oraz ścieżkę migracji. Przesunięcie da się ująć w jednym zdaniu: widoki, które nie odczytały zmienionej właściwości, nie są ponownie ewaluowane, gdy ta właściwość ulegnie zmianie.

Niniejszy wpis przechodzi przez wnętrzności frameworku, opierając się na dokumentacji Apple oraz propozycji SE-03952. Ramą rozważań jest „co makro faktycznie generuje i dlaczego”, ponieważ większość zespołów adoptuje @Observable ze względu na składnię i przeocza strukturalne przesunięcie w propagacji aktualizacji — a to właśnie tam kryje się rzeczywisty zysk wydajnościowy (i pułapki migracyjne).

TL;DR

  • @Observable to makro Swifta, które rozwija klasę w typ zgodny z protokołem znacznikowym Observable, syntezując instancję _$observationRegistrar: ObservationRegistrar jako właściwość przechowywaną3.
  • Getter każdej właściwości opakowuje wywołanie _$observationRegistrar.access(self, keyPath:). Setter każdej właściwości opakowuje _$observationRegistrar.withMutation(of:keyPath:_:). Rejestrator śledzi, które zakresy uzyskały dostęp do których ścieżek kluczy.
  • Słownik zamienników: class Foo: ObservableObject staje się @Observable class Foo. @Published var name staje się var name. @StateObject var foo = Foo() staje się @State var foo = Foo(). @EnvironmentObject staje się @Environment(Foo.self). @ObservedObject var foo zamienia się w zwykłe użycie właściwości.
  • @Bindable to nowy property wrapper służący do tworzenia powiązań z właściwościami instancji obserwowalnej (zastępuje część zastosowań @ObservedObject w kontekście bindingów).
  • Pułapka migracji: @State z typem referencyjnym zachowuje się inaczej niż @StateObject w subtelnych aspektach związanych z tożsamością widoku. Aplikacje, które podmieniają je bez rozwagi, mogą wytwarzać mylące zachowania inicjalizacyjne podczas przebudów widoku.

Rozwinięcie makra

Gdy kompilator napotka @Observable, rozwija typ, dodając trzy elementy3:

@Observable
class UserProfile {
    var name: String = ""
    var email: String = ""
    var preferences: [String] = []
}

Rozwinięcie (uproszczone) generuje:

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
            }
        }
    }
    // ... ten sam wzorzec dla email i 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)
    }
}

Trzy zmiany strukturalne:

Rejestrator. Prywatna instancja ObservationRegistrar jest właścicielem stanu śledzenia. Rejestrator stanowi pomost między mutacjami w modelu a ponownymi ewaluacjami zależnych zakresów. Makro oznacza go jako @ObservationIgnored, dzięki czemu sam rejestrator nie podlega śledzeniu.

Przepisanie magazynu właściwości. Każda zadeklarowana właściwość przechowywana zostaje zamieniona na prywatne pole zaplecza oraz właściwość obliczeniową, której getter i setter wywołują rejestrator. To właśnie akcesory generowane przez kompilator sprawiają, że śledzenie na poziomie pojedynczych właściwości w ogóle działa.

Zgodność z Observable. Protokół znacznikowy, którego oczekuje API rejestratora. Protokół nie ma wymagań; jest sprawdzeniem zgodności, a nie kontraktem interfejsu.

Zadanie rejestratora

ObservationRegistrar wykonuje dwie czynności3:

Śledzenie dostępu. Gdy withObservationTracking { ... } onChange: { ... } (bazowe API śledzenia, którego SwiftUI używa dla ciał widoków) uruchamia domknięcie, rejestrator zapisuje każdą parę (self, keyPath), która została odczytana. Zbiór odczytanych ścieżek stanowi „odcisk zależności” danego zakresu.

Wyzwalanie unieważnienia. Gdy właściwość zostaje zmodyfikowana, rejestrator odnajduje każdy zakres, który uzyskał dostęp do tej konkretnej ścieżki klucza, i wyzwala jego domknięcie onChange. Zakresy, które nie sięgnęły po daną ścieżkę klucza, pozostają nietknięte.

Kontrast z ObservableObject polega na przesunięciu strukturalnym. Publisher objectWillChange z ObservableObject wyzwala się przy każdej mutacji @Published, a wszyscy subskrybenci otrzymują powiadomienie. Maszyneria ciał widoków SwiftUI używa tego publishera, aby dowiedzieć się: „coś się zmieniło — przeprowadź ponowną ewaluację”. Ponowna ewaluacja przebiega na całym widoku; SwiftUI następnie ustala, które widoki zależne faktycznie się zmieniły, i aktualizuje tylko je, ale ponowna ewaluacja ciała już się odbyła. W przypadku @Observable sama ponowna ewaluacja ciała jest bramkowana: jeśli ciało nie odczytało zmienionej właściwości, nie zostaje uruchomione ponownie.

Dla UserProfile z trzema właściwościami i widoku, który czyta wyłącznie name, różnica jest realna: model @ObservableObject wyzwala ponowną ewaluację ciała również przy zmianach email i preferences; model @Observable — nie. W rozbudowanej aplikacji z wieloma modelami i wieloma widokami skumulowane oszczędności są znaczące.

Mapowanie migracji

Słownik migracji, w zestawieniu obok siebie4:

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 (lub @Bindable var foo: Foo dla bindingów)
@EnvironmentObject var foo: Foo @Environment(Foo.self) var foo
.environmentObject(foo) .environment(foo)

Wrapper @Bindable zasługuje na osobną wzmiankę. To nowy sposób tworzenia obiektów Binding do właściwości instancji @Observable:

@Bindable var profile: UserProfile

TextField("Name", text: $profile.name)
TextField("Email", text: $profile.email)

Bez @Bindable składnia $profile.name nie zadziała, ponieważ typy @Observable nie udostępniają automatycznie projected values. Z @Bindable każda właściwość zyskuje formę bindingu. Warto sięgać po @Bindable, gdy widok podrzędny potrzebuje dwukierunkowego powiązania z modelem obserwowalnym rodzica; w sytuacji, gdy widok podrzędny tylko odczytuje, wystarczy zwykła referencja (var profile: UserProfile).

Pułapka @State kontra @StateObject

Linia migracji, która wywołuje najwięcej błędów produkcyjnych: @StateObject var foo = Foo() staje się @State var foo = Foo(). Zmiana się kompiluje. Zachowanie rozjeżdża się przez subtelny mechanizm — sposób ewaluowania wyrażenia wartości domyślnej5.

Zarówno @State, jak i @StateObject zachowują instancję między przebudowami widoku w SwiftUI, gdy tożsamość widoku jest stabilna; oba magazyny zaplecza, kluczowane tożsamością, odrzucają reinicjalizacje wymuszone przez rodzica. Różnica leży w tym, kiedy uruchamiane jest wyrażenie inicjalizatora.

@StateObject deklaruje swój parametr przez @autoclosure. Wyrażenie inicjalizatora Foo() jest opakowane i ewaluowane tylko wtedy, gdy SwiftUI faktycznie potrzebuje skonstruować instancję. Podczas przebudów rodzica, w których tożsamość widoku zostaje zachowana, a istniejąca instancja jest ponownie używana, wyrażenie nie zostaje w ogóle wywołane. Kosztowny inicjalizator nigdy się nie uruchamia.

@State nie jest opakowane w autoclosure. Wyrażenie inicjalizatora Foo() zostaje gorliwie wyewaluowane przy każdym uruchomieniu init widoku (a to dzieje się przy każdej przebudowie rodzica, nawet gdy tożsamość widoku jest zachowana, a istniejąca instancja pozostaje w magazynie). Alokacja Foo() ma miejsce; SwiftUI odrzuca nową instancję i kontynuuje pracę z tą przechowywaną. Dla modeli z tanim init() zmarnowana alokacja pozostaje niewidoczna. Dla modeli z kosztownym init() (żądania sieciowe, ładowanie dużych danych, praca asynchroniczna uruchamiana w init) różnica jest różnicą między aplikacją, która działa, a aplikacją, która sama wykonuje DDoS na własnym backendzie przy każdej przebudowie rodzica.

Wzorzec defensywny: utrzymywać tani init() modelu, aby ta różnica nie miała znaczenia, lub jednorazowo zainicjalizować kosztowny model na poziomie aplikacji i przekazać go w dół przez .environment(). Modele wymagające kosztownego setupu nie powinny wykonywać tej pracy w init, niezależnie od tego, który property wrapper je trzyma; inicjalizacja leniwa lub jawne metody konfiguracyjne to właściwy wzorzec zarówno dla @State, jak i dla @StateObject.

withObservationTracking dla śledzenia jawnego

Poza SwiftUI prymitywem śledzenia jest withObservationTracking { ... } onChange: { ... }6:

import Observation

let profile = UserProfile()

withObservationTracking {
    print("Name: \(profile.name)")
} onChange: {
    print("Something we read changed")
}

profile.name = "Alice"  // Wyzwala onChange
profile.email = "..."   // NIE wyzwala onChange (nie odczytaliśmy go)

Domknięcie uruchamia się jednorazowo i zapisuje każdy obserwowalny dostęp. Gdy którakolwiek z tych właściwości źródłowych ulegnie zmianie, onChange wyzwala się dokładnie raz (jest to wywołanie zwrotne typu jednostrzałowego). Aby ponownie śledzić, domknięcie trzeba ustanowić od nowa. Tego wzorca SwiftUI używa wewnętrznie do śledzenia zależności ciała widoku; dla kodu spoza SwiftUI (NSWindowController, aplikacje Cocoa, narzędzia wiersza poleceń) withObservationTracking jest właściwym prymitywem.

Kiedy ObservableObject pozostaje słusznym wyborem

Trzy przypadki, w których ObservableObject zachowuje swoje miejsce:

Aplikacje celujące w iOS 16 i wcześniejsze. Framework Observation jest dostępny dopiero od iOS 17. Aplikacje ze starszymi celami wdrożeniowymi muszą korzystać z ObservableObject. Po przesunięciu celu wdrożeniowego na iOS 17+ migracja staje się bezpieczna.

Modele, które muszą publikować powiadomienia poza grafem wartości. objectWillChange z ObservableObject jest publisherem Combine; kod, który chce subskrybować „dowolną zmianę” poprzez potoki Combine (debouncing, throttling, transformowanie strumienia zdarzeń), otrzymuje to za darmo z ObservableObject i musiałby odbudować równoważną funkcjonalność z @Observable. Framework Observation stawia wydajność ponownej ewaluacji widoków ponad dowolnymi subskrypcjami publishera.

Istniejące bazy kodu, w których koszt migracji przewyższa zysk. Działająca baza kodu na ObservableObject, która nie wykazała problemu wydajnościowego, nie zyskuje na migracji wystarczająco, aby uzasadnić audyt. Migrację warto przeprowadzać, gdy plik i tak jest dotykany albo gdy profilowanie wskazuje punkt zapalny.

Dla nowego kodu, w celach iOS 17+, @Observable jest nowoczesnym domyślnym wyborem, a ścieżka migracji jest jasna.

Co ten wzorzec oznacza dla aplikacji iOS 26+

Trzy wnioski.

  1. Domyślnie używać @Observable w nowym kodzie. Makro jest zwięzłe, śledzenie na poziomie pojedynczych właściwości poprawia wydajność w typowych przypadkach, a słownik migracji jest klarowny. Nowe modele w bazach kodu iOS 17+ powinny być @Observable.

  2. Audytować migracje @StateObject@State pod kątem tożsamości widoku. Zamiana kompiluje się czysto, ale może wytwarzać zaskakujące reinicjalizacje w widokach o strukturze warunkowej. Modele wykonujące kosztowną pracę w init() wymagają starannej migracji; modele, które tego nie robią, są bezpieczne.

  3. @Bindable używać świadomie. To nowy wzorzec dla powiązań dwukierunkowych z modelami obserwowalnymi. Warto sięgać po niego w widokach podrzędnych, które muszą mutować model rodzica; zwykłą referencję (var foo: Foo) zachować dla widoków tylko do odczytu.

Pełny klaster Apple Ecosystem: typowane App Intents; serwery MCP; pytanie o routing; Foundation Models; rozróżnienie runtime kontra tooling LLM; trzy powierzchnie; wzorzec pojedynczego źródła prawdy; Two MCP Servers; hooki dla rozwoju Apple; Live Activities; runtime watchOS; wnętrzności SwiftUI; przestrzenny model mentalny RealityKit; dyscyplina schematu SwiftData; wzorce Liquid Glass; dostarczanie wieloplatformowe; matryca platform; framework Vision; Symbol Effects; inferencja Core ML; API Writing Tools; Swift Testing; Privacy Manifest; Dostępność jako platforma; system typografii SF Pro; wzorce przestrzenne visionOS; framework Speech; migracje SwiftData; silnik fokusu tvOS; o czym odmawiam pisać. Centrum znajduje się w serii Apple Ecosystem. Szerszy kontekst iOS w połączeniu z agentami AI znaleźć można w przewodniku iOS Agent Development.

FAQ

Dlaczego Apple zastąpiło ObservableObject?

Z dwóch powodów. Po pierwsze — wydajność: publisher objectWillChange z ObservableObject wyzwala się przy każdej mutacji @Published, wymuszając ponowną ewaluację ciała każdego zależnego widoku, niezależnie od tego, czy widok faktycznie odczytuje zmienioną właściwość. Śledzenie na poziomie pojedynczych właściwości w @Observable bramkuje ponowną ewaluację ciała na właściwości, do której widok rzeczywiście sięga. Po drugie — składnia: adnotacja @Published przy każdej właściwości oraz drabina @StateObject/@ObservedObject/@EnvironmentObject była rozwlekła w stosunku do tego, co jest koncepcyjnie jedną ideą („to jest mutowalny stan współdzielony”). @Observable plus @State plus @Environment jest krócej.

Czy @Observable działa ze strukturami?

Nie. @Observable wymaga semantyki referencyjnej; struktury jej nie mają. Makro jest przeznaczone dla klas trzymających mutowalny stan między widokami. Dla stanu typu wartościowego w pojedynczym widoku należy używać @State bezpośrednio z typem wartościowym.

Czy mogę używać @Observable i ObservableObject w tej samej aplikacji?

Tak. Współistnieją bez konfliktu. Migracja może postępować plik po pliku. Granica przebiega na poziomie typu: klasa jest albo ObservableObject, albo @Observable, ale nie jednocześnie obu, jednakże różne klasy w tej samej aplikacji mogą stosować różne podejścia.

A co z właściwościami @Published zasilającymi potoki Combine?

@Observable nie udostępnia odpowiednika publishera Combine dla pojedynczych właściwości. Kod używający wzorców $foo.publisher z właściwości @Published musi przebudować subskrypcję inaczej w @Observable (np. opakowując właściwość w model typu wartościowego i obserwując przez cykl aktualizacji SwiftUI lub używając wielokrotnie withObservationTracking). Dla ścieżek kodu mocno opartych na Combine migracja oznacza realną pracę inżynierską.

Jak @Observable współgra z @Model z SwiftData?

Typy @Model (SwiftData) są automatycznie @Observable. Framework persystencji dodaje zgodność z Observable w ramach swojego codegenu, dzięki czemu modele SwiftData uczestniczą w tym samym śledzeniu na poziomie pojedynczych właściwości co zwykłe typy @Observable. Widoki obserwujące właściwości typu @Model otrzymują to samo drobnoziarniste zachowanie ponownej ewaluacji. Wpisy z klastra migracje SwiftData i dyscyplina schematu SwiftData omawiają stronę persystencji tej samej powierzchni obserwacji.

Do czego służy @ObservationIgnored?

Wyłącza właściwość przechowywaną ze śledzenia obserwacji. Makro normalnie przepisuje każdą właściwość przechowywaną tak, aby przechodziła przez rejestrator; właściwości oznaczone @ObservationIgnored zachowują bezpośredni magazyn bez śledzenia. Warto z tego korzystać dla właściwości, które nie powinny wyzwalać ponownej ewaluacji widoku: cache’y, uchwyty plików, liczniki metryk, sam rejestrator.

Źródła


  1. Dokumentacja Apple Developer: Framework Observation. Referencja frameworku obejmująca protokół Observable oraz makro @Observable. Dostępne od iOS 17+, macOS 14+, Swift 5.9+. 

  2. Swift Evolution: SE-0395 Observability. Zaakceptowana propozycja Swifta zawierająca uzasadnienie projektowe, wymagania semantyczne i kontrakt protokołu rejestratora. 

  3. Dokumentacja Apple Developer: ObservationRegistrar i Observable. Typy runtime’u, do których makro generuje zgodność, oraz API rejestratora wywoływane przez syntezowane akcesory. 

  4. Dokumentacja Apple Developer: Migracja z protokołu Observable Object do makra Observable. Oficjalny przewodnik migracji Apple obejmujący tabelę mapowania property wrapperów oraz zmiany w integracji ze SwiftUI. 

  5. Dokumentacja Apple Developer: State i StateObject. Udokumentowana semantyka inicjalizacji obu property wrapperów wokół tożsamości widoku i cyklu życia przebudowy. 

  6. Dokumentacja Apple Developer: withObservationTracking(_:onChange:). Jawny prymityw śledzenia używany poza automatycznym śledzeniem ciała widoku w SwiftUI. 

Powiązane artykuły

Liquid Glass in SwiftUI: Three Patterns From Shipping Return on iOS 26

Apple's Liquid Glass is a one-line SwiftUI API. Three patterns from Return go beyond .glassEffect(): glass on text via C…

19 min czytania

HealthKit + SwiftUI on iOS 26: Authorization, Sample Types, and Cross-Platform Patterns

Real production patterns from Water (water tracking, HKQuantitySample) and Return (mindful sessions, HKCategorySample). …

17 min czytania

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 min czytania