Migracja z ObservableObject na @Observable w SwiftUI — praktyczny przewodnik

Praktyczny przewodnik po migracji z ObservableObject na @Observable w SwiftUI. Poznaj framework Observation, @Bindable, zamienniki Combine i sprawdzone strategie migracji przyrostowej z działającymi przykładami kodu.

Dlaczego warto przejść na @Observable?

Jeśli pracujesz ze SwiftUI już jakiś czas, to na pewno znasz ten schemat — tworzysz klasę z ObservableObject, dodajesz @Published do każdej właściwości, a potem zastanawiasz się, czy w widoku użyć @StateObject, @ObservedObject czy @EnvironmentObject. Działało? Jasne, że działało. Ale miało jedną sporą wadę — zmiana dowolnej właściwości @Published powodowała ponowne obliczenie body każdego widoku obserwującego dany obiekt. Nawet tych, które tej właściwości w ogóle nie używały.

I tu wchodzi @Observable.

Wraz z iOS 17 i Swift 5.9 Apple wprowadził framework Observation oraz makro @Observable, które podchodzi do obserwacji danych zupełnie inaczej. Zamiast mechanizmu push-based (obiekt krzyczy „coś się zmieniło!"), nowy system działa w modelu pull-based — SwiftUI śledzi dokładnie, które właściwości dany widok odczytuje i aktualizuje wyłącznie te widoki, których dane faktycznie się zmieniły.

W 2026 roku, gdy większość aplikacji celuje w iOS 17+, migracja na @Observable to już nie opcja — to w zasadzie konieczność, jeśli zależy Ci na wydajnym i nowoczesnym kodzie.

Jak działa @Observable pod spodem?

Makro @Observable to zdecydowanie więcej niż cukier syntaktyczny. Podczas kompilacji generuje konkretny kod, który zamienia zwykłą klasę w pełnoprawny obserwowalny typ. Zobaczmy, co dokładnie się dzieje:

// To piszesz:
@Observable class UserViewModel {
    var name = ""
    var email = ""
    var age = 0
}

// Kompilator generuje mniej więcej:
class UserViewModel: Observable {
    private let _$observationRegistrar = ObservationRegistrar()
    
    private var _name = ""
    var name: String {
        get {
            _$observationRegistrar.access(self, keyPath: \.name)
            return _name
        }
        set {
            _$observationRegistrar.withMutation(of: self, keyPath: \.name) {
                _name = newValue
            }
        }
    }
    // ... analogicznie dla email i age
}

Trzy kluczowe elementy, na które warto zwrócić uwagę:

  • ObservationRegistrar — centralny mechanizm rejestrujący, które właściwości są odczytywane i przez kogo
  • access() — wywoływane przy każdym odczycie właściwości, informuje system, że dany widok zależy od tej wartości
  • withMutation() — wywoływane przy zapisie, powiadamia system o zmianie konkretnej właściwości

Efekt? SwiftUI wie precyzyjnie, który widok odczytuje którą właściwość i aktualizuje wyłącznie to, co trzeba. To fundamentalna zmiana w porównaniu z ObservableObject, gdzie każda modyfikacja @Published uruchamiała globalny sygnał objectWillChange. Szczerze mówiąc, kiedy pierwszy raz zobaczyłem wygenerowany kod — od razu wiedziałem, że to będzie game changer.

Krok po kroku: migracja istniejącego kodu

No dobra, przejdźmy do konkretów. Migracja składa się z kilku prostych (ale ważnych) kroków.

Krok 1: Zamień ObservableObject na @Observable

Zaczynamy od klasy view modelu. Usuwasz konformancję do ObservableObject, wyrzucasz wszystkie @Published i dodajesz makro @Observable:

// PRZED:
class TaskListViewModel: ObservableObject {
    @Published var tasks: [Task] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    func loadTasks() async {
        isLoading = true
        defer { isLoading = false }
        do {
            tasks = try await TaskService.fetchAll()
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

// PO:
@Observable class TaskListViewModel {
    var tasks: [Task] = []
    var isLoading = false
    var errorMessage: String?
    
    func loadTasks() async {
        isLoading = true
        defer { isLoading = false }
        do {
            tasks = try await TaskService.fetchAll()
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

Widzisz, jak dużo czystszy jest ten kod? Żadnych @Published, żadnego protokołu — same proste właściwości. To jedna z rzeczy, które najbardziej lubię w nowym podejściu.

Krok 2: Zamień @StateObject na @State

W widoku, który tworzy i posiada instancję view modelu, zamieniasz @StateObject na @State:

// PRZED:
struct TaskListView: View {
    @StateObject private var viewModel = TaskListViewModel()
    
    var body: some View {
        List(viewModel.tasks) { task in
            TaskRowView(task: task)
        }
    }
}

// PO:
struct TaskListView: View {
    @State private var viewModel = TaskListViewModel()
    
    var body: some View {
        List(viewModel.tasks) { task in
            TaskRowView(task: task)
        }
    }
}

Jedna linijka zmieniona. Tyle.

Krok 3: Zamień @ObservedObject na zwykłą właściwość

W widokach, które otrzymują view model z zewnątrz (od rodzica), nie potrzebujesz już żadnego property wrappera:

// PRZED:
struct TaskDetailView: View {
    @ObservedObject var viewModel: TaskDetailViewModel
    
    var body: some View {
        Text(viewModel.task.title)
    }
}

// PO:
struct TaskDetailView: View {
    var viewModel: TaskDetailViewModel
    
    var body: some View {
        Text(viewModel.task.title)
    }
}

SwiftUI automatycznie wykryje, że viewModel jest typem @Observable i będzie śledzić zmiany bez dodatkowych adnotacji. Magia? Nie — dobrze zaprojektowany system.

Krok 4: Zamień @EnvironmentObject na @Environment

Jeśli przekazujesz obiekty przez środowisko SwiftUI, składnia też się zmienia — i tu jest nieco więcej do zrobienia:

// PRZED:
// Przekazywanie:
ContentView()
    .environmentObject(userSettings)

// Odczytywanie:
struct SettingsView: View {
    @EnvironmentObject var settings: UserSettings
    var body: some View {
        Toggle("Tryb ciemny", isOn: $settings.isDarkMode)
    }
}

// PO:
// Przekazywanie:
ContentView()
    .environment(userSettings)

// Odczytywanie:
struct SettingsView: View {
    @Environment(UserSettings.self) private var settings
    var body: some View {
        @Bindable var settings = settings
        Toggle("Tryb ciemny", isOn: $settings.isDarkMode)
    }
}

@Bindable — dwukierunkowe wiązanie danych

Skoro już pojawił się @Bindable, to warto poświęcić mu chwilę. To nowy property wrapper, który służy do tworzenia dwukierunkowych bindingów do właściwości obiektów @Observable.

Kiedy używać @Bindable?

W praktyce są trzy główne sytuacje:

  1. Widok otrzymuje obiekt @Observable z zewnątrz i potrzebuje tworzyć bindingi do jego właściwości
  2. W ciele widoku z @Environment — gdy potrzebujesz bindingu do obiektu środowiskowego
  3. W pętlach ForEach/List — gdy chcesz edytować poszczególne elementy kolekcji
// Przykład 1: Widok z przekazanym obiektem
struct ProfileEditView: View {
    @Bindable var user: UserViewModel
    
    var body: some View {
        Form {
            TextField("Imię", text: $user.name)
            TextField("Email", text: $user.email)
            Stepper("Wiek: \(user.age)", value: $user.age, in: 0...120)
        }
    }
}

// Przykład 2: Bindable w pętli ForEach
struct TodoListView: View {
    @State private var todos: [TodoItem] = []
    
    var body: some View {
        List(todos) { todo in
            @Bindable var todo = todo
            HStack {
                Toggle("", isOn: $todo.isCompleted)
                TextField("Zadanie", text: $todo.title)
            }
        }
    }
}

Różnica między @Binding a @Bindable

Nazwy łudząco podobne, ale robią inne rzeczy:

  • @Binding — tworzy dwukierunkowe połączenie do zewnętrznej wartości @State (typy wartościowe)
  • @Bindable — umożliwia tworzenie bindingów do właściwości obiektów @Observable (typy referencyjne)

Prosta zasada: @Binding dla struktur, @Bindable dla klas.

Zastępowanie wzorców Combine

To jest chyba najtrudniejsza część całej migracji. W ObservableObject każda właściwość @Published miała swój publisher z możliwością łączenia operatorów typu debounce, throttle czy map. W @Observable te publishery po prostu nie istnieją.

Ale spokojnie — są dobre zamienniki.

Debounce z Task.sleep

Najczęstszy przypadek to debouncing wyszukiwania. Oto proste i czyste rozwiązanie bez żadnych zewnętrznych zależności:

@Observable class SearchViewModel {
    var query = ""
    var results: [SearchResult] = []
    private var searchTask: Task<Void, Never>?
    
    func onQueryChanged() {
        searchTask?.cancel()
        searchTask = Task {
            try? await Task.sleep(for: .milliseconds(300))
            guard !Task.isCancelled else { return }
            await performSearch()
        }
    }
    
    private func performSearch() async {
        guard !query.isEmpty else {
            results = []
            return
        }
        do {
            results = try await SearchService.search(query: query)
        } catch {
            // obsługa błędów
        }
    }
}

// Użycie w widoku:
struct SearchView: View {
    @State private var viewModel = SearchViewModel()
    
    var body: some View {
        TextField("Szukaj...", text: $viewModel.query)
            .onChange(of: viewModel.query) {
                viewModel.onQueryChanged()
            }
        List(viewModel.results) { result in
            Text(result.title)
        }
    }
}

Debounce z AsyncAlgorithms

Jeśli potrzebujesz czegoś bardziej zaawansowanego, warto sięgnąć po pakiet Swift Async Algorithms od Apple. Oferuje natywne operatory debounce i throttle dla sekwencji asynchronicznych — w zasadzie to duchowy następca Combine dla async/await:

import AsyncAlgorithms

struct SearchView: View {
    @State private var query = ""
    @State private var results: [SearchResult] = []
    private let queryChannel = AsyncChannel<String>()
    
    var body: some View {
        TextField("Szukaj...", text: $query)
            .task(id: query) {
                await queryChannel.send(query)
            }
            .task {
                for await debouncedQuery in queryChannel.debounce(for: .milliseconds(300)) {
                    results = (try? await SearchService.search(query: debouncedQuery)) ?? []
                }
            }
        List(results) { result in
            Text(result.title)
        }
    }
}

Optymalizacja wydajności — ile zyskujesz?

Różnica w wydajności między ObservableObject a @Observable jest naprawdę odczuwalna, szczególnie w większych aplikacjach. Wyjaśnijmy to na prostym przykładzie:

  • ObservableObject: Zmiana jednej właściwości @Published uruchamia objectWillChange, co wymusza ponowne obliczenie body wszystkich widoków obserwujących ten obiekt — nawet jeśli nie korzystają ze zmienionej właściwości
  • @Observable: Zmiana właściwości powiadamia wyłącznie widoki, które odczytują tę konkretną właściwość w swoim body

W praktyce oznacza to, że przy dużej liście, gdy zmienia się element nr 42, tylko widok wyświetlający ten element jest aktualizowany. Rodzic listy nie jest ponownie renderowany. W testach przeprowadzonych przez społeczność Swift, zbędne przerysowania widoków zmniejszyły się nawet o kilkadziesiąt procent — a w optymistycznych scenariuszach jeszcze bardziej.

@ObservationIgnored — wykluczanie właściwości

Nie każda właściwość powinna powodować aktualizację widoków. Dane pomocnicze, cache czy wewnętrzny stan logiki biznesowej — to wszystko można oznaczyć jako @ObservationIgnored:

@Observable class MediaPlayerViewModel {
    var currentTrack: Track?
    var isPlaying = false
    var volume: Float = 0.5
    
    @ObservationIgnored
    var analyticsBuffer: [AnalyticsEvent] = []
    
    @ObservationIgnored
    private var internalTimer: Timer?
}

Zmiany w tych właściwościach nie spowodują ponownego renderowania żadnego widoku. Idealne rozwiązanie dla danych, które nie wpływają na interfejs użytkownika.

Typowe pułapki i jak ich unikać

No więc, migracja nie zawsze przebiega gładko. Oto kilka rzeczy, na które warto uważać.

Pułapka 1: Wielokrotna inicjalizacja @State

To jest dość podstępne. @StateObject przyjmuje @autoclosure, więc inicjalizacja obiektu następuje tylko raz przez cały cykl życia widoku. Natomiast @State wywołuje inicjalizator za każdym razem, gdy SwiftUI odbudowuje hierarchię widoków — choć sam obiekt jest przechowywany i reużywany.

Co to znaczy w praktyce? Nie rób ciężkich operacji w inicjalizatorze view modelu. Przenieś je do metod wywoływanych w .task lub .onAppear:

// ŹLE:
@Observable class HeavyViewModel {
    var data: [Item]
    
    init() {
        // Ta operacja zostanie wywołana wielokrotnie!
        data = UserDefaults.standard.loadItems()
    }
}

// DOBRZE:
@Observable class HeavyViewModel {
    var data: [Item] = []
    
    func loadData() {
        data = UserDefaults.standard.loadItems()
    }
}

struct ContentView: View {
    @State private var viewModel = HeavyViewModel()
    
    var body: some View {
        List(viewModel.data) { item in
            Text(item.name)
        }
        .task {
            viewModel.loadData()
        }
    }
}

Pułapka 2: Utrata publisherów Combine

W ObservableObject składnia $property dawała dostęp do publishera Combine. W @Observable ta sama składnia tworzy binding, a nie publisher. Jeśli masz kod korzystający z łańcuchów Combine — musisz go zrefaktoryzować na async/await. Nie ma drogi na skróty.

Pułapka 3: @Observable działa tylko z klasami

To akurat proste, ale łatwo o tym zapomnieć. Makro @Observable można stosować wyłącznie do klas. Jeśli masz view modele oparte na strukturach, będziesz musiał przemyśleć architekturę albo zostać przy @State z wartościami.

Pułapka 4: Zagnieżdżone obiekty Observable

Gdy obiekt @Observable zawiera inne obiekty @Observable, zmiany w zagnieżdżonych obiektach mogą nie propagować się automatycznie do widoków nadrzędnych. Klucz to upewnienie się, że widoki odczytują właściwości bezpośrednio z zagnieżdżonego obiektu:

@Observable class AppState {
    var user = UserProfile()
    var settings = AppSettings()
}

@Observable class UserProfile {
    var name = ""
    var avatar: UIImage?
}

// DOBRZE — widok odczytuje zagnieżdżoną właściwość bezpośrednio:
struct ProfileView: View {
    var appState: AppState
    
    var body: some View {
        Text(appState.user.name) // ✅ SwiftUI śledzi appState.user.name
    }
}

// ŹLE — przechowywanie referencji w zmiennej lokalnej poza body:
// let user = appState.user // ❌ Może nie śledzić zmian prawidłowo

Strategia migracji przyrostowej

Nie musisz migrować całej aplikacji naraz (i szczerze, raczej nie powinieneś próbować). Oto podejście, które sprawdza się w praktyce:

  1. Nowy kod — wszystkie nowe view modele twórz od razu z @Observable
  2. Moduły z testami — migruj najpierw moduły z dobrym pokryciem testowym, żeby szybko wyłapać regresje
  3. Proste view modele — zacznij od klas, które nie korzystają z operatorów Combine
  4. Złożone view modele — na końcu migruj klasy z łańcuchami Combine, zastępując je wzorcami async/await

Oba podejścia mogą współistnieć w jednej aplikacji — ObservableObject i @Observable nie wykluczają się wzajemnie, więc nie ma presji, żeby zrobić wszystko od razu.

Kompletny przykład: lista zadań przed i po migracji

Na koniec — pełny, działający przykład, który pokazuje cały proces w jednym miejscu.

Przed migracją (ObservableObject + Combine)

import SwiftUI
import Combine

class TodoViewModel: ObservableObject {
    @Published var todos: [TodoItem] = []
    @Published var newTodoTitle = ""
    @Published var filter: TodoFilter = .all
    
    @Published var filteredTodos: [TodoItem] = []
    
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        Publishers.CombineLatest($todos, $filter)
            .map { todos, filter in
                switch filter {
                case .all: return todos
                case .active: return todos.filter { !$0.isCompleted }
                case .completed: return todos.filter { $0.isCompleted }
                }
            }
            .assign(to: &$filteredTodos)
    }
    
    func addTodo() {
        guard !newTodoTitle.isEmpty else { return }
        todos.append(TodoItem(title: newTodoTitle))
        newTodoTitle = ""
    }
    
    func toggleTodo(_ todo: TodoItem) {
        if let index = todos.firstIndex(where: { $0.id == todo.id }) {
            todos[index].isCompleted.toggle()
        }
    }
}

struct TodoListView: View {
    @StateObject private var viewModel = TodoViewModel()
    
    var body: some View {
        NavigationStack {
            VStack {
                HStack {
                    TextField("Nowe zadanie", text: $viewModel.newTodoTitle)
                    Button("Dodaj") { viewModel.addTodo() }
                }
                .padding()
                
                Picker("Filtr", selection: $viewModel.filter) {
                    ForEach(TodoFilter.allCases) { filter in
                        Text(filter.label).tag(filter)
                    }
                }
                .pickerStyle(.segmented)
                
                List(viewModel.filteredTodos) { todo in
                    HStack {
                        Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                        Text(todo.title)
                    }
                    .onTapGesture { viewModel.toggleTodo(todo) }
                }
            }
            .navigationTitle("Zadania")
        }
    }
}

Po migracji (@Observable)

import SwiftUI

@Observable class TodoViewModel {
    var todos: [TodoItem] = []
    var newTodoTitle = ""
    var filter: TodoFilter = .all
    
    var filteredTodos: [TodoItem] {
        switch filter {
        case .all: return todos
        case .active: return todos.filter { !$0.isCompleted }
        case .completed: return todos.filter { $0.isCompleted }
        }
    }
    
    func addTodo() {
        guard !newTodoTitle.isEmpty else { return }
        todos.append(TodoItem(title: newTodoTitle))
        newTodoTitle = ""
    }
    
    func toggleTodo(_ todo: TodoItem) {
        if let index = todos.firstIndex(where: { $0.id == todo.id }) {
            todos[index].isCompleted.toggle()
        }
    }
}

struct TodoListView: View {
    @State private var viewModel = TodoViewModel()
    
    var body: some View {
        NavigationStack {
            VStack {
                HStack {
                    TextField("Nowe zadanie", text: $viewModel.newTodoTitle)
                    Button("Dodaj") { viewModel.addTodo() }
                }
                .padding()
                
                Picker("Filtr", selection: $viewModel.filter) {
                    ForEach(TodoFilter.allCases) { filter in
                        Text(filter.label).tag(filter)
                    }
                }
                .pickerStyle(.segmented)
                
                List(viewModel.filteredTodos) { todo in
                    HStack {
                        Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                        Text(todo.title)
                    }
                    .onTapGesture { viewModel.toggleTodo(todo) }
                }
            }
            .navigationTitle("Zadania")
        }
    }
}

Zwróć uwagę na najważniejsze różnice:

  • filteredTodos to teraz zwykła właściwość obliczana — nie potrzeba Combine do reaktywnego filtrowania
  • Cały zestaw cancellables i Publishers.CombineLatest zniknął
  • Kod jest krótszy, czytelniejszy i (co tu dużo mówić) po prostu przyjemniejszy w utrzymaniu

FAQ — najczęstsze pytania

Czy @Observable jest drop-in zamiennikiem dla ObservableObject?

Nie do końca. Koncepcyjnie pełni tę samą rolę, ale są istotne różnice w zachowaniu — szczególnie dotyczące inicjalizacji (@State vs @StateObject) oraz braku publisherów Combine. Migracja wymaga świadomego dostosowania kodu, a nie prostej zamiany słów kluczowych.

Czy mogę używać @Observable i ObservableObject jednocześnie?

Tak, bez problemu. Oba podejścia mogą współistnieć w jednej aplikacji bez żadnych konfliktów. Nowy kod tworzysz z @Observable, a istniejący migrujesz stopniowo.

Jakie jest minimalne wymaganie systemowe?

@Observable wymaga iOS 17, macOS 14, tvOS 17 lub watchOS 10 (bądź nowszego). Jeśli Twoja aplikacja musi obsługiwać wcześniejsze wersje, musisz zostać przy ObservableObject albo utrzymywać dwa zestawy kodu z dyrektywami @available.

Co z Combine — czy Apple porzuca ten framework?

Oficjalnie Apple tego nie ogłosił, ale brak nowych funkcji i wyraźny nacisk na async/await oraz framework Observation mówią same za siebie. Combine wygląda na framework w trybie utrzymania. Dla nowych projektów zdecydowanie lepiej korzystać z natywnych mechanizmów Swift Concurrency i pakietu Async Algorithms.

Czy @Observable działa ze SwiftData?

Tak, i to świetnie. Modele SwiftData oznaczone makrem @Model automatycznie implementują Observable, więc widoki SwiftUI mogą je bezpośrednio obserwować bez dodatkowej konfiguracji. To jedna z największych zalet przejścia na nowy ekosystem.

O Autorze Editorial Team

Our team of expert writers and editors.