Агентство AGIMA corporate blog
Development for iOS
Development for MacOS
2 July

Архитектурные подходы в iOS-приложениях

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


Также можно использовать более универсальную схему запросов, поскольку много методов сервисов выглядят так: сходи в сеть, примени маппинг, сохрани в базу данных. Это тоже можно универсализировать, задать общий скелет.


Мы с вами рассмотрели архитектурные подходы, однако не стоит забывать о том, что качественное приложение – это не только архитектура, но и плавный, отзывчивый, удобный интерфейс. Любите своих пользователей и пишите качественные приложения.


+8
3.8k 44
Comments 4