Дата публикации: 19-07-2022

The Elm Architecture в Android проекте: 3 года спустя

elmrevisited4.jpg

Почти 3 года назад я “продал” TEA своей команде в Авто.ру. С тех пор наша команда написала 180 TEA-компонентов - от простых экранов с парой кнопок до сложных форм-визардов с кучей логики. Тогда одним из ключевых требований дизайна, которые я перед собой ставил, был интероп с тогдашним стеком приложения - MVP + Clean Architecture, RxJava, Dagger 2. С тех пор я много думал о том, как бы выглядела архитектура, если убрать ограничение на совместимость с наследием эпохи Clean Architecture. Эта статья - компиляция моих заметок на эту тему.

Какие проблемы я пытался решить при помощи TEA в 2018

Любое архитектурное решение должно подкрепляться проблемами, которое оно призвано решить, а не только хотелкой создателя. Некоторые из проблем специфичны для Авто.ру, некоторые характерны в целом для Android разработки эпохи CleanArch. Я рассказывал об общих проблемах в нескольких докладах. Давайте я в этом разделе расскажу про более специфичные для Авто.ру. Для меня в дизайне новой архитектуры были помимо прочих такие проблемы:

Наличие готовой логики в виде интеракторов, репозиториев и прочего Clean

Когда не пишешь приложение с нуля, а мигрируешь уже существующее, у тебя связаны руки. Тебе нужно иметь возможность взаимодействовать с любым старым кодом, как бы странно он не был написан. Поэтому решение должно было быть максимально абстрактным и легко расширяемым.

Решение в Puerh

Effect-ы в Puerh - это просто data class с параметрами для выполнения задачи, но никак не suspend CoroutineScope.(dispatch: Dispatch<Msg>) -> Any?, как можно увидеть, например, в фреймворке oolong. Это позволяет отделить обьявление эффекта от его реализации. И за цену горки бойлерплейта поддержать любую фантазию автора. Такая интерпретация, кстати, убрала надобность в такой сущности как Sub. Это, как оказалось, легко выражается через EffectHandler без эффектов. Хоть и многословное, но мне это решение кажется удачным. Оно позволило развивать архитектуру без лишнего переписывания компонентов.

Устаревшая ещё тогда RxJava 1

Ещё в марте 2018 RxJava 1 достигла последнего релиза. Создатели фреймворка не собирались далее её поддерживать. Но RxJava была на всех слоях. Типичное приложение того времени состояло из реактивных обёрток над БД и ретрофитом, реактивных обёрток над Вьюхами и заставки Windows XP “Водопровод” из реактивных операторов между ними.

elmrevisited3.jpg

Мигрировать такое большое приложение, как Авто.ру, на другую версию Rx - очень не простая задача, на которую не хватало технического бюджета.

Решение в Puerh

Примитивный дизайн EffectHandler, намеренное избегание изобилующих тогда Rx-интерфейсов, ставших позднее модными корутин и их саспендов - всё это был задел на “Великий Переезд”. Но, как оказалось, тут случился оверкилл. Когда пишешь TEA для тебя становится совсем не важен фреймворк асинхронщины. Благодаря тому что все эффекты максимально тупы, ты можешь их писать хоть на джавовых экзекьюторах. У тебя не будет никакой сложной логики, где ты можешь отстрелить себе ногу.

Скудность опыта решения таких масштабных задач

elmrevisited1.jpg

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

Решение в Puerh

Я откладывал любое решение “до последнего”. Старался оперировать максимально общими интерфейсами, а иногда даже и просто конвенциями. А реализацию давал на откуп своим коллегам и надеялся на их фидбек. Это привело к нескольким проблемам. Так, например, аналог действия оператора distinct возник как сторонее расширение одного из наших разработчиков. А из-за того что я никак не мог определиться с тред-сейфети методов у коллег возникали проблемы.

Проблемы Puerh

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

Раздробленность источников правды в Android

TEA, как и любая другая UDF архитектура, очень хочет знать всё про всех и владеть т.н. источником правды - неделимой истиной, постулатом, из которого выводится всё остальное состояние системы, как из пачки аксиом можно вывести всё геометрию. И это хорошо соотносится с веб-приложениями, для которых дизайнилось всё семейство UDF - MVI, Redux, TEA, <ваш arch-name>. Но как бы мы не желали унести всю логику на бекенд, от Android приложения ожидается, что оно при отсутствии интернета не превращается в тыкву. А ещё, что открыв его через 10 минут ты останешься на том же экране с тем же введённым запросом и состоянием. Это значит, что часть стейта нужно хранить не в памяти приложения, a на диске. В Puerh предполагалось что часть стейта будет отправляться эффектом для сохранения на диск, но это раздрабливает стейт и заставляет вас его синхронизировать. Это сложно и этим редко когда есть время заморачиваться.

Ещё однин пример - сторонние SDK

Когда работаешь с картой, SDK распознавания чего-то или любым другим сложным UI, тебе опять приходится синхронизировать свою копию стейта с оригиналом. Это довольно накладно и заставляет писать много кода, который просто заботится о том, чтобы у тебя всегда была актуальное состояние для твоей бизнес-логики.

Отсутствие решения для коэффектов

Я ранее писал о коэффектах - ситуациях, когда, в отличие от эффектов, нужно что-то получить из “грязного внешнего мира”, нежели что-то туда записать. Например, текущую дату для блокировки кнопки распродажи вовремя или случайное число для запроса. В каком-то смысле это всё тоже кусочки “источника правды”. Но моя реализация предполагала что эта “правда” придёт в Msg либо вообще минует бизнес-логику и будет добавлена на стороне эффекта. Несмотря на то, что ситуация довольно редкая, тем не менее хотелось бы иметь для неё консистентное решение и не полагаться ad hoc. Тем более что она перекликается с проблемой выше.

Сложность state-based навигации

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

Декомпозиция

Когда я хочу выделить какую-то общую логику с мессаджами, эффектами и логикой вокруг них - я могу завести “фичу-донора”. Это будет фича, единственное предназначение которой - быть донором логики для других фичей. Из-за того, что я могу заглянуть внутрь стейта фичи-донора, потрогать её эффекты, я могу очень эффективно “допиливать напильником” фичи-доноры под свои нужды. И это очень круто работает, когда это необходимо. Когда же я хочу не вдаваться в подробности, чтобы фича-донор просто “делала своё дело” - это мешает, потому что мне всё равно надо заплатить цену необходимого бойлерплейта за это. Особенно эта цена становится ощутимой, когда пытаешься сделать всё приложение на едином стейте. Обычно экраны на одном уровне навигации между собой слабо связаны, но должны быть донорами для фичи верхнего уровня.

Общая проблема - в Android разработке не бывает единого источника правды

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

Иерархичная структура

В Puerh нам навязывается иерархичная структура приложения. Это не совсем плохо, ведь есть трейд-офф между жесткой иерархичностью и динамизмом плоской структуры. В иерархии всегда можно найти проблему, спускаясь по дереву сверху вниз. В такой структуре сложнее пропустить Msg или забыть обработать Effect. Однако это всё обрастает бюрократией в виде бесконечных wrapping-ов для передачи данных вверх-вниз, пока не добредёшь до настоящей бизнес-логики.

Плоская структура

В Puerh мы в команде долго обсуждали, стоит ли делать EffectHandler иерархичным, где верхнеуровневый EffectHandler определяет, как эффекты передать его “подчинённым” или же плоским, где каждому EffectHandler выдаётся все Effect, а он лишь отбирает нужные. С одной стороны, в иерархичном подходе мы гарантированно обрабатываем все Effects, благодаря exhaustive matching. С другой стороны, плоский подход позволяет писать меньше кода и даёт больше гибкости. Мы остановились на плоском подходе и мне кажется, что это решение позволило больше сэкономить кода, чем бы нанёс пользы exhaustive matching. Ведь проблемы, которые в теории решал иерархический подход, ни разу не всплыли в продакшне.

Тезис - Android приложения по своей природе более плоские

Хорошей практикой в мире Android приложений сейчас считается выделять компоненты, которые закрывают собой целиком юзер-стори. Как правило, такие компоненты выстраиваются в довольно плоскую структуру; единственными зависимостями между ними выступают навигационные переходы. Типичный граф экранов в приложении можно поделить на подграфы в 2-5 экранов каждый, которые являются “вещами в себе” и требуют минимум внешних зависимостей и внешней координации.

Коэффекты спешат на помощь

То, что в реальности не бывает единого источника правды, а только несколько разрозненных “тематик”, в каждой из которых есть единый источник, не должно отталкивать от использования UDF. Мне по-прежнему важно иметь логику, описанную чистыми функциями. Мне по-прежнему важно обрабатывать входящие Msg строго последовательно. Мне по-прежнему важно знать состояние интересующих нас источников правды. Нужно лишь придумать удобную абстракцию для описания этих источников независимо друг от друга. Для этого я предлагаю обратиться к коэффектам.

Кейс 1: Коэффекты как вычисление аргумента извне

Изначально коэффектами я заинтересовался как возможное решение досадной проблемы - как получить случайное число для использования в своей логике. Логика должна быть чистой, поэтому написать getRandom() посреди редьюсера нельзя. В гайде по TEA предлагается выдавать некий Effect.RandomInt и ловить ответное сообщение. Но это делает обработку изначального сообщения асинхронным и сильно усложняет структуру стейта и логики. В похожей архитектуре TCA в редьюсер передаётся некоторый Environment, который моментально делает редьюсер грязным и заставляет себя мокать в автотестах. В фреймворке re-frame же все просто - ты указываешь некий Coeffect.RandomInt и получаешь случайное число сразу же в аргументы для своей функции, описывающей логику.

Кейс 2: Стейт внешних SDK тоже можно получать как Coeffect

Если ты работаешь с SDK карт или авторизации, тебе не нужно постоянно синхронизировать состояние точки на карте со своим стейтом. Если описать Coeffect.GetPoint то я могу опрашивать карту и получать актуальное состояние в каждый обработчик сообщений, который этого потребует. Точно так же можно иметь Coeffect.UserState который достаёт текущего юзера для моего обработчика.

Кейс 3: Стейт своего юзер-стори тоже можно получать как Coeffect

Если стейт карт и текущего юзера можно получать из коэффектов, то чем “наш” стейт лучше/хуже? И как вообще определить, какой стейт “наш”, а какой уже “общий”? Поэтому я предлагаю любой стейт доставать из коэффектов, а записывать его эффектами. Это позволит также хранить куски стейта по-разному - в памяти или на диске и полностью скрыть это от нашей бизнес-логики.

Кейс 4: Стейт фичи-донора тоже можно получать как Coeffect

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

Что получается на практике?

elmrevisited2.jpg

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

Основные части

В целом архитектура будет состоять из пяти необходимых компонентов

  • MessageHandler (aka reducer) с чистой бизнес-логикой
  • CoeffectHandler с обработкой коэффектов
  • EffectHandler с обработкой эффектов
  • Selector с подпиской на стейт
  • Engine который будет связывать всё выше и следить за очередью сообщений

Получается такой классический паттерн Impureim Sandwich, где у меня сначала есть получение входных данных из грязного внешнего мира, чистая обработка нашей бизнес-логикой в середине и применение изменений к грязному внешнему миру в конце.

MessageHandler

Классическая сигнатура Msg, State -> State, Set<Effect> упрощается до Msg, Coeffects -> Set<Effect>. Теперь стейт “такой же как все” и теряется в толпе эффектов и коэффектов. Однако мне теперь нужна ещё одна функция, выполняющая “заказ” нужных коэффектов - заведём и её: Msg -> Coeffects. Таким образом, вся бизнес-логика укладывается в две функции.

class MsgHandler(
    val coeffect: (Message) -> Coeffects,
    val update: (Message, Coeffects) -> Set<Effect<Message>>
)

Как представлять Coeffects

При достаточной внимательности ты можешь хмыкнуть “что же это за Coeffects такие?”. И будешь прав. Реализация Coeffects это краеугольный камень, который важно сделать правильно. Я вдохновлялся re-frame и не нашёл способа лучше чем сделать коэффекты гетерогенной мапой. Ты наверняка мог встречать подобную структуру данных, если смотрел как устроен CoroutineContext. Если кратко, то это мапа, где хранятся разные типы данных. А какой тип у значения - определяется ключом. Я решил не пользоваться какой-то специальной реализацией, а просто сделать тайпалиас к обычной мапе и парочку утилитных функций.

interface Coeffect<out RESULT>
typealias CoeffectMap<T> = Map<Coeffect<T>, T>

Синтаксический сахар для корректности

Если присмотреться к двум функциям, которые определяют MessageHandler, то кажется, что мы теряем type-safety.

class MsgHandler(
    val coeffect: (Message) -> CoeffectMap<*>,
    val update: (Message, CoeffectMap<*>) -> Set<Effect<Message>>
)

И если для re-frame и Clojure это ок, то в Kotlin мы хотим статической типизации. Тут на помощь приходит то, что MsgHandler нужно определять сразу весь в одном месте и мы можем замазать динамическую типизацию сахарным сиропом. Например, если нам нужен только один коэффект, мы можем написать такой factory method:

// utils.kt
inline fun <reified CORESULT : Any> messageHandler(
    coeffect: Coeffect<CORESULT>,
    noinline update: (Message, CORESULT) -> Set<Effect<Message>>
): MsgHandler = MsgHandler(
    coeffect = { coeffectMapOf(coeffect) },
    update = { msg, coeffectMap -> update(msg, coeffectMap.safeGet<CORESULT>()) }
)

// logic.kt
val messageHandler = messageHandler(MyFeature.Coeffects.GetState) { msg: Msg, state: MyFeatureState ->
    // logic
}

Похожим образом мы можем опрелить factory method для любого числа коэффектов. И всё будет достаточно типобезопасно. Если, конечно, не забыть обработать все коэффекты.

EffectHandler

EffectHandler в Puerh был достаточно хорош. Я бы и не вносил в него изменения, но всё равно есть несколько идей, как его можно улучшить. Я хочу добавить в него suspend-функции, потому что мне кажется что корутины в Android разработке надолго - нам-то точно в обозримом будущем не завезут Loom. У меня существуют некоторые предубеждения насчёт использования Flow в интерфейсе хендлера, хочется использовать более низкоуровневое API. Практика показывает, что этот приём отбивает желание пихать логику в EffectHandler. Да и концептуально хендлеры нифига не холодные потоки, а ближе к акторам. Пока что кажется, что интерфейс EffectHandler может выглядеть как-то так.

abstract class EffectHandler(
    private val continuation: suspend fun (Effect) -> Unit
) {
    abstract suspend fun handleEffect(effects: Set<Effect<*>>)
}

Ещё одно отличие в данном случае - хендлер принимает все эффекты сразу вместо того чтобы обрабатывать их по отдельности. Это сделано для того чтобы удобнее было работать с эффектами изменения стейта и обрабатывать их синхронно и транзакционно.

EffectHandler как Middleware

По сравнению с Puerh хотелось улучшить эргономику эффектов. Если мы делаем всё приложение на эффектах, нам неизбежно захочется выделить низкоуровневые эффекты типа HttpGet или SaveState и хендлеры для них. Для поддержки этого я заменил listener: (Message) -> Unit коллбеком continuation. Теперь EffectHandler может делегировать исполнение другим хендлерам, и при желании собирать целые саги как в redux-saga, когда мы будем ожидать нового эффекта для продолжения работы. Ну а отправка сообщений будет осуществляться специальным эффектом Dispatch(Message). Возможно, вместо suspend функции здесь лучше бы смотрелся SendChannel<Effect>, но я не уверен, что хочу давать возможность хендлерам закрывать канал.

CoeffectHandler

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

interface CoeffectHandler {
    fun handleCoeffect(coeffects: CoeffectMap<*>): CoeffectMap<*>
}

Контракт CoeffectHandler в таком случае гласит, что он пробегается по мапе коэффектов, находит среди ключей интересующие его, исполняет их и подкладывает результат в мапу.

Маппинг

Одну штуку я подсмотрел в re-frame. Там в процессе обработки сообщение пропускается через стек интерцепторов, которые по своей сути похожи на интерцепторы в http-библиотеках. Точно так же наша бизнес-логика может оперировать высокоуровневыми коэффектами типа LoadState(id) или GetDate, которые можно потом пропустить по стеку мапперов и “рассахарить” в низкоуровневые DiskState или GetDateTimeFormatted. Это можно сделать частью контракта CoeffectHandler. Теперь можно получать CoeffectMap перед выполнением коэффектов и после. Например, чтобы переложить результат GetDateTimeFormatted в ячейку коэффекта GetDate, который ожидает MessageHandler

interface CoeffectHandler {
    fun before(coeffects: CoeffectMap<*>): CoeffectMap<*>
    fun after(coeffects: CoeffectMap<*>): CoeffectMap<*>
}

Selector

Так как источников правды несколько, нужна будет сущность, которая будет подписываться на коэффекты, собирать из нескольких источников стейт и отдавать его в виде ViewState в Compose или другой UI. Интерфейс его должен выглядеть так:

interface Selector{
    fun subscriptions(): CoeffectMap
    fun update(coeffects: CoeffectMap)
}

Если присмотреться, то этот интерфейс чем-то дуален MessageHandler. Не знаю, есть ли выкладки теорката под этот случай.

Engine

Engine должен связывать все компоненты воедино.

interface Engine {
    fun dispatch(message: Message)

    fun /*un*/registerEffectHandler(effectHandler: EffectHandler)
    fun /*un*/registerCoeffectHandler(coeffectHandler: CoeffectHandler)
    fun /*un*/registerMessageHandler(messageHandler: MessageHandler)
    fun /*un*/registerSelector(selector: Selector)

    fun dispose()
}

DI и навигация

Зачем мне динамическая регистрация компонентов? Меня очень вдохновил концепт CompositionLocal в compose. Идея о том что в зависимости позиции в дереве одни и те же мессаджи могут обрабатываться по-разному сулит классное переиспользование UI-компонентов. Кроме того мне очень нравится Decompose и я надеюсь что с ним можно будет интегрировать своё решение, чтобы не переизобретать навигацию. В моих планах каждый такой decompose-компонент будет просто регистрировать/дерегистрировать хендлеры в результате навигации и подписывать Selector на изменения стейта.

Request For Comments

Эта статья лишь компиляция моих размышлений на тему интеграции коэффектов в TEA. Приблизительный план, как это должно выглядеть. Мне было бы очень интересно узнать, где этот план хромает и где можно его улучшить. Приходи в канал, находи этот пост и присоединяйся к обсуждению. Особенно меня интересуют критика новых EffectHandler и возможные подводные камни реализации Engine.

Подпишись и обсуждай в Telegram