Сегодня с вами Никита, 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 в одном месте. Присоединяйтесь!
Комментарии (3)
Bardakan
20.01.2025 19:39Но мы просто не можем рассказать про все аспекты архитектуры в одной статье, поэтому вместе с демо-проектом вы сможете посмотреть DocC туториалы и документацию.
Почему у вас в туториалах используется enum без case? Он же тогда работает как обычная структура
Headmast
20.01.2025 19:39Я бы добавил в список сравнения что-нибудь из UDF, так как для SwiftUI они гораздо популярнее MVC и MVP.
NineNineOne
Забыли указать минимальную версию iOS 16, прежде чем красиво расписывать архитектурное чудо. Много у нас приложений в продакшене для iOS 16+?