Сегодня поговорим об архитектурных подходах в iOS-разработке, про некоторые нюансы и наработки реализации отдельных вещей. Расскажу, каких подходов придерживаемся мы и немного углубимся в детали.
Сразу раскроем все карты. Мы используем MVVM-R (MVVM + Router).
По сути, это обычный MVVM, в котором навигация между экранами вынесена в отдельный слой – Router, а логика получения данных – в сервисы. Далее рассмотрим наши наработки в реализации каждого слоя.
Почему MVVM, а не VIPER или MVC?
В отличии от MVC в MVVM достаточно разделена ответственность между слоями. В нем нет такого количества «обслуживающего» кода, как в VIPER, хотя ViewModel для экранов также закрываются протоколами. Эта архитектура чем-то похожа на VIPER, только Presenter и Interactor объединены во ViewModel, и связи между слоями упрощены за счет применения реактивного программирования и биндингов (мы используем ReactiveSwift).
Entity
Мы используем два слоя моделей данных: первый – привязанный к базе данных (далее managed objects), второй – так называемые plain objects, которые к базе данных не имеют никакого отношения.
Каждая plain-сущность реализует протокол Translatable, который может быть инициализирован из managed object’a и из которого можно создать managed object. В качестве базы данных используем Realm, в нашем случае ManagedObject
– это RealmSwift.Object
. Маппинг происходит через Codable
: маппятся как plain-объекты и сохраняются как managed-объекты. Далее сервисы и ViewModel работают только с plain-объектами.
protocol Translatable {
associatedtype ManagedObject: Object
init(object: ManagedObject)
func toManagedObject() -> ManagedObject
}
Для сохранения, получения и удаления объектов из базы данных используется отдельная сущность – Storage. Поскольку Storage закрыта протоколом, мы не зависим от реализации конкретной базы данных и при необходимости можем заменить Realm на CoreData.
protocol StorageProtocol {
func cachedObjects<T: Translatable>() -> [T]
func object<T: Translatable>(byPrimaryKey key: AnyHashable) -> T?
func save<T: Translatable>(objects: [T]) throws
func save<T: Translatable>(object: T) throws
func delete<T: Translatable>(objects: [T]) throws
func delete<T: Translatable>(object: T) throws
func deleteAll<T: Translatable>(ofType type: T.Type) throws
}
Какие плюсы и минусы у такого подхода?
У каждой базы данных есть свои особенности. Например, Realm-объект, уже сохраненный в базу данных, может быть использован в только рамках потока, в котором он был создан. Это доставляет неудобства.
Также, объект может быть удален из базы данных, при этом он лежит в оперативной памяти, и при обращении к нему будет краш. У Core Data такие же особенности. Поэтому мы получаем объекты из базы данных, конвертируем их в plain-объекты и далее работаем с ними.
При таком подходе код становится больше, и его необходимо поддерживать. Без зависимости от особенностей базы данных мы теряем возможность использования крутых фишек. В случае CoreData это FetchedResultsController, где мы можем контролировать все вставки, удаления, изменения в рамках массива сущностей. Примерно такой же механизм у Realm.
Core Components
Core-компоненты – это сущности, которые выполняют одну свою задачу. Например, маппинг, взаимодействие с базой данных, посыл и обработка сетевых запросов. Storage из предыдущего пункта как раз является одним из core-компонентов.
Protocols
Мы активно используем протоколы. Все core-компоненты закрываются протоколами, и есть возможность сделать mock или тестовую реализацию для unit-тестов. Таким образом мы получаем определенную гибкость реализации. Все зависимости передаются в init. При инициализации каждого объекта мы понимаем, какие там зависимости, что он использует внутри себя.
HTTP Client
Сетевой запрос описывается протоколом NetworkRequestParams
.
protocol NetworkRequestParams {
var path: String { get }
var method: HTTPMethod { get }
var parameters: Parameters { get }
var encoding: ParameterEncoding { get }
var headers: [String: String]? { get }
var defaultHeaders: [String: String]? { get }
}
Мы используем enum
для описания сетевых запросов. Выглядит это так:
enum UserNetworkRouter: URLRequestConvertible {
case info
case update(userJson:[String : Any])
}
extension UserNetworkRouter: NetworkRequestParams {
var path: String {
switch self {
case .info:
return "/users/profile"
case .update:
return "/users/update_profile"
}
}
var method: HTTPMethod {
switch self {
case .info:
return .get
case .update:
return .post
}
}
var encoding: ParameterEncoding {
switch self {
case .info:
return URLEncoding()
case .update:
return JSONEncoding()
}
}
var parameters: Parameters {
switch self {
case .info:
return [:]
case .update(let userJson):
return userJson
}
}
}
Каждый NetworkRouter
реализрует протокол URLRequestConvertible
. Отдаем его сетевому клиенту, который преобразует его в URLRequest
и использует по своему назначению.
Сетевой клиент выглядит следующим образом:
protocol HTTPClientProtocol {
func load(request: NetworkRequestParams & URLRequestConvertible) -> SignalProducer<Data, Error>
}
Mapper
Мы используем Codable
для маппинга данных.
protocol MapperProtocol {
func map<MappingResult: Codable>(data: Data, dateDecodingStrategy: JSONDecoder.DateDecodingStrategy) -> SignalProducer<MappingResult, Error>
}
Пуш — уведомления
У каждого пуш-уведомления есть тип и на каждый тип есть свой обработчик. Обработчик получает словарь с информацией из уведомления. Обработчики держит агрегирующая сущность, именно она будет получать пуши и направлять его нужному обработчику. Это довольно масштабируемый подход, с которым удобно работать, если по-разному надо обрабатывать несколько видов пуш-уведомлений.
Сервисы
Грубо говоря, один сервис отвечает за одну сущность. Рассмотрим это на примере приложения соцсети. Есть сервер пользователя, который получает пользователя – себя, и отдает измененные сущности, если мы его отредактировали. Есть сервис постов, который получает список постов, детальный пост, сервис платежей и т.д. и т.п.
Все сервисы содержат в себе core-компоненты. Когда мы вызываем метод у сервиса, он начинает дергать различные методы core-компонентов и в итоге отдает результат наружу.
Сервис, как правило, выполняет работу для определенного экрана, вернее для вьюмодели экрана(об этом ниже). Если при уходе с экрана сервис не уничтожится, а продолжит выполнять уже ненужный сетевой запрос и будет тормозить другие запросы. Этим можно управлять вручную, но поддерживать такую систему будет сложнее. Однако, у такого подхода есть и минус: если результат работы сервиса нужен даже после того, как мы вышли с экрана, придется искать другие решения, возможно, делать некоторые сервисы синглтонами.
Сервисы не содержат состояния. Поскольку сервисы не синглтоны, мы можем иметь несколько экземпляров одного сервиса, в котором состояния могут отличаться друг от друга. Это может привести к некорректному поведению.
Пример метода одного из сервисов:
func currentUser() -> SignalProducer<User, Error> {
let request = UserNetworkRouter.info
return httpClient.load(request: request)
.flatMap(.latest, mapUser)
.flatMap(.latest, save)
}
ViewModel
ViewModel мы поделим на 2 типа:
- ViewModel для экрана (ViewController)
- ViewModel для UIView (в том числе для ячеек таблицы или UICollectionView)
ViewModel для ViewController отвечает за логику работы экрана. Как правило, это отправка сетевых запросов, подготовка данных, реакция на UI-события.
ViewModel подготавливает все данные для view, которые пришли от сервиса. Если пришел список сущностей, то ViewModel трансформирует его в список ViewModel и биндит их на view. Если есть состояния (есть галочка / нет галочки), это тоже управляется и передается во ViewModel.
Также ViewModel управляет логикой навигации. Для навигации существует отдельный слой Router, но команды дает именно ViewModel.
Типичные функции view-модели: получить юзера, обратиться к юзер-сервису, сделать ViewModel из полученного значения. Когда все загрузится, View берет ViewModel и отрисовывает view-ячейку.
ViewModel для экрана закрыта протоколом по тем же соображениям, что и сервисы. Однако есть еще один интересный кейс: например, банковское приложение, где каждое действие (перевод средств, открытие счета, блокировка счета) подтверждается по смс. На экране подтверждения есть поле ввода кода и кнопка «отправить заново».
ViewModel закрыта таким протоколом:
protocol CodeInputViewModelProtocol {
/// Отправить введенный код
func send(code: String) -> SignalProducer<Void, Error>
/// Отправить смс заново
func resendCode() -> SignalProducer<Void, Error>
}
Во ViewController она хранится в таком виде:
var viewModel: CodeInputViewModelProtocol?
В зависимости от того, что именно мы пытаемся подтвердить по смс, отправка кода и переотправка смс могут быть представлены абсолютно разными запросами, а после подтверждения нужны переходы на разные экраны и т.п. Поскольку ViewController'у без разницы, какой на самом деле тим имеет ViewModel, мы можем иметь несколько реализаций ViewModel для различных кейсов, а UI будет общий.
ViewModel для View и ячеек, как правило, занимается форматированием данных и обработкой пользовательского ввода. Например, хранение состояния «выбрано / не выбрано».
final class FeedCellViewModel {
let url: URL?
let title: String
let subtitle: String
init(feed: FeedItem) {
url = URL(string: feed.imageUrl)
title = feed.title
subtitle = DateFormatter.feed.string(from feed.publishDate)
}
}
Навигация
Переходы между экранами осуществляет Router.
class BaseRouter {
init(sourceViewController: UIViewController) {
self.sourceViewController = sourceViewController
}
weak var sourceViewController: UIViewController?
}
Каждый экран имеет свой роутер, который наследуется от базового. Он имеет методы переходов на конкретные экраны.
final class FeedRouter : BaseRouter {
func showDetail(viewModel: FeedDetailViewModelProtocol) {
let vc = FeedDetailViewController()
vc.viewModel = viewModel
sourceViewController?.navigationController?.pushViewController(vc, animated: true)
}
}
Как видно из примера выше, сборка «модуля» происходит в роутере. Это формально противоречит букве S из SOLID, но на практике оказывается довольно удобно и не вызывает проблем.
Бывают случаи, когда один и тот же метод нужен в разных роутерах. Чтобы не писать его несколько раз, создаем протокол, в котором будут общие методы, и реализуем extension
к нему. Теперь достаточно подписать нужный роутер на этот протокол, и он будет иметь необходимые методы.
protocol FeedRouterProtocol {
func showDetail(viewModel: FeedDetailViewModelProtocol)
}
extension FeedRouterProtocol where Self: BaseRouter {
func showDetail(viewModel: FeedDetailViewModelProtocol) {
let vc = FeedDetailViewController()
vc.viewModel = viewModel
sourceViewController?.navigationController?.pushViewController(vc, animated: true)
}
}
View
View отвечает традиционно за отображение информации для пользователя и обработку пользовательских действий. В MVVM мы считаем, что ViewController – это View. Важно, чтобы там не было сложной логики, которой место во ViewModel. В любом случае, даже в MVC не стоит нагружать сильно ViewController, хоть сделать это сложно.
View командует ViewModel. Если загрузился ViewController, мы даем команду ViewModel: загрузить данные из сети или из кеша. Также View принимает сигналы с ViewModel. Если ViewModel говорит, что что-то изменилось (например, загрузились те самые данные), то View на это реагирует и перерисовывается.
Мы не используем сториборды. Навигация сильно завязана на ViewController, и это тяжело вписать в архитектуру. В сторибордах зачастую возникают конфликты, править которые – отдельное «удовольствие».
Что делать дальше?
Можно использовать кодогенерацию для моделей (Translatable), поскольку вся инициализация из объекта базы данных в плэйн-объект и наоборот сейчас прописывается вручную.
Также можно использовать более универсальную схему запросов, поскольку много методов сервисов выглядят так: сходи в сеть, примени маппинг, сохрани в базу данных. Это тоже можно универсализировать, задать общий скелет.
Мы с вами рассмотрели архитектурные подходы, однако не стоит забывать о том, что качественное приложение – это не только архитектура, но и плавный, отзывчивый, удобный интерфейс. Любите своих пользователей и пишите качественные приложения.