image
За 9 лет работы фич в проектах роботов становилось все больше, запутаться в коде становилось все проще.

Когда разработчиков стало больше десятка, появилась еще одна проблема – болезненная ротация людей между проектами. Аутсорс-разработка славится жесткими дедлайнами, и у разработчиков нет месяцев или недель на погружение в особенности нового проекта, в то же время работа над разными проектами нужна для развития специалистов.

Главная проблема, которая возникает при долгосрочном развитии приложения – масштабируемость. Решить ее может переход на новую архитектуру или рефакторинг кодовой базы и добавление новых сущностей, которые разгружают объекты с большим количеством обязанностей.

Disclaimer


В нашем понимании не существует такого понятия как «универсальная архитектура». Каждый делает выбор в пользу той, с которой эффективнее и удобнее всего работать на протяжении всего проекта. То, что эффективно используется у нас, может стать бесполезным overhead’ом для других команд.

Начало


Началось все с популярного MVC и его приятеля network manager.

Основные проблемы MVC: вызов сетевых запросов и запросы к базе данных, реализация бизнес-логики и навигация расположены в контроллере. Из-за этого объекты сильно взаимосвязаны, а уровень переиспользования и тестирования низок.

Network manager в серьезных проектах превращается в “божественный объект”, который становится невозможно поддерживать из-за его размера.

Представим, что у нас приложение для сети магазинов, в котором есть экраны акций, профиля и настроек. На первом экране отображается список действующих акций, в профиле – текущий бонусный баланс, имя и номер телефона пользователя, в настройках, например, возможность включить push-уведомления о новых акциях. Для входа в приложение необходимо авторизоваться с помощью логина и пароля.

Получается, что в таком случае все запросы к серверу – авторизация, получения списка акций, получение информации по профилю, изменение настроек – находятся в network manager, в том числе логика по созданию модельных объектов из JSON.

Прощание с NetworkManager


На слое взаимодействия с сетью было принято решение придерживаться подхода SOA – разделения сервисного слоя на множество сервисов в зависимости от типа сущности.

image

В качестве ConcreteService в нашем примере будут выступать – AuthService, UserService, DealService, SettingsService. Каждый сервис занимается своим делом – сервис авторизации работает с авторизацией, сервис пользователя – с данными пользователя и так далее. Хорошим правилом является разделение со стороны сервера на разные path: /auth, /user, /deals, /settings, но необязательно.

Более подробное описание сервисного слоя есть в нашей прошлой статье.

Сериализация/десериализация JSON/XML и др.


Сериализацию/десериализацию объектов выделяем в отдельные сущности: parser и serializer. Операции взаимно обратные: parser преобразует объект типа данных, который принимает от сервера, в модельный объект, serializer – из модельного объекта в объект данных для передачи по сети. Внутри этих классов реализована проверка обязательности полей и логирование ошибок.

Примеры интерфейсов для работы с сущностью «пользователь»
class UserParser: JSONParser<User> {
    func parseObject(_ data: JSON) -> User?
}

class UserSerializer: JSONSerializer<User> {
    func serializeObject(_ object: User) -> Data?
}


image

Для каждой сущности у нас отдельные парсеры AuthParser, UserParser, DealParser и SettingsParser. С сериализаторами – ровно так же.

Разделение на слои


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

image

Выше по порядку: слой пользовательского интерфейса, слой бизнес-логики, слой сервисов и слой данных.

Слой данных


Этот слой мы чаще всего реализуем через паттерн DAO, абстрагируясь от реализации и особенностей базы данных на всех остальных слоях. У нас есть готовые решения для Realm и CoreData, чаще всего используем Realm. Пример реализации тут.

image

Представим, что в приложении мы хотим кэшировать скидки. Поэтому при использовании DAO у нас появятся следующие классы:

  • DBDeal – сущность скидки в БД Realm.
  • DealTranslator – транслятор сущностей.
  • DealDAO – DAO для скидок.

В разделе «Кодогенерация» я приведу примеры реализации данных классов.

Как быть с UI-слоем?


Здесь у нас было несколько итераций, в ходе которых мы анализировали существующие решения: MVVM и VIPER, но без практического применения было сложно оценить их объективно. VIPER для наших проектов показался избыточным: большое количество сущностей для одного модуля (во многих случаях являются только посредниками в цепочке вызовов), сложная реализация роутинга с использованием storyboard’ов, отдаление от UIKit. Конечно, тестирование модулей можно отнести к плюсам.

Использование MVVM, по нашему мнению, было проще для понимания со знанием MVC, биндинги решали проблему явных вызовов для обновления данных, стало возможно писать тестируемый код. Проблем с использованием реактивного программирования не было – мы использовали его в связке с MVC.

Переходим на MVVM


Эта архитектура разобрана в деталях, и вряд ли стоит пытаться делать это n+1 раз. Какое преимущество перед MVC мы здесь увидели? В большинстве случаев информация, отображаемая пользователю, является преобразованием моделей от сервера. Поэтому логика по преобразованию этой информации инкапсулируется внутрь view model, или, если есть зависимость между объектами, частично в фабрике view model’ей. Пример того, как номер телефона пользователя преобразуется для дальнейшего отображения на экране:

image

Переходим к presentation model и router


Через какое-то время мы поняли, что одним MVVM не обойдемся. Класс view controller постепенно «распухал», особенно это было заметно, если на экране вызывается несколько запросов. Следующим шагом выделили обращение к сервисам в отдельную сущность – presentation model и view controller перестал знать об их существовании.

Использование навигации (с segue или без них) на множество экранов так же приводило к разрастанию view controller. Замечу, что сам по себе вызов для показа экрана займет у вас 2-3 строчки кода, тогда как конфигурация и передача нужных данных на другой экран может занять, скажем, 10 строк. Поэтому router был выделен в отдельную сущность (да-да, еще чуть-чуть и VIPER). Использование роутера оказалось удобным в том числе, когда мы отказались от stroyboard'ов в пользу xib. Читать класс router'а, безусловно, тяжелее, чем визуально воспринимать карту экранов с переходами. Но еще менее удобно, если ваш код навигации разбросан повсюду.

image

Router в этой схеме – отдельное свойство на view controller'е, который создается в методе viewDidLoad. На каких-то проектах мы создавали его непосредственно в момент совершения навигации.

Здесь важно понимать, что мы не требуем соблюдение разделения на presentation model и router для относительно простых экранов, скажем, где все помещается в 200 строк.

К примеру, у роутера в нашем приложении будут методы

  • showProfile() – показать профиль пользователя.
  • showDeal(_ deal: Deal) – показать подробное описание скидки.
  • showSettings() – показать настройки.

Так как настройки и пользователь в приложении существует в единственном экземпляре, нет необходимости передавать его в роутер для конфигурации нового экрана. Напротив, имея множество скидок, presentation model экрана подробной информации о скидке должен быть создан с параметром сущности скидки (или view model’и, если ее достаточно).

А как быть с таблицей и коллекцией?


Изначально мы создавали реализацию data source и delegate отдельным классом, который хранили на view controller'е. Этот класс в свою очередь забирал данные (view model'и) из presentation model.

image

Cell mapper в данной схеме – замыкание, которое приводит в соответствие класс ячейки классу view model. Это сделано для того, чтобы не регистрировать вручную классы ячеек на каждом экране.

Таким образом, мы выделили большую часть кода data source и delegate в отдельную сущность.
Попробовали, оказалось, что делегирование в отдельном классе неудобно, а при выделении одного data source выигрыш не столь существенен.

Поэтому следующей итерацией перешли к использованию table presentation model в качестве data source, view controller стал delegate'ом.

Схема упростилась, ушли ненужные сущности data source и cell mapper. Проще – лучше.



Схема упростилась, ушли ненужные сущности data source и cell mapper. Проще – лучше.

Пересмотрели роутинг


Реализация роутинга, которая была описана выше, плоха тем, что все переходы жестко прописаны во view controller'е. Для реализации слабой связанности между навигацией и внутренним устройством отдельного view controller'а мы делаем следующее:

  1. На конкретной реализации presentation model заводим в виде optional переменных нужное замыкание – handler (или несколько, если навигация ведет в несколько мест)
  2. При создании presentation model в router'е устанавливаем этот handler. Например, при вызове которого должен произойти переход на другой экран.
  3. Из view controller'а в нужный момент вызываем handler у presentation model.

Итого, view controller перестал обладать знанием о router'е.

Кодогенерация


Отдельно стоит упомянуть еще одну особенность разработки в Redmadrobot – это использование кодогенерации. На основе модельных сущностей с помощью консольной утилиты генерируются parser'ы, translator'ы для DAO.

Рассмотрим это на примере работы с сущностью скидка.
/* 
    Скидка
    @model
 */
class Deal: Entity {

    /* 
        Заголовок
        @json 
     */    
    let title: String

    /* 
        Подзаголовок
        @json 
     */    
    let subtitle: String?

    /* 
        Дата окончания
        @json end_date
     */    
    let endDateString: String 

    init(title: String, subtitle: String?, endDateString: String) {
        self.title = title
        self.subtitle = subtitle
        self.endDateString = endDateString
        super.init()
    }

}


Имеем написанный собственноручно класс скидки Deal. На основе его и вспомогательных аннотаций (@model, @json) утилита кодогенерации создает класс парсера DealParser, класс сущности БД DBDeal и класс транслятора DealTranslator.

Класс парсера, сущности БД и транслятора
class DealParser: JSONParser<Deal> {

    override func parseObject(_ data: JSON) -> Deal? {
        guard
            let title: String = data["title"]?.string,
            let endDateString: String = data["end_date"]?.string
        else { return nil }
        
        let subtitle: String? = data["subtitle"]?.string

        let object = Deal(
            title: title,
            subtitle: subtitle,
            endDateString: endDateString
        )
        return object
    }

}


class DBDeal: RLMEntry {
    
    @objc dynamic var title = ""

    @objc dynamic var subtitle: String? = nil

    @objc dynamic var endDateString = ""

}

class DealTranslator: RealmTranslator<Deal, DBDeal> {    
    
    override func fill(_ entity: Deal, fromEntry: DBDeal) {
        entity.entityId = fromEntry.entryId
        entity.title = fromEntry.title
        entity.subtitle = fromEntry.subtitle
        entity.endDateString = fromEntry.endDateString
    }
    
    
    override func fill(_ entry: DBDeal, fromEntity: Deal) {
        if entry.entryId != fromEntity.entityId {
            entry.entryId = fromEntity.entityId
        }
        entry.title = fromEntity.title
        entry.subtitle = fromEntity.subtitle
        entry.endDateString = fromEntity.endDateString
    }
    
}


С недавних пор мы научились генерировать еще и service на основе документированного protocol (про это стоит написать отдельную статью). До момента, когда начали использовать zeplin, генерировали стили цветов и шрифтов на основе текстового файла с их описанием.

Для написания утилит для генерации мы используем свою библиотеку Model Compiler, но для этой задачи вполне может подойти и Sourcery.

Заключение


Развивая архитектуру мы, прежде всего, задумывались над возможностью расширяемости наших проектов, явным разделением обязанностей и низким порогом входа для новых разработчиков. Безусловно, мы так же сталкиваемся со сложными сценариями, где какие-то из элементов нашей архитектуры «проседают», и мы придумываем, как выйти из этой ситуации, как разнести ответственности на вспомогательные сущности и сделать код более понятным. Очевидно, что ни одна архитектура не решает абсолютно все проблемы. На нескольких проектах, которые мы разрабатываем уже не первый год, наши подходы оказались удобными, и редко с этим возникают какие-то проблемы.

Мы не проповедуем MVC, MVVM, VIPER, Riblets и другие архитектуры. Мы постоянно пробуем что-то новое не в ущерб эффективности. При этом стараемся не изобретать велосипедов. Потом проверяем, насколько удобно работать с тем или иным подходом, насколько новые разработчики быстро могут схватить эти изменения.

Комментарии (3)


  1. iWheelBuy
    20.12.2017 16:01
    +1

    Вы меня совершенно запутали в плане MVVM подхода!

    Вы пишите:

    Через какое-то время мы поняли, что одним MVVM не обойдемся. Класс view controller постепенно «распухал», особенно это было заметно, если на экране вызывается несколько запросов.
    Я всегда думал, что ViewController (View) не вызывает запросы в MVVM подходе! Но в вашей интерпретации с запросами работает именно ViewController (View). Ведь это речь о запросах к сети / к базе?!

    Далее вы пишите:
    Следующим шагом выделили обращение к сервисам в отдельную сущность – presentation model и view controller перестал знать об их существовании.
    Эти слова подтверждают, что с сервисами (в том числе сервис по работе с сетью) работает ViewController (View) в вашем MVVM подходе. Это точно не про MVC шла речь?

    Чуть раньше в публикации вы ссылаетесь на вот эту статью, но в ней не описывается работа с сервисами. Зато в ней в самом низу есть ссылка на более подробную публикацию этого же автора:
    you can check out this blog post explaining the benefits of MVVM in greater detail
    В этом источнике вот что говорится о запросах к сети и их расположении:
    ...because network calls should be done asynchronously, so if a network request outlives the model that owns it, well, it gets complicated. You definitely should not put network code in the view, so that leaves… controllers. This is a bad idea, too, since it contributes to our Massive View Controller problem...
    ...view model is an excellent place to put validation logic for user input, presentation logic for the view, kick-offs of network requests, and other miscellaneous code...
    Создается впечатление, что PresentationModel у вас очень уж похож на ViewModel (by Ash Furrow)… или нет?

    Помогите мне найти правду и понять: кто в действительности в MVVM занимается работой с сервисами?


    1. vani2 Автор
      20.12.2017 16:37

      У себя мы разделили:

      1. View Model для View Controller (как у Ash Furrow) – назвали её Presentation Model. Она выполняет запросы к серверу и отвечает за создание View Model для View и ячеек.
      2. View Model для отдельной ячейки, View – «глупые» объекты, которые знают только о том, как поля модели преобразовать для отображения пользователю. Никакие запросы View Model у нас не выполняет.


      Отвечая на вопрос про каноничный MVVM – здесь я считаю, что нужно ориентироваться на Ash Furrow, в конце его книге «Functional Reactive Programming on iOS» разобран пример с вызовом запросов к серверу из View Model. Пример слегка устарел – Objective-C и ReactiveCocoa, но для понимания этого достаточно.


  1. iWheelBuy
    20.12.2017 16:43

    View Model для View Controller (как у Ash Furrow) – назвали её Presentation Model
    Теперь понял!