Архитектурные подходы в 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
    73,73
    Компания
    Поделиться публикацией

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

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

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

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

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