@Observable в SwiftUI: руководство по Observation framework с миграцией и продвинутыми паттернами

Разбираемся с @Observable в SwiftUI: как работает Observation framework, чем отличается от ObservableObject, пошаговая миграция, типичные ошибки и продвинутые архитектурные паттерны для iOS 17+.

Введение: зачем Apple вообще переписала систему наблюдения

Если вы, как и я, разрабатываете на SwiftUI с первых версий, то наверняка помните эту боль с ObservableObject: обязательный @Published на каждое свойство, зависимость от Combine, перерисовка всего дерева вью при изменении любого свойства. Честно говоря, иногда это доводило до отчаяния.

На WWDC 2023 Apple представила фреймворк Observation и макрос @Observable — и это, без преувеличения, самое значительное изменение в управлении состоянием SwiftUI с момента его появления.

Начиная с iOS 17 и Swift 5.9, @Observable стал рекомендованным подходом для всех новых проектов. А с выходом Swift 6 и Xcode 26 этот фреймворк окончательно вытеснил ObservableObject из повседневной практики. Так что давайте разберёмся во всём — от базового синтаксиса до подводных камней миграции и продвинутых архитектурных паттернов.

Что такое Observation framework и как работает @Observable

Принцип работы: pull-based отслеживание

Старый ObservableObject использовал push-модель: объект рассылал общий сигнал objectWillChange через Combine, и все подписанные вью перерисовывались, даже если их вообще не касалось ни одно изменённое свойство. Это было, мягко говоря, неэффективно — особенно в сложных иерархиях.

@Observable работает принципиально иначе — по pull-модели с отслеживанием доступа. Когда SwiftUI выполняет body вашего вью, он запоминает, к каким именно свойствам @Observable-объекта было обращение. При последующем изменении перерисуются только те вью, которые действительно читали изменённое свойство. Красота, правда?

// SwiftUI автоматически отслеживает обращения
struct ProfileView: View {
    let user: UserModel // @Observable класс

    var body: some View {
        // SwiftUI запоминает: этот вью читает только .name
        Text(user.name)
        // Изменение user.email НЕ вызовет перерисовку этого вью
    }
}

Макрос, а не протокол

Важный момент: @Observable — это макрос Swift, а не property wrapper и не протокол. На этапе компиляции он трансформирует ваш класс, добавляя внутренний ObservationRegistrar и обёртывая каждое хранимое свойство в геттер/сеттер с вызовами access и withMutation.

Вычисляемые свойства отслеживаются автоматически через зависимость от хранимых.

import Observation

@Observable
class UserModel {
    var name: String = ""
    var email: String = ""
    var avatarURL: URL?

    // Вычисляемое свойство — отслеживается автоматически
    var displayName: String {
        name.isEmpty ? "Аноним" : name
    }
}

Важно: @Observable работает только с классами. Для структур по-прежнему используйте @State — они отслеживаются через value semantics.

Сравнение @Observable и ObservableObject: что реально изменилось

Вот ключевые отличия двух подходов (и поверьте, разница существенная):

ХарактеристикаObservableObject@Observable
ФреймворкCombineObservation (Swift stdlib)
Минимальная версияiOS 13iOS 17
Нужен @PublishedДа, на каждое свойствоНет
Гранулярность обновленийНа уровне объектаНа уровне свойства
Property wrappers во вью@StateObject, @ObservedObject, @EnvironmentObject@State, @Bindable, @Environment
Вложенные объектыНе отслеживаются автоматическиОтслеживаются корректно
КроссплатформенностьТолько Apple (Combine)Swift stdlib — потенциально кроссплатформенный

Производительность на практике

Давайте посмотрим на типичную ситуацию: модель профиля с часто обновляемым прогрессом загрузки и редко меняющимся именем пользователя.

// Старый подход — ObservableObject
class ProfileVM: ObservableObject {
    @Published var username = "Иван"
    @Published var downloadProgress: Double = 0.0 // обновляется 60 раз/сек
}

// Каждый тик прогресса → перерисовка ВСЕХ вью,
// включая те, которые показывают только username
// Новый подход — @Observable
@Observable
class ProfileVM {
    var username = "Иван"
    var downloadProgress: Double = 0.0

    // Вью, читающий только username, НЕ перерисуется
    // при обновлении downloadProgress
}

В реальных приложениях со сложной иерархией вью разница ощутима. Меньше перерисовок — меньше нагрузка на CPU — плавнее анимации и прокрутка. Я лично заметил улучшение на экранах с длинными списками и множеством вложенных компонентов.

Четыре способа использования @Observable во вью

Итак, в SwiftUI существует четыре основных паттерна работы с @Observable-объектами. Разберём каждый.

1. @State — вью владеет объектом

struct ContentView: View {
    @State private var viewModel = ItemStore()

    var body: some View {
        ItemListView(store: viewModel)
    }
}

Используйте @State, когда вью создаёт и владеет экземпляром модели. SwiftUI будет хранить объект между перерисовками.

2. Простая передача — вью читает, но не владеет

struct ItemListView: View {
    let store: ItemStore // передан из родителя

    var body: some View {
        ForEach(store.items) { item in
            Text(item.title)
        }
    }
}

Если вью только читает данные и не создаёт привязок, достаточно передать объект как обычное свойство. Никаких дополнительных обёрток не нужно — @Observable отслеживает обращения автоматически. Вот это я называю прогрессом по сравнению со старым API.

3. @Bindable — двусторонняя привязка к свойствам

struct EditItemView: View {
    @Bindable var store: ItemStore

    var body: some View {
        TextField("Название", text: $store.title)
        Toggle("Активен", isOn: $store.isActive)
    }
}

@Bindable — новый атрибут, появившийся в iOS 17. Он позволяет создавать привязки ($property) к свойствам @Observable-объекта. По сути, это аналог @ObservedObject, но для нового фреймворка.

4. @Environment — внедрение через окружение

// Родительский вью
struct AppRootView: View {
    @State private var settings = AppSettings()

    var body: some View {
        NavigationStack {
            MainView()
        }
        .environment(settings)
    }
}

// Дочерний вью — на любой глубине вложенности
struct ThemePickerView: View {
    @Environment(AppSettings.self) private var settings

    var body: some View {
        // Для привязки нужно создать локальный @Bindable
        @Bindable var settings = settings
        Picker("Тема", selection: $settings.theme) {
            Text("Светлая").tag(Theme.light)
            Text("Тёмная").tag(Theme.dark)
        }
    }
}

Обратите внимание: @Environment теперь принимает тип класса напрямую, а не key path. Это полностью заменяет @EnvironmentObject. Но будьте внимательны — если объект не найден в окружении, приложение упадёт с runtime-ошибкой.

Пошаговая миграция с ObservableObject на @Observable

Если ваш минимальный таргет — iOS 17 и выше, миграция однозначно имеет смысл. Вот план, которого я придерживаюсь сам.

Шаг 1: Замените протокол на макрос

// Было:
class CartViewModel: ObservableObject {
    @Published var items: [CartItem] = []
    @Published var totalPrice: Decimal = 0
    @Published var isLoading = false
}

// Стало:
@Observable
class CartViewModel {
    var items: [CartItem] = []
    var totalPrice: Decimal = 0
    var isLoading = false
}

Удалите : ObservableObject, все аннотации @Published и добавьте @Observable перед class. Просто и приятно.

Шаг 2: Обновите property wrappers во вью

// Было:
struct CartView: View {
    @StateObject var viewModel = CartViewModel()
    // или
    @ObservedObject var viewModel: CartViewModel

    var body: some View { ... }
}

// Стало:
struct CartView: View {
    @State var viewModel = CartViewModel()
    // или (для двусторонней привязки)
    @Bindable var viewModel: CartViewModel

    var body: some View { ... }
}

Шаг 3: Замените @EnvironmentObject на @Environment

// Было:
.environmentObject(settings)
// ...
@EnvironmentObject var settings: AppSettings

// Стало:
.environment(settings)
// ...
@Environment(AppSettings.self) var settings

Шаг 4: Исключите ненужные свойства из наблюдения

@Observable
class AnalyticsModel {
    var currentScreen = ""

    @ObservationIgnored
    var internalCache: [String: Any] = [:] // не вызывает перерисовку

    @ObservationIgnored
    var analyticsSessionId = UUID() // служебные данные
}

Используйте @ObservationIgnored для свойств, которые не должны вызывать обновление UI: кэши, метаданные аналитики, внутренние счётчики. Этот атрибут — ваш лучший друг при оптимизации.

Подводные камни и типичные ошибки

А теперь о граблях. И поверьте, на некоторые из них я наступал лично.

1. Повторная инициализация @State-объектов

Это самая опасная ловушка при миграции. @StateObject использовал @autoclosure и гарантировал единственную инициализацию объекта за весь жизненный цикл вью. @State этого не гарантирует — SwiftUI может вызвать инициализатор вью многократно, каждый раз создавая новый экземпляр объекта (хотя потом отбрасывает его и использует сохранённый).

// ⚠️ Потенциальная проблема
@Observable
class DataLoader {
    var data: [Item] = []

    init() {
        print("DataLoader создан") // может вызваться многократно!
        loadFromDisk() // тяжёлая операция в init — плохая идея
    }
}

struct MyView: View {
    @State private var loader = DataLoader()
    var body: some View { ... }
}

Решение: не выполняйте тяжёлых операций в init(). Используйте .task { } для асинхронной загрузки данных или объявляйте глобальное состояние в App-структуре, которая не пересоздаётся.

// ✅ Правильный подход
@main
struct MyApp: App {
    @State private var appState = AppState()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(appState)
        }
    }
}

2. Забытый @Bindable при создании привязок

Классическая ошибка — пытаться создать привязку без @Bindable:

// ❌ Не скомпилируется
struct EditView: View {
    var model: ItemModel // @Observable класс

    var body: some View {
        TextField("Имя", text: $model.name) // ошибка: нет $
    }
}

// ✅ Нужен @Bindable
struct EditView: View {
    @Bindable var model: ItemModel

    var body: some View {
        TextField("Имя", text: $model.name) // работает
    }
}

3. Потокобезопасность и @MainActor

@Observable не гарантирует выполнение на главном потоке. Если ваш объект обновляет свойства из фоновых задач и эти свойства привязаны к UI, вы получите предупреждения компилятора в Swift 6 или (что хуже) гонки данных.

// ✅ Рекомендуемый подход для UI-моделей
@MainActor
@Observable
class SearchViewModel {
    var query = ""
    var results: [SearchResult] = []
    var isSearching = false

    func search() async {
        isSearching = true
        let fetched = await SearchService.fetchResults(for: query)
        results = fetched
        isSearching = false
    }
}

4. Путаница между .environment и .environmentObject

Частая ошибка при миграции — перепутать модификаторы. Я сам попадался на этом не раз:

// ❌ Для @Observable — не сработает
.environmentObject(myObservableModel)

// ✅ Правильно для @Observable
.environment(myObservableModel)

// Модификатор .environmentObject() — только для ObservableObject

5. Утечки памяти при замыканиях

Поскольку @Observable — это класс, при использовании в замыканиях не забывайте о retain cycles:

@Observable
class TimerModel {
    var elapsed: TimeInterval = 0
    private var timer: Timer?

    func start() {
        // ⚠️ Потенциальный retain cycle
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
            self?.elapsed += 1
        }
    }

    deinit {
        timer?.invalidate()
    }
}

Продвинутые паттерны: архитектура с @Observable

Вложенные Observable-объекты

Одно из ключевых преимуществ нового фреймворка — корректная работа с вложенными объектами. Помните, как с ObservableObject приходилось городить костыли с ручной подпиской на вложенные модели? Забудьте об этом.

@Observable
class Order {
    var items: [OrderItem] = []
    var customer: Customer // тоже @Observable
    var status: OrderStatus = .draft

    var totalAmount: Decimal {
        items.reduce(0) { $0 + $1.price * Decimal($1.quantity) }
    }
}

@Observable
class Customer {
    var name: String
    var email: String
    var loyaltyPoints: Int = 0
}

// Вью будет перерисован ТОЛЬКО при изменении customer.name
struct OrderHeaderView: View {
    let order: Order

    var body: some View {
        Text("Заказ для: \(order.customer.name)")
    }
}

Паттерн Repository + @Observable ViewModel

Вот архитектурный подход, который хорошо зарекомендовал себя в продакшене:

// Слой данных — не Observable, просто async-сервис
struct ProductRepository {
    func fetchProducts() async throws -> [Product] {
        let (data, _) = try await URLSession.shared.data(from: productsURL)
        return try JSONDecoder().decode([Product].self, from: data)
    }
}

// ViewModel — @Observable + @MainActor
@MainActor
@Observable
class ProductListViewModel {
    var products: [Product] = []
    var isLoading = false
    var errorMessage: String?

    private let repository = ProductRepository()

    func loadProducts() async {
        isLoading = true
        errorMessage = nil
        do {
            products = try await repository.fetchProducts()
        } catch {
            errorMessage = "Не удалось загрузить товары: \(error.localizedDescription)"
        }
        isLoading = false
    }
}

// Вью
struct ProductListView: View {
    @State private var viewModel = ProductListViewModel()

    var body: some View {
        List(viewModel.products) { product in
            ProductRow(product: product)
        }
        .overlay {
            if viewModel.isLoading {
                ProgressView()
            }
        }
        .task {
            await viewModel.loadProducts()
        }
    }
}

withObservationTracking вне SwiftUI

Кстати, фреймворк Observation работает не только в SwiftUI. Функция withObservationTracking позволяет отслеживать изменения в любом контексте — это бывает очень удобно для логирования или синхронизации:

@Observable
class AppConfig {
    var apiEndpoint = "https://api.example.com"
    var debugMode = false
}

// Использование вне SwiftUI
func monitorConfig(_ config: AppConfig) {
    withObservationTracking {
        print("Текущий endpoint: \(config.apiEndpoint)")
    } onChange: {
        print("Конфигурация изменилась!")
        // Важно: onChange вызывается один раз
        // Для непрерывного мониторинга нужно вызвать снова
        Task { @MainActor in
            monitorConfig(config)
        }
    }
}

Стратегия миграции для существующих проектов

Не обязательно мигрировать весь проект за один раз. Вот стратегия, которая работает на практике:

  1. Новый код — только @Observable. Все новые модели и ViewModel создавайте с @Observable. Без исключений.
  2. Постепенная миграция по модулям. При работе над экраном мигрируйте его ViewModel по принципу Boy Scout Rule: оставь код чище, чем нашёл.
  3. Глобальное состояние — в App-структуре. Перенесите @State и .environment() на уровень App.
  4. Тестирование. Обязательно проверьте поведение инициализации — помните о разнице между @StateObject и @State.
  5. Мониторинг с Instruments. В Xcode 26 используйте SwiftUI Instrument для визуализации причинно-следственных связей между изменениями состояния и перерисовками.

Часто задаваемые вопросы

Можно ли использовать @Observable со структурами?

Нет. Макрос @Observable работает только с классами. Структуры в SwiftUI отслеживаются через @State с value semantics — при изменении любого свойства SwiftUI получает новую копию и обновляет вью. Для классов же нужна ссылочная семантика и явное отслеживание, которое и обеспечивает @Observable.

Нужен ли Combine при использовании @Observable?

Нет, и это отличная новость. Фреймворк Observation полностью независим от Combine — он входит в стандартную библиотеку Swift. Если ваш проект использовал Combine исключительно для ObservableObject и @Published, при миграции на @Observable можно спокойно от него отказаться. Впрочем, Combine по-прежнему полезен для сложных цепочек обработки данных, таймеров и работы с NotificationCenter.

Как @Observable влияет на производительность?

Положительно. Благодаря гранулярному отслеживанию на уровне свойств, SwiftUI перерисовывает только те вью, которые реально зависят от изменённых данных. В приложениях со сложной иерархией и высокочастотными обновлениями (индикаторы прогресса, таймеры) разница может быть весьма существенной — меньше ненужных вызовов body, меньше нагрузка на CPU, плавнее анимации.

Безопасен ли @Observable для работы с потоками?

Внутренний ObservationRegistrar потокобезопасен — мутации регистрируются в критической секции. Однако сами свойства @Observable-объекта не защищены от гонок данных автоматически. Для UI-моделей рекомендуется @MainActor. В Swift 6 с полной проверкой конкурентности компилятор сам подскажет потенциальные проблемы.

Как отлаживать поведение @Observable?

В Xcode 26 есть специализированный SwiftUI Instrument, который визуализирует связи между изменениями свойств и обновлениями вью. Для ручной отладки можно использовать withObservationTracking с print(), а также проверять утечки памяти через Memory Graph Debugger (обращайте особое внимание на фантомные экземпляры @State-объектов).

Об авторе Editorial Team

Our team of expert writers and editors.