SwiftUI: типичные ошибки с @State, @Binding и @StateObject
Property wrappers в SwiftUI выглядят простыми ровно до того момента, пока приложение не начинает терять состояние при перерисовке, или наоборот — держит лишний объект в памяти и тянет за собой весь граф вью. За три года в проде с SwiftUI я собрал список ошибок, которые повторяются у всех: и у джунов, и у людей, мигрировавших с UIKit. Разберу их по порядку, на реальных примерах из банковского приложения и delivery-стартапа.
Главная проблема в том, что компилятор молчит. @State, @StateObject, @ObservedObject и @Binding внешне взаимозаменяемы — везде ты пишешь свойство и обращаешься через $. А семантика разная, и неправильный выбор всплывёт только в рантайме: то экран моргнёт, то ViewModel пересоздастся при каждом скролле списка.
@State: только для приватного значения вью
Самая частая ошибка — хранить в @State что-то, что должно жить дольше одной вью. @State создан для маленьких локальных штук: открыт ли шит, что введено в текстовое поле прямо сейчас, выбран ли таб. Тип должен быть значимый — Bool, String, Int, своя структура.
struct ProfileView: View {
@State private var isEditing = false
@State private var draftName = ""
var body: some View {
// ...
}
}Что часто делают неправильно: кладут в @State ссылочный тип, например сетевого клиента или менеджер сессии. Формально работает, но при пересоздании родительской вью SwiftUI не гарантирует сохранение объекта. На практике — иногда сохраняет, иногда нет, и баг плавающий.
Второе — забывают private. @State по дизайну приватный, доступ к нему снаружи — это уже @Binding. Если хочется передать значение в дочернюю вью с возможностью изменения, передавай $value, а не сам объект.
@Binding: ссылка на чужое состояние
С @Binding главная путаница — кто владеет состоянием. @Binding ничем не владеет, это просто двусторонний ключ к чужому @State или другому источнику.
struct ToggleRow: View {
let title: String
@Binding var isOn: Bool
var body: some View {
Toggle(title, isOn: $isOn)
}
}
struct SettingsView: View {
@State private var notificationsEnabled = true
var body: some View {
ToggleRow(title: "Уведомления", isOn: $notificationsEnabled)
}
}Ошибка номер один — пытаются сделать @Binding с дефолтным значением, чтобы вью «работала и без родителя». Так нельзя: @Binding по определению указывает на чужое состояние. Если нужна вью с локальным состоянием по умолчанию и опциональным внешним управлением — есть паттерн с init и @State + опциональный Binding, но в 90% случаев это перебор. Сделай две вью или пробрось @State из родителя.
Ошибка номер два — .constant(...) в продакшене. Это инструмент для превью и тестов: ToggleRow(title: "X", isOn: .constant(true)). В реальной вью с таким биндингом значение не будет меняться, и ты потратишь час, дебажа «почему тогл не переключается».
@StateObject vs @ObservedObject: главная ловушка
Это место, где ошибаются чаще всего. Разница простая, но не очевидная.
@StateObjectсоздаёт и владеет объектом. SwiftUI гарантирует, что объект будет создан один раз за время жизни вью.@ObservedObjectтолько наблюдает за чужим объектом. Не владеет, не создаёт. Если родитель пересоздаст ObservedObject, дочерняя вью получит новый.
Классическая ошибка — создать ViewModel прямо в инициализаторе как @ObservedObject:
// Так делать НЕ нужно
struct OrderListView: View {
@ObservedObject var viewModel = OrderListViewModel()
var body: some View { /* ... */ }
}Что здесь не так. SwiftUI пересоздаёт OrderListView при каждом изменении родительского состояния. Каждый раз вызывается инициализатор, каждый раз создаётся новый OrderListViewModel, и его сетевые запросы стартуют заново. У меня это проявилось как баг «список заказов мигает и теряет позицию скролла» — банальный перезапуск загрузки на каждый ререндер.
Правильно — @StateObject:
struct OrderListView: View {
@StateObject private var viewModel = OrderListViewModel()
var body: some View { /* ... */ }
}Теперь объект создаётся один раз, выживает между ререндерами, и SwiftUI правильно подписывается на его objectWillChange.
@ObservedObject остаётся для случаев, когда ViewModel создаётся снаружи и пробрасывается в дочернюю вью. Например, в детальном экране, который получает уже готовый объект из списка.
Инициализация @StateObject с параметрами
Здесь начинается отдельный пласт боли. Если ViewModel требует параметры (например, ID заказа), наивный код выглядит так:
struct OrderDetailView: View {
let orderId: UUID
@StateObject private var viewModel: OrderDetailViewModel
init(orderId: UUID) {
self.orderId = orderId
self._viewModel = StateObject(wrappedValue: OrderDetailViewModel(orderId: orderId))
}
var body: some View { /* ... */ }
}Работает. Но есть нюанс: StateObject(wrappedValue:) вычисляет аргумент при каждом вызове init, а сам объект сохраняется только при первом. То есть OrderDetailViewModel(orderId:) будет создаваться при каждом ререндере родителя, и его конструктор вызовется впустую много раз. Если в конструкторе тяжёлая инициализация — это проблема. Если лёгкая — игнорируй и спи спокойно.
Альтернатива — выносить ViewModel в родителя или в координатор и передавать как @ObservedObject. Но тогда ты теряешь автоматическое управление жизненным циклом, и нужно следить самому, когда отпускать объект.
@EnvironmentObject: ещё один вариант стрельбы в ногу
Раз уж зашла речь, добавлю про @EnvironmentObject. Удобный механизм для глобальных штук вроде сессии или темы. Главная ошибка — забыть его положить в environmentObject(...) у корня. Получаешь крэш в рантайме с трейсбэком, который ни о чём не говорит, если не знать, куда смотреть.
@main
struct MyApp: App {
@StateObject private var session = SessionManager()
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(session)
}
}
}Второй момент: @EnvironmentObject провоцирует пересборку всех потомков при изменении любого свойства объекта. Если в нём 30 проперти и большинство меняется независимо, ты получишь лавину перерисовок. Решение — дробить объект на несколько мелких или использовать ObservableObject с ручным objectWillChange.send() там, где это критично.
Как распознать проблему
Самый простой способ поймать «лишние пересоздания» — поставить print в init ViewModel и в body вью. В Xcode 15+ удобнее использовать let _ = Self._printChanges() прямо в body:
var body: some View {
let _ = Self._printChanges()
// ...
}Эта функция печатает в консоль, какое именно изменение состояния триггернуло перерисовку. Очень полезно, когда вью перерисовывается чаще, чем кажется логичным.
Дополнительно стоит включить в схеме SWIFTUI_VIEW_DEBUG и поглядывать на Time Profiler в Instruments — если в нём body какой-то вью занимает заметное время в каждом фрейме, это первый кандидат на оптимизацию.
Что запомнить
Простые правила, которые экономят время:
@State— только для приватного значимого типа.@Binding— ссылка на чужой@Stateили биндинг от объекта.@StateObject— создание и владение ObservableObject внутри вью.@ObservedObject— наблюдение за объектом, который создал кто-то другой.@EnvironmentObject— для глобальных синглетоноподобных штук, но дроби их по ответственности.
Если когда-то путаешься, спроси себя: «Кто владеет этим объектом? Что произойдёт, если родитель пересоздастся?» Большинство ошибок ловится именно этим вопросом.