В предыдущей статье я сформулировал нашу главную проблему при масштабировании Unidirectional Data Flow (UDF) — модуляризацию. Сегодня существует много UDF-фреймворков на Swift, но мало кто уточняет, как их масштабировать на большое, многомодульное приложение. Во второй части я решил поделиться, с какими сложностями мы столкнулись и к каким решениям пришли.

В статье рассмотрим 3 проблемы и сравним, как разные фреймворки их решают. Для сравнения я выбрал одни из самых популярных — ReSwift и The Composable Architecture (TCA). В конце расскажу, какое решение мы в итоге реализовали. Приступим!

3 проблемы, с которыми мы столкнулись

1. Все компоненты знают о состоянии всего приложения.

Предположим, есть приложение и компонент в нем. Например, компонент для отображения данных пользователя или информации о заказе. У этого компонента есть состояние, которое лежит в AppState. Компонент знает об AppState, подписывается на него и при изменении рендерит данные на экран:

Таких компонентов в проекте может быть множество и каждый из них знает об AppState. Это плохо, потому что такой подход нарушает Закон Деметры. Большинству компонентов нужен только собственный State, но им приходится пробираться сквозь AppState к своим данным. Часто в проекте замечаем такой код: 

let state = appState.someInnerState.anotherInnerState.andAnotherOneState.finallyMyState

Писать и поддерживать такой код крайне сложно, хоть такой подход и работает. Ситуация меняется, когда мы захотим переиспользовать наш компонент в другом приложении. Для этого нужно вынести его в отдельный модуль:

Получаем сразу 2 проблемы:

  1. App1 нужно знать про Component, чтобы его использовать. Component нужно знать про App1, чтобы получить доступ к AppState. Мы получаем циклическую зависимость между модулями, что не понравится компоновщику Swift.

  2. Component теперь нужно знать про App2, а в перспективе и о App3, App4 и так далее.

Решить проблемы можно, забрав State компонента внутрь его модуля:

Теперь модуль компонента ни о ком не знает и не имеет никаких зависимостей. Сам компонент знает только о своем стейте. Модули приложений знают о модуле компонента, и AppState каждого из приложений использует State компонента из его модуля. Остается научить компонент слушать только ту часть стейта, в котором он заинтересован. Посмотрим, как эта проблема решается в популярных фреймворках.

ReSwift. ReSwift идет по пути своего предшественника и реализует механизм, схожий с cелекторами из Redux. Подписка нашего компонента выглядела бы так:

store.subscribe(self) { $0.select { $0.componentState } }

Селекторы в Redux — мощный механизм, который оптимизирует частоту обновления состояния для компонента с помощью библиотеки reselect. В ReSwift такой же механизм, поэтому если после диспетчеризации Action стейт компонента не изменился, компонент не получит тот же стейт еще раз.

TCA. У TCA в Store есть метод scope. Он позволяет получить промежуточный Store с уменьшенной областью видимости:

let componentStore = store.scope(state: \.componentState) // componentStore имеет тип Store<ComponentState>

Теперь можно просто подписать наш компонент на componentStore. Более подробно мы разберем работу scope в решении третьей проблемы. TCA, как и ReSwift, позволяет игнорировать одинаковые стейты, но с помощью Combine и removeduplicates().

inDriver UDF. Мы пошли по простому пути и добавили KeyPath параметр в метод connect:

component.connect(to: store, state: \.componentState)

Пока мы не делаем проверку дубликатов стейта (она появится в решении третьей проблемы). Зато добавляем в метод connect проверку на дубликаты View-моделей, поэтому компонент все равно не будет рендерить 2 одинаковых View-модели подряд. Правда, придется создать View-модель еще раз.

2. 2 модуля хотят использовать одну фичу.

Предположим, есть данные профиля пользователя, которые мы хотим использовать в 2 модулях. Оба модуля используются в нашем приложении. Логично, что стейт каждого из модулей должен содержать стейт профиля, а AppState содержит данные и модулей, и профиля:

В таком случае мы получаем копии ProfileState в 3 местах: AppState, Feature1State и Feature2State. Пользователь в приложении один, поэтому и ProfileState нам нужен в единственном экземпляре. При этом Feature1 и Feature2 определенно должны иметь в своих стейтах ProfileState, так как доступа к AppState они не имеют. Рассмотрим 2 решения, которые мы условно назвали Computed Module State и State Protocol and Where Clause:

Computed Module State. Разделим FeatureState на 2 части. Непосредственно FeatureState — данные, которые принадлежат только одной фиче. И FeatureModuleState — FeatureState + переиспользуемые стейты:

struct FeatureModuleState {
    let feature: FeatureState
    let profile: ProfileState
}

Тогда в AppState будем хранить только FeatureState и ProfileState:

struct AppState {
    let feature1: Feature1State
    let feature2: Feature2State
    let profile: ProfileState
}

ModuleState реализуем как вычисляемые свойства:

extension AppState {
    var feature1Module: Feature1ModuleState {
        .init(feature: feature1, profile: profile)
    }

 var feature2Module: Feature2ModuleState {
    .init(feature: feature2, profile: profile)
}
}

В результате получаем такую картину:

На уровне AppState мы гарантируем, что ProfileState в приложении всегда будет в единственном экземпляре. ModuleState же собираем свой под каждый модуль. Такой подход легко масштабируется под любое количество переиспользуемых фич, но требует реализацию вычислимого свойства под каждую новую фичу.

Пример такого подхода есть в учебных материалах TCA (например, здесь). В примере PrimeModelState является вычислимым и не хранится в AppState.

State Protocol and Where Clause. Вместо вычислимых свойств используем протоколы. Объявим протокол Feature1ModuleState и реализуем его в AppState:

protocol Feature1ModuleState {
    let feature: Feature1State
    let protifle: ProfileState
}
extension AppState: Feature1ModuleState {}

Аналогично сделаем для Feature2. Теперь фичи могут хранить у себя FeatureModuleState и не знать, что AppState их реализует. Но есть проблема — в Swift ковариантность работает только для системных дженериков, поэтому мы не сможем Store<AppState> привести к Store<FeatureModuleState>. Зато можем задать FeatureModuleState как ограничения для компонента:

class FeatureViewController<S: StoreType>: UIViewController where S.State: FeatureModuleState {
let store: S
init(store: S) {
  self.store = store
super.init(nibName: nil, bundle: nil)
  }
}

Из плюсов такого подхода выделим отсутствие необходимости в вычислимых свойствах, достаточно просто реализовать протокол. Минус — каждый компонент реализуется как Generic на Store с требованием, что его State реализует FeatureModuleState. Такой подход можно найти в обсуждении модуляризации ReSwift.

Для себя мы выбрали Computed Module State как более простое и не накладывающее ограничения на реализацию компонентов решение.

3. Подписка на Store внутри модуля

В предыдущих двух пунктах рассматривался случай, когда подписка на компонент происходит в том же модуле, в котором находится Store<AppState>. Но это не всегда удобно. Если есть модули, в которых много компонентов и которые могут динамически подключаться и отключаться от Store, то было бы удобно это делать непосредственно в этих модулях. Но если передавать Store<AppState> в модуль целиком, то мы получим циклическую зависимость. Главному модулю нужно знать про FeatureModule, чтобы обратиться к нему. FeatureModule должен знать про AppModule, так как AppState лежит в AppModule:

Рассмотрим, как эту проблему решают в других фреймворках:

ReSwift. В отличии от первой проблемы, select здесь не поможет. В FeatureModule нужно получить Store с каким-то типом State, и только потом использовать select. Создатели ReSwift не дают никаких официальных рекомендаций, что делать в такой ситуации. Для решения проблемы подойдет State Protocol and Where Clause из второго пункта. Если мы закроем AppState протоколом, то передадим Store<AppState> в FeatureModule, не раскрывая сам тип AppState.

TCA. В TCA для решения этой проблемы мы можем использовать метод scope, который упоминался в первом пункте. Метод scope позволяет получить Store, который смотрит только на часть всего State. В нашем случае из Store<AppState> мы получаем Store<FeatureState> и передаем в FeatureModule. Реализацию метода scope и всего Store можно найти здесь, прочитать про особенности реализации — здесь.

inDriver UDF. В своем фреймворке мы пошли по пути TCA и разлиновали метод scope. Так как мы не используем Combine и запускаем reducer на отдельной очереди, детали реализации отличаются от TCA, но идея сохранена. Рассмотрим схему работы:

  1. Component отправляет Action в LocalStore.

  2. LocalStore никак не обрабатывает Action, а транслирует дальше в родительский Store.

  3. Reducer родительского Store обрабатывает Action и обновляет AppState.

  4. LocalStore по подписке получает обновленный AppState и сохраняет из него LocalState.

  5. Все подписчики LocalStore получают обновленный LocalStore.

Также на 4 шаге есть возможность сравнить новый и старый LocalState. Если он не изменился, мы можем не уведомлять подписчиков LocalStore об изменениях. Это избавляет целые модули от реагирования на каждый Action из других модулей, если они не влияют на их собственный стейт. 

Заключение

Конечно, сложностей в реализации модульности гораздо больше. Мы рассмотрели лишь основные. В следующих частях обязательно вернемся и к остальным. В результате применения Unidirectional Data Flow у нас получился собственный фреймворк UDF, который мы решили сделать публичным. В нем собраны решения, которые помогли нам сделать большой и многомодульный проект. Около половина нашего приложения работает на UDF, и мы продолжаем масштабировать его на остальные части приложения.

Хочу сказать, что UDF хорошо показал себя как архитектура для приложения большого коммерческого продукта. И для реализации в своем проекте UDF совершенно не обязательно использовать сторонние фреймворки. На своем примере мы показали, что UDF-подход можно реализовать самостоятельно, и для этого не потребуется много кода.

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


  1. matpaul
    24.09.2021 11:44

    А как реализована работа с сайд эффектами?

    reSwift - middlewares, есть thunk

    TCA - редьюсер может возвращать эффект


    1. MasterWatcher Автор
      24.09.2021 13:22

      Мы одинаково подходим к работе как с сайд эффектами в виде рендеринга UI так и к асинхронным сайд эффектам. Например в виде запросов к серверу или локальной базе данных. То есть, если нам нужно сделать запрос к серверу, то мы создаем состояние для этого запроса, создаем компонент для этого запроса, который следит за своим состоянием и как только видит что ему необходимо выполнить запрос, то переходит к его исполнению. Впервые такой подход мы встретили у Алексея Демедецкого и попробовали у себя. На данный момент такой подход решает все наши задачи. По опыту можем выделить такие плюсы и минусы:

      Плюсы:
      - Единый подход ко всем сайд эффектам. Какой бы не был сайт эффект разработчик всегда будет создавать компонент, который этот сайд эффект реализует.
      - Максимальная гибкость. Мы полностью управляем временем жизни сайд эффекта и можем в нужный момент подключить его к стору и отключить. Так же такой подход позволяет синхронизировать между собой работу отдельных сайд эффектов через стейт.
      - В случае TCA если не нужен сайд эффект, то в редюсере необходимо вернуть .none. Это может усложнять чтение кода. В используемом нами подходе редюсеру не нужно ничего возвращать.

      Минусы:
      -В простых случаях получается избыточно. Реализовать Thunk для простого сценария получается быстрее.
      -В TCA на уровне редюсера можно понять какой сайд эффект исполнится следующим. В случае сайд эффектов как отдельных компонентов необходимо смотреть кто подписывается на изменения данного стейта.

      В будущих статьях планируем детальнее раскрыть тему работы с сайд эффектами.


      1. matpaul
        24.09.2021 14:38

        Спасибо за развернутый ответ, было бы здорово увидеть пример в репе с простым асинк запросом