SwiftUI: @State, @Binding, @ObservedObject и когда что выбирать

Property wrappers в SwiftUI — вопрос, на котором отсеиваются те, кто «писал по туториалам»: разницу @StateObject и @ObservedObject видно только через баги.

SwiftUI-View — не объект на экране, а лёгкое value-описание того, как экран выглядит при текущем состоянии. Фреймворк создаёт и выбрасывает эти структуры десятки раз.

Вопрос 1: «Чем SwiftUI принципиально отличается от UIKit?»

Что на самом деле проверяют

Понял ли кандидат смену модели мышления — с императивной («возьми лейбл и поменяй текст») на декларативную («вот функция из состояния в интерфейс»).

Развёрнутый ответ

В UIKit вы вручную синхронизируете модель и вьюхи, и забытая ветка даёт рассинхрон. В SwiftUI вы описываете body — чистую функцию от состояния, а diff считает фреймворк.

Отсюда главное правило: body вызывается много раз и в непредсказуемые моменты, поэтому в нём нельзя делать побочные эффекты — запросы, запись в базу, аналитику. Для этого есть .task, .onAppear, .onChange.

struct FeedView: View {
    @StateObject private var model = FeedModel()

    var body: some View {
        List(model.items) { item in
            Text(item.title)
        }
        .task { await model.load() }        // сюда, а не в body
    }
}

Вопрос 2: «Разница между @State, @Binding, @StateObject, @ObservedObject и @EnvironmentObject?»

Что на самом деле проверяют

Кто владеет состоянием и что переживает перерисовку. Именно здесь рождается баг «форма сбрасывается сама собой».

Развёрнутый ответ

@State — локальное значение View. Хранится не в структуре (она пересоздаётся), а во внутреннем хранилище фреймворка, привязанном к позиции View в дереве. @Binding — двусторонняя ссылка на чужое состояние, передаётся через $:

struct ParentView: View {
    @State private var name = ""

    var body: some View {
        NameField(text: $name)          // $ отдаёт Binding<String>
    }
}

struct NameField: View {
    @Binding var text: String
    var body: some View { TextField("Имя", text: $text) }
}

Для ссылочного состояния берут класс, реализующий ObservableObject, и помечают поля @Published:

final class CartModel: ObservableObject {
    @Published var items: [CartItem] = []
}

Дальше — самое важное. @StateObject означает «эта View владеет объектом»: он создаётся один раз и переживает все перерисовки. @ObservedObject — «объект пришёл извне, я только подписан». Классический баг: объект создают прямо в @ObservedObject, и тогда при каждой перерисовке родителя рождается новый объект, а введённое в форму исчезает.

@ObservedObject var model = CartModel()          // неверно: новый объект на каждую перерисовку
@StateObject private var model = CartModel()     // верно: владелец создаёт один раз

struct CartSummary: View {
    @ObservedObject var model: CartModel         // верно: объект пришёл извне
}

@EnvironmentObject — сквозная передача через дерево без прокидывания в промежуточные инициализаторы: объект кладут в окружение через .environmentObject(...). Плата — если его там нет, приложение падает в рантайме.

ОбёрткаТипКто владеетКогда брать
@Statevalueсама ViewФлаг, текст поля, выбранный таб
@BindingvalueродительРебёнок правит чужое значение
@StateObjectclassсама ViewView создаёт модель экрана
@ObservedObjectclassкто-то вышеМодель пришла в инициализаторе
@EnvironmentObjectclassпредокСессия, тема, корзина

С iOS 17 макрос @Observable убирает половину этой таблицы: @Published не нужен, для владения хватает @State. Бонус — точечная инвалидация: перерисовывается только та View, которая реально читала изменившееся свойство.

@Observable
final class CartModel { var items: [CartItem] = [] }

struct CartScreen: View {
    @State private var model = CartModel()               // владение
    var body: some View { CartSummary(model: model) }    // передача
}

Вопрос 3: «Что такое identity во View и зачем ForEach стабильный идентификатор?»

Развёрнутый ответ

Чтобы понять, что считать «тем же элементом», SwiftUI сопоставляет старое и новое дерево по идентичности: структурная берётся из позиции в дереве, явная — из id в ForEach или модификатора .id(...).

ForEach(items, id: \.self) на неуникальных данных склеивает элементы: анимации идут не туда, локальное @State строки достаётся чужому элементу. Правильный путь — Identifiable со стабильным id из модели, а не индекс массива:

struct CartItem: Identifiable { let id: UUID; let title: String }

ForEach(items) { item in ItemRow(item: item) }   // берёт item.id

Обратная сторона: .id(value) с меняющимся значением заставляет SwiftUI считать View новой и сбрасывает состояние. Это осознанный приём «пересоздай экран», но по неосторожности выглядит как баг.

Когда всё ещё берут UIKit

Сложные коллекции с нестандартным layout, камера и AVFoundation, богатый текстовый ввод, зрелые legacy-проекты. Мосты: UIViewRepresentable и UIViewControllerRepresentable вставляют UIKit в SwiftUI, UIHostingController — SwiftUI в UIKit-навигацию.

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

  • Считают View объектом на экране и удивляются повторным вызовам body.
  • Делают сетевой запрос прямо в body вместо .task.
  • Создают модель в @ObservedObject и ловят сброс состояния.
  • Ставят @StateObject на объект из инициализатора: он замрёт на первом значении.
  • Используют id: \.self для неуникальных данных.
  • Говорят «SwiftUI заменил UIKit», не называя ни одного сценария для UIKit.

Как ответить кратко

SwiftUI декларативен: body — чистая функция из состояния в описание интерфейса, diff считает фреймворк, поэтому эффекты идут в task и onChange, а не в body. @State — локальное значение View, @Binding — ссылка на чужое состояние через доллар, @StateObject — владение, объект создаётся один раз, @ObservedObject — объект пришёл извне; создать модель прямо в ObservedObject значит терять её при каждой перерисовке родителя. @EnvironmentObject — сквозная передача, а в iOS 17 макрос Observable всё это упрощает. Identity нужна для сопоставления деревьев: у ForEach должен быть стабильный id из модели. UIKit остаётся для сложных коллекций, камеры и legacy, мосты — UIViewRepresentable и UIHostingController.

Проверьте себя
1. Разработчик написал в дочерней View: @ObservedObject var model = FormModel(). Что произойдёт?
AНичего плохого: @ObservedObject и @StateObject взаимозаменяемы
BПриложение упадёт, потому что объекта нет в окружении
CПри каждой перерисовке родителя структура View пересоздастся вместе с новой моделью, и введённые данные сбросятся
DМодель станет глобальным синглтоном для всего приложения
2. Почему сетевой запрос нельзя выполнять прямо в body?
Abody выполняется в фоновом потоке, а сеть требует главного
Bbody — чистое описание интерфейса и может вызываться много раз, поэтому побочные эффекты дадут лавину запросов; их место в .task или .onChange
CКомпилятор SwiftUI запрещает async-вызовы внутри body
DЗапрос из body не пройдёт App Transport Security
3. Что не так с ForEach(tags, id: \.self), где tags — массив строк с возможными повторами?
Aid: \.self работает только с числами
BИдентификаторы перестают быть уникальными, из-за чего ломаются анимации вставки и удаления и состояние строк достаётся не тем элементам
CForEach вообще не поддерживает параметр id
DТакой ForEach не скомпилируется без Identifiable