Про проект

Всем привет! Меня зовут Даниил Климчук. Год назад я пришел в vivid.money третьим Android-разработчиком. Несмотря на это, в проекте практически не было кода, а первые фичи только начинали разрабатываться. Нам нужно было запустить новое банковское приложение в европе, где придется конкурировать с такими компаниями, как Revolut. Уже тогда было понятно, что команда очень быстро значительно вырастет. Конечно, стоило сразу задуматься о том, как будет развиваться архитектура проекта. Через год, когда проект запустится, на это не останется времени, а оправданий вносить значительные изменения просто не будет. Одним из ключевых решений на начальном этапе стал выбор архитектуры слоя представления. В этой статье я поделюсь тем, как мы его принимали.

Про выбор

Возможные подходы для нас явно разделились на две группы: проверенные временем и надежные MVP, MVVM и MVC, а также новые архитектуры, использующие Unidirectional Data Flow: Redux, MVI, Elm (aka MVU) и т.д.. Не хотелось сравнивать каждые в отдельности, а для упрощения определиться в какую сторону смотреть в первую очередь. Поэтому быстро набросали список требований.

Хотелось чтобы:

  • Код был поддерживаемым
    Лучше помнить про то, что с прошествием времени код все еще нужно будет понимать и менять.

  • Новые люди могли быстро влиться. 

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

  • Уменьшить boilerplate
    Печатать одно и то же на каждом экране утомительно и в добавок может привести к ошибкам.

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

  • Было проще покрыть тестами
    Мы сразу думали о том, что будем покрывать всю логику unit-тестами и хотелось по возможности облегчить себе работу.

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

За старое доброе

  • Нет boilerplate

    Достаточно реализации базовых классов MVP, после этого на каждый экран нужно создавать только Presenter/ViewModel/Controller. В отличие от UDF архитектур, для которых даже каждое событие требует своего класса.

  • Это всем известные архитектуры

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

  • Проще code review

    При изменении экрана меняется только Presenter и View. В UDF архитектурах логика из Presenter разбивается на несколько классов, каждый из которых приходится просматривать в отдельности.

  • Нет проблемы SingleLiveEvent

    Проблема описана в issue для android architecture components. В MVP в принципе отсутствует, а в MVVM с LiveData можно использовать собственно сам класс SingleLiveEvent. Для UDF архитектур нет устоявшегося подхода с решением этой проблемы, для нее придется придумывать что-то свое.

  • Простота в понимании

Если рассматривать саму архитектуру, то MVP и MVVM определяют только наличие двух классов View и Presenter (или соответственно ViewModel). В UDF архитектурах структура более сложная и у их составляющих более узкая зона ответственности.

За новое хайповое

  • Собственно сам UDF

    В таких архитектурах есть только один фиксированный путь, по которому данные передаются в приложении. В отличие например MVP, где в Presenter со временем может накапливаться огромное количество спагетти-кода, который со временем становится сложно понимать.

  • Single immutable state

    Состояние экрана выделяет в отдельный класс, который называется State. Если нет такого явного ограничения, состояние может описываться множеством флагов иногда частично находиться где-то во View или дублируется в нескольких местах. Такой подход позволяет иметь single source of truth о текущем состоянии экрана. Важным достоинством этого подхода является возможность в каждый момент времени обратиться с State и понять, например, идет ли сейчас загрузка данных.

  • Обработка смены конфигурации и восстановления процесса

    Намного проще, поскольку есть single state. Достаточно просто отрисовать его заново на экране, чтобы полностью восстановить предыдущее состояние. При обработке смерти процесса есть необходимость сохранить только единственный класс. Справедливости ради, например, использование LiveDatа позволит обработать смену конфигурации. Однако это дополнительная зависимость, которую придется тянуть в проект. Также, стандартный механизм обработки смерти процесса для ViewModel на основе SavedStateHandle намного сложнее в реализации и усложняет логику во ViewModel.

  • Separation of Concerns

    Логика слоя представления разделена на несколько классов, каждый из которых выполняет свою функцию. В отличие, например, от MVP в котором все логика находится в Presenter. Получается, что он отвечает за обработку изменения состояния, загрузку данных, изменение модели итд. Явного разделения на зоны ответственности нет и часто она вся находится в одном классе.

  • Thread safety

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

  • Проблема bloated presenter

    Со временем Presenter или ViewModel может вырасти до нескольких тысяч строк кода. В этот момент придется думать о том как разделять логику, что может вполне вылиться в решение, менее гибкое, чем изначально заложенная UDF архитектура.

  • Горизонтальное масштабирование

    В некоторых UDF архитектурах есть возможность составлять экран из нескольких частей вместо одного большого Presenter. Например в MviCore есть разделение на Feature, а в ELM - компоненты. Каждая из них написана в одном стиле и вместе они составляют логику экрана. Вдобавок эти части можно переиспользовать, в отличие MVP и MVVM, где придется придумывать свое нестандартное решения этой проблемы.

  • Простота тестирования

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

  • Заставляет планировать логику экрана заранее

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

  • Возможность реализовать Time Travel Debug

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

  • Jetpack Compose

    UDF архитектуры лучше подходят для работы с Jetpack Compose, для которого недавно уже вышла alpha версия. UDF архитектуры имеют единый метод для отрисовки состояния, которое сразу можно преобразовать в иерархию View.

  • Хайп

    Больше шансов, что разработчиков заинтересует вакансия с современной архитектурой, которая даст возможность развиваться или попробовать что-то новое.

Как принимали решение

У UDF архитектур очень много преимуществ, которыми не хотелось жертвовать в угоду простоте. Помимо хайпа и интересных фичей подкупала возможность борьбы с запутанной логикой в Presenter. Не хотелось через несколько лет возвращаться к той же проблеме или оказаться с кучей неподдерживаемого кода. В итоге решили остановиться на UDF.

MVI vs ELM

Многие реализации UDF архитектур сильно похожи, поэтому выделили основное различие: в MVI логика экрана разделена между Reducer и Intent, а в ELM полностью находится в Update.

Например, при нажатии на кнопку загрузки, в MVI Intent знает про то, что нужно получить данные, а reducer отвечает за то, чтобы показать состояние загрузки. В Elm за все это отвечает один класс Update, и только само получение данных происходит в рамках Side Effect.

Почему выбрали ELM

Решили руководствоваться уже существующими недостатками UDF архитектур, в которых были различия. Победил однозначно Elm:

  • Покрытие тестами

    Elm позволяет покрыть тестами всю логику экрана, написав тесты всего на один класс. При этом этот класс не содержит асинхронного кода и писать тесты значительно легче. Более сложные сценарии будут покрываться ui тестами, а работа по написанию unit тестов значительно сократится.

  • Понимание новыми членами команды

    Человеку, который только что пришел работать Elm проще объяснить: "вот здесь логика, а вот здесь асинхронные операции". В отличии от MVI, в котором приходится представлять как все работает в целом.

  • Code review

    Update из Elm можно рассматривать отдельно, поскольку в нем содержится вся логика. При code review кода, написанного на mvi, приходится больше переключаться между Intent и Reducer, потому что логика разделена между ними.

На текущий момент уже есть несколько open-source реализаций Elm архитектуры, например Teapot, Puerh и Elmo, однако мы решили сделать свою.

Как решить проблемы UDF

Остались нерешенными еще два пункта, по ним пришлось искать решения.

Схематично нашу реализацию с итоговым неймингом можно представить вот так:

Boilerplate

Головной болью таких подходов является создание большого числа классов на этапе создания экрана. Например, в нашей реализации это Actor, Reducer, State, Event, Effect, Command и StoreFactory. Простой экран с одним запросом превращается в долгое печатание давно заученного наизусть кода. Для решения этой проблемы был реализован плагин для Android Studio. Весь повторяющийся код можно сгенерировать и добавить новый экран становится не сложнее чем в привычном MVP.

SingleLiveEvent

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

Восстановление состояния

Эту проблему можно разделить на две части: восстановление состояния при смене конфигурации и при восстановлении процесса. Для решения первой проблемы хватает хранения Elm компонента внутри Dagger Scope. Новый инстанс фрагмента подключится к компоненту и при инициализации получит последнее состояние. Чуть более сложной получилась обработка смерти процесса. По скольку есть выделенное в отдельный класс состояние, достаточно сохранить его в onSaveInstanceState.

А что дальше

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