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:
- Widok otrzymuje obiekt @Observable z zewnątrz i potrzebuje tworzyć bindingi do jego właściwości
- W ciele widoku z @Environment — gdy potrzebujesz bindingu do obiektu środowiskowego
- 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
@PublisheduruchamiaobjectWillChange, co wymusza ponowne obliczeniebodywszystkich 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:
- Nowy kod — wszystkie nowe view modele twórz od razu z
@Observable - Moduły z testami — migruj najpierw moduły z dobrym pokryciem testowym, żeby szybko wyłapać regresje
- Proste view modele — zacznij od klas, które nie korzystają z operatorów Combine
- 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:
filteredTodosto teraz zwykła właściwość obliczana — nie potrzeba Combine do reaktywnego filtrowania- Cały zestaw
cancellablesiPublishers.CombineLatestzniknął - 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.