Historia pytania
Przed Swift 5.9 programowanie reaktywne w SwiftUI opierało się na protokole ObservableObject w połączeniu z frameworkiem Combine. Programiści ręcznie oznaczali właściwości za pomocą @Published, aby zsyntetyzować publikatory, lub wywoływali objectWillChange.send(), aby powiadomić widoki o mutacjach. Ten wzorzec cierpiał z powodu grubego graniczenia aktualizacji - każda zmiana właściwości powodowała pełną reewaluację ciała widoku - i wymuszał semantykę referencyjną, co uniemożliwiało użycie struktur dla złożonych modeli widoków. Framework Observation został wprowadzony w celu zapewnienia drobnoziarnistej, automatycznej reaktywności bez jawnych deklaracji publikatorów.
Problem
Głównym wyzwaniem było wykrywanie dostępu do właściwości i mutacji bez jawnego kodu szablonu, przy jednoczesnym zachowaniu bezpieczeństwa typów i wysokiej wydajności. Tradycyjne rozwiązania wymagały manualnego obserwowania zmiany wartości klucza (KVO), co jest typu stringly-typed i kruchym, lub typów opakowujących, które zagracały model domeny. System musiał przechwytywać operacje odczytu i zapisu, aby dynamicznie rejestrować zależności, ale bez kosztów związanych z czasem wykonywania swizzlingu metod lub ograniczeń architektonicznych propagacji tożsamości liczników referencji ObservableObject.
Rozwiązanie
Swift wykorzystuje makro @Observable, które łączy możliwości makr rówieśników, członków i akcesorów. Podczas kompilacji makro przekształca oznaczoną klasę, wstrzykując prywatny obiekt ObservationRegistrar. Następnie przekształca każdą przechowywaną właściwość w akcesory obliczeniowe, które otaczają odczyty _$observationRegistrar.track(self, keyPath: ...) i zapisy powiadomieniami willSet/didSet. To rozszerzenie automatycznie syntetyzuje zgodność z protokołem Observable i implementuje wymaganą właściwość obliczeniową observationRegistrar. SwiftUI integruje się z tym rejestratorem podczas oceny ciała widoku, rejestrując siebie jako obserwatora tylko dla właściwości, które faktycznie były używane, osiągając w ten sposób drobnoziarniste aktualizacje bez ręcznej konfiguracji Combine.
@Observable class SettingsViewModel { var isDarkModeEnabled = false var notificationCount = 0 } // Koncepcyjna ekspansja kompilatora: class SettingsViewModel { private let _$observationRegistrar = ObservationRegistrar() var isDarkModeEnabled: Bool { get { _$observationRegistrar.track(self, keyPath: \.isDarkModeEnabled) return _isDarkModeEnabled } set { _$observationRegistrar.willSet(self, keyPath: \.isDarkModeEnabled) let oldValue = _isDarkModeEnabled _isDarkModeEnabled = newValue _$observationRegistrar.didSet(self, keyPath: \.isDarkModeEnabled, oldValue: oldValue) } } private var _isDarkModeEnabled = false // ... identyczny wzorzec dla notificationCount }
Projektujesz aplikację SwiftUI do real-time'owych tablic finansowych, wyświetlającą na żywo ceny akcji, sumy portfela użytkownika i źródła wiadomości. Model widoku zawiera trzydzieści różnych właściwości, od flag UI booleanowych po złożone struktury danych wykresów.
Początkowo zespół zrealizował to przy użyciu ObservableObject z opakowaniami @Published dla każdej właściwości. Spowodowało to poważne pogorszenie wydajności: gdy jedna cena akcji uległa aktualizacji, cały dashboard był przeliczany, ponieważ ObservableObject powiadamia, że „coś się zmieniło” w całym obiekcie, brakuje granularity. Kod był również obszerny, wymagając powtarzających się deklaracji @Published var oraz ręcznego magazynowania AnyCancellable w celu zapobieżenia wyciekom pamięci w subskrypcjach.
Zespół ocenił trzy podejścia architektoniczne w celu rozwiązania problemów z wydajnością i kodem szablonu.
Pierwsze podejście polegało na manualnej optymalizacji Combine. Tworzyliby indywidualne instancje PassthroughSubject dla każdej krytycznej właściwości i subskrybowaliby konkretne aktualizacje za pomocą .onReceive. Zaletą była precyzyjna kontrola nad tym, które komponenty UI były odświeżane. Wadą było masywne powiększenie kodu - trzydzieści subiektów wymagało trzydziestu subskrypcji i podatnego na błędy ręcznego zarządzania pamięcią z Set<AnyCancellable>, co uczyniło bazę kodu nie do utrzymania.
Drugie podejście sugerowało użycie SwiftUI's @State z modelami widoków typu wartości. Traktowaliby model widoku jako niezmienny wynik i zastępowaliby go przy każdej mutacji. Zaletą była naturalna semantyka wartości i automatyczne sprawdzanie równości zapobiegające zbędnym aktualizacjom. Wadą było utrata tożsamości referencyjnej; każda mutacja tworzyła nową instancję, łamiąc przywracanie pozycji ScrollView i uniemożliwiając złożoną koordynację obiektów z powodu utraty tożsamości.
Trzecie podejście przyjęło makro @Observable. Oznaczając klasę jako @Observable i usuń wszystkie atrybuty @Published, kompilator automatycznie przekształcał właściwości, aby używać ObservationRegistrar. Zaletą była podwójna: składnia pozostała czysta z prostymi deklaracjami var, a SwiftUI automatycznie śledziło, które właściwości były używane w każdej ciałowej widoku, aktualizując tylko te konkretne podwidoki. Wadą był wymóg migracji do Swift 5.9 i przeszkolenia zespołu w technikach debugowania makr.
Zespół wybrał trzecie podejście, ponieważ zlikwidowało 200 linii kodu subskrypcyjnego Combine, rozwiązując problem granularności. Zauważyli 40% redukcję zużycia CPU podczas aktualizacji dla częstych cen. Rezultat był responsywną tablicą, na której etykiety indywidualnych cen akcji aktualizowały się niezależnie, nie wywołując ponownych obliczeń układu dla statycznej sekcji wiadomości.
Dlaczego framework Observation wymaga, aby typ obserwowany był klasą (semantyka referencyjna), a jaki błąd kompilacji występuje, jeśli @Observable zostanie zastosowane do struktury?
Makro @Observable rozszerza się przez wstrzykiwanie ObservationRegistrar jako przechowywanej właściwości i implementację protokołu Observable. Struktury są typami wartości z semantyką kopiowania przy zapisie; każda mutacja konceptualnie tworzy nową instancję z odmienną tożsamością. ObservationRegistrar utrzymuje stan wewnętrzny - listy obserwatorów i zakresy śledzenia, które muszą przetrwać przez mutacje, aby utrzymać wykres obserwacji. Jeśli zastosowane do struktury, mutacje kopiowałyby stan rejestratora niewłaściwie, łamiąc połączenie między obserwatorami a instancją. Kompilator uniemożliwia to, generując błąd wskazujący, że makro nie może dodać wymaganej przechowywanej właściwości do typu wartości w sposób, który spełnia wymagania protokołu Observable dla stabilnej tożsamości, a dokładniej mówiąc, że wynikowy typ nie może spełniać wymagań protokołu Observable, ponieważ brakuje mu niezbędnej stabilności referencyjnej.
Jak framework Observation obsługuje zagnieżdżone obiekty obserwowalne i dlaczego nie ma projekcji wartości (takiej jak $property) dla indywidualnych właściwości, jak to miało miejsce w przypadku @Published?
Gdy klasa @Observable zawiera właściwość, która jest również klasą @Observable, framework śledzi dostęp na poziomie właściwości, a nie przez automatyczne zagnieżdżone obserwowanie obiektów. Dostęp do outer.inner.name rejestruje zależność na właściwości inner zewnętrznego obiektu. Jeśli instancja inner zostanie całkowicie zastąpiona, obserwatorzy są powiadamiani. Jednak zmiany w inner.name nie powiadamiają obserwatorów zewnętrznego obiektu, chyba że zewnętrzny obiekt jawnie śledzi wewnętrzny. W przeciwieństwie do Combine, nie ma pojęcia projekcji wartości dla indywidualnych właściwości w Observation, ponieważ framework używa bezpośredniego śledzenia właściwości za pośrednictwem rejestratora, a nie strumieni publikatora. Składnia $ w SwiftUI dla Observation jest zamiast tego używana na całej instancji, gdy jest otoczona przez @Bindable (np. @Bindable var viewModel: SettingsViewModel pozwala $viewModel.isDarkModeEnabled), a nie na indywidualnych deklaracjach właściwości.
Jakie konkretne gwarancje bezpieczeństwa wątków zapewnia framework Observation, i jak współdziała z aktorami współbieżności Swift?
ObservationRegistrar sam w sobie nie jest z natury bezpieczny dla wątków; zakłada, że dostęp do obiektu obserwowanego będzie zserializowany. Gdy klasa @Observable jest izolowana do aktora (na przykład @MainActor), wszystkie mutacje i obserwacje automatycznie odbywają się w kontekście tego aktora, zapobiegając wyścigom danych. Framework zapewnia, że wywołania zwrotne obserwacji respektują domenę izolacji obserwatora, używając kontroli Sendable. Krytycznym szczegółem implementacji jest to, że mechanizm śledzenia wykorzystuje magazyn TaskLocal, aby utrzymać bieżący zakres obserwacji podczas wykonania ciała widoku. Oznacza to, że rejestracja obserwacji jest implicitnie związana z kontekstem bieżącego Task i nie może wyciekać przez nieuporządkowane granice współbieżności bez jawnego transferu, zapewniając, że obserwacje są aktywne tylko w trakcie konkretnej transakcji asynchronicznej, w której zostały zarejestrowane.