@State объявляет хранилище, которым владеет сама вью: SwiftUI держит значение вне структуры вью и перерисовывает её, когда оно меняется. @Binding работает как ссылка на чтение и запись к состоянию, которым владеет кто-то другой: ребёнок может менять значение, но не хранит его.
Владение: @State является источником истины, а @Binding даёт одолженный доступ к нему. У одного куска состояния ровно один владелец.
Объявление: @State делают private и обычно задают начальное значение (или присваивают его в init); у @Binding его нет, родитель передаёт $value.
Где применяют: @State для локального UI-состояния: переключатель, текст в поле, показан ли sheet. @Binding для переиспользуемого контрола, который правит значение родителя. Ровно так устроены Toggle(isOn:) и TextField(text:).
Value-типы: @State рассчитан на значения. Для модели-класса нужен @Observable (см. вопросы ниже).
iOSMiddle
Состояние в SwiftUI
@State, @Binding, @StateObject, @ObservedObject, @EnvironmentObject, Observation
Закрыто вопросов
0 / 24
- 0Повторить
- 8Часто спрашивают
Вопросы
count возвращает текущее значение (wrappedValue обёртки), $count возвращает Binding<Int> к нему (projectedValue), а _count ссылается на саму обёртку State<Int>, к которой обращаются только в init.
count: чтение в body делает вью зависимой от него; присваивание планирует перерисовку.
$count: префикс $ запрашивает у обёртки её projectedValue. У @State и @Binding это Binding, поэтому Stepper(value: $count) может записывать обратно.
_count: хранимое свойство, которое компилятор генерирует для обёртки. State(initialValue:) задаёт стартовое значение, и оно используется только при первом появлении этой вью.
$ не магия SwiftUI: его получает любой property wrapper с projectedValue. @FocusState отдаёт FocusState<Value>.Binding, а @Published отдаёт Combine-паблишер.
@ObservedObject получает объект явно через инициализатор, а @EnvironmentObject и @Environment читают то, что внедрено выше по дереву, поэтому промежуточным вью не нужно передавать это дальше (prop drilling).
@ObservedObject: ObservableObject, который передаёт родитель. Зависимость видна в init вью.
@EnvironmentObject: ObservableObject, внедрённый через .environmentObject(obj) и найденный по типу. Если забыть внедрение, приложение падает в рантайме с «No ObservableObject of type … found».
@Environment читает значения по key path (\.dismiss, \.colorScheme, свои ключи, объявленные через @Entry), а с iOS 17 ещё и @Observable-объекты по типу: .environment(session) плюс @Environment(AppSession.self). В новом коде это заменяет @EnvironmentObject.
Когда брать environment: для сквозных вещей, которые читают многие экраны: сессия пользователя, тема, роутер, фича-флаги. Если значение нужно одному ребёнку, передайте его через init: зависимость останется видимой, а превью и тестам не понадобится настройка.
Она читает @Environment(\.dismiss) и вызывает его. Environment знает, показана ли вью как sheet или запушена в навигационный стек, поэтому ребёнку не нужен биндинг обратно к тому, кто его показал.
\.dismiss: DismissAction, заменивший старый presentationMode. На запушенном экране он делает pop, а в sheet закрывает sheet.
\.scenePhase: .active, .inactive или .background; здесь сохраняют черновик и ставят таймер на паузу.
\.horizontalSizeClass: подстройка вёрстки под iPad и Split View вместо проверки модели устройства.
\.colorScheme, \.dynamicTypeSize, \.locale: внешний вид и форматирование. Переопределив их через .environment(...) в превью, проверяют тёмную тему, крупный шрифт или другой язык.
Чтение значения из environment делает вью зависимой от него, и при его изменении вью перерисовывается.
Не хватает @FocusState (вместе с .onSubmit). @FocusState отражает фокус клавиатуры: поля привязывают к нему через .focused(...), присваивание поля переводит туда фокус, а nil прячет клавиатуру.
Настройка: enum Field { case email, password }, @FocusState private var focused: Field? и .focused($focused, equals: .email) на каждом контроле. Для одного поля есть вариант с Bool: .focused($isFocused).
Next: .submitLabel(.next) плюс .onSubmit { focused = .password }.
Done: focused = nil из .onSubmit или из кнопки в ToolbarItemGroup(placement: .keyboard).
Он двусторонний: система сама пишет в него, когда пользователь тапает другое поле или смахивает клавиатуру, поэтому реагируйте через .onChange(of: focused), а не считайте, что меняете его только вы.
Начальный фокус: задают в .onAppear; в sheet, который ещё анимируется, запись может потеряться, тогда используйте .task.
State(initialValue:) используется только при первом появлении вью с этой identity. Дальше SwiftUI хранит своё значение и игнорирует каждое новое начальное значение от родителя, поэтому count остаётся 0.
Если числом владеет родитель: у ребёнка вообще не должно быть @State. Принимайте @Binding var count: Int или обычный let count: Int, если значение только показывается.
Если владеет ребёнок, но при смене входа нужно начать заново: дайте ему новую identity: Counter(start: preset.start).id(preset.id). Новый id выбрасывает старое состояние, и значение из инициализатора снова используется.
Если остальное состояние ребёнка должно сохраниться: храните let start: Int и обновляйте только это значение через .onChange(of: start) { _, new in count = new }.
Заполнение @State из параметра остаётся одной из самых частых причин багов «форма показывает устаревшие данные». Увидев _x = State(initialValue:), спросите себя, кто на самом деле владеет значением.
@StateObject значит, что вью владеет объектом: SwiftUI создаёт его один раз и держит живым всё время жизни вью. @ObservedObject лишь наблюдает за объектом, которым владеет кто-то другой, поэтому созданный прямо в нём объект пересоздаётся при каждой перерисовке родителя.
Симптом неверного выбора: @ObservedObject var vm = CartViewModel() молча сбрасывается: форма очищается во время ввода, загруженный список пропадает, счётчик откатывается в ноль, когда перерисовывается посторонний родитель.
Правило: вью, которая создаёт модель, использует @StateObject; каждая вью, которая её получает, использует @ObservedObject или @EnvironmentObject.
Обратная ошибка: @StateObject для объекта, переданного снаружи, запоминает первый экземпляр, а следующие экземпляры от родителя игнорируются.
Сегодня: с @Observable (iOS 17+) владелец держит модель в @State, а дети принимают обычный let. Обе старые обёртки всё равно нужны для существующего кода и таргетов ниже iOS 17.
@Published оборачивает свойство и отправляет событие в паблишер objectWillChange объекта непосредственно перед изменением значения; вью, наблюдающие объект (@StateObject, @ObservedObject, @EnvironmentObject), после этого заново выполняют body.
Один сигнал на весь объект: objectWillChange срабатывает один раз при изменении любого @Published-свойства, поэтому перерисовываются все наблюдатели, даже те, что читали другое свойство.
**$title и есть паблишер**: проецируемое значение есть Combine Published.Publisher, поэтому на него можно подписаться через sink или применить debounce, как в классическом случае с полем поиска.
Ручное оповещение: состояние без @Published никого не уведомляет; вызовите objectWillChange.send() до его изменения.
Главный поток: меняйте @Published-значения на главном потоке; запись из фонового вызывает фиолетовое предупреждение в рантайме.
Сегодня: @Observable убирает и протокол, и обёртку и сужает обновления до свойств, которые вью прочитала.
Cart объявлен как класс, поэтому @Published var cart хранит ссылку, которая не меняется. Добавление в cart.items дёргает собственный objectWillChange у Cart, на который не подписана ни одна вью, поэтому ShopModel молчит, а в тулбаре остаётся старое число.
Почему так: @Published срабатывает, только когда свойству присваивают новое значение. Для класса это значит заменить всю ссылку cart; SwiftUI подписан на ShopModel.objectWillChange, а не на объекты, лежащие внутри него.
Исправление: сделать Cart структурой. Тогда изменение model.cart.items записывает новое значение через сеттер cart, @Published срабатывает, и body выполняется заново.
**Если Cart должен остаться общим классом**: передайте его в дочернюю вью, которая наблюдает за ним через @ObservedObject, или пробросьте его изменения: cart.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() } с хранением в Set<AnyCancellable>.
**С @Observable**: вложенность работает: если оба класса @Observable, чтение model.cart.items в body регистрирует зависимость от items именно этого Cart.
Как поймать в следующий раз: юнит-тест, который подписывается на model.objectWillChange, добавляет товар и ждёт одно событие; с вложенным классом их ноль.
@Observable делает наблюдаемым каждое хранимое свойство класса без @Published, а SwiftUI отслеживает, какие свойства прочитала каждая вью, поэтому вью перерисовывается только при изменении этих свойств, а не при любом изменении объекта.
Меньше бойлерплейта: ни conformance к протоколу, ни @Published, ни objectWillChange. Свойство исключают из отслеживания через @ObservationIgnored.
Точечные обновления: с ObservableObject любая запись в @Published вызывает objectWillChange, и перерисовываются все наблюдающие вью. С @Observable вью, читающая только model.title, не реагирует на изменения model.posts.
Проще обёртки: владелец использует @State, дети принимают обычный let, @Environment(Model.self) заменяет @EnvironmentObject, а @Bindable даёт биндинги.
Шире охват: отслеживание идёт за тем, что читает body, поэтому работает и опциональная модель, и массив моделей, а @ObservedObject за таким наблюдать не умел.
Ограничения: только классы, iOS 17+. Механизм не привязан к SwiftUI: withObservationTracking и, начиная со Swift 6.2, асинхронная последовательность Observations (iOS 26+) наблюдают ту же модель из обычного кода, а UIKit в iOS 26 так же отслеживает чтения в layoutSubviews.
@Bindable превращает @Observable-объект в источник биндингов: $profile.name становится Binding<String> для TextField. Он нужен только во вью, которая не создавала модель, но должна отдать контролам двусторонние биндинги.
Не для наблюдения: ребёнок, который только читает модель, принимает обычный let, и обновления всё равно приходят.
Не у владельца: @State private var profile = Profile() уже даёт $profile.name.
Для переданной модели: объявите @Bindable var profile: Profile.
Для модели из environment: @Environment(Profile.self) не даёт $, поэтому внутри body объявляют локальный @Bindable var profile = profile.
**Только для @Observable-классов**: value-типу нужен @Binding; для ObservableObject ту же работу делает $ у @ObservedObject.
@State перерисовывает вью, когда заменяется хранимое в нём значение, а draft.title = "Hello" меняет объект за ссылкой, не заменяя саму ссылку, поэтому SwiftUI не видит изменений.
Со структурой та же строка работает: изменение свойства структуры записывает целиком новое значение через сеттер @State.
Исправление: пометить класс @Observable. Тогда перерисовку вызывает Observation, который запомнил, что body читал draft.title; @State лишь сохраняет экземпляр между перерисовками.
Ниже iOS 17: ObservableObject с @Published var title и владение через @StateObject.
Та же ловушка в другом виде: структура, внутри которой лежит обычный класс, и модель, объявленная во вью как let model = Model(): она пересоздаётся вместе с каждой новой структурой вью.
Правило: @State отвечает на вопрос «кто владеет», а @Observable на вопрос «кому сообщат об изменении». Модели-классу нужно и то и другое.
Состоянием владеет вью, которая его создала: @State для значения или @Observable-модели. Остальные получают обычный let для чтения, @Binding (или @Bindable для модели) для записи либо environment для всего поддерева.
Value-типы (Int, String, структуры)
Владеет эта вью: @State private var.
Владеет родитель, а ребёнок меняет значение: @Binding.
Ребёнок только показывает значение: обычный let.
Модели-классы (@Observable, iOS 17+)
Эта вью создаёт модель: @State private var model = Model().
Передана и только читается: let model: Model.
Передана, и вью нужен $model.field: @Bindable var model: Model.
Общая для целого поддерева: .environment(model) плюс @Environment(Model.self).
Старый ObservableObject
Создаёт (@StateObject); получает (@ObservedObject); читает из environment (@EnvironmentObject).
Специальные случаи
@AppStorage для сохраняемых настроек, @SceneStorage для восстановления UI отдельного окна, @FocusState для фокуса клавиатуры, @Environment(\.key) для системных значений.
Две ошибки, на которые обращают внимание на собеседовании: второй владелец у одного значения (@State-копия того, чем уже владеет родитель) и модель, созданная вью, которая ею не владеет (@ObservedObject var vm = VM()).
Binding представляет собой просто геттер плюс сеттер, поэтому его можно собрать вручную через Binding(get:set:), когда значение лежит там, куда не дотянуться через $: за преобразованием типа или внутри опционала.
Сначала встроенные проекции: $value у @State и @Binding, $model.field через @Bindable и List($items) { $item in ... }, который даёт по биндингу на элемент.
Преобразование: Slider нужен Double, а модель хранит Int: Binding(get: { Double(user.age) }, set: { user.age = Int($0) }).
Опционалы: Binding($nickname) превращает Binding<String?> в Binding<String>?, который разворачивают через if let и отдают ребёнку неопциональный биндинг.
Только чтение: .constant(true) для превью и заблокированных контролов.
Держите замыкания лёгкими: SwiftUI вызывает геттер при каждом вычислении body, поэтому работы и побочных эффектов в нём быть не должно; сеттер срабатывает при каждой правке пользователя, так что в нём только присваивание значения, без побочных эффектов вроде запроса на каждое нажатие клавиши.
Не копируйте значение из биндинга в локальный @State «чтобы его редактировать»: копия перестаёт следить за источником.
Экран деталей получил копию. Order объявлен как структура, поэтому передача как let order или заполнение из неё локального @State даёт ребёнку собственное значение, и все правки попадают в эту копию.
Значения: один владелец, биндинги вниз: List($orders) { $order in ... } даёт каждой строке биндинг в массив, а экран деталей принимает @Binding var order: Order. Правки пишутся прямо в массив списка.
Удаление при открытых деталях: биндинги элементов указывают в массив по позиции, и удаление элемента под ними может упасть с «Index out of range». Если элементы могут исчезать в другом месте, соберите Binding(get:set:), который ищет заказ по id.
Ссылки: один общий объект: сделайте Order @Observable-классом, который хранит store. Оба экрана держат один и тот же экземпляр, а детали используют @Bindable var order для $order.note.
Выбор: структуры дают семантику значений и безопасную передачу между акторами, но требуют одного владельца и биндингов; класс проще, когда одна сущность видна сразу в нескольких местах.
Недопустимо одно: держать оба варианта сразу. Два источника истины для одной сущности и есть баг из вопроса.
SwiftUI заново выполняет body, когда изменилось то, что вью прочитала: её состояние, биндинг, значение environment, наблюдаемое свойство или входные данные от родителя. @State сбрасывается при смене identity вью, потому что состояние принадлежит identity.
Зависимости: всё, что body прочитал в прошлый раз. Изменение инвалидирует только те вью, которые это читали; остальное дерево не трогается.
Структурная identity: позиция вью в body родителя. Две ветки if/else дают две разные вью, поэтому переключение веток уничтожает одну и создаёт другую с чистым состоянием.
Явная identity: .id(value) и id в ForEach. Новый id означает новую вью, так что @State сбрасывается, .task перезапускается, onAppear срабатывает снова.
Время жизни: @State, @StateObject и запущенная работа .task живут ровно столько, сколько живёт identity.
На практике: чтобы сохранить состояние, предпочитайте условный модификатор (.opacity(isHidden ? 0 : 1)) вместо if; используйте .id(...) осознанно, когда нужен сброс; давайте ForEach стабильные id, а не индексы массива, если список меняется.
Флаг живёт в @State строки, а List или LazyVStack вправе уничтожать строки, ушедшие далеко за экран. SwiftUI не обещает сохранять их состояние, поэтому строка возвращается собранной заново со значением по умолчанию.
Что теряется: при уничтожении строки пропадает её @State и отменяется её .task. При возврате строка начинает с дефолтов, а .task запускается снова, поэтому и загрузки на уровне строки могут повторяться за одну прокрутку.
Исправление: состояние, которое должно пережить прокрутку, держат над ленивым контейнером с ключом по id: Set<Item.ID> развёрнутых строк у родителя, переданный вниз как Binding, или флаг на @Observable-модели, которую держит store.
Загрузки в строках: через кэш, чтобы повторное появление было поиском, а не запросом.
Когда состояние в строке допустимо: подсветка нажатия или смещение при перетаскивании: их потеря за экраном незаметна. Проверка простая: заметит ли пользователь сброс.
ForEach внутри обычного VStack держит все строки живыми, поэтому тот же код выглядит рабочим на десяти элементах и ломается на тысяче в List.
Только не в body: SwiftUI может выполнить его когда угодно и сколько угодно раз. Побочные эффекты кладут в замыкания действий и событийные модификаторы (.task, .onChange, .onAppear), а .task автоматически отменяется, когда его вью исчезает.
.task { }: стартует при появлении вью и отменяется при её исчезновении. .task(id: value) отменяет и перезапускает работу при каждом изменении value, поэтому это инструмент для перезагрузки по новому id и для debounce поля поиска.
.onChange(of:): реагирует на изменение одного значения; замыкание принимает (oldValue, newValue), а initial: true запускает его и при первом появлении.
.onAppear: синхронный и срабатывает при каждом возврате на экран. Запущенный в нём Task { } не отменяется вместе с вью.
.refreshable: pull-to-refresh; спиннер крутится, пока не вернётся асинхронное замыкание.
Отмена кооперативная: URLSession, Task.sleep и task group останавливаются при отмене; собственные циклы должны проверять Task.isCancelled.
**Запись состояния внутри body**: даёт предупреждение «Modifying state during view update» и может зациклиться.
Саму логику держите в async-методах модели; вью только запускает их, и логику можно тестировать без вью.
@AppStorage объявляет свойство поверх UserDefaults, которое перерисовывает вью при изменении ключа; @SceneStorage хранит UI-состояние отдельного окна, которое система сохраняет и восстанавливает при перезапуске приложения.
@AppStorage
Все вью, читающие один ключ, перерисовываются, когда любая из них его записывает.
Поддерживаются типы UserDefaults (Bool, Int, Double, String, URL, Data) плюс любой RawRepresentable на основе String или Int, что покрывает enum-ы настроек.
Передайте в store: suite из App Group, чтобы делить значение с виджетом или расширением.
@SceneStorage
Ограничен одной сценой: на iPad у каждого окна своя копия. Он нужен для восстановления UI (выбранной вкладки, неотправленного черновика) и никогда не служит источником истины.
Где они перестают подходить
Секреты: UserDefaults хранится в незашифрованном plist, который попадает в бэкапы. Токенам и паролям место в Keychain.
Данные модели: всё, что вы запрашиваете, сортируете или мигрируете, должно жить в SwiftData, Core Data или файле, а не в Data-блобе под ключом defaults.
Тестируемый код: @AppStorage является глобальным состоянием, к которому обращаются изнутри вью. В крупном приложении спрячьте настройки за @Observable-объект, принимающий экземпляр UserDefaults, и внедряйте его.
Макрос превращает каждое хранимое свойство в вычисляемое, которое сообщает о чтениях и записях в ObservationRegistrar, а SwiftUI выполняет body под отслеживанием наблюдения, поэтому вью зависит ровно от тех свойств, которых коснулась за этот прогон.
Механизм
@Observable переносит хранилище в скрытое свойство с @ObservationIgnored; геттер вызывает registrar.access(self, keyPath:), а сеттер оборачивает запись в registrar.withMutation(of:keyPath:).
SwiftUI вычисляет body внутри withObservationTracking(_:onChange:), и каждый access за это вычисление записывается на вью.
Последующая запись в любой записанный key path вызывает обработчик изменения, и SwiftUI планирует обновление. Обработчик срабатывает один раз; SwiftUI заново подписывается при следующем прогоне body. Ваш собственный вызов withObservationTracking тоже нужно перерегистрировать: именно этот цикл за вас пишет последовательность Observations (iOS 26+) из Swift 6.2.
Что оно пропускает
**Свойство, которое body не читал**: чтение в onAppear, внутри Task или после guard, который вернулся раньше, зависимости не создаёт.
**Свойства с @ObservationIgnored: и вычисляемые свойства, читающие только игнорируемое хранилище. Вычисляемые значения отслеживаются через хранимые свойства, которые они читают, а не сами по себе.
Вложенный обычный класс**: изменение объекта, который сам не @Observable, регистратор не видит.
Момент срабатывания: обработчик выполняется до того, как новое значение записано, поэтому чтение свойства внутри него вернёт старое значение.
Ментальная модель: наблюдение работает как подписка на каждое прочитанное свойство, а не на объект. Если вью не прочитала свойство в body, она о нём не узнает.
Каждая запись, которую может увидеть вью, должна происходить на main actor. Вью SwiftUI, @State и рендеринг, читающий вашу модель, изолированы @MainActor, поэтому запись из другого потока означает гонку данных (data race), а не просто медленное обновление.
Что с этим делает каждое поколение
**ObservableObject**: ловит это в рантайме: запись в @Published не с главного потока даёт фиолетовое предупреждение «Publishing changes from background threads is not allowed».
**@Observable: не предупреждает. Запись не с главного потока проявляется как устаревшая строка, падение глубоко внутри SwiftUI или ничего до первого медленного устройства.
Языковой режим Swift 6**: диагностирует это на этапе компиляции. Захват не-Sendable модели в @Sendable-замыкании или в Task.detached помечается: ошибкой для Task.detached и ваших собственных @Sendable-замыканий, но лишь предупреждением для старых API с completion handler вроде URLSession.dataTask. Новые таргеты приложений в Xcode 26 к тому же по умолчанию изолированы MainActor, так что код приложения работает на main actor, если не указано иное.
Как писать
Пометьте модель @MainActor. Тогда её свойства можно записывать только на main actor, а вью читают их без переключения.
Медленный ввод-вывод вызывайте через await из метода на main actor. URLSession.data(from:) приостанавливается, не блокируя главный поток; Task.detached не нужен.
Тяжёлую работу на CPU, например декодирование большого ответа, вынесите в функцию с @concurrent (Swift 6.2) и сделайте await; результат присваивается обратно на main actor. С настройками approachable concurrency из Swift 6.2 обычная nonisolated async-функция выполняется на акторе вызывающего, так что сама по себе с главного потока уже не уходит.
Колбэчные API оборачивайте через withCheckedContinuation или переключайтесь через Task { @MainActor in ... } внутри колбэка.
Ловушка, которая остаётся: Task { }, запущенный из вью, наследует изоляцию main actor, поэтому долгая синхронная работа в нём всё равно замораживает UI. Изоляция решает, где выполняется код, а не сколько он выполняется.
@State вычисляет начальное значение при каждом создании структуры вью, а SwiftUI пересоздаёт SupportChatScreen при каждой перерисовке родителя. Каждый раз новый ChatModel открывает сокет в init; @State оставляет первую модель и выбрасывает остальные, но они уже подключились.
Почему так: init вью не является событием жизненного цикла. Когда родитель заново выполняет body, он снова вызывает SupportChatScreen(), и значение по умолчанию ChatModel() создаётся снова. После первого появления @State его игнорирует, но побочный эффект уже выполнен.
Почему всплывает после миграции: @StateObject принимает @autoclosure и создаёт объект один раз на идентичность вью. @State принимает готовое значение, поэтому переход с ObservableObject на @Observable + @State приносит этот баг с собой.
Исправление: держать init дешёвым и без побочных эффектов, а работу запускать в .task: он запускается при появлении вью и отменяется при исчезновении, а не при каждой перерисовке родителя; отмена завершает цикл for await; сокет закрывать при отмене задачи (defer { socket.disconnect() }). Лишние модели, которые всё ещё создаются, стоят только памяти.
Когда дорого даже создание: хранить @State private var model: ChatModel? и создавать модель внутри .task, или отдать владение модели родителю, который живёт дольше.
Как поймать в следующий раз: лог или счётчик в init модели и переключение несвязанного состояния родителя; на ревью I/O, таймеры и подписки в init модели, которую создаёт вью, дают повод насторожиться.
Сначала докажите, какие вью перерисовываются и почему, потом устраняйте причину. Инструменты: Self._printChanges() внутри подозрительного body и инструмент SwiftUI в Instruments, запущенный на release-сборке на реальном устройстве.
Измерение
let _ = Self._printChanges() в начале body печатает, что его инвалидировало: @self, когда изменились входные данные вью, @identity при новой identity или имя изменившегося свойства. Только для отладки.
Инструмент SwiftUI показывает, как часто выполняется body каждой вью и сколько это занимает, а в Xcode 26 ещё и какое изменение состояния вызвало каждое обновление. Так видно, выполняется ли одна вью сто раз или один body занимает десять миллисекунд.
Типичные причины
Вью получает всю модель, а нужно одно поле: с ObservableObject любая запись в @Published перерисовывает всех наблюдателей. @Observable сужает это до реально прочитанных свойств, и это самый сильный довод за миграцию.
**Дорогая работа в body**: сортировка, группировка, создание DateFormatter, которые повторяются при каждом вычислении.
Потерянная identity: AnyView, .id(...) из нестабильного значения или ветки if, подменяющие вью, вызывают уничтожение и пересборку вместо дешёвого обновления.
Состояние слишком высоко: @State текстового поля в корне экрана перезапускает весь экран на каждое нажатие клавиши.
Исправления
Делите большие вью, чтобы инвалидация оставалась локальной, и опускайте @State в самую маленькую вью, которая его использует.
Передавайте строкам значения, которые они показывают, а не всю модель.
Вычисляйте производные данные в модели один раз на изменение, а не на каждый body.
Частые вызовы body нормальны и дёшевы по замыслу. Проблема почти всегда в работе внутри них, а не в их количестве.
У каждого куска состояния ровно один владелец на самом узком уровне, которому оно нужно. Данные идут вниз как значения или наблюдаемые модели, а изменения возвращаются наверх как вызовы методов владельца, а не как запись в копию.
Уровни состояния
Локальное UI-состояние вью: @State во вью: флаги sheet, набираемый текст, развёрнутая строка.
Состояние фичи: модель @MainActor @Observable, которой через @State владеет корневая вью фичи: данные экрана, фаза загрузки и намерения пользователя в виде методов.
Состояние приложения: сессия, настройки, общие кэши. Создаются один раз на уровне App, внедряются через .environment(...) и читаются только там, где нужны.
Персистентные данные: SwiftData, Core Data или репозиторий; модели читают из них, а не держат собственную расходящуюся копию.
Однонаправленный поток
Вью отображают состояние и отправляют намерения вроде cart.add(item); меняет состояние только владелец. Тогда каждое изменение прослеживается до одного метода и тестируется без вью.
TCA формализует ту же идею.
Вью-модель оправдана, когда у экрана есть логика для тестирования; сервисы прячутся за протоколами и внедряются через init или environment.
Что ломается на масштабе
Одна гигантская модель на всё приложение, которую читает каждый экран, связывает все фичи с ней; делите состояние по фичам.
Две копии одной сущности, например у списка и у деталей своя, расходятся.
Навигационное состояние, размазанное по вью; держите путь в одной модели-роутере, тогда deep link или возврат к корню сводится к одному присваиванию.