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


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


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

    Агентство AGIMA
    Компания

    Похожие публикации

    Комментарии 5

      0
      Вы используете ваш Mapper в отрыве от HTTP Client? Ведь обычно каждый сетевой запрос имеет фиксированный контракт. Добавить ассоциированный тип и сразу в него парсить. И вот уже из двойной прослойки можно выбрасывать SignalProducer<Data, Error> и сразу получать SignalProducer<MappingResult, Error> на выходе. А потом еще поверх вашего swagger файла написать парсер, который сразу будет генерировать enum с описанием сетевых запросов и ответов.
        +1
        Идея хорошая, поддерживаю, это как раз описано в «что делать дальше»
        0
        У меня была похожая реализация слоя модели, и я как-то захотел заменить Realm на CoreData. Это оказалось нетривиально, и я в конце концов к этой идее потерял интерес и отказался от нее.

        Поскольку Storage закрыта протоколом, мы не зависим от реализации конкретной базы данных и при необходимости можем заменить Realm на CoreData.

        С описанной реализацией Translatable заменить Realm на CoreData без серьезных изменений не получится, т.к. NSManagedObject нужно создавать в контексте (NSManagedObjectContext), а в интерфейсе Translatable это никак не учитывается.

        func object<T: Translatable>(byPrimaryKey key: AnyHashable) -> T?

        В отличие от Realm, в CoreData нет понятия первичного ключа как такового — это не база данных, а object graph and persistence framework (конечно, ключи там есть на уровне нижележащей БД, но абстрагированы от конечного пользователя). Т.е. это будет еще одной сложностью.
          0
          ViewModel подготавливает все данные для view

          Также ViewModel управляет логикой навигации

          а это «формально не противоречит принципу S», по-вашему?

          Бывают случаи, когда один и тот же метод нужен в разных роутерах

          т.е. архитектура завязана на конкретный язык? в obj-c к протоколу не создать extension.
          По факту, Assembly решает эту проблему…

          Не скажу, что я люблю VIPER, но вот не понял из статьи: чем же он так плох с его «обслуживающим кодом»? Ведь, по сути, тут описан просто урезанный VIPER с нарушениями принципа ед. ответственности, которые вообще возможно сделать только за счёт плюшек конкретного языка (к которому не должна быть привязана архитектура)…
            0
            Ведь, по сути, тут описан просто урезанный VIPER с нарушениями принципа ед. ответственности, которые вообще возможно сделать только за счёт плюшек конкретного языка (к которому не должна быть привязана архитектура)…


            Все верно. И при разрастании и усложнении проекта придется всё-равно идти в сторону VIPER.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое