Сегодня с вами Никита, 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 есть вертикальные уровни и горизонтальные уровни. Каждый уровень отвечает за свою часть приложения и соответствует определенной зоне ответственности. Модули одного уровня не должны зависеть друг от друга.

Вертикальные уровни
Вертикальные уровни
Горизонтальные уровни
Горизонтальные уровни

Разделение на уровни обеспечивает:

  • упрощение поддержки;

  • возможность переиспользования модулей;

  • уменьшение зависимостей.

Мы не будем подробно останавливаться на каждом уровне, но отметим, что у каждого есть требования к:

  1. разрешённым внешним зависимостям:

    1. никакие;

    2. только утилитарные (не SDK);

    3. Любые зависимости.

  2. необходимым типам тестов:

    1. Unit;

    2. UI;

    3. 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.

Развернутая часть макроса NavigationState
Развернутая часть макроса NavigationState

У внимательного читателя ещё останутся вопросы про weakReference или про facade. Но мы просто не можем рассказать про все аспекты архитектуры в одной статье, поэтому вместе с демо-проектом вы сможете посмотреть DocC туториалы и документацию

Скажем честно, проект находится на стадии обкатки и пока не нашёл применение ни на одном продакшн-проекте. Но мы возлагаем на него большие надежды.

Заключение

Кому-то может показаться, что мы изобретаем велосипед. Кто-то упрекнет нас в том, что мы специально накосячили с одной из классических архитектур, чтобы выставить её в плохом свете. Другой скажет, что архитектура вообще не нужна.

Но мы уверены в своих силах. Ведь у нас уже была SurfMVP, а теперь пришел час VSURF. Технологии не стоят на месте, а задачи остаются прежними. Заказчику нужна «картинка». Разработчики собирают «картинку» как пазл. А архитектор продумывает детали этого пазла.

Больше полезного про нативную разработку — в Telegram-канале Surf Tech Team

Кейсы, лучшие практики, новости и вакансии в команду Android Surf в одном месте. Присоединяйтесь!