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

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

@Observable SwiftUI 2026: миграция и паттерны

Введение: зачем 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-объектов).

Об авторе Mei-Lin Chen

Mei-Lin joined Robinhood in 2020 as an iOS engineer on the Crypto team and stayed through the SwiftUI rewrite of the order-entry flow before leaving in 2025. She also did a two-year stint at Asana earlier in her career working on the iPad app and the Mac Catalyst port. She writes about the parts of Apple's frameworks that the WWDC talks gloss over - what Observable actually does to your view-update graph, why @Bindable bindings tear in some animation contexts, and the surprisingly deep rabbit hole of Swift macros for boilerplate elimination. She has shipped two indie apps to the App Store, one of which hit #4 in the Health & Fitness category for a week in 2023. Mei-Lin is based in Seattle and has been writing Swift for 8 years.