Сергей Опивалов, Senior Software Engineer в Gradle, на весеннем Мобиусе сделал доклад об управлении сложностью состояния. Я поговорил с Сергеем и обсудил возможности предлагаемого им решения.
Начнем с основ. Что такое UDF?
UDF — это тренд, который наблюдается в Android-архитектурах (по примерным прикидкам) последние 5-7 последних лет. Для читателей, незнакомых с этим подходом, позволю себе кратко напомнить.
UDF — Unidirectional Data Flow. Это принцип, когда данные в архитектуре “текут” только в одном направлении.
Основные достоинства это:
- явная декларация состояния презентационного слоя
- единая точка входа в презентационный слой
- простота Unit тестирования
- удобный debug
Для сравнения, в классическом MVP у нас двунаправленный поток данных, так как View вызывает методы на презентере, а презентер вызывает методы абстракции над View. Состояние экрана/фичи “размазано” между методами абстракции над View — точками входа в UI являются методы абстракции.
В классическом MVVM уже появляется однонаправленный поток данных, так как ViewModel не знает ничего о View, а View подписывается на публичные шины(Observable/Flow) данных ViewModel. Эти шины являются точками входа в UI, и между их состояниями “размазывается” состояние экрана/фичи.
UDF это принцип, у него есть некоторое количество реализаций, самые популярные из которых, по моему мнению, это Redux, MVI и TEA. Всех их объединяет:
- Декларация состояния фичи/экрана как единого immutable класса. Этот компонент позволяет наблюдать, какие состояния может принимать фича. Единое состояние позволяет UI иметь только одну точку входа — подписку на шину этого состояния
- Исчерпывающее перечисление событий, которые могут происходить в фиче.
- Логика фичи, описывается в функции, принимающей новое событие, текущее состояние, и возвращающая новое состояние. Скажем,
fun update(event : Event, state : State) : State
. Важным является то, что эта функция — чистая (pure). Это значит, что функция может выполнять только операции над переданными аргументами, и не может выполнять любой другой работы. Таким образом, Unit-тестирование чистой функции — очень тривиальная вещь, для которой не нужны даже моки.
Где в UDF возникает “сложность состояния”?
Логика, использующая UDF принцип, хорошо визуализируется и ложится на модель конечных автоматов:
- Набор событий в фиче — это набор переходов конечного автомата.
- Количество состояний фичи — количество состояний конечного автомата.
- Функция `
update
` — диаграмма переходов.
В свою очередь, в теории конечных автоматов есть гипотезы, которые делают возможным сравнение двух конечных автоматов по сложности, на основе структурных особенностей автомата, таких как количество возможных состояний или количество возможных переходов.
Таким образом, в своем докладе я предлагаю объявить сложность работы с фичей равной количеству возможных событий в фиче + количество возможных состояний фичи.
И как сложностью надо управлять?
Сегодня все реже мобильные приложения остаются “тонкими” клиентам для бэкендов. Современное мобильное приложение может содержать сотни модулей логики.
Совокупность размера плюс быстро меняющихся бизнес требований способствуют накоплению технического долга.
Зависимость размера команда разработки к ее эффективности тоже далеко не линейная — то есть нельзя нанять 100 разработчиков и получить х100 в скорости разработки.
В таких условиях я считаю критически важно, чтобы кодовая база предоставляла эффективные инструменты для управления сложностью фич. Можно размышлять об этом, как о Developer Experience: у нас есть богатая IDE для эффективной механической работы с кодовой базой, и теперь мы хотим инструменты для эффективной работы с кодом с когнитивной точки зрения.
Какие сложности есть в “управлении сложностью”? :)
Я бы сказал, что сложно соблюсти баланс между преимуществами, которые вы получаете, “управляя сложностью”, и ценой, которую за это платите. Хотя это справедливо почти для всего, о чем бы мы не говорили :)
Другой интересный момент — при попытках управления сложностью вы едва ли можете надеяться на привыкание. В моем докладе я привожу пример исследования, которое показывает, что сам по себе эффект привыкания мозга очень хрупок и может быть “сброшен” минимальными изменениями в алгоритме.
Какие решения уже были для управления сложностью?
Мне не известно о существовании решений, сфокусированных именно на управлении сложностью. Из архитектурных решений, которые позволяли “легко” компонентам собираться в композиции, стоило бы вспомнить RIBs от Uber, и все его производные/переосмысления. Я работал на проекте, где оригинальный RIBs был основной архитектурой, и работал на проекте, который переезжал с одной из ее производных (MVI Core) на собственное решение, о котором я говорю в своем докладе.
Оригинальный RIBs был призван решить проблемы в Uber, которые едва ли появятся в абсолютном большинстве проектов, и это отразилось на его дизайне. При попытках максимально строго типизировать описание компонентов, появилась сложная параметризация и вложенные дженерики, которые делали порог входа в архитектуру довольно высоким.
Таким образом, количество связывания (bootstrap), которое было необходимо сделать для композиции компонентов, вызывало недоумение. В этом случае цена, которую мы платили за “упрощение восприятия” компонентов, была выше, чем потенциальные плюсы от этого.
Что интересно, на мой взгляд, все переосмысления RIBs имеют схожие недостатки. MVI Core оставляет у меня такие же впечатления. Даже в Decompose, при моем глубоком уважении к автору, остаются эти черты избыточной параметризации.
Хотел бы еще раз явно проговорить — я осознаю все плюсы строго типизированных компонентов и типизации в целом. Но в экстремальных реализациях приходится делать слишком много “церемоний”, чтобы решать бизнес задачи.
В чем же ваше предложение и решение?
Я убежден, что не предлагаю ничего сверхнового, это всего лишь переосмысление использования базовых принципов и подходов, таких как композиция и принцип единой ответственности.
Следование практически любой архитектуре позволяет вашей кодовой базе так или иначе дробиться на компоненты. С этим шагом обычно нет проблем. Но вспоминаем мы об этом, как правило, когда пишем новый компонент, или когда хотим переиспользовать какую-то общую часть между двумя компонентами. На этом этапе мне кажется разумным дробить сложные фичи на компоненты, даже если они не будут переиспользованы в обозримом будущем.
Это приносит больше плюсов, чем может показаться на первый взгляд:
- Натуральная подготовка к модуляризации. Это не просто значит “положить код в разные директории”. Это значит иметь хорошую изоляцию тестов для вашего компонента, хорошую изоляцию ресурсов, возможность строго управлять зависимостями компонента. Я намеренно не перечисляю преимуществ, которые вы получите при сборке качественно модуляризированной кодовой базы.
- С маленькими простыми компонентами проще работать. Теорию о том, почему это так, я рассказываю в докладе. Вкратце — есть исследования о прямой зависимости между структурными характеристиками конечного автомата (количество состояний, количество переходов, наличие агрегирующего состояния) и тем, насколько легко он обрабатывается мозгом
Вторая вещь, которую я предлагаю — не ограничиваться визуальными признаками при выделении компонента. Ваш компонент вообще может быть просто логикой, без UI. Это может быть очень удобно, если у вас есть какая-то фоновая работа, результат которой можно представить как поток событий.
Третья идея, без которой первые две имеют мало смысла — научиться удобно, без “церемоний”, объединять компоненты воедино. Изолированный результат работы каждого компонента не слишком полезен с точки зрения бизнеса. В докладе я как раз показываю, как можно декларативно композировать компоненты (намеренный каламбур).
Эти три принципа настолько базовые, что могут быть спроецированы на разные архитектуры, поэтому фокусироваться на конкретных деталях реализации я не вижу большого смысла.
В комментариях к докладу пишут, что на самом деле это еще больше усложняет ситуацию?
Я так не считаю. Если говорить о пороге входа в какое-то решение — он всегда будет. Не бывает бесплатных решений. Вопрос в том, готова ли команда инвестировать в такой подход прямо сейчас.
В моем решении я старался быть “more pragmatic than dogmatic”, там довольно много упрощений: связывание компонентов делается в adhoc манере, статическая структура компонентов и т.д. Несмотря на это, используя этот подход на нескольких проектах, никогда способ композирования/декомпозирования компонентов (намеренный каламбур) не был “бутылочным горлышком”.
Опять же, в экстремально сложных случаях техническая архитектура не может дать приемлемый уровень простоты. В таких случая можно “подняться” на продуктовый уровень и договориться о разделении сложного экрана на несколько, или даже на флоу экранов.
Вернемся к общим архитектурам — как будут развиваться Android-архитектуры в будущем? Куда смотреть разработчикам?
Я бы сказал, что продолжится прагматичное движение в сторону функциональных подходов в архитектурах. В качестве примера: построить архитектуру на Arrow.kt — функционально, но не прагматично, построить архитектуру на MVVM — прагматично, но не функционально. Идея в том, что стоит перенимать подходы, которые приносят бенефиты, плюс идиоматично ложатся на экосистему Android-разработки (основной язык, модель программирования UI и т.д)
Очевидно, что модель программирования, которую принес Jetpack Compose, является сильнейшим стимулом для развития архитектур. В то время как абсолютное большинство сценариев для Compose это построение UI, уже сегодня есть решения, использующие Compose в бизнес логике.
Помимо этого, инвестиции JetBrains в кроссплатформенное использование Kotlin и Compose уже сегодня дают возможность иметь одну кодовую базу для Android + iOS приложений. Более того, новый лэйаут проекта в KMP делает постепенный шаринг кода тоже очень удобным — не нужно все переписывать с нуля. Вероятно, что мы увидим подходы в архитектурах, сфокусированные на кроссплатформенных сценариях использования.
Спасибо, давайте продолжим в следующем нашем разговоре!