Продвинутая архитектура iOS-приложений на MVVM с примерами кода — обложка

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

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

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

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

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

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

Сравнивая популярные MV(Х) паттерны, мы выбрали 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 и AppCoordinator

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

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

Делаем по аналогии с 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>, а также методы для изменения текущего состояния.

Потом создадим реализацию нашего протокола, назвав её как название протокола + «Impl». Свойство CurrentState помечаем как @Published — этот property wrapper делает объект наблюдаемым и автоматически уведомляет о любых изменениях. То же самое можно было бы реализовать с помощью BehaviorRelay, который сочетает в себе и наблюдаемое значение, и возможность подписки, но его логика обновления данных была громоздкой и занимала три строки кода, тогда как @Published справляется с этим одной строкой. Также установим уровень доступа 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 подпишемся на состояние провайдера и преобразуем его в нужный формат для интерфейса с помощью функции 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
 

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


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.

Стейт

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

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

  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 созданием архитектуры нам стало намного проще работать. Теперь не так страшно заменять коллегу в отпуске и подключаться к новым проектам. А если нужно реализовать какую-то задачу — можно посмотреть, как это сделали другие, и не ломать голову, как всё правильно подключить к нашей архитектуре.

  • Разработка