Сегодня с вами Никита, iOS Team Lead в Surf. Никита объяснит, почему мы в Surf решили создать собственную архитектуру для разработки на SwiftUI.
SwiftUI фундаментально отличается от UIKit. Поэтому он требует своего подхода к архитектуре. Всем известные MVP, MVVM, наша SurfMVP и прочие подходы в чистом виде не адаптированы под особенности SwiftUI.
Зачем нужна архитектура
Что это такое
Архитектура — это принципы организации компонентов системы и взаимодействия между ними. Соблюдение принципов архитектуры позволяет создавать предсказуемые в плане оценки и масштабируемые проекты. Такие проекты, которые легко поддерживать и оценивать, если разработчик знаком с используемой архитектурой.
Универсальной архитектуры нет. У популярных подходов есть и плюсы, и минусы. Но у всех них есть кое-что общее. В основе любой архитектуры лежат принципы ООП (объектно ориентированного программирования). Какие-то из них соблюдаются в меньшей степени, какие-то — в большей. Но в итоге рождаются новые, предметно ориентированные принципы. Та самая архитектура.
Нас же сейчас интересуют архитектуры, созданные для разработки мобильных приложений:
MVC — Model View Controller, он же Massive ViewController;
MVP — Model View Presenter;
MVVM — Model View ViewModel;
VIPER — View Interactor Presenter Entity Router.


Компоненты и выбор
Компоненты разных архитектур отличаются связями друг с другом. Можно представить, что каждый компонент — это деталь пазла. А характер связей с соседним компонентом определяет выпуклости и впуклости выемки этой детали.
Видим, что архитектуры выше обладают общими типами деталей: View и Model. Так, View — это UI, а Model — это данные или «бизнес-логика».
При выборе архитекторы и разработчики опираются на множество факторов: от размеров проекта (пет-проект — нет смысла залезать в VIPER, большой проект — огребём с MVC) до личного опыта.
Если обобщить, то архитектуру выбирают исходя из стека технологий. Например, MVVM часто идет в комплекте с реактивщиной, будь то Combine или RxSwift. При этом VIPER не сочетается с реактивными фреймворками.
До появления SwiftUI выбор архитектуры основывался именно на стеке технологий сервисного слоя или бизнес-логики. Вёрстка на UIKit не накладывает ограничений при выборе архитектуры. View остаётся UI и не содержит никакой логики.
Что изменилось с появлением SwiftUI
Conditional Views позволяют верстать не просто UI, а динамичный UI с несколькими неявными State.
Published-свойства позволяют обновлять State и синхронно, и реактивно, но трансформации через Combine делать удобнее.
Всё это расширяют представление о View и, конечно, затрудняют выбор архитектуры.
SwiftUI-View — это не только UI, это View-швейцарский нож. И многофункциональность здесь нарушает принцип единой ответственности, который лежит в основе многих существующих архитектур. Эта новая деталь имеет слишком много выемок и выступов. Пазл не сходится.
И вот теперь пришла пора рассмотреть проблемы совместимости с существующими архитектурами.
MVC
В архитектуре MVC всего два компонента: View и Model. Может показаться, что для многофункциональной SwiftUI View эта архитектура подходит больше. Но не забываем, что в крупных проектах такую архитектуру сложно использовать. Чем больше логики размещается во View, тем сложнее ее поддерживать. И тем больше конфликтов будет возникать у разработчиков при работе над одним экраном. А в особо запущенных случаях можно даже получить ошибку:

Использовать MVC вместе со SwiftUI можно для прототипирования или для работы в маленькой команде. Применение conditional views улучшит читаемость ветвлений, если сравнивать с той же логикой в UIKit-контроллере.
Собрать пазл с использованием MVC и SwiftUI очень просто. Детали такие большие, что это пазл для младенцев. Серьёзным ребятам часто приходится сражаться за одну деталь.
MVP
Отличительная особенность MVP — passive view. Presenter не зависит от представления. Он отдает команды в View через анонимный протокол. При этом View ничего не знает о Presenter.
В SwiftUI сложно следовать этой архитектуре, поскольку все реализации View — это структуры. Из-за этого мы не можем обновлять состояние View командами из presenter-а.
Обновить состояние возможно через специальные State переменные или StateObject. Его как раз можно сделать классом и изменять с помощью presenter. Но тогда мы получим Model View State Presenter — 4 компонента вместо 3, указанных на диаграмме.
Документируем связь между старыми и новым компонентом — иначе не получится следовать архитектуре единообразно.
public struct MVPViewGroup: View { private static let model: Model = .init() @StateObject private var catalogPresenter: CatalogPresenter // MARK: - State @State private var isCartShown = false @State private var detailSelected: Item? // MARK: - Init public init() { self._catalogPresenter = .init(wrappedValue: .init(model: MVPViewGroup.model)) } // MARK: - View public var body: some View { NavigationStack { CatalogView(items: $catalogPresenter.allItems, cart: $catalogPresenter.cartItems, output: catalogPresenter) .navigationTitle("Items") } .onAppear { catalogPresenter.onShowDetail = { item in self.detailSelected = item } catalogPresenter.onCartShow = { self.isCartShown = true } } }
Собрать пазл по каноничному MVP с использованием SwiftUI не получится. Детали не склеятся.
MVVM
В отличие от MVP в MVVM именно View держит ViewModel. Он же получает от неё данные и отправляет команды обратно.
Но как передать в View реализацию ViewModel? Передавая ViewModel через инициализатор ViewB из родительской ViewA, мы наткнёмся на то, что при перерисовке ViewA будет создаваться новый экземпляр ViewModel. А локально сохранённое состояние потеряется.
Чтобы прикрепить экземпляр ViewModel к жизненному циклу View и не зависеть от циклов перерисовки родительской View, подключим ViewModel через EnvironmentObject.
public struct MVVMViewGroup: View { // MARK: - Properties private static let model: Model = .init() private var catalogVM: CatalogViewModel // MARK: - State @State private var isCartShown = false @State private var detailSelected: Item? // MARK: - Init public init() { self.catalogVM = .init(model: MVVMViewGroup.model) } // MARK: - View public var body: some View { NavigationStack { CatalogView() .environmentObject(catalogVM) .navigationTitle("Items") } .onAppear { catalogVM.onShowDetail = { item in self.detailSelected = item } catalogVM.onCartShow = { self.isCartShown = true } } } }
Но и у этого способа есть недостатки:
связь между ViewModel и View неявная;
установка ViewModel становится обязательной, иначе — краш;
закрыть ViewModel протоколом не получится. А это снижает возможности интеграционного тестирования экрана.
Выходит, собрать пазл из MVVM и SwiftUI можно — с помощью специальных инструментов и переходников.
VIPER
VIPER — это эволюция MVP. Не самая популярная архитектура. Обилие компонентов вызывает обманчивое ощущение её сложности.
С другой стороны, обилие компонентов более жёстко определяет зоны ответственности компонентов и их назначение. А это приближает нас к clean code и максимальному соблюдению принципов ООП.
Но собрать пазл не получится по той же причине, по которой не вышло с MVP. Вдобавок, мы тут же сталкиваемся с трудностями с вынесением навигации в Router. Всё из-за того, что в SwiftUI управление навигацией привязано к View.
public struct VIPERViewGroup: View { // MARK: - Properties private static let model: Model = .init() // MARK: - Init public init() {} // MARK: - View public var body: some View { NavigationStack { CatalogView(presenter: .init(interactor: .init(model: VIPERViewGroup.model), router: .init() ) ) .navigationTitle("Items") } } }
Рецепт успеха
Чтобы собрать пазл из SwiftUI и какой-либо архитектуры, нужно учесть особенности новой многопрофильной детальки SwiftUI View:
View — это структуры;
View обновляются через State-переменные;
View могут иметь локальный State;
View имеют DI на основе Environment.
Эти особенности помогают нам создать новый набор компонентов и принципы взаимодействия между ними. В общем, создаём новую архитектуру, без недостатков адаптаций.
VSURF
Да, аббревиатура нашей архитектуры не случайно совпадает с названием компании. Но не спешите гневаться, расшифровка не лишена смысла:
View
view State
business Unit
navigation Routing
singleton services Factory
View — первая буква и основной компонент архитектуры. Мы используем дизайн-систему и Playbook, поэтому часто разработка начинается именно с UI.
ViewState — мы учитываем механизм обновления SwiftUI View на основе State переменных. Строим binding с бизнес-логикой на основе формирования динамических Published свойств.
Business Unit — отделение логики от View. В этом компоненте происходит общение с сервисами и обновление глобальных состояний процессов: авторизации, наполнение корзины и других. То есть процесс, который занимает больше одного экрана. Иными словами, законченный flow.
Navigation Routing — особенности навигации между экранами, ведь в SwiftUI навигация завязана на Binding. Привычным координатором тут не обойтись.
SIngleton Services Factory — характер низкоуровневых сервисов. Network-сервисы, сервисы общения с БД и другие — это синглтоны, которые порождаются через фабрики.
Перечисленные аспекты можно назвать основными принципами нашей архитектуры. Если погружаться глубже, будем говорить о модульности и уже потом — о компонентах.
Модульность
Ни одна из классических архитектур из первого раздела не описывает принципы создания модулей и связи между ними. Хотя концепция многомодульности уже давно используется на больших проектах.
За счёт более четкой организации кода многомодульность позволяет:
оптимизировать время сборки проекта;
повысить тестируемость проекта;
уменьшить время обучения новых разработчиков;
упростить распределение задач.
Для модулей у VSURF есть вертикальные уровни и горизонтальные уровни. Каждый уровень отвечает за свою часть приложения и соответствует определенной зоне ответственности. Модули одного уровня не должны зависеть друг от друга.


Разделение на уровни обеспечивает:
упрощение поддержки;
возможность переиспользования модулей;
уменьшение зависимостей.
Мы не будем подробно останавливаться на каждом уровне, но отметим, что у каждого есть требования к:
разрешённым внешним зависимостям:
никакие;
только утилитарные (не SDK);
Любые зависимости.
необходимым типам тестов:
Unit;
UI;
Snapshot.
Такое деление на модули позволяет наладить фабрику по сборке деталей большого пазла. А они, в свою очередь, состоят из более мелких деталей — компонентов. При этом разделение на уровни позволяет функционально разделить команду по «линиям» сборки.
Компоненты
Чтобы проще было сравнить нашу архитектуру с классическими вариантами, посмотрим на пример связи представления с бизнес-логикой.
В нашем случае за это отвечают компоненты View, ViewStateHolder и Unit.

Основная задача ViewStateHolder — подписаться на сервис и сконвертировать бизнес-модель в данные для View. Кроме того, ViewStateHolder пробрасывает команды от View в Unit, добавляя необходимые параметры.

Технически Unit — это сервис с:
Input-протоколом для приёма команд от ViewStateHolder;
Output-протоколом, перечисляющим потоки данных AnyPublisher.
ViewStateHolder — это ObservableObject, который подключается в родительскую View в качестве StateObject:
Input-протокол для приёма команд от View;
Output в виде @Published свойства ViewState.
View содержит логику и локальные State свойства для управления child View:
инициализируется с Binding<ViewState>;
отправляет изменения через Weak референс на Input StateHolder, подключённый через Environment.
В этой концепции можно найти общие черты и с MVP, и с MVVM.
Идём смотреть подробнее.
Задача
Специально для этой статьи мы подготовили 5 реализаций упрощённого каталога с корзиной. Каждая реализация использует только SwiftUI и немного Combine — только нативные фреймворки.
Дано
статичный список элементов каталога.
Сделать
пополнение и очистка корзины из каталога;
открытие детального экрана элемента через презентацию;
пополнение и очистка корзины с детального экрана;
открытие корзины через navigationStack;
очистка корзины — покупка.
В конечном счёте на каждом табе получаем вариант соответствующей архитектуры. Визуально все варианты одинаковы, но по коду в них множество отличий.

Исходники проекта лежат тут. Если вы эксперт в одной из архитектур и видите недочёты в нашей реализации, предлагайте собственное решение через pr или issue.
Мы же уделим внимание реализации на основе нашей новои��печённой VSURF.
Реализация
Согласно VSURF, любой модуль Flow уровня имеет публичную View. Эта View станет точкой входа во Flow, и позволит встраивать его в нужное место.
В нашем случае ViewGroup всех пяти реализаций встроены в TabView. Но SwiftUI прекрасен тем, что View — всё ещё протокол. Это позволяет легко менять композицию View.
TabView(selection: $selectedTab) { MVCViewGroup() .tag(AppTab.mvc) MVPViewGroup() .tag(AppTab.mvp) MVVMViewGroup() .tag(AppTab.mvvm) VIPERViewGroup() .tag(AppTab.viper) VSURFViewGroup() .tag(AppTab.vsurf) }
Чтобы подчеркнуть гибкость SwiftUI, обязательное условие VSURF — публичный init ViewGroup без параметров.
Подключение сервисов, вёрстка и навигация внутри модуля описываются непосредственно в ViewGroup. Изоляция инициализации позволяет эксплуатировать эту особенность SwiftUI и перестраивать конечный рисунок пазла, не меняя характер деталек (flow).
public struct VSURFViewGroup: View { // MARK: - State //... @StateObject private var catalogStateHolder: CatalogViewStateHolder @StateObject private var cartStateHolder: CartViewStateHolder // MARK: - Private Properties private let catalogUnit: CatalogUnitOutput private let cartUnit: CartUnitInput & CartUnitOutput // MARK: - Init public init() { let catalogUnit = VSURFStateFacade.Units.catalog() let cartUnit = VSURFStateFacade.Units.cart() self.catalogUnit = catalogUnit self.cartUnit = cartUnit self._catalogStateHolder = .init(wrappedValue: .init(catalogUnit: catalogUnit, cartUnit: cartUnit)) self._cartStateHolder = .init(wrappedValue: .init(cartUnit: cartUnit)) } //... }
Правило инициализации без параметров распространяется не только на ViewGroup, но и на инициализацию business unit. В нашем примере это — catalogUnit и cartUnit, такие адаптеры для бизнес-логики. Они похожи на interactor из VIPER.
final class CatalogUnit { // MARK: - Private Properties private let localItems: [Item] // MARK: - Init init(items: [Item]) { self.localItems = items } } // MARK: - CatalogUnitOutput extension CatalogUnit: CatalogUnitOutput { var items: AnyPublisher<[Item], Never> { Just(localItems).eraseToAnyPublisher() } }
Простейший unit определяет, откуда взять данные. Для упрощения мы используем в этом примере статичный список элементов каталога. На практике же вместо этого может быть обращение к сетевому слою, базе данных и так далее.
final class CartUnit { // MARK: - Private Properties private let model: Model // MARK: - Init init(model: Model) { self.model = model } } // MARK: - CartUnitInput extension CartUnit: CartUnitInput { func addItem(_ item: Item) { model.addItem(item) } func removeItem(_ item: Item) { model.removeItem(item) } func removeAll() { model.removeAll() } } // MARK: - CartUnitOutput extension CartUnit: CartUnitOutput { var items: AnyPublisher<[Item], Never> { model.cart } }
В более интересном случае у unit появляются input-методы, с помощью которых мы меняем глобальное состояние сервиса с бизнес-логикой.
Внутри flow модуля может быть больше одного экрана и больше одного unit. Unit позволяет передавать локальное состояние между разными экранами.
final class CartViewStateHolder: ObservableObject { // MARK: - Private Properties private var cancellables: Set<AnyCancellable> = [] private var cartUnit: CartUnitInput & CartUnitOutput // MARK: - Published @Published var state: CartView.ViewState = .init(items: []) // MARK: - Init init(cartUnit: CartUnitInput & CartUnitOutput) { self.cartUnit = cartUnit subscribe() } } // MARK: - ViewOutput extension CartViewStateHolder: CartViewOutput { func buy() { cartUnit.removeAll() } }
Преобразованием бизнес-состояния в модель View будет заниматься StateHolder. Это сущность, привязанная к View. Ближайший аналог — Presenter или ViewModel.
Кроме того, StateHolder передаёт команды в unit через его input-методы.
// MARK: - Private Methods private extension CartViewStateHolder { func subscribe() { cartUnit.items .map { cartItems -> [(String, Int)] in cartItems.reduce(into: [Item: Int]()) { result, item in result.updateValue((result[item] ?? 0) + 1, forKey: item) } .sorted(by: { $0.key.title > $1.key.title }) .compactMap { ($0.key.title, $0.value) } } .receive(on: DispatchQueue.main) .map { items -> CartView.ViewState in CartView.ViewState(items: items.map { (item, count) -> CartView.CartItem in .init(title: item, count: count) }) } .assign(to: \.state, on: self) .store(in: &cancellables) } }
С помощью магии Combine формируем ViewState и записываем его в Published переменную. Она будет подключаться к View через Binding.
public struct CatalogView: View { // MARK: - Nested Types struct CatalogItem { let title: String let canRemoveFromCart: Bool } struct CartSnapshot { let count: Int var isEmpty: Bool { count == 0 } } struct ViewState { let items: [CatalogItem] let cart: CartSnapshot } // MARK: - States @Binding private var state: ViewState @Binding private var navigationState: VSURFNavigationState @Binding private var detailSelected: String? // MARK: - Weak Reference @WeakReference private var output: CatalogViewOutput? // MARK: - Init init(state: Binding<ViewState>, navigationState: Binding<VSURFNavigationState>, detailSelected: Binding<String?>) { _state = state _navigationState = navigationState _detailSelected = detailSelected } //... }
Кроме Binding<ViewState> в инициализаторе View есть ещё два Binding. Один отвечает за навигацию в NavigationStack, другой — за презентацию детального экрана элемента каталога.
public var body: some View { List { ForEach(state.items, id: \.title) { item in Button(action: { detailSelected = item.title }, label: { HStack { Text(item.title) Spacer() Button(action: { output?.removeItem(item.title) }, label: { Image(systemName: "minus") }) .disabled(!item.canRemoveFromCart) Button(action: { output?.addItem(item.title) }, label: { Image(systemName: "plus") }) } }) } }.toolbar { Button(action: { navigationState.push(destination: .cart) }, label: { Image(systemName: "cart") Text("\(state.cart.count)") }) .disabled(state.cart.isEmpty) } }
У нас есть подготовленный для экрана ViewState. Остаётся только сверстать его с помощью SwiftUI.
Кстати, binding предоставляет двустороннюю связь — мы можем не только прочесть значения, но и изменить их.
С ViewState эта особенность нам не нужна, но для управления навигацией пригодится.
// MARK: - Preview struct CatalogView_Previews: PreviewProvider { enum Preset: String, CaseIterable { case cartIsEmpty case cartIsNotEmpty } static var previews: some View { snapshots.previews } static var snapshots: PreviewSnapshots<Preset> { return PreviewSnapshots(states: Preset.allCases, name: \.rawValue, configure: { preset in switch preset { case .cartIsEmpty: CatalogView(state: .constant(.init(items: [ .init(title: "Item 1", canRemoveFromCart: false), .init(title: "Item 2", canRemoveFromCart: false), .init(title: "Item 3", canRemoveFromCart: false) ], cart: .init(count: 0))), navigationState: .constant(.initial), detailSelected: .constant(nil)) case .cartIsNotEmpty: CatalogView(state: .constant(.init(items: [ .init(title: "Item 1", canRemoveFromCart: true), .init(title: "Item 2", canRemoveFromCart: true), .init(title: "Item 3", canRemoveFromCart: true) ], cart: .init(count: 3))), navigationState: .constant(.initial), detailSelected: .constant(nil)) } }) } }
Unit на binding позволяет легко инициализировать View для preview. А расширение PreviewSnapshots для библиотеки SnapshotTesting помогает использовать эти preview в snapshot-тестах.
public var body: some View { NavigationStack(path: $navigationState.navigationPath) { CatalogView(state: $catalogStateHolder.state, navigationState: $navigationState, detailSelected: $detailSelected) .navigationTitle("Items") .navigationDestination(for: VSURFNavigationState.Destination.self) { destination in switch destination { case .cart: CartView(state: $cartStateHolder.state, navigationState: $navigationState) .navigationTitle("Cart") } } } .sheet(item: $detailSelected) { item in DetailViewGroup(item: item, cartUnit: cartUnit) } .weakReference(cartStateHolder, as: CartViewOutput.self) .weakReference(catalogStateHolder, as: CatalogViewOutput.self) }
Вернёмся в ViewGroup и покажем, как обрабатывается навигация.
Презентация через sheet сменой оператора легко заменяется на popover, fullscreen или кастом. NavigationStack позволяет нам пушить экраны с привычным NavigationBar.
@NavigationState struct VSURFNavigationState { enum Destination: Hashable, CaseIterable { case cart } }
NavigationPath для NavigationStack формируется в NavigationState, основная магия которого скрыта под макросом.
Чтобы разработчикам, привыкшим к UIKit-навигации, было проще адаптироваться под SwiftUI, мы добавили знакомые по UINavigationController методы: push, pop, popToRoot.

У внимательного читателя ещё останутся вопросы про weakReference или про facade. Но мы просто не можем рассказать про все аспекты архитектуры в одной статье, поэтому вместе с демо-проектом вы сможете посмотреть DocC туториалы и документацию.
Скажем честно, проект находится на стадии обкатки и пока не нашёл применение ни на одном продакшн-проекте. Но мы возлагаем на него большие надежды.
Заключение
Кому-то может показаться, что мы изобретаем велосипед. Кто-то упрекнет нас в том, что мы специально накосячили с одной из классических архитектур, чтобы выставить её в плохом свете. Другой скажет, что архитектура вообще не нужна.
Но мы уверены в своих силах. Ведь у нас уже была SurfMVP, а теперь пришел час VSURF. Технологии не стоят на месте, а задачи остаются прежними. Заказчику нужна «картинка». Разработчики собирают «картинку» как пазл. А архитектор продумывает детали этого пазла.
Больше полезного про нативную разработку — в Telegram-канале Surf Tech Team.
Кейсы, лучшие практики, новости и вакансии в команду Android Surf в одном месте. Присоединяйтесь!
