В поисках идеальной архитектуры

в 9:56, , рубрики: architecture, iOS, ios development, swift, Блог компании REDMADROBOT, Проектирование и рефакторинг, разработка под iOS

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. Проще – лучше.

В поисках идеальной архитектуры - 9

Схема упростилась, ушли ненужные сущности 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 и другие архитектуры. Мы постоянно пробуем что-то новое не в ущерб эффективности. При этом стараемся не изобретать велосипедов. Потом проверяем, насколько удобно работать с тем или иным подходом, насколько новые разработчики быстро могут схватить эти изменения.

Автор: vani2

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js