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

Мы в 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

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

Продвинутая архитектура iOS-приложений на MVVM с примерами кода, image #1

В 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)

}

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

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

Продвинутая архитектура iOS-приложений на MVVM с примерами кода, image #2

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

Продвинутая архитектура iOS-приложений на MVVM с примерами кода, image #3

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

Создадим протокол, по которому будет происходить общение с нашим провайдером, объявляя нужные поля и методы, а также создадим структуру 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<Self>

let dateCreated: Date

var text: String

var dateChanged: Date?

}

protocol NotesListProvider {

var state: Observable<NotesListProviderState> { 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

Продвинутая архитектура iOS-приложений на MVVM с примерами кода, image #4

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

protocol NotesListViewModel: AnyObject {

var viewInputData: Observable<NotesListViewInputData> { get }

var events: PublishSubject<NotesListViewEvent> { get }

var onNoteSelected: ((Note) -> ())? { get set }

var onCreateNote: (() -> ())? { get set }

}

class NotesListViewModelImpl: NotesListViewModel {

let disposeBag = DisposeBag()

let viewInputData: Observable<NotesListViewInputData>

let events = PublishSubject<NotesListViewEvent>()

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<Note>) {

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

Продвинутая архитектура iOS-приложений на MVVM с примерами кода, image #5

В этом слое настраиваем 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<NotesListSection, NotesListSectionItem>()

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
}
Продвинутая архитектура iOS-приложений на MVVM с примерами кода, image #6

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

— Лёша, iOS-разработчик

728 views·5 shares
728 views