Вот бывает: пишешь код, пишешь, а в итоге получаешь настолько большой модуль, что полностью теряешь над ним контроль. И всё это добро изменяется со страшным скрипом, расширяется медленно и совсем не покрывается тестами. Ровно это с нами и произошло.

Привет! Меня зовут Саша, я — iOS-разработчик в hh.ru. В сегодняшней статье расскажу, как мы ушли от этого монструозного ужаса и что у нас в итоге получилось. Спойлер: мы использовали стейт-машину.

Критерии лучшего решения

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

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

Второй критерий: исходящие сигналы. Это моментальное событие, которое отправляется стейт-машиной без изменения стейта. Они нужны, например, для отображения уведомлений и реализации сервисных фичей. Увы, далеко не все библиотеки их поддерживают.

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

У нас над приложением работают одновременно две нативные команды – iOS и Android. Oдна и та же функциональность всегда внедряется сразу на двух платформах. При переходе к стейт-ориентированному программированию мы следили, чтобы было удобно проектировать фичи в команде

Пятый критерий: роутинг. Проще говоря, возможность организовать переходы между экранами. В iOS с организацией роутинга всё очень не просто, и эту проблему мы тоже решили, правда уже за рамками стейт-машины. Для этого мы запилили отдельную библиотеку — она называется Nivelir и доступна в опенсорс

Еще добавлю пару слов про SwiftUI. Сейчас в нашем проекте используется собственная декларативная дизайн-система и нам не хотелось случайно заблокировать себе переход к SwiftUI в будущем. Ни один из рассматриваемых нами фреймворков никак нас не ограничивал в его использовании.

После ресерча нас заинтересовали четыре варианта:

  1. MVI — это просто реализация стейт-машины, которая не сильно завязана на конкретную архитектуру и может быть применима в разных местах приложения. Из плюсов: она используется в нашей Android-команде.

  2. ReactorKit — реактивный фреймворк для построения однонаправленной архитектуры конкретного модуля. В своих зависимостях он включает RxSwift и по смыслу очень похож на MVI.

  3. ReSwift — это вариант реализации Redux-архитектуры с единым стором для приложения. 

  4. The Composable Architecture — это комплексный подход для построения архитектуры с кучей возможностей и Combine под капотом. 

Пройдемся по сводной таблице, которая у нас получилась. 

В MVI нет готовой реализации на iOS, но она активно используется на Android и хорошо описана в сообществе. Есть исходящие сигналы, можно настроить взаимодействие между несколькими фичами, и она никак не завязана на архитектуру. Также в ней нет никаких встроенных инструментов для роутинга, кроме исходящих сигналов.

В ReactorKit всё почти как в MVI, но он реализован на RxSwift, а у нас в проекте уже был OpenCombine для поддержки iOS 12. Он напоминает подход в Android-команде, но с некоторыми отличиями. Например, нет исходящих сигналов. Роутинг, как и в случае с MVI, полностью лежит за пределами ReactorKit. 

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

TCA (The Composable Architecture) по своим плюсам и минусам очень похожа на ReSwift. Вот только под капотом у нее Combine, и поддерживается только от iOS 13+, что стало для нас стоп-фактором. 

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

Готовой реализации под iOS не было и наша core-команда запилила свою.

Знакомимся с MVI

Аббревиатура MVI расшифровывается как "Model-View-Intent". Следите за руками: Model отдает стейт во View, View его отображает, действия пользователей формируются в Intent, которые отправляются в Model, Model обрабатывает Intent и формирует новый стейт. И так по кругу.

Данные всегда движутся в одном направлении, поэтому мы получаем однонаправленный поток данных. И, несмотря на схожесть в названии с популярными MV* паттернами, MVI таким паттерном не является. Она никак не ограничивает архитектуру или область применения. Вместо View Intent может отправлять ViewModel или даже другая стейт-машина. Так что мы используем MVI для написания логики работы и UI, и для реализации отдельных сервисов.

Теперь копнем немного глубже. Под капотом MVI состоит из нескольких кусков. Первые три составляющие — Wish, State и Reducer. Wish и State – наша публичная часть. Wish – это входящий сигнал, то что принимает стейт-машина. State – описывает состояние стейт-машины. И Reducer – это линейная функция, которая преобразует текущий стейт с учетом входящего сигнала.

Важно отметить, что Reducer работает синхронно и не содержит никаких зависимостей, а просто меняет одно состояние на другое. Однако бывает так, что для перехода в новое состояние нужны данные, которых еще нет. Самый распространенный пример — это запрос на сервер или в базу. Для таких асинхронных задач в MVI есть отдельная сущность — Actor. Наш Wish влетает в него, а Actor уже может содержать зависимости, например, какой-нибудь провайдер данных. Результат работы Actor — это внутренний переход, который отправляется в Reducer, в терминологии MVI он называется Effect. Именно с ним чаще всего и работает Reducer. Дальше всё происходит ровно так же, только для определения следующего стейта используется не Wish, а Effect. В итоге мы получаем новое состояние.

Если обобщить, то Actor служит для вычисления перехода из текущего стейта с учетом входящего сигнала и доступности данных в провайдерах. При этом он не знает, какой стейт будет следующим. Интересно, что при получении входящего сигнала Actor может вызвать несколько Effect-ов. 

Небольшой пример: нам нужно открыть резюме. Мы отправляем соответствующий Wish в стейт-машину, и Actor должен запросить данные. Но перед запросом мы отправляем дополнительный Effect, что загрузка началась. Reducer поменяет стейт на загрузку данных и когда запрос отработает, Actor отправит второй Effect — данные загружены. Reducer вновь поменяет стейт, но уже с учетом тех данных, которые пришли от сервера. В этом случае по одному Wish произойдут два изменения стейта. 

Третья часть MVI — это исходящие сигналы. В паттерне они называются News и для их отправки используется NewsPublisher. Он работает с Effect-ом и измененным стейтом, а используется, например, для показа уведомлений или передачи данных другим фичам. Работает он так: мы меняем стейт, новый стейт вместе с Effect попадает в NewsPublisher, а он принимает решение, что делать с этим сочетанием данных. И, если необходимо, отправляет сигнал. 

Логику работы стейт-машины можно расширить, если вести PostProcessor. Он, как и NewsPublisher, работает с Effect и измененным стейтом, но это исключительно внутренний механизм. Он нужен, чтобы отправить еще один Wish в Actor после изменения стейта предыдущим Effect-ом.

Если брать пример с загрузкой резюме, то такую же логику можно реализовать без отправки двух Effect-ов из одного Wish. Сначала мы меняем стейт на загрузка началась потом отдаем его в постпроцессор и создаем новый Wish. И именно по этому Wish-у начнётся реальная загрузка данных с сервера. 

Короче говоря, PostProcessor нужен для возможности усложнять внутреннюю логику за счет создания еще одного Wish после изменения стейта.

Последняя часть MVI — это Bootstraper. Он может служить для предварительной загрузки данных и для работы с подписками на внешние сервисы и события. 

Например, сигнал о том, что пользователь залогинился или разлогинился, получит именно Bootstraper. Это можно сделать и через внешние Wish, но такое решение скорее всего приведет к усложнению интерфейса и увеличению количества сервисов в классах, которые работают с MVI.

Как это работает у нас

В нашем проекте мы используем MVVM и часть модулей совершенно законно идут без MVI и стейт-машины. Они достаточно простые, с одной-двумя внешними зависимостями, и выглядят примерно так:

Но если сервисов больше, то вычисление итогового состояния становится нетривиальной задачей:

И тут появляется MVI. Он включает в себя все нужные для модуля сервисы и сценарии работы. Если нам нужны дополнительные сигналы, вроде показа уведомлений, или стейт-машина должна инициировать переход на другой экран, то мы добавляем подписку на News.

На практике это выглядит так: ViewModel отправляет Wish с действием пользователя в MVI, MVI этот Wish обрабатывает, формирует на его основе новый стейт, и он отправляется в UIStateMapper. UIStateMapper через ViewModel отправляет данные на рендер во View слой — и всё, работа завершена. 

Обратие внимание, что на схеме есть два стейта. Стейт, который формирует MVI, описывает состояние с точки зрения бизнес-логики, а не с точки зрения его отображения на UI. Там нет нужного форматирования данных, локализованных ресурсов, в основном там только сервисные модели. И за то, чтобы превратить этот сервисный стейт в красивый интерфейс, отвечают UIStateMapper и наша дизайн система. 

Собственно, по теории – это всё, переходим к практике. Создадим незамысловатую штуку под названием “пагинатор”. 

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

Какие действия мы хотим совершать:

  1. Загружать начальные данные – загрузка первой страницы при входе на экран.

  2. Отображать эти данные – нам нужно не только загрузить данные, но и сохранить их в стейт.

  3. Догружать по страницам – когда пользователь начнет скроллить вниз, нам нужно загрузить следующую страницу.

  4. Обновлять загруженные данные – не забывайте про действие pullToRefresh. 

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

Давайте остановимся на однозначном и простом варианте, а уже потом его можно будет усложнить. Например: загружать данные заранее, для более плавного UI, добавлять возможность удалить или редактировать объект сразу в стейте, а потом обновлять данные на сервере.

Построим схему и разберем основные сценарии. 

Сценарий первый: загрузка первой страницы. В начале у нас нет ничего, и мы отправляем событие в систему для загрузки первой страницы. Затем переходим в состояние загрузка, в этот момент на экране пользователя отображаются шиммеры. Когда пришел ответ сервера, мы отправляем данные в Reducer и сохраняем их. Тут мы просто добавляем загруженные объекты  в стейт и отображаем их на UI.

Загрузка первой страницы
Загрузка первой страницы

Для второй страницы ситуация очень похожа, только в начале мы уже имеем определенный набор данных. Отправляем сигнал на загрузку и переходим в состояние загрузки следующей страницы. Важно, что у нас уже есть данные, их тоже нужно отображать. Передаем загруженные данные для второй страницы, сохраняем их, отображаем на UI – всё то же самое. 

Загрузка второй страницы
Загрузка второй страницы

Идем дальше, и рассмотрим сценарий рефреша данных. Рефреш данных всегда начинается со стейта Content. Отправляем запрос на перезагрузку и, пока ждем обновления, отображаем все ранее загруженные данные. На экране сверху в этот момент может быть ромашка. Когда мы отображаем новые данные, хочется чтобы не сбилось положение скролла у пользователя, но с этим разбираются наши UIStateMapper и дизайн-система, а не MVI.

Обновение данных
Обновение данных

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

Обработка ошибки
Обработка ошибки

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

Обработка ошибки при дозагрузке данных
Обработка ошибки при дозагрузке данных

На этом заканчивать с проектированием, и немножко посмотрим код. Сначала опишем наш стейт. В нем будет одно основное хранимое свойство с текущим состоянием, и это может быть одно из следующих значений – initial, loading, content или error. Для состояния content есть дополнительный флаг, он используется, чтобы мы могли изменять стейт, но при этом сохранять уже загруженные данные.

struct PaginationFeatureState {

    enum LoadingOption: Equatable {
        case refreshing
        case nextPageLoading
        case nextPageLoadingError(_ error: Error)
    }

    enum DataState: Equatable {
        case initial
        case loading
        case content(paginationItems: PaginationItems, loadingOption: LoadingOption?)
        case error(_ error: Error)
    }

    let data: DataState
}

Теперь добавим Wish-и. Их всего два: для загрузки и для рефреша. И Effect-ы, коих четыре: загрузка началась, данные загружены, произошла ошибка и рефреш.

public enum PaginationWish {
    case load(isNextPage: Bool)
    case refresh
}

public enum PaginationEffect {
    case itemsDidLoad(paginationItems: PaginationItems)
    case itemsLoadingDidFail(error: Error, isPaginationError: Bool)
    case itemsLoadingDidStart(isNextPage: Bool)
    case itemsRefreshingDidStart
}

Посмотрим, как может выглядеть Actor. Например, нам приходит Wish на загрузку данных, мы смотрим на текущий стейт и проверяем, что загрузка возможна в принципе, ничего не загружается в данный момент, следующая страница доступна, а стейт не находится в состоянии ошибки. Если всё хорошо, мы отправляем запрос на серверы и функция фич возвращает Effect с данными или ошибкой загрузки, а через prepend мы отправляем Effect, что загрузка началась.

public func process(
    state: PaginationFeatureState,
    wish: PaginationWish
) -> AnyPublisher<PaginationEffect> {
    switch wish {
    case let .load(isNextPage):
        guard
            !state.isProcessing,
            state.canLoadNextPage || !isNextPage,
            state.loadingError == nil
        else {
            return .none
        }
        return fetch(
                for: state,
                page: isNextPage ? state.paginationItems?.nextPageIndex : 0
            )
            .prepend(.itemsLoadingDidStart(isNextPage: isNextPage))
            .eraseToAnyPublisher()

Теперь, собственно, обработка Effect в Reducer. На входе мы получаем текущий стейт и Effect. Например, когда нам приходит Effect с данными, мы создаем новый стейт с ними. Как видите, сам код получается достаточно простым. 

    public func process(
        state: PaginationFeatureState,
        effect: PaginationEffect
    ) -> PaginationFeatureState {
        switch effect {
        case let .itemsDidLoad(paginationItems):
            return state.changing(
                \.state,
                to: .items(paginationItems: paginationItems, loadingStatus: .none)
            )
        case let .itemsLoadingDidFail(error, isPaginationError):
            let errorModel = error.mapToHHSDKErrorModel()
            return state.changing(
                \.state,
                to: .failed(
                    error: isPaginationError
                        ? .paginationError(paginationItems: state.paginationItems, error: errorModel)
                        : .loadingError(error: errorModel)
                )
            )

Заключение 

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

Пишите в комментах любые вопросы или идеи, которые возникли у вас при прочтении. С радостью отвечу на всё. 

Пока!

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


  1. flyer2001
    17.06.2022 13:32

    Немного запутался. Если после Wish -> Actor -> Reducer . То получается когда идет запрос и нужно отобразить лоадер, то дальше уже Actor рулит, сначала пуляет в Reducer - покажи лоадер, а потом уже отрисуй состояние (ошибка/успех)?


    1. alextsybulko Автор
      17.06.2022 13:59
      +1

      Да, все так. Actor отправит два эффекта в редьюсер. Первый на отрисовку лоудера, второй с данными через несколько секунд.