lenec ru

← все посты

SwiftUI: типичные ошибки с @State, @Binding и @StateObject

12K

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 — для глобальных синглетоноподобных штук, но дроби их по ответственности.

Если когда-то путаешься, спроси себя: «Кто владеет этим объектом? Что произойдёт, если родитель пересоздастся?» Большинство ошибок ловится именно этим вопросом.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.