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(...). Плата — если его там нет, приложение падает в рантайме.
| Обёртка | Тип | Кто владеет | Когда брать |
| @State | value | сама View | Флаг, текст поля, выбранный таб |
| @Binding | value | родитель | Ребёнок правит чужое значение |
| @StateObject | class | сама View | View создаёт модель экрана |
| @ObservedObject | class | кто-то выше | Модель пришла в инициализаторе |
| @EnvironmentObject | class | предок | Сессия, тема, корзина |
С 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.