Badoo разрабатывает несколько приложений, и каждое из них — это отдельный продукт со своими особенностями, менеджментом, продуктовыми и инженерными командами. Но все мы работаем вместе, в одном офисе и решаем похожие проблемы.
Развитие каждого проекта происходило по-своему. Влияние на кодовую базу оказывали не только разные временные рамки и продуктовые решения, но и видение разработчиков. В конце концов мы заметили, что проекты имеют одинаковую функциональность, которая при этом кардинально отличается в реализации.
Тогда мы решили прийти к структуре, которая дала бы нам возможность переиспользовать фичи между приложениями. Теперь вместо разработки функциональности в отдельных проектах мы создаём общие компоненты, которые интегрируются во все продукты. Если вам интересно, как мы к этому пришли, добро пожаловать под кат.
Но для начала давайте остановимся на проблемах, решение которых привело к созданию общих компонентов. Их было несколько:
- копипаста между приложениями;
- процессы, вставляющие палки в колёса;
- разная архитектура проектов.
Данная статья является текстовым вариантом моего доклада с AppsConf 2019, который можно посмотреть здесь.
Проблема: копипаста
Некоторое время назад, когда деревья были пушистее, трава — зеленее, а я был на год младше, у нас частенько происходила следующая ситуация.
Есть разработчик, назовём его Лёша. Он делает крутой модуль для своей задачи, рассказывает о нём коллегам и кладёт его в репозиторий к своему приложению, где его использует.
Проблема заключается в том, что все наши приложения лежат в разных репозиториях.
Разработчик Андрей в это время как раз работает над другим приложением в другом репозитории. Он хочет использовать этот модуль в своей задаче, которая подозрительно похожа на ту, которой занимался Лёша. Но возникает проблема: процесс переиспользования кода совершенно не отлажен.
В этой ситуации Андрей либо напишет своё решение (что происходит в 80% случаев), либо скопипастит решение Лёши и всё в нём поменяет, чтобы оно подходило под его приложение, задачу или настроение.
После этого Лёша может обновить свой модуль, добавив в код изменения под свою задачу. Он не знает о другой версии и обновит только свой репозиторий.
Эта ситуация приносит несколько проблем.
Во-первых, у нас есть несколько приложений, каждое со своей историей разработки. При работе над каждым приложением продуктовая команда часто создавала решения, которые сложно привести к единой структуре.
Во-вторых, проектами занимаются отдельные команды, которые слабо коммуницируют друг с другом и, следовательно, редко сообщают друг другу об обновлениях/переиспользованиях того или иного модуля.
В-третьих, архитектура приложений сильно отличается: от MVP до MVI, от god activity до single activity.
Ну и «гвоздь программы»: приложения находятся в разных репозиториях, каждый со своими процессами.
В начале борьбы с этими проблемами мы поставили конечную цель: переиспользовать наши наработки (и логику, и UI) между всеми приложениями.
Решения: налаживаем процессы
Из вышеописанных проблем к процессам относятся две:
- Два репозитория, которые разделяли проекты непроницаемой стеной.
- Отдельные команды без налаженной коммуникации и разные требования продуктовых команд приложений.
Начнём с первой: мы имеем дело с двумя репозиториями с одинаковой версией модуля. Теоретически мы могли бы использовать git-subtree или похожие решения и вынести общие модули проектов в отдельные репозитории.
Проблема возникает при модификации. В отличие от open-source-проектов, которые имеют стабильный API и распространяются через внешние источники, во внутренних компонентах очень часто происходят изменения, которые всё ломают. При использовании subtree каждая такая миграция становится болью.
Мои коллеги из iOS-команды имеют похожий опыт, и он оказался не очень удачным, о чём Антон Щукин рассказывал на конференции Mobius в прошлом году.
Изучив и осмыслив их опыт, мы перешли на монорепозиторий. Все Android-приложения теперь лежат в одном месте, что даёт нам определённые бенефиты:
- можно спокойно переиспользовать код, используя модули Gradle;
- нам удалось соединить тулчейн на CI, используя одну инфраструктуру для билдов и тестов;
- эти изменения устранили физический и некий ментальный барьер между командами, так как теперь мы можем свободно пользоваться наработками и решениями друг друга.
Конечно, это решение имеет и минусы. У нас огромный проект, который временами неподвластен IDE и Gradle. Проблему могли бы частично решить модули Load/Unload в Android Studio, но их сложно задействовать, если необходимо работать одновременно над всеми приложениями и часто переключаться.
Вторая проблема — взаимодействие между командами — состояла из нескольких частей:
- отдельные команды без налаженной коммуникации;
- невнятное распределение ответственности за общие модули;
- разные требования продуктовых команд.
Чтобы её решить, мы сформировали команды, которые занимаются реализацией определённой функциональности в каждом приложении: например, чатом или регистрацией. Помимо разработки, они также отвечают за интеграцию этих компонентов в приложение.
Продуктовые команды уже имеют на руках существующие компоненты, улучшая и кастомизируя их под нужды определённого проекта.
Таким образом, теперь создание переиспользуемого компонента является частью процесса для всей компании, от этапа появления идеи до запуска на продакшен.
Решения: упорядочиваем архитектуру
Следующим нашим шагом в сторону переиспользования стало упорядочивание архитектуры. Для чего мы это сделали?
Наша кодовая база несёт в себе историческое наследие нескольких лет разработки. Вместе со временем и людьми менялись и подходы. Так мы оказались в ситуации с целым зоопарком архитектур, что вылилось в следующие проблемы:
- Интеграция общих модулей происходила чуть ли не медленнее написания новых. Помимо особенностей функционала, приходилось мириться со строением и компонента, и приложения.
- Разработчики, которым приходилось очень часто переходить между приложениями, тратили много времени на освоение новых подходов.
- Зачастую писались обёртки от одного подхода к другому, которые составляли половину кода в интеграции модуля.
В конце концов мы остановились на подходе MVI, который структурировали в нашей библиотеке MVICore (GitHub). Нам была особенно интересна одна из его особенностей — атомарные обновления состояния, которые всегда гарантируют валидность. Мы пошли несколько дальше и объединили состояния логического и презентационного слоёв, уменьшив фрагментацию. Таким образом, мы приходим к структуре, где за логику отвечает единственная сущность, а view только отображает модель, созданную из состояния.
Разделение обязанностей происходит через трансформацию моделей между уровнями. Благодаря этому мы получаем бонус в виде реюзабельности. Мы соединяем элементы снаружи, то есть каждый из них не подозревает, что другой существует, — они просто отдают некие модели и реагируют на то, что им приходит. Это позволяет вытащить компоненты и использовать их в другом месте, написав адаптеры под их модели.
Давайте на примере простого экрана посмотрим, как это выглядит в реальности.
Мы используем базовые интерфейсы RxJava для обозначения типов, с которыми элемент работает. Input обозначается интерфейсом Consumer<T>, output — ObservableSource<T>.
// input = Consumer<ViewModel>
// output = ObservableSource<Event>
class View(
val events: PublishRelay<Event>
): ObservableSource<Event> by events, Consumer<ViewModel> {
val button: Button
val textView: TextView
init {
button.setOnClickListener {
events.accept(Event.ButtonClick)
}
}
override fun accept(model: ViewModel) {
textView.text = model.text
}
}
Используя эти интерфейсы, мы можем выразить View как Consumer<ViewModel> и ObservableSource<Event>. Заметьте, что ViewModel только содержит состояние экрана и имеет мало общего с MVVM. Получив модель, мы можем показать данные из неё, а при нажатии на кнопку отправляем ивент, который передаётся наружу.
// input = Consumer<Wish>
// output = ObservableSource<State>
class Feature: ReducerFeature<Wish, State>(
initialState = State(counter = 0),
reducer = ReducerImpl()
) {
class ReducerImpl: Reducer<Wish, State> {
override fun invoke(state: State, wish: Wish) = when (wish) {
is Increment -> state.copy(counter = state.counter + 1)
}
}
}
Feature уже реализует ObservableSource и Consumer за нас; нам же нужно передать туда изначальное состояние (counter, равный 0) и указать, как это состояние менять.
После передачи Wish вызывается Reducer, который на основе последнего состояния создаёт новое. Помимо Reducer, логика может быть описана другими компонентами. Больше про них можно узнать здесь.
После создания двух элементов нам остаётся их связать.
val eventToWish: (Event) -> Wish = {
when (it) {
is ButtonClick -> Increment
}
}
val stateToModel: (State) -> ViewModel = {
ViewModel(text = state.counter.toString())
}
Binder().apply {
bind(view to feature using eventToWish)
bind(feature to view using stateToModel)
}
Сначала мы указываем, каким образом трансформируем элемент одного типа в другой. Так, ButtonClick становится Increment, а поле counter из State переходит в текст.
Теперь мы можем создать каждую из цепочек с нужной трансформацией. Для этого мы используем Binder. Он позволяет создавать связи между ObservableSource и Consumer, соблюдая жизненный цикл. И всё это — с приятным синтаксисом. Такой вид связи приводит нас к гибкой системе, которая позволяет вытаскивать и использовать элементы по отдельности.
MVICore-элементы достаточно неплохо работают с нашим «зоопарком» архитектур после написания обёрток из ObservableSource и Consumer. Например, мы можем обернуть методы Use Case из Clean Architecture в Wish/State и использовать в цепочке вместо Feature.
Компонент
Наконец, мы переходим к компонентам. Что же они из себя представляют?
Рассмотрим экран в приложении и разделим его на логические части.
Можно выделить:
- тулбар с логотипом и кнопками наверху;
- карточку с профайлом и логотипом;
- секцию Instagram.
Каждая из этих частей является тем самым компонентом, который может быть переиспользован в совершенно другом контексте. Так, секция Instagram может стать частью редактирования профайла в другом приложении.
В общем же случае компонент — это несколько View, элементов логики и вложенных компонентов внутри, объединённых общей функциональностью. И сразу возникает вопрос: как их собрать в поддерживаемую структуру?
Первая же проблема, с которой мы столкнулись, заключается в том, что MVICore помогает создавать и связывать элементы, но не предлагает общей структуры. При переиспользовании элементов из общего модуля не совсем очевидно, где собирать эти кусочки воедино: внутри общей части или на стороне приложения?
В общем случае мы определённо не хотим давать приложению разбросанные кусочки. В идеале мы стремимся к некой структуре, которая позволит получить зависимости и соберёт компонент в целое с нужным жизненным циклом.
Изначально мы делили компоненты по экранам. Соединение элементов происходило рядом с созданием DI-контейнеров для активити или фрагмента. Эти контейнеры уже знают про все зависимости, имеют доступ к View и жизненному циклу.
object SomeScopedComponent : ScopedComponent<SomeComponent>() {
override fun create(): SomeComponent {
return DaggerSomeComponent.builder()
.build()
}
override fun SomeComponent.subscribe(): Array<Disposable> =
arrayOf(
Binder().apply {
bind(feature().news to otherFeature())
bind(feature() to view())
}
)
}
Проблемы начались сразу в двух местах:
- DI начал работать с логикой, что привело к описанию всего компонента в одном классе.
- Так как контейнер привязан к Activity или Fragment и описывает как минимум целый экран, на такой экран/контейнер приходится очень много элементов, что выливается в огромное количество кода на соединение всех зависимостей этого экрана.
Решая проблемы по порядку, мы начали с вынесения логики в отдельный компонент. Так, мы можем собрать все Feature внутри этого компонента и общаться с View через input и output. С точки зрения интерфейса это выглядит как обычный элемент MVICore, но при этом он создан из нескольких других.
Решив эту проблему, мы разделили ответственность за соединение элементов. Но мы всё ещё делили компоненты по экранам, что явно не было нам на руку, выливаясь в огромное количество зависимостей в одном месте.
@Scope
internal class ComponentImpl @Inject constructor(
private val params: ScreenParams,
news: NewsRelay,
@OnDisposeAction onDisposeAction: () -> Unit,
globalFeature: GlobalFeature,
conversationControlFeature: ConversationControlFeature,
messageSyncFeature: MessageSyncFeature,
conversationInfoFeature: ConversationInfoFeature,
conversationPromoFeature: ConversationPromoFeature,
messagesFeature: MessagesFeature,
messageActionFeature: MessageActionFeature,
initialScreenFeature: InitialScreenFeature,
initialScreenExplanationFeature: InitialScreenExplanationFeature?,
errorFeature: ErrorFeature,
conversationInputFeature: ConversationInputFeature,
sendRegularFeature: SendRegularFeature,
sendContactForCreditsFeature: SendContactForCreditsFeature,
screenEventTrackingFeature: ScreenEventTrackingFeature,
messageReadFeature: MessageReadFeature?,
messageTimeFeature: MessageTimeFeature?,
photoGalleryFeature: PhotoGalleryFeature?,
onlineStatusFeature: OnlineStatusFeature?,
favouritesFeature: FavouritesFeature?,
isTypingFeature: IsTypingFeature?,
giftStoreFeature: GiftStoreFeature?,
messageSelectionFeature: MessageSelectionFeature?,
reportingFeature: ReportingFeature?,
takePhotoFeature: TakePhotoFeature?,
giphyFeature: GiphyFeature,
goodOpenersFeature: GoodOpenersFeature?,
matchExpirationFeature: MatchExpirationFeature,
private val pushIntegration: PushIntegration
) : AbstractMviComponent<UiEvent, States>(
Правильное решение в такой ситуации — разбить компонент. Как мы увидели выше, каждый экран состоит из множества логических элементов, которые мы можем разделить на независимые части.
Немного поразмыслив, мы пришли к древовидной структуре и, наивно построив её из уже имеющихся составляющих, получили такую схему:
Разумеется, поддерживать синхронизацию двух деревьев (из View и из логики) практически нереально. Однако если компонент ответственен за показ своего View, мы можем упростить эту схему. Изучив уже созданные решения, мы переосмыслили наш подход, опираясь на RIBs от Uber.
Идеи, лежащие в основе этого подхода, очень похожи на основы MVICore. RIB является неким «чёрным ящиком», общение с которым происходит через строго определённый интерфейс из зависимостей (а именно input и output). Несмотря на кажущуюся сложность поддержки такого интерфейса в быстро итерирующемся продукте, мы получаем большие возможности по переиспользованию кода.
Таким образом, в сравнении с предыдущими итерациями мы получаем:
- инкапсулированную логику внутри компонента;
- поддержку вложенности, что даёт возможность разделить экраны на части;
- взаимодействие с другими компонентами через строгий интерфейс из input/output c поддержкой MVICore;
- compile-time safe соединение зависимостей компонентов (опираясь на Dagger в качестве DI).
Конечно же, это далеко не всё. Репозиторий на GitHub содержит более подробное и актуальное описание.
И вот у нас есть идеальный мир. В нём есть компоненты, из которых мы можем построить полностью переиспользуемое дерево.
Но мы живём в неидеальном мире.
Добро пожаловать в реальность!
В неидеальном мире есть куча вещей, с которыми мы должны мириться. Нас тревожат следующие:
- разный функционал: несмотря на всю унификацию, мы всё ещё имеем дело с отдельными продуктами с разными требованиями;
- поддержка: как же без новой функциональности под A/B-тестами?
- легаси (всё, что было написано до нашей новой архитектуры).
Сложность решений увеличивается экспоненциально, так как каждое приложение добавляет что-то своё в общие компоненты.
Рассмотрим процесс регистрации как пример общего компонента, интегрируемого в приложения. В общем случае регистрация — это цепочка экранов с действиями, влияющими на весь флоу. Каждое приложение имеет разные экраны и собственный UI. Конечная цель — сделать гибкий переиспользуемый компонент, который также поможет нам решить проблемы из списка выше.
Разные требования
Каждое приложение имеет свои уникальные вариации регистрации как со стороны логики, так и со стороны UI. Поэтому обобщать функциональность в компоненте мы начинаем с минимума: с загрузки данных и роутинга всего флоу.
Такой контейнер передаёт в приложение данные с сервера, который преобразуется в готовый экран с логикой. Единственное требование: экраны, переданные в такой контейнер, должны удовлетворять зависимости для взаимодействия с логикой всего флоу.
Проделав этот трюк с парой приложений, мы заметили, что логика экранов почти не отличается. В идеальном мире мы бы создавали общую логику, кастомизируя View. Вопрос в том, как их кастомизировать.
Как можно вспомнить из описания MVICore, и View, и Feature имеют в основе интерфейс из ObservableSource и Consumer. Используя их как абстракцию, мы можем подменить имплементацию, не меняя основных частей.
Таким образом, мы переиспользуем логику, разделив UI. Как результат — поддержка становится намного удобнее.
Поддержка
Рассмотрим A/B-тест на вариацию визуальных элементов. В этом случае логика у нас не меняется, что позволяет подставить ещё одну имплементацию View под имеющийся интерфейс из ObservableSource и Consumer.
Разумеется, иногда новые требования противоречат уже написанной логике. В таком случае мы всегда можем вернуться к изначальной схеме, где приложение поставляет экран целиком. Для нас это некий «чёрный ящик», и контейнеру без разницы, что ему передают, пока его интерфейс соблюдён.
Интеграция
Как показывает практика, большинство приложений использует Activity как базовые единицы, средства общения между которыми давно известны. Нам оставалось только научиться оборачивать компоненты в Activity и передавать данные через input и output. Как оказалось, такой подход отлично работает и с фрагментами.
Для single activity-приложений ничего сильно не меняется. Практически все фреймворки предлагают свои базовые элементы, в которые RIB-компоненты позволяют себя обернуть.
В итоге
Пройдя эти этапы, мы значительно увеличили процент переиспользования кода между проектами нашей компании. В данный момент количество компонентов приближается к 100, и большинство из них реализует функционал для нескольких приложений сразу.
Наш опыт показывает, что:
- несмотря на увеличившуюся сложность проектирования общих компонентов, учитывая требования разных приложений, их поддержка даётся намного проще в долгосрочной перспективе;
- построив компоненты изолированно друг от друга, мы значительно упростили их интеграцию в приложения, построенные по разным принципам;
- пересмотр процессов вкупе с упором на разработку и поддержку компонентов положительно сказывается на качестве общей функциональности.
Мой коллега Zsolt Kocsi ранее писал про MVICore и идеи, стоящие за ним. Я крайне рекомендую прочитать его статьи, которые мы перевели в своём блоге (1, 2, 3).
Про RIBs можно прочитать оригинальную статью от Uber. А для получения практических знаний рекомендую пройти несколько уроков от нас (на английском).