Вот бывает: пишешь код, пишешь, а в итоге получаешь настолько большой модуль, что полностью теряешь над ним контроль. И всё это добро изменяется со страшным скрипом, расширяется медленно и совсем не покрывается тестами. Ровно это с нами и произошло.
Привет! Меня зовут Саша, я — iOS-разработчик в hh.ru. В сегодняшней статье расскажу, как мы ушли от этого монструозного ужаса и что у нас в итоге получилось. Спойлер: мы использовали стейт-машину.
![](https://habrastorage.org/getpro/habr/upload_files/972/0aa/aa5/9720aaaa594b425b3ee95b136fd267cf.jpeg)
Критерии лучшего решения
Хотя сама идея стейт-машины довольно простая, существует множество подходов и библиотек, которые помогают организовать ее в коде. Даже в самой iOS есть нативный вариант реализации, а простые вещи можно реализовать на перечислениях. Но нам хотелось найти полноценное решение, поэтому мы собрали основные критерии, которые для нас важны и пустились в поиски.
Наш первый критерий — это взаимодействие между фичами. Фичей мы называем конкретный кусок законченной бизнес-логики, в данном случае — стейт-машину. И поскольку мы решали проблему создания больших фич, нам хотелось не раздувать одну стейт-машину, а иметь возможность комбинировать несколько маленьких.
Второй критерий: исходящие сигналы. Это моментальное событие, которое отправляется стейт-машиной без изменения стейта. Они нужны, например, для отображения уведомлений и реализации сервисных фичей. Увы, далеко не все библиотеки их поддерживают.
Третий критерий: итеративное внедрение. Это касается процесса добавления выбранного подхода в код приложения. Нужна возможность не переписывать всё, а внедрять постепенно.
У нас над приложением работают одновременно две нативные команды – iOS и Android. Oдна и та же функциональность всегда внедряется сразу на двух платформах. При переходе к стейт-ориентированному программированию мы следили, чтобы было удобно проектировать фичи в команде.
Пятый критерий: роутинг. Проще говоря, возможность организовать переходы между экранами. В iOS с организацией роутинга всё очень не просто, и эту проблему мы тоже решили, правда уже за рамками стейт-машины. Для этого мы запилили отдельную библиотеку — она называется Nivelir и доступна в опенсорс.
Еще добавлю пару слов про SwiftUI. Сейчас в нашем проекте используется собственная декларативная дизайн-система и нам не хотелось случайно заблокировать себе переход к SwiftUI в будущем. Ни один из рассматриваемых нами фреймворков никак нас не ограничивал в его использовании.
После ресерча нас заинтересовали четыре варианта:
MVI — это просто реализация стейт-машины, которая не сильно завязана на конкретную архитектуру и может быть применима в разных местах приложения. Из плюсов: она используется в нашей Android-команде.
ReactorKit — реактивный фреймворк для построения однонаправленной архитектуры конкретного модуля. В своих зависимостях он включает RxSwift и по смыслу очень похож на MVI.
ReSwift — это вариант реализации Redux-архитектуры с единым стором для приложения.
The Composable Architecture — это комплексный подход для построения архитектуры с кучей возможностей и Combine под капотом.
Пройдемся по сводной таблице, которая у нас получилась.
![](https://habrastorage.org/getpro/habr/upload_files/6f9/ba6/6c3/6f9ba66c3ece32ba797d9f6ae24c3046.png)
В 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, и для реализации отдельных сервисов.
![](https://habrastorage.org/getpro/habr/upload_files/c5e/e8d/1b0/c5ee8d1b00e91c72a4c9579c915ce28e.png)
Теперь копнем немного глубже. Под капотом MVI состоит из нескольких кусков. Первые три составляющие — Wish, State и Reducer. Wish и State – наша публичная часть. Wish – это входящий сигнал, то что принимает стейт-машина. State – описывает состояние стейт-машины. И Reducer – это линейная функция, которая преобразует текущий стейт с учетом входящего сигнала.
![](https://habrastorage.org/getpro/habr/upload_files/626/b8f/5d1/626b8f5d1218e4da8a8550e9c7071b66.png)
Важно отметить, что Reducer работает синхронно и не содержит никаких зависимостей, а просто меняет одно состояние на другое. Однако бывает так, что для перехода в новое состояние нужны данные, которых еще нет. Самый распространенный пример — это запрос на сервер или в базу. Для таких асинхронных задач в MVI есть отдельная сущность — Actor. Наш Wish влетает в него, а Actor уже может содержать зависимости, например, какой-нибудь провайдер данных. Результат работы Actor — это внутренний переход, который отправляется в Reducer, в терминологии MVI он называется Effect. Именно с ним чаще всего и работает Reducer. Дальше всё происходит ровно так же, только для определения следующего стейта используется не Wish, а Effect. В итоге мы получаем новое состояние.
![](https://habrastorage.org/getpro/habr/upload_files/2fd/83a/ac9/2fd83aac9bef91e8102b5051400eae35.png)
Если обобщить, то Actor служит для вычисления перехода из текущего стейта с учетом входящего сигнала и доступности данных в провайдерах. При этом он не знает, какой стейт будет следующим. Интересно, что при получении входящего сигнала Actor может вызвать несколько Effect-ов.
Небольшой пример: нам нужно открыть резюме. Мы отправляем соответствующий Wish в стейт-машину, и Actor должен запросить данные. Но перед запросом мы отправляем дополнительный Effect, что загрузка началась. Reducer поменяет стейт на загрузку данных и когда запрос отработает, Actor отправит второй Effect — данные загружены. Reducer вновь поменяет стейт, но уже с учетом тех данных, которые пришли от сервера. В этом случае по одному Wish произойдут два изменения стейта.
Третья часть MVI — это исходящие сигналы. В паттерне они называются News и для их отправки используется NewsPublisher. Он работает с Effect-ом и измененным стейтом, а используется, например, для показа уведомлений или передачи данных другим фичам. Работает он так: мы меняем стейт, новый стейт вместе с Effect попадает в NewsPublisher, а он принимает решение, что делать с этим сочетанием данных. И, если необходимо, отправляет сигнал.
![](https://habrastorage.org/getpro/habr/upload_files/3ee/766/c4b/3ee766c4b0aa1487266efd35c7ced85d.png)
Логику работы стейт-машины можно расширить, если вести PostProcessor. Он, как и NewsPublisher, работает с Effect и измененным стейтом, но это исключительно внутренний механизм. Он нужен, чтобы отправить еще один Wish в Actor после изменения стейта предыдущим Effect-ом.
![](https://habrastorage.org/getpro/habr/upload_files/d17/d85/db4/d17d85db4af58ac25ccfce6a6d50cdaf.png)
Если брать пример с загрузкой резюме, то такую же логику можно реализовать без отправки двух Effect-ов из одного Wish. Сначала мы меняем стейт на загрузка началась потом отдаем его в постпроцессор и создаем новый Wish. И именно по этому Wish-у начнётся реальная загрузка данных с сервера.
Короче говоря, PostProcessor нужен для возможности усложнять внутреннюю логику за счет создания еще одного Wish после изменения стейта.
Последняя часть MVI — это Bootstraper. Он может служить для предварительной загрузки данных и для работы с подписками на внешние сервисы и события.
![](https://habrastorage.org/getpro/habr/upload_files/7a7/b9d/5de/7a7b9d5de8bc392a26725b3968bf0513.png)
Например, сигнал о том, что пользователь залогинился или разлогинился, получит именно Bootstraper. Это можно сделать и через внешние Wish, но такое решение скорее всего приведет к усложнению интерфейса и увеличению количества сервисов в классах, которые работают с MVI.
Как это работает у нас
В нашем проекте мы используем MVVM и часть модулей совершенно законно идут без MVI и стейт-машины. Они достаточно простые, с одной-двумя внешними зависимостями, и выглядят примерно так:
![](https://habrastorage.org/getpro/habr/upload_files/de9/7ea/ace/de97eaacec307c5af160f6e869d41b95.png)
Но если сервисов больше, то вычисление итогового состояния становится нетривиальной задачей:
![](https://habrastorage.org/getpro/habr/upload_files/ae9/069/d1c/ae9069d1c7e135fbe3063924cefd6519.png)
И тут появляется MVI. Он включает в себя все нужные для модуля сервисы и сценарии работы. Если нам нужны дополнительные сигналы, вроде показа уведомлений, или стейт-машина должна инициировать переход на другой экран, то мы добавляем подписку на News.
![](https://habrastorage.org/getpro/habr/upload_files/bf8/28d/03b/bf828d03b09ee9be6b4df3a235b0e17c.png)
На практике это выглядит так: ViewModel отправляет Wish с действием пользователя в MVI, MVI этот Wish обрабатывает, формирует на его основе новый стейт, и он отправляется в UIStateMapper. UIStateMapper через ViewModel отправляет данные на рендер во View слой — и всё, работа завершена.
Обратие внимание, что на схеме есть два стейта. Стейт, который формирует MVI, описывает состояние с точки зрения бизнес-логики, а не с точки зрения его отображения на UI. Там нет нужного форматирования данных, локализованных ресурсов, в основном там только сервисные модели. И за то, чтобы превратить этот сервисный стейт в красивый интерфейс, отвечают UIStateMapper и наша дизайн система.
Собственно, по теории – это всё, переходим к практике. Создадим незамысловатую штуку под названием “пагинатор”.
Типичная ситуация: пользователь заходит на экран и загружается первая страница с данными, скроллит и загружается вторая и так далее. Экран можно дернуть вверх, и вызовется действие pullToRefresh, а значит на любой запрос в любой момент может прийти ошибка.
Какие действия мы хотим совершать:
Загружать начальные данные – загрузка первой страницы при входе на экран.
Отображать эти данные – нам нужно не только загрузить данные, но и сохранить их в стейт.
Догружать по страницам – когда пользователь начнет скроллить вниз, нам нужно загрузить следующую страницу.
Обновлять загруженные данные – не забывайте про действие pullToRefresh.
Отображать ошибки – ошибка может прийти на загрузке первой страницы, когда у нас еще нет данных для отображения, или при загрузке последующих, когда мы должны не только показать ошибку, но и не потерять уже загруженный контент.
Давайте остановимся на однозначном и простом варианте, а уже потом его можно будет усложнить. Например: загружать данные заранее, для более плавного UI, добавлять возможность удалить или редактировать объект сразу в стейте, а потом обновлять данные на сервере.
Построим схему и разберем основные сценарии.
![](https://habrastorage.org/getpro/habr/upload_files/0c9/646/f84/0c9646f8411ed4ea7d6f0d17bbba556d.png)
Сценарий первый: загрузка первой страницы. В начале у нас нет ничего, и мы отправляем событие в систему для загрузки первой страницы. Затем переходим в состояние загрузка, в этот момент на экране пользователя отображаются шиммеры. Когда пришел ответ сервера, мы отправляем данные в Reducer и сохраняем их. Тут мы просто добавляем загруженные объекты в стейт и отображаем их на UI.
![Загрузка первой страницы Загрузка первой страницы](https://habrastorage.org/getpro/habr/upload_files/169/a62/12f/169a6212f355c9d372c2c203cb7f6337.gif)
Для второй страницы ситуация очень похожа, только в начале мы уже имеем определенный набор данных. Отправляем сигнал на загрузку и переходим в состояние загрузки следующей страницы. Важно, что у нас уже есть данные, их тоже нужно отображать. Передаем загруженные данные для второй страницы, сохраняем их, отображаем на UI – всё то же самое.
![Загрузка второй страницы Загрузка второй страницы](https://habrastorage.org/getpro/habr/upload_files/416/a4d/9b4/416a4d9b4e10c833ee82dc1fed7c1d1b.gif)
Идем дальше, и рассмотрим сценарий рефреша данных. Рефреш данных всегда начинается со стейта Content. Отправляем запрос на перезагрузку и, пока ждем обновления, отображаем все ранее загруженные данные. На экране сверху в этот момент может быть ромашка. Когда мы отображаем новые данные, хочется чтобы не сбилось положение скролла у пользователя, но с этим разбираются наши UIStateMapper и дизайн-система, а не MVI.
![Обновение данных Обновение данных](https://habrastorage.org/getpro/habr/upload_files/83e/b7d/c85/83eb7dc8518f7a4837e97c730420288c.gif)
Напоследок разберем сценарий с ошибкой. Начинается всё как загрузка первой страницы: отправляем сигнал для загрузки и ждем. Нам приходит ошибка, а мы отображаем её на экран. Из этого состояния можно перейти только на перезагрузку, но никак не загрузить следующую страницу.
![Обработка ошибки Обработка ошибки](https://habrastorage.org/getpro/habr/upload_files/df6/b36/9b7/df6b369b7800db35025a3fc08006e1a8.gif)
Немного другая ситуация будет, когда ошибка произойдет на загрузке второй странице или последующих. Начало будет очень похожим, но в этом случае нам нужно отобразить и контент, и ошибку вместе. Здесь для загрузки следующей страницы нужно нажать кнопку, а не просто поскроллить экран.
![Обработка ошибки при дозагрузке данных Обработка ошибки при дозагрузке данных](https://habrastorage.org/getpro/habr/upload_files/d12/3ea/5de/d123ea5de78f6e5074f34ff5035a6798.gif)
На этом заканчивать с проектированием, и немножко посмотрим код. Сначала опишем наш стейт. В нем будет одно основное хранимое свойство с текущим состоянием, и это может быть одно из следующих значений – 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)
)
)
Заключение
Теперь у нас есть общий подход для написания логики как внутри команды, так и между платформами. Подход, который понятен всем, его легко расширять, менять логику и тестировать. Бонусом к этому идет шаблонизация всех базовых сущностей. Из минусов могу заметить, что для простых экранов и простых сервисов стейт-машина не нужна и даже избыточна.
Пишите в комментах любые вопросы или идеи, которые возникли у вас при прочтении. С радостью отвечу на всё.
Пока!
flyer2001
Немного запутался. Если после Wish -> Actor -> Reducer . То получается когда идет запрос и нужно отобразить лоадер, то дальше уже Actor рулит, сначала пуляет в Reducer - покажи лоадер, а потом уже отрисуй состояние (ошибка/успех)?
alextsybulko Автор
Да, все так. Actor отправит два эффекта в редьюсер. Первый на отрисовку лоудера, второй с данными через несколько секунд.