В MVC от Apple контроллер связывает модель и view, но UIViewController ещё и владеет жизненным циклом своей view, поэтому вёрстка, состояние, сеть и навигация оседают в одном классе, который нельзя протестировать без view.
Model, View, Controller: модель хранит данные и бизнес-правила, наследники UIView рисуют и передают касания дальше, а контроллер получает события, обновляет модель и отдаёт новые значения обратно во view.
Почему он разбухает: контроллер и так делегат и data source каждой таблицы, target каждой кнопки и место, откуда начинается навигация, поэтому каждая новая фича по умолчанию попадает туда же.
Как выйти: логику представления вынести во view model или presenter, навигацию перенести в координатор, data source и сеть выделить в отдельные типы; контроллеру остаётся только связывание.
iOSMiddle
Архитектура iOS
MVC, MVVM, VIPER, Coordinators, TCA, Clean Swift
Закрыто вопросов
0 / 24
- 0Повторить
- 9Часто спрашивают
Вопросы
Внедрение зависимостей (dependency injection) означает, что объект получает свои зависимости снаружи, а не создаёт и не ищет их сам. Передавайте их через init как протоколы, и тест подставит заглушку там, где приложение передаёт настоящий клиент.
Внедрение через конструктор: вариант по умолчанию: зависимости видны в сигнатуре, и компилятор не даст создать объект без них.
Внедрение через метод: зависимость передаётся в тот единственный вызов, которому она нужна.
Composition root: одно место на входе в приложение (App, SceneDelegate, корневой координатор) создаёт настоящие реализации и собирает граф.
Какой запах кода убирает: NetworkManager.shared, вызванный внутри метода: тест не может его подменить и поэтому ходит в сеть.
MVVM выносит логику представления и состояние экрана из view во view model, которая отдаёт готовые к показу значения; view только привязывается к ним и передаёт события пользователя, поэтому логику можно покрыть юнит-тестами без всякого UI.
Роли: Model: доменные данные и сервисы. ViewModel: состояние экрана, форматирование, валидация, вызовы сервисов. View: отрисовывает состояние и отправляет действия.
Всё решает связывание: в SwiftUI оно бесплатное: @Observable view model в @State, и Observation перерисовывает только те view, которые читали изменившееся свойство. В UIKit связываете сами, замыканием или паблишером Combine (сохранённое замыкание привязки должно захватывать self слабо, иначе контроллер и view model удерживают друг друга), а начиная с iOS 26 можно положиться на автоматическое отслеживание @Observable-свойств, прочитанных в updateProperties() (или viewWillLayoutSubviews() / layoutSubviews()). До iOS 26 используйте withObservationTracking или замыкание.
Никакого UIKit во view model: отдавайте enum или строку, а не UIColor и не UILabel; тогда тип собирается в пакете, на watchOS и в тестах без иерархии view.
Что он не решает: навигацию. MVVM ничего не говорит о том, кто открывает следующий экран, поэтому его сочетают с координаторами (MVVM-C) или роутером.
В SwiftUI он необязателен: экран с двумя @State и одним .task читается лучше без view model; заводите её, когда у экрана есть настоящий сценарий (валидация, пагинация, повторы), который стоит тестировать.
Каждое нажатие клавиши запускает свой Task, и старые никто не отменяет, поэтому побеждает ответ, который пришёл последним: медленный ответ для "iph" приходит после быстрого для "iphone" и перезаписывает results устаревшими данными.
Почему так происходит: сервер не обязан отвечать в порядке запросов: короткий запрос находит больше строк и часто выполняется дольше, а повторы и попадания в кэш ещё сильнее перемешивают ответы. Запись прямо в results полагается на порядок, который никто не гарантирует.
Как работает исправление: задача хранится в searchTask, перед запуском следующей предыдущая отменяется, а перед записью проверяется Task.isCancelled. Проверка важна: отмена кооперативная, и API, который её игнорирует, или try?, превращающий CancellationError в [], всё равно вернёт результат, и без guard отменённый поиск перезапишет или очистит список.
Бонус: отмена заодно останавливает ненужные запросы URLSession; дебаунс 300 мс (try await Task.sleep(for: .milliseconds(300)) в начале задачи) превращает шесть запросов в один. .task(id: query) в SwiftUI и switchToLatest в Combine делают ту же работу «отменить предыдущий».
Как поймать заранее: юнит-тест со стабом API, у которого задержка зависит от запроса ("i" медленнее, чем "iph"): отправьте три запроса и проверьте, что на экране результат последнего. Вручную на быстром офисном Wi-Fi этот баг почти не воспроизводится.
Выбирайте по форме связи: несколько сообщений одному известному собеседнику отправляют через делегат, один результат возвращают async-вызовом, а многих неизвестных слушателей оповещают нотификацией или наблюдаемым значением.
Делегат: протокол плюс weak var delegate, для долгой связи один-к-одному с несколькими сообщениями, особенно когда вызывающему нужен ответ. На нём построен UIKit.
Замыкание: один колбэк, заданный в месте вызова: действие кнопки в ячейке, onFinish у координатора. Сохранённое замыкание удерживает всё, что захватило, поэтому важны списки захвата.
async/await: заменяет большинство completion-замыканий: результат, который вы запросили, возвращается из вызова.
NotificationCenter: широковещание один-ко-многим между объектами, которые не должны знать друг о друге, например уход приложения в фон. Нетипизирован и легко превращается в свалку, поэтому оставляйте его для системных событий.
Observation, Combine, AsyncSequence: @Observable для состояния, которое читает UI; паблишер или AsyncStream для потока значений во времени (текст поиска, сообщения сокета), который потребляют через sink или for await.
KVO: observe(\.keyPath) на @objc dynamic-свойстве наследника NSObject; сегодня в основном для объектов Apple, например WKWebView.estimatedProgress или AVPlayer.status. Храните возвращённый NSKeyValueObservation, иначе наблюдение прекратится.
Две приметы выдают неверный выбор: нотификация, которой достают ровно один известный объект, и протокол делегата с единственным методом, который читался бы лучше как замыкание.
В UIKit и Foundation собран каталог классических паттернов: делегат и target-action для колбэков, наблюдатель через NotificationCenter и KVO, синглтоны для общих сервисов приложения, компоновщик для дерева view и цепочка обязанностей для responder chain.
Делегат (Delegate): UITableViewDelegate, UITextFieldDelegate: один объект настраивает поведение другого через протокол.
Target-action и команда (Command): addTarget(_:action:for:) у UIControl; UIAction заворачивает обработчик в объект, а UndoManager записывает команды для отмены.
Наблюдатель (Observer): NotificationCenter, KVO, а теперь и Observation.
Синглтон (Singleton): UIApplication.shared, FileManager.default, URLSession.shared, UserDefaults.standard.
Компоновщик (Composite): UIView и её subviews: контейнер и лист имеют один интерфейс, поэтому вёрстка и отрисовка рекурсивно проходят по дереву.
Цепочка обязанностей (Chain of responsibility): responder chain: необработанное касание или action с nil-target идёт от first responder вверх через superview и контроллеры до UIApplication.
Шаблонный метод (Template method): viewDidLoad, layoutSubviews, draw(_:): фреймворк задаёт алгоритм, вы переопределяете шаги.
Фабрика и кластер классов: UIFont.preferredFont(forTextStyle:), UIButton(type:); NSNumber и NSString возвращают приватные подклассы.
Адаптер (Adapter, interop с SwiftUI): UIViewRepresentable адаптирует UIKit-view для SwiftUI; UIHostingController работает в обратную сторону.
SOLID объединяет пять правил, которые держат типы маленькими и заменяемыми: одна причина для изменения, расширение без правки, подтип соблюдает контракт, маленькие протоколы, зависимость от абстракций. В iOS это в основном аргументы против massive view controller.
S (единственная ответственность): view controller, который верстает, ходит в API, парсит JSON и решает навигацию, имеет четыре причины для изменения; разделите его на view model, сервис и координатор.
O (открытость/закрытость): switch по типам оплаты, повторённый в пяти местах, приходится править ради каждого нового способа; протокол PaymentMethod с отдельным типом на каждый способ позволяет добавить способ, добавив тип.
L (подстановка Лисков): подтип обязан выполнять обещания родителя: наследник UITableViewCell, который не зовёт super.prepareForReuse(), или ReadOnlyCache, бросающий ошибку из save, ломают код, написанный под базовый тип.
I (разделение интерфейсов): UIKit разделяет UITableViewDataSource, UITableViewDelegate и UITableViewDataSourcePrefetching, чтобы вы реализовывали только нужное; делайте так же вместо одного ServiceProtocol на двадцать методов.
D (инверсия зависимостей): view model зависит от protocol UserRepository, а не от URLSession или конкретного клиента; настоящий тип подставляется в composition root, а в тестах его место занимает заглушка.
Если применять SOLID механически, получится протокол на каждый класс и пять файлов на фичу; хороший ответ называет принцип и говорит, где остановиться.
Координатором называют объект, который владеет навигацией флоу: экраны сообщают о событиях вроде «логин прошёл», а координатор решает, что показать дальше, поэтому view controller-ы никогда не пушат и не презентуют друг друга.
Переиспользуемые экраны: один и тот же view controller работает и в онбординге, и в настройках, потому что не знает, что идёт после него.
У флоу есть владелец: у логина, онбординга и оформления заказа свой координатор; родитель запускает дочерние и хранит их в массиве children.
Диплинки: один объект может собрать весь стек под маршрут.
Тестирование: view controller-ам больше не нужен настоящий UINavigationController, чтобы их протестировать.
Типичный баг: дочерний координатор, не убранный из children по окончании флоу, утекает вместе со своими экранами.
Когда не нужен: приложение из двух экранов только заплатит за церемонию. В SwiftUI ту же идею воплощает объект-роутер, владеющий NavigationStack(path:); MVVM с координаторами называют MVVM-C.
Дочерний координатор добавляется в children, но никогда оттуда не удаляется, поэтому родитель держит каждое завершённое оформление вместе с его view controller-ами и черновиком. Вдобавок замыкание сильно захватывает self и создаёт цикл через children.
Удалять дочерний при завершении: checkout.onFinish = { [weak self, weak checkout] in self?.nav.popToRootViewController(animated: true); self?.children.removeAll { $0 === checkout } }.
Цикл: AppCoordinator → children → checkout → onFinish → AppCoordinator. Для корня, живущего всё время работы приложения, это безвредно, а для любого координатора ниже это настоящая утечка.
Путь, о котором все забывают: пользователь уходит кнопкой «Назад» или свайпом от края, и onFinish не вызывается. Станьте делегатом navigation controller-а и в navigationController(_:didShow:animated:) проверьте, есть ли ещё from-контроллер из transitionCoordinator в viewControllers; если нет, флоу закрыт, и дочерний координатор надо убрать. Модальное окно (sheet), смахнутое вниз, по той же причине требует presentationControllerDidDismiss(_:).
Проверка: пройдите флоу дважды и посмотрите логи deinit или Memory Graph Debugger.
В одном наблюдаемом объекте-роутере выше навигационного контейнера: он хранит типизированный массив path, который меняют обработчик ссылок, обработчик уведомлений и сам UI.
Опишите маршруты как Hashable-enum на вкладку или на флоу и передайте массив маршрутов в NavigationStack(path:). Добавление элемента делает push, замена всего массива сразу открывает вложенный экран.
Диплинки работают надёжно только тогда, когда навигация представляет собой состояние, которым вы владеете, а не цепочку push-ов по экранам; с одним роутером ссылка при холодном старте, тап по уведомлению и тап внутри приложения попадают в один и тот же path.
Разбирайте ссылку или payload уведомления в маршрут ровно в одном месте. onOpenURL и делегат уведомлений после этого только передают готовый маршрут роутеру.
Каждый экран должен открываться с холодного старта, а не только когда он уже в стеке. Маршрут, который роутер не умеет собрать по id, недостижим ни по какой ссылке.
В UIKit ту же роль играет координатор: он владеет UINavigationController, поэтому может сбросить стек до корня и собрать его заново одним шагом.
Три типичные поломки диплинков: восстановление стека последовательными push-ами с задержками, целая модель внутри маршрута вместо id, и отсутствие места, где маршрут подождёт, пока пользователь залогинится.
Репозиторий представляет собой протокол на доменное понятие, который прячет, откуда пришли данные (сеть, база, кэш), и отдаёт доменные типы. DTO остаются за ним, поэтому переименованное поле в JSON становится правкой в одном файле, а не в трёх экранах.
Репозиторий: view model зависит от protocol PostRepository, а не от сетевого клиента; живая реализация сама выбирает между сетью и кэшем, поэтому тест передаёт заглушку вообще без сети.
DTO: обязан терпеть всё, что делает сервер: optional-поля, которые «обычно» есть, snake_case, даты строками, id, меняющий тип между релизами. Держите эту форму подальше от мест вызова, где она расползается россыпью if let и ??.
Неизвестные значения enum: сгенерированный Decodable для RawRepresentable enum бросает ошибку на незнакомом raw value, поэтому один новый статус на сервере роняет весь ответ. Храните status в DTO как String и приводите через PostStatus(rawValue:) ?? .unknown.
Доменный тип: форма, нужная приложению: не-optional поля, Date, URL, enum со значением по умолчанию. toDomain() у DTO, покрытый тестом на сохранённой JSON-фикстуре, падает громко и один раз, на границе, вместо защитного кода, размазанного по экранам.
Состояние экрана: третий тип на экран обычно не нужен; добавляйте его, когда экран собирает данные из двух источников.
Одной модели достаточно, когда бэкенд ваш и выкатывается вместе с приложением; но CodingKeys всё равно пишите явно.
Правило: слой окупается, когда поглощает изменения. Если DTO и доменный тип совпадают навсегда, это аренда ни за что.
Редко: внедрение через конструктор из composition root проверяется компилятором и достаточно для большинства приложений. Рантайм-реестр вроде Swinject окупается только при десятках сервисов и областях жизни, а его цена в том, что отсутствующая регистрация падает на старте приложения, а не на сборке.
Service locator вида container.resolve(AuthService.self)! превращает отсутствующую зависимость в поиск во время выполнения. Ничего не падает на сборке, когда фича переезжает в таргет, чью assembly забыли обновить, или когда resolve случается раньше своей регистрации.
Constructor injection ставит ту же ошибку перед компилятором: тип, которому нужен AuthService, нельзя создать без него. Единственным местом, где всё собирается, служит composition root: структура App или принадлежащий ей объект AppDependencies.
Встроенный ответ SwiftUI тоже проверяется: EnvironmentKey обязан задать defaultValue (макрос @Entry напишет ключ за вас), поэтому незарегистрированного случая просто нет.
Factory и swift-dependencies сохраняют проверку через значение по умолчанию при объявлении: Factory требует фабричное замыкание, swift-dependencies требует liveValue у каждого DependencyKey, а withDependencies подменяет зависимость в пределах одного теста.
Правило: если приложение может упасть из-за отсутствующей зависимости, значит она не внедрена, а найдена поиском.
Оба теста используют UserDefaults.standard по умолчанию, а он сохраняется на диск. testFinishHidesOnboarding записывает onboardingDone = true, и каждый следующий запуск другого теста (в этом прогоне или в следующем) читает это оставшееся значение вместо чистой установки.
Почему так происходит: бандл тестов запускается внутри приложения, поэтому .standard оказывается настоящим доменом настроек приложения на симуляторе и переживает тесты и прогоны. XCTest по умолчанию идёт по алфавиту, поэтому testFinish… выполняется раньше testFresh….
Как работает исправление: view model уже принимает UserDefaults через init; тесты просто не пользовались этой точкой подмены. Каждый тест получает свой suite, UserDefaults(suiteName: name), и очищает его через removePersistentDomain(forName:), поэтому всегда стартует как после чистой установки.
Ловушка в аргументе по умолчанию: = .standard сокращает места вызова, но позволяет тесту незаметно взять настоящую зависимость. То же касается синглтонов .shared, Keychain и файлов на диске: всё, что переживает тест, нужно внедрять и подменять.
Как поймать заранее: включите «Randomize execution order» в тест-плане и гоняйте набор повторно (xcodebuild -test-iterations 5); Swift Testing по умолчанию запускает тесты параллельно, и общее состояние всплывает ещё быстрее.
Оба делят экран на объекты с одной обязанностью, связанные протоколами (VIPER на View, Interactor, Presenter, Entity и Router, Clean Swift на односторонний цикл ViewController → Interactor → Presenter), и дают тестируемые швы и единообразную форму ценой примерно пяти файлов на экран.
VIPER: в центре Presenter: он принимает события от View, запрашивает данные у Interactor (бизнес-логика над Entity), форматирует результат и говорит View, что показать, держа её weak; Router собирает модуль и выполняет навигацию.
Цикл Clean Swift: view controller отправляет Request в Interactor; интерактор делает работу и передаёт Response в Presenter; презентер форматирует ViewModel и вызывает метод display… у view controller. Назад не зовёт никто, и каждая сторона держит следующую через протокол, поэтому любой переход подменяется на spy. Интерактор же служит DataStore, из которого читает следующая сцена, вместо того чтобы view controller передавал payload.
Выигрыш: всё форматирование живёт в презентере, поэтому числа, строки и «3 часа назад» тестируются без view.
Цена: церемония: экрану с одним полем всё равно нужны Models, Interactor, Presenter, Router, ViewController и протоколы, а новое отображаемое поле правит пять файлов. Команды генерируют это из шаблона и перестают задавать вопросы.
Против MVVM: MVVM связывает состояние, и view сама перечитывает изменившееся, а VIP пушит явные вызовы отображения. Это хорошо ложится на UIKit и плохо на SwiftUI, где view уже является функцией от состояния, и push-вызову презентера некуда приземлиться.
Где окупается: в больших UIKit-кодовых базах со множеством контрибьюторов, где одинаковая форма каждого экрана ценнее краткости.
Модель мышления: VIP можно считать MVP, у которого бизнес-логику вынесли в Interactor, а все стрелки направили в одну сторону.
Feature flag работает как переключатель во время выполнения, который решает, активен ли код, уже лежащий в бинарнике. Так можно мержить незаконченную работу, раскатывать фичу на долю пользователей и выключать её удалённо, не дожидаясь App Review.
Виды: релизные тоглы, которые прячут незаконченную работу в main, эксперименты (A/B-тесты), аварийные выключатели (kill switch) и флаги для когорты пользователей. Живут они разное время; релизный тогл должен умереть за несколько недель.
Источник: сервис удалённой конфигурации (Firebase Remote Config, LaunchDarkly или свой эндпоинт), закэшированный на диске, со значением по умолчанию, вкомпилированным в приложение на случай первого запуска без сети.
Читать один раз за сессию: вычисляйте значение в стабильной точке и держите его, чтобы экран не менялся под пользователем, когда посреди сессии приходит новый конфиг.
За протоколом: зависимость FeatureFlags внедряется как любая другая; тесты и превью передают фиксированные значения, а debug-меню переопределяет флаги на устройстве.
Вместе с поэтапным релизом App Store: phased release растягивает автообновление на 7 дней (от 1% до 100%) и умеет вставать на паузу, но выключить код не может; флаг может.
Никогда не граница безопасности: всё, что должно оставаться закрытым, проверяется на сервере.
Долг: каждый флаг удваивает число путей для тестирования; удаляйте его, как только фича раскатана на 100%.
Они различаются тем, где живёт логика, не относящаяся к view, и насколько строго течут данные: MVC держит её в контроллере, MVP и MVVM выносят в тестируемый объект, VIPER дробит дальше, TCA проводит каждое изменение через reducer. Выбирают по команде, UI-фреймворку и тому, что нужно тестировать.
Сравнение
MVC: пишется быстрее всего; логика оседает в UIViewController и тестируется только через UI.
MVP: Presenter без UIKit управляет пассивной view через протокол (showLoading(), show(items:)); очень тестируемо, но каждое изменение UI превращается в метод протокола.
MVVM: view привязывается к наблюдаемому состоянию; естественно в SwiftUI с @Observable, в UIKit требует механизма связывания и ничего не говорит о навигации; отсюда MVVM-C.
VIPER / Clean Swift: по объекту на обязанность плюс роутер; единообразная форма для больших UIKit-команд ценой пяти и более файлов на экран.
TCA: однонаправленный поток: state, actions, reducer, effects; исчерпывающие тесты и композиция ценой сторонней зависимости и порога входа.
Как выбирать
UI-фреймворк: SwiftUI располагает к лёгкому MVVM или TCA; стиль VIPER с пушем во view спорит с view, которая и так является функцией от состояния.
Размер команды: жёсткий шаблон окупается при тридцати инженерах на одно приложение и превращается в церемонию для троих.
Что тестировать: сложные сценарии оправдывают reducer или presenter; CRUD-экраны оправдывают их редко.
Цена выхода: TCA и сгенерированные VIPER-модули трудно убрать; MVVM на обычных Swift-типах легко мигрировать.
Смешивать подходы по фичам в модульном приложении нормально, если правила навигации и зависимостей общие; единообразие важно на границах модулей, а не внутри каждого экрана.
Сильный ответ называет компромисс для этой команды, а не любимый паттерн.
Однонаправленный поток данных (unidirectional data flow) означает, что состояние меняется только в одну сторону: view отправляет action, reducer вычисляет новое состояние, view его отрисовывает. TCA, Redux-подобная библиотека от Point-Free, строит так каждую фичу из State, Action, Reducer, Effect и Store.
Составные части
State: одна Equatable-структура, описывающая всё, что показывает экран.
Action: enum всех событий, от тапов пользователя до пришедших ответов.
Reducer: функция без побочных эффектов, которая меняет inout-состояние и возвращает Effect; сети внутри неё не происходит.
Effect: async-работа, обёрнутая так, что её можно отменить, а в тестах подменить.
Store: держит состояние, принимает action-ы, запускает эффекты.
Что это даёт
Один источник правды на фичу, а фичи собираются в более крупные через reducer-комбинаторы.
TestStore переигрывает каждый action и роняет тест, если хоть одно изменение state или незавершённый эффект не учтены: это самая строгая история с тестами среди iOS-архитектур.
Зависимости объявляются через @Dependency и переопределяются на тест, так что нет окружающего синглтона, который надо сбрасывать между кейсами.
Цена реальна: заметная библиотека, которую надо изучить и поддерживать в актуальном состоянии, больше косвенности, чем у MVVM на простом экране, и boilerplate, который макросы @Reducer и @ObservableState сокращают, но не убирают.
Clean Architecture раскладывает код по кольцам (в центре домен, вокруг данные и представление), и все зависимости направлены внутрь, поэтому бизнес-правила ничего не знают об URLSession, SwiftData или SwiftUI и тестируются отдельно.
Domain: сущности, протоколы репозиториев и use case-ы на чистом Swift без импортов фреймворков; от него зависят все остальные.
Data: реализации репозиториев, DTO, API-клиент и хранилище; слой реализует протоколы домена. Это и есть инверсия зависимостей на практике.
Presentation: view model или presenter и view; к домену они обращаются только через протоколы.
Use case (interactor): тип с одной задачей, например PublishDraft, который координирует несколько репозиториев и применяет правила. Он оправдан, когда сценарий затрагивает несколько репозиториев или нужен двум экранам.
Типичная ошибка: use case, который просто пробрасывает один вызов в один репозиторий, только добавляет шум; удалите его. Именно к пятислойным схемам для экрана со списком придираются на собеседовании.
Пусть границы держат модули: вынесите Domain в отдельный Swift-пакет без зависимостей; тогда import SwiftUI в use case сломает сборку, а не будет ждать ревью.
Добавляйте слой, когда он убирает зависимость или делает тестируемым то, что раньше не было, а не потому, что на схеме пять квадратов.
Сначала делите по фичам, а внутри фичи уже по слоям: локальный Swift-пакет на каждую фичу поверх общих модулей Core и DesignSystem и тонкий app-таргет, который только связывает их между собой.
По слоям или по фичам: папки верхнего уровня Models/, Views/, ViewModels/ годятся для маленького приложения, но разбрасывают одну фичу по всему дереву; папки по фичам держат правку в одном месте и потом превращаются в модули.
Локальные SPM-пакеты: свой Package.swift на фичу, подключается по пути. Каждый модуль собирается независимо, инкрементальные сборки распараллеливаются, а public становится настоящей границей API. Сегодняшний дефолт.
Xcode-фреймворки: способ до SPM. Всё ещё повсюду в старых проектах; собираются медленнее, зависимости описывать неудобнее.
XCFramework-и: предсобранные бинарники для распространения SDK или кода, который нельзя отдать исходниками.
Раскладка, которая масштабируется
App/: точка входа и composition root, больше ничего.
Features/Feed, Features/Profile, Features/Search: по модулю на фичу, у каждого свои тесты.
Core/Networking, Core/Persistence, Core/Logging: сквозная инфраструктура.
DesignSystem: общие компоненты, цвета, типографика.
Здоровье держат два правила: фичи зависят от Core и DesignSystem, но никогда друг от друга, и всё, что понадобилось двум фичам, уезжает вниз в Core, а не вбок. Именно кросс-фичевые импорты тихо превращают модульный проект обратно в один модуль.
Выигрыш виден во времени сборки и радиусе поражения: правка внутри фичи пересобирает эту фичу, а модуль, который собирается отдельно, отдельно и тестируется.
В объекте, которым владеет сам флоу: он создаётся при старте флоу и освобождается при его завершении. Старый адрес означает, что черновик пережил флоу, а именно это и гарантируют синглтон или environment-объект на корне приложения.
Состояние на уровне экрана слишком мелкое: три экрана должны договориться об одном черновике. Состояние на уровне приложения слишком крупное: его никто не чистит, а «почистить в конце» превращается в строку, которую однажды забудут на ветке отмены. Правильное время жизни совпадает со временем жизни флоу.
В SwiftUI им владеет контейнер. @State private var draft = CheckoutDraft() во view, которая показывает первый шаг, плюс NavigationStack(path:), а каждый шаг получает черновик через инициализатор или через .environment(draft), применённый к этому поддереву, а не к корню приложения. Выход из флоу разрушает контейнер, и черновик уходит вместе с ним.
В UIKit черновик держит координатор, передаёт его во view model каждого шага, а сам освобождается родителем при завершении или отмене. CheckoutDraft.shared означает баг длиной в одну строку.
Шаги меняют черновик, и только последний шаг отправляет его через репозиторий. Запись на сервер на каждом шаге приводит к тому, что в бэкенде появляются недооформленные заказы, а возобновлённый флоу порождает второй такой же.
Освобождение памяти не равно персистентности. Если терять черновик при краше недопустимо, сохраняйте его явно под id флоу и удаляйте и при отправке, и при отмене: возобновляемость представляет собой решение с путём удаления, а не побочный эффект долгоживущего объекта.
Тест, который ловит весь этот класс багов: пройти флоу, отменить, начать заново и проверить, что второй черновик пуст. На синглтоне он падает сразу.
Правило: давайте состоянию минимальное время жизни, которого всё ещё хватает всем читателям; владеть состоянием должен объект, чья жизнь совпадает с жизнью фичи.
Внедряйте SwiftUI целыми экранами через UIHostingController, чтобы шов проходил по границе навигации, а не внутри вью.
UIHostingController оборачивает SwiftUI-вью во view controller, который можно запушить, показать модально или добавить дочерним. Экран служит правильной единицей, потому что навигация, жизненный цикл и координатор остаются в UIKit.
UIViewRepresentable и UIViewControllerRepresentable работают в обратную сторону и нужны для того, чего в SwiftUI нет: превью камеры, зрелый график, текстовое поле со своим input accessory.
Общее состояние держите в одном объекте, за которым следят обе стороны. @Observable-хранилище (или хранилище с паблишерами, если код на Combine) даёт UIKit-экрану и SwiftUI-экрану один источник истины вместо синхронизации двух копий.
Навигацией должен владеть кто-то один. Пока стеком владеет UIKit, встроенный SwiftUI-экран сообщает о событиях через замыкание или координатор, а не открывает собственный NavigationStack.
Начинайте с листового экрана с малым числом зависимостей (настройки, деталка), а не с узла, на который пушат все остальные.
Известные трудности: хостинг-контроллер внутри ячейки требует аккуратного самоопределения размера, а для ячеек списка предназначен UIHostingConfiguration. Следите за двойным навбаром и дважды применёнными safe area insets, если встроенный экран рисует своё оформление.
Рефакторьте по швам, которых вы и так касаетесь: новое пишется в целевой архитектуре, а старые экраны меняются только когда в них приходит задача.
Остановите кровотечение: договоритесь, что новые фичи выходят модулями, а новые view controller-ы не обрастают бизнес-логикой, и закрепите это ревью или правилом линтера.
Разберитесь с синглтонами. Пока общий экземпляр вызывается прямо в коде, тестировать нечего: введите протокол, оставьте синглтон значением по умолчанию и переводите вызовы постепенно, без огромного диффа.
Инфраструктуру выносите в локальные Swift-пакеты раньше фич. Сеть, хранилище и дизайн-систему тянет за собой каждая фича, поэтому они должны собираться отдельно первыми.
Душите экран за экраном: правя экран, выносите его логику в передаваемую извне зависимость, покрывайте тестами, а контроллер оставляйте оболочкой.
Ведите метрику (сколько файлов осталось в таргете приложения, время инкрементальной сборки, покрытие внутри новых модулей), чтобы миграция не встала тихо на половине.
Характеризующие тесты пишутся до переноса, а не после: снапшот-тесты дёшево закрывают экраны, у которых тестов нет вообще, и громко падают, если поведение изменилось.
По слоям: кэш в памяти для декодированных и уменьшенных картинок, кэш на диске для исходных байтов и загрузчик, который склеивает одинаковые запросы, декодирует вне главного потока и отменяет работу для ячеек, ушедших с экрана.
Слои
Кэш в памяти: NSCache с ключом «URL плюс целевой размер» и totalCostLimit в байтах; при нехватке памяти он сам вытесняет записи. Храните декодированные картинки, иначе каждое переиспользование снова платит за декодирование.
Кэш на диске: файлы в Caches/ с лимитом размера и очисткой по LRU, или отдельный URLCache, который учитывает Cache-Control и ETag. Система может очистить Caches/, и для данных, которые можно скачать заново, это допустимо.
Сеть и предзагрузка: URLSession с ограничением на число одновременных загрузок; начинайте заранее из UICollectionViewDataSourcePrefetching или для строк, которые вот-вот появятся.
Детали, которые решают производительность
Уменьшение (downsampling): фото 4000×3000 после декодирования занимает 48 МБ (ширина × высота × 4 байта). Используйте CGImageSourceCreateThumbnailAtIndex из ImageIO с kCGImageSourceThumbnailMaxPixelSize, равным размеру view × масштаб экрана, или preparingThumbnail(of:).
Декодирование вне главного потока: byPreparingForDisplay() или ImageIO в фоновой задаче; декодирование на главном потоке остаётся классической причиной подёргиваний при скролле.
Склеивание запросов: actor держит словарь выполняющихся Task по ключу, поэтому десять ячеек, запросивших один аватар, порождают один запрос.
Правильная отмена: переиспользованная ячейка отменяет своё ожидание, но общая загрузка останавливается, только когда её больше никто не ждёт; перед присваиванием результата сверяйте URL, иначе ячейка покажет чужую картинку.
Компромиссы
Ключ кэша должен включать размер и преобразования, иначе превью и полная картинка затрут друг друга.
Nuke, Kingfisher и SDWebImage уже делают всё это; на собеседовании важно понимать, зачем нужен каждый слой.
Сделайте локальную базу источником истины: UI читает и пишет только локально, каждое изменение дополнительно ставится в очередь исходящих (outbox), а движок синхронизации, когда может, отправляет outbox и забирает изменения с сервера, разрешая конфликты по заранее выбранному правилу.
Поток данных
Чтение: экраны наблюдают за локальным хранилищем (@Query, NSFetchedResultsController, наблюдение в GRDB), поэтому одинаково обновляются и от локальной правки, и от пришедшего изменения.
Запись: сохраняйте локально и в той же транзакции добавляйте мутацию в таблицу outbox; пользователь никогда не ждёт сеть.
Id, созданные на клиенте: UUID на каждую запись и каждую мутацию делает повторы идемпотентными; сервер игнорирует id мутации, которую уже применил.
Получение: запрашивайте изменения начиная с серверного курсора или change token; удаления приходят как tombstone-записи, а не как пропавшие строки.
Конфликты
Последняя запись побеждает (last writer wins) по серверному времени: просто, но молча теряет одну правку; годится для настроек.
Проверка версии: у каждой записи есть version; сервер отклоняет устаревшую запись с кодом 409, а клиент накатывает свои изменения заново или спрашивает пользователя.
Слияние по полям: изменения разных полей сливаются; решение нужно только для одного и того же поля.
CRDT: для совместного редактирования текста, где должны выжить обе правки, ценой объёма хранения и сложности.
Когда запускать
При старте, при выходе на передний план, после локальной записи и когда NWPathMonitor сообщает о сети (как подсказка, а не условие); просто пробуйте запрос.
BGAppRefreshTask для получения изменений при случае; фоновый URLSession для больших загрузок, которые должны завершиться после приостановки приложения.
Для данных, которые живут только в iCloud, большую часть этого реализуют CKSyncEngine или SwiftData с CloudKit.