Введение: зачем 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 |
|---|---|---|
| Фреймворк | Combine | Observation (Swift stdlib) |
| Минимальная версия | iOS 13 | iOS 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)
}
}
}
Стратегия миграции для существующих проектов
Не обязательно мигрировать весь проект за один раз. Вот стратегия, которая работает на практике:
- Новый код — только @Observable. Все новые модели и ViewModel создавайте с
@Observable. Без исключений. - Постепенная миграция по модулям. При работе над экраном мигрируйте его ViewModel по принципу Boy Scout Rule: оставь код чище, чем нашёл.
- Глобальное состояние — в App-структуре. Перенесите
@Stateи.environment()на уровеньApp. - Тестирование. Обязательно проверьте поведение инициализации — помните о разнице между
@StateObjectи@State. - Мониторинг с 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-объектов).