Мы в iOS отделе решили создать единую архитектуру для приложений. За 16 лет работы мы разработали больше 60 приложений. Регулярно происходят ситуации, когда, приходя на другой проект, приходилось неделями вникать в код, чтобы понять его структуру и работу. Какие-то проекты мы писали на MVP, какие-то на MVVM, где-то придумывали своё. Переключение между проектами и ревью чужого кода — всё это увеличивало время разработки приложений еще на несколько часов. Решившись на создание архитектуры, мы в начале определили главные цели, которых хотим добиться:

Простота и скорость. Одна из главных задач — упростить жизнь разработчикам. Для этого код должен быть читаемым, а приложение должно иметь простую и понятную структуру.

Быстрое погружение в проект. Аутсорс-разработка не предоставляет большого количества времени на погружение в проект. Важно, чтобы при переходе на другой проект разработчику не потребовалось много времени на изучение кода приложения.

Масштабируемость и расширяемость. Разрабатываемое приложение должно быть готово к большим нагрузкам и иметь возможность простого добавления новой функциональности. Для этого важно, чтоб архитектура соответствовала современным принципам разработки, таким как SOLID, и последним версиям SDK

Постоянное развитие. За один раз не создашь идеальную архитектуру, это приходит со временем. Каждый разработчик вносит свой вклад в неё — еженедельно мы проводим митинги, на которых обсуждаем преимущества и недостатки существующей архитектуры и моменты, которые хочется усовершенствовать.

Основа нашей архитектуры — паттерн MVVM с координаторами

Сравнивая популярные MV(X) паттерны, мы остановились на MVVM. Он показался самым оптимальным из-за хорошей скорости разработки и гибкости.

MVVM означает Model, View, ViewModel:

  • Model — предоставляет данные и методы работы с ними. Запрос на получение, проверка на корректность и т.д.
  • View — слой, ответственный за уровень графического представления.
  • ViewModel — посредник между Model и View. Отвечает за изменения Model, реагируя на действия пользователя, выполненные на View, и обновляет View, используя изменения из Model. Главная отличительной особенность от других посредников в MV(X) паттернах — реактивные биндинги View и ViewModel, что существенно упрощает и сокращает код работы с данными между этими сущностями.

Вкупе к MVVM мы добавили координаторы. Это объекты, которые контролируют навигационный поток нашего приложения. Они помогают:

  • изолировать и повторно использовать ViewControllers
  • передавать зависимости вниз по иерархии навигации
  • определять варианты использования приложения
  • внедрить Deep Links

Также в архитектуре мы использовали паттерн DI (внедрения зависимостей). Это настройки над объектами, при которых зависимости объекта задаются извне, а не создаются самим объектом. Используем DITranquillity — легкий, но мощный фреймворк, с которым можно конфигурировать зависимости в декларативном стиле.

Давайте разберем нашу архитектуру на примере приложения для заметок.

Создадим основу для будущего приложения. Реализуем необходимые протоколы для роутинга.

 
 import UIKit 
protocol Presentable { 
    func toPresent() -> UIViewController? 
} 
extension UIViewController: Presentable { 
    func toPresent() -> UIViewController? { 
        return self 
    } 
} 
protocol Router: Presentable { 
  func present(_ module: Presentable?) 
  func present(_ module: Presentable?, animated: Bool) 
  func push(_ module: Presentable?) 
  func push(_ module: Presentable?, hideBottomBar: Bool) 
  func push(_ module: Presentable?, animated: Bool) 
  func push(_ module: Presentable?, animated: Bool, completion: (() -> Void)?) 
  func push(_ module: Presentable?, animated: Bool, hideBottomBar: Bool, completion: (() -> Void)?) 
  func popModule() 
  func popModule(animated: Bool) 
  func dismissModule() 
  func dismissModule(animated: Bool, completion: (() -> Void)?) 
  func setRootModule(_ module: Presentable?) 
  func setRootModule(_ module: Presentable?, hideBar: Bool) 
  func popToRootModule(animated: Bool) 
}
 

Настройка AppDelegate и AppCoordintator

Схема взаимодействия делегата и координаторов представлена ниже.

В App Delegate создадим для DI контейнер. В методе registerParts() добавляем все наши зависимости в приложении. Далее инициализируем AppCoordinator, передавая window и контейнер и вызывая метод start(), тем самым передавая ему управление.

 
@main 
class AppDelegate: UIResponder, UIApplicationDelegate { 
    private let container = DIContainer() 
    var window: UIWindow? 
    private var applicationCoordinator: AppCoordinator? 
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 
        // Override point for customization after application launch. 
        registerParts() 
        let window = UIWindow() 
        let applicationCoordinator = AppCoordinator(window: window, container: container)
        self.applicationCoordinator = applicationCoordinator
        self.window = window 
        window.makeKeyAndVisible() 
        applicationCoordinator.start() 
        return true 
    } 
    private func registerParts() { 
        container.append(part: ModelPart.self) 
        container.append(part: NotesListPart.self) 
        container.append(part: CreateNotePart.self) 
        container.append(part: NoteDetailsPart.self) 
    } 
}


App Coordinator определяет, по какому сценарию должно запуститься приложение. Например, если пользователь не авторизован, то для него показывается авторизация, иначе запускается основной сценарий приложения. В случае с приложением заметок, у нас 1 сценарий — показ списка заметок.

Делаем по аналогии с App Coordinator, только вместо window, уже передаем router.

 
final class AppCoordinator: BaseCoordinator {
 private let window: UIWindow
 private let container: DIContainer
 init(window: UIWindow, container: DIContainer) {
 self.window = window
 self.container = container
 }
 override func start() {
 openNotesList()
 }
 override func start(with option: DeepLinkOption?) {
 }
 func openNotesList() {
 let navigationController = UINavigationController()
 navigationController.navigationBar.prefersLargeTitles = true
 let router = RouterImp(rootController: navigationController)
 let notesListCoordinator = NotesListCoordinator(router: router, container: container)
 notesListCoordinator.start()
 addDependency(notesListCoordinator)
 window.rootViewController = navigationController
 }
}
 

В NoteListCoordinator берем зависимость экрана списка заметок, через метод container.resolve(). Обязательно надо указать тип нашей зависимости чтоб библиотека знала какую именно зависимость надо извлечь. Также настраиваем обработчики перехода на следующие экраны. Настройка зависимостей представим потом.

 
class NotesListCoordinator: BaseCoordinator {
 private let container: DIContainer
 private let router: Router
 init(router: Router, container: DIContainer) {
 self.router = router
 self.container = container
 }
 override func start() {
 setNotesListRoot()
 }
 func setNotesListRoot() {
 let notesListDependency: NotesListDependency = container.resolve()
 router.setRootModule(notesListDependency.viewController)
 notesListDependency.viewModel.onNoteSelected = { [weak self] note in
 self?.pushNoteDetailsScreen(note: note)
 }
 notesListDependency.viewModel.onCreateNote = { [weak self] in
 self?.pushCreateNoteScreen(mode: .create)
 }
 

Создание модуля

Каждый модуль в приложении можно представить в следующем виде

Слой Модели у нас представляет сущность Provider. Схема у него такая

Провайдер — это сущность, ответственная за общение с сервисами и менеджерами, для получения, отправки и обработки данных, нужных для экрана, например обратиться к сервисам для получения данных из сети, или из БД.

Создадим протокол, по которому будет происходить общение с нашим провайдером, объявляя нужные поля и методы, а также создадим структуру ProviderState, там объявляем данные, от которых будет зависеть наш экран. В протоколе объявляем такие поля, как Current State с типом ProviderState и его наблюдателя State с типом Observable<ProviderState> и методы для изменения нашего Current State.

Потом создадим реализацию нашего протокола, именуя как название протокола+”Impl”. CurrentState помечаем, как @Published, этот property wrapper, позволяет создавать наблюдаемый объект, который автоматически сообщает о внесении изменений. Аналогично с этим мог справиться BehaviorRelay, имеющий свойства как наблюдаемого так и наблюдателя, но у него был достаточно сложный флоу обновления данных, который занимал 3 строки, а с использованием @Published всего 1. Также установим уровень доступа private(set), так как стейт провайдера не должен изменятся вне провайдера. State будет выступать наблюдателем CurrentState и транслировать изменения своим подписчикам, а именно а нашу будущую View Model. Не забываем реализовать методы, которые понадобятся нам при работе на данном экране.

 
 struct Note {
 let id: Identifier
 let dateCreated: Date
 var text: String
 var dateChanged: Date?
}
protocol NotesListProvider {
 var state: Observable { get }
 var currentState: NotesListProviderState { get }
}
class NotesListProviderImpl: NotesListProvider {
 let disposeBag = DisposeBag()
 lazy var state = $currentState
 @Published private(set) var currentState = NotesListProviderState()
 init(sharedStore: SharedStore<[Note], Never>) {
 sharedStore.state.subscribe(onNext: { [weak self] notes in
 self?.currentState.notes = notes
 }).disposed(by: disposeBag)
 }
}
struct NotesListProviderState {
 var notes: [Note] = []
}
View-Model
 

Также как и для провайдера создаем протокол. Объявляем такие поля как ViewInputData, и Events. ViewInputData это данные, которые непосредственно будут передаваться в наш viewController. Создадим реализацию нашей ViewModel, у viewInputData подпишемся на state провайдера и изменим в нужный формат для view c помощью функции mapToViewInputData. Создаем enum Events, в котором определяем все события, которые должны обрабатывать на экране, например загрузка вью, нажатие на кнопку, выбор ячейки и т.д. Делаем Events типом PublishSubject, для возможности подписки и добавления новых элементов, подписываемся и делаем обработку для каждого события.

 
 protocol NotesListViewModel: AnyObject {
 var viewInputData: Observable { get }
 var events: PublishSubject { get }
 var onNoteSelected: ((Note) -> ())? { get set }
 var onCreateNote: (() -> ())? { get set }
}
class NotesListViewModelImpl: NotesListViewModel {
 let disposeBag = DisposeBag()
 let viewInputData: Observable
 let events = PublishSubject()
 let notesProvider: NotesListProvider
 var onNoteSelected: ((Note) -> ())?
 var onCreateNote: (() -> ())?
 init(notesProvider: NotesListProvider) {
 self.notesProvider = notesProvider
 self.viewInputData = notesProvider.state.map { $0.mapToNotesListViewInputData() }
 events.subscribe(onNext: { [weak self] event in
 switch event {
 case .viewDidAppear, .viewWillDisappear:
 break
 case let .selectedNote(id):
 self?.noteSelected(id: id)
 case .createNote:
 self?.onCreateNote?()
 }
 }).disposed(by: disposeBag)
 }
 private func noteSelected(id: Identifier) {
 if let note = notesProvider.currentState.notes.first(where: { $0.id == id }) {
 onNoteSelected?(note)
 }
 }
}
private extension NotesListProviderState {
 func mapToNotesListViewInputData() -> NotesListViewInputData {
 return NotesListViewInputData(notes: self.notes.map { ($0.id, NoteCollectionViewCell.State(text: $0.text)) })
 }
}
View
 

В этом слое настраиваем UI экрана и биндинги с вью-моделью. Слой View у нас представляет UIViewController. Во viewWillAppear() подписываемся на ViewInputData и отдаем данные в render, который распределяет их в нужные UI элементы


override func viewWillAppear(_ animated: Bool) {
 super.viewDidAppear(animated)
 let disposeBag = DisposeBag()
 viewModel.viewInputData.subscribe(onNext: { [weak self] viewInputData in
 self?.render(data: viewInputData)
 }).disposed(by: disposeBag)
 self.disposeBag = disposeBag
 }
 private func render(data: NotesListViewInputData) {
 var snapshot = DiffableDataSourceSnapshot()
 snapshot.appendSections([.list])
 snapshot.appendItems(data.notes.map { NotesListSectionItem.note($0.0, $0.1) })
 dataSource.apply(snapshot)
 }
Также добавляем биндинги событий, с помощью RxSwift или базовым способом через селекторы.
@objc private func createNoteBtnPressed() {
 viewModel.events.onNext(.createNote)
 }
 
 

Теперь, когда все компоненты модуля готовы, приступим к связи объектов между ними. Модуль представляет собой класс, подписанный к протоколу DIPart, который в первую очередь служит для поддержания иерархии кода, объединяя некоторые части системы в один общий класс и в будущем включает часть, а не некоторые компоненты списка. Реализуем обязательный метод load(container:), где и будем регистрировать наши компоненты.


final class NotesListPart: DIPart {
 static func load(container: DIContainer) {
 container.register(SharedStore.notesListScoped)
 .as(SharedStore<[Note], Never>.self, tag: NotesListScope.self)
 .lifetime(.objectGraph)
 container.register { NotesListProviderImpl(sharedStore: by(tag: NotesListScope.self, on: $0)) }
 .as(NotesListProvider.self)
 .lifetime(.objectGraph)
container.register(NotesListViewModelImpl.init(notesProvider:)).as(NotesListViewModel.self).lifetime(.objectGraph)
 container.register(NotesListViewController.init(viewModel:)).lifetime(.objectGraph)
container.register(NotesListDependency.init(viewModel:viewController:)).lifetime(.prototype)
 }
}
struct NotesListDependency {
 let viewModel: NotesListViewModel
 let viewController: NotesListViewController
}

Регистрировать компоненты будем с помощью метода container.register(), передавая туда наш объект, и указывая протокол, по которому он будет общаться, а также время жизни объекта. По такому же принципу работаем со всеми остальными компонентами

Наш модуль готов, не забудем добавить модуль в контейнер в AppDelegate. Перейдем в NoteListCoordinator в функцию открытия списка. Возьмем нужную зависимость через функцию container.resolve, обязательно явно объявляя тип переменной. Затем создаем обработчики событий onNoteSelected и onCreateNote, а viewController передаем роутеру.


func setNotesListRoot() {
 let notesListDependency: NotesListDependency = container.resolve()
 router.setRootModule(notesListDependency.viewController)
 notesListDependency.viewModel.onNoteSelected = { [weak self] note in
 self?.pushNoteDetailsScreen(note: note)
 }
 notesListDependency.viewModel.onCreateNote = { [weak self] in
 self?.pushCreateNoteScreen(mode: .create)
 }
 }
 

По этим шагам создаются и другие модули и навигация. В заключение можно сказать, что архитектура не лишена недостатков. Из проблем можно выделить - изменение одного поля в viewInputData заставляет обновлять весь UI, а не определенные его элементы, не разработан общий флоу работы с UITabBarController и UIPageViewController.

[UPD]: Ноябрь 2022

С публикации статьи прошло полгода. За это время мы проработали слабые места, которые описали ранее, и вот, что мы сделали:

  1. Теперь не нужно обновлять весь стейт провайдера при изменении одного поля.
  2. В нашу архитектуру теперь можно реализовать UIPageViewController и UITabBarController.

Стейт

Мы писали, что сделали State провайдера через кастомный propertyWrapper — RxPublished, аналог Published в Combine, только через RxSwift. В него завёрнут BehaviorRelay. Поэтому, когда мы изменяли State, мы автоматически присылали новый экземпляр сабджекту, И только потом он транслировал его своим подписчикам. Но возникал случай, когда надо было обновить несколько полей в стейте, а изменённый стейт транслировать только при завершении операции.

Мы нашли ёмкое решение с inout параметром и замыканием. Функция с параметром через inout после завершения возвращает измененный параметр в переменную, которая была указана в функции. Решение получилось на 3 строчки (и экономит ОЧЕНЬ много времени):

  1. Копируем текущий стейт;
  1. Выполняем замыкание;
  1. Присваиваем полученный изменённый стейт в сабджект.

func commit(changes: (inout State) -> ()) {
var updatedState = stateRelay.value
changes(&updatedState)
value = updatedState
}

UIPageViewController

Реализация UIPageViewController в архитектуре упростила процесс разработки. Вот, как сделать это по шагам:

  1. Создаём модуль для PageViewController
  2. В провайдере подготавливаем данные, которые нужны будут для конфигурации моделей внутри UIPageViewController.
  3. Делаем ViewModel как обычно: преобразуем стейт провайдера в стейт представления, обрабатываем события и изменения состояния.
  4. Добавляем DI-модкли экранов в viewController через инициализатор.
    Если нужно переиспользовать модули, убедитесь, чтобы при последующем обращении к этому модулю, возвращался новый экземпляр. Это можно сделать через свойство Provider (не путать с провайдером модуля). Он как раз отвечает за то, чтобы при обращении к переменной возвращался новый экземпляр. Рекомендуем библиотеку SwiftLazy, от автора DITranquility. Она заменяет нативный lazy более разнообразным и крутым функционалом. Там как раз есть нужный Provider.
  5. Уже при рендере конфигурируем каждый экран с необходимыми данными. Например:

ViewController
….
let someDependency: SomeModuleDependency
let anptherDependency: AnotherModuleDependency
….
init(....) { }
func render(with data: InputData) {
someDependency.viewModel.setup(data.dataForSomeModule)
anotherDependency.viewModel.setup(data.dataForAnotherModule)
…
pageVC.setViewControllers([someDependency.viewController, ….])
}

UITabBarController

У TabBarController теперь есть собственный координатор, чтобы для каждой вкладки можно было конфигурировать отдельный флоу — пару координатора и роутера. Тут важно не забыть добавить его в хранилище дочерних координаторов методом addDependency() и вызвать у них метод start():


TabBarCoordinator
private typealias Flow = (Coordinator, Presentable)
…
override func start() {
let flows = [someFlow(), anotherFlow()]
let coordinators = flows.map { $0.0 }
let controllers = flows.compactMap { $0.1 as? UINavigationController }
router.setViewControllers(controllers: controllers)
coordinators.forEach {
addDependency($0)
$0.start()
}
}
func someFlow() -> Flow {
let coordinator = someCoordinator()
let router = Routerlmpl(rootController: UINavigationController())
return (coordinator, router)
}

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

Заключение

C созданием архитектуры, нам стало намного легче работать. Не так страшно стало заменять коллегу в отпуске и принимать новый проект. А решения той или иной реализации можно посмотреть у коллег, не ломая голову, как это прикрутить, чтобы правильно работало с нашей архитектурой.

  • Разработка