Всем привет! Недавно я перешёл в команду, которая прокачивает программу лояльности, персонализацию и геймификацию в приложении Додо Пицца.

На новом месте я почти сразу столкнулся с классической задачей — рефакторингом сложной продуктовой фичи. Весь этот путь — от диагностики проблемы до «продажи» решения — и лёг в основу этой статьи. Она будет полезна:

  • разработчикам продуктовых команд. Постоянно пилите фичи? Велком!

  • разработчикам core-команд. Расскажу вам про общие принципы рефакторинга;

  • менеджерам. Помогу вам понять, чем отличается зрелое предложение о рефакторинге от обычного «хочу всё переписать».

Как мы поняли, что пора

Рефакторинг бывает разным, как и бизнес-процессы в командах. Например, у нас над техническими задачами работает вся Android-гильдия — и feature-, и core-команды. Такие техпроекты инициирует техлид платформы или даже бизнес, а потому согласовывать их не надо — время и ресурсы на них уже есть. Как говорится, вперёд и с песней.

Представьте, что прямые бенефициары от рефакторинга — это вы и ваши напарники по команде, а правок требует архитектура бизнесовой фичи вашего домена. Ресурс на такие работы ниоткуда не появится, а core-команды не заберут их к себе в бэклог — у них есть дела поважнее. Да и кто лучше вас разбирается в вашей же фиче?

В общем, такой рефакторинг надо ещё отстоять перед лидом и продактом. А потом ещё и тщательно спланировать — никто же им по ночам заниматься не будет.

Давайте разберём на примере реального кейса, с которым вы можете столкнуться. У нас в приложении есть персональные акции. Выглядят они так:

Экран списка акций
Экран списка акций

Как эта фича работает для пользователя? Он видит список своих акций в Профиле. Они же видны ему при заходе в Корзину. А теперь он должен их видеть ещё и на Главном экране.

Бизнес-требование простое: неважно, где пользователь применил, отменил или просто увидел акцию — её состояние должно быть одинаковым и синхронизированным на всех экранах.

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

В определённый момент работа с ними превратилась в минное поле. Костыли стали угрожать стабильности приложения, а скорость разработки упала. Знакомо? Сегодня расскажу, как мы исправили эту ситуацию.

Когда фича начинает болеть

Момент, когда проблемы в архитектуре вылезают наружу, наступает незаметно. Вот и мы его не предвидели.

Изначально в приложении список акций был написан на Fragment, а сами акции добавлялись в RecyclerView. Затем акции расширили зону своего обитания в приложении и появились в корзине.

Но не всё так гладко. По неизвестной мне причине обновление состояния акций выполнялось в около ручном режиме: список акций запрашивался и обновлялся из кэша оперативной памяти или сервера, когда View впервые присоединялся к Fragment — каждый раз при открытии экрана.

Схема legacy-архитектуры персональных акций
Схема legacy-архитектуры персональных акций

Архитектурно это выглядело как игра в испорченный телефон: Презентеры через Интеракторы стучались в Сервис. Тот, в свою очередь, дёргал Сеть. Всё бы ничего, но этот путь приходилось проходить заново при каждом открытии экрана — список запрашивался и обновлялся с нуля, даже если данные лежали в кэше.

Ещё веселее становилось, когда акция применялась «бесшовно» (например, при вводе промокода или автоматической активации подарка). Цепочка выглядела монструозно:

  • интерактор активировал акцию;

  • шёл запрос в сеть;

  • обновлялся анализатор стейта (State Analyzer);

  • в шину событий (EventBus) улетал крик «Что-то изменилось!»;

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

При этом на iOS всё было сделано реактивно через единый источник. Сейчас концов уже не сыщешь, да и какой-то вн��тной документации по этому функционалу не было.

Факт остаётся фактом: больше трёх лет такая архитектура не доставляла проблем в Android-приложении. Но потом всё изменилось...

Акции появились на главной, но не все. Например, мы не показывали там секретную акцию. О ней мы уже рассказывали, хоть и в контексте iOS-реализации.

Проблема в том, что экран главной — это RecyclerView. Внутри него надо было добавить несколько кастомных Compose-элементов, а уже рядом с ними выстроить список акций.

Компонент был написан на XML-фрагменте. Поэтому был только один способ завести всё это дело: использовать LazyColumn и вытащить из фрагмента вёрстку без бизнес-логики.

Тащить Fragment целиком в RecyclerView — не вариант. Ничем хорошим это не кончится, каким бы простым этот путь не казался.

Вытащив вёрстку, мы смогли завести акции на главной, но не без последствий. Из-за отсутствия реактивности пришлось продублировать логику применения/отмены акций в ещё одном месте. А ещё нужно было как-то синхронизировать старый и новый списки. В общем, без костылей тут не обошлось.

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

Как мы докатились до жизни такой? Чтобы ответить на этот вопрос, мы провели полное обследование нашей фичи.

Диагноз. Собираем «анамнез» фичи

Предпосылки к появлению багов можно не заметить или спустить на старый добрый «авось». Но сам момент их появления вы точно не пропустите.

Первое, что я увидел на доске, попав в новую команду :)
Первое, что я увидел на доске, попав в новую команду :)

Чтобы решить все накопившиеся проблемы, их надо собрать. Это можно сделать в любом формате. Я воспользовался Mind Map и собрал на ней все источники багов и трудностей.

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

Именно поэтому наша задача была чуть сложнее — документации-то не было. Для начала пришлось собрать её.

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

План лечения. Проектируем идеальное будущее

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

Так вышло и у меня. Я пришёл к команде с готовым концептом, схемами и диаграммами.

Основная идея была очевидна: главные проблемы возникали из-за паттерна EventBus и нарушения консистентности UI. Логичным решением было создать Single Source of Trust для списка акций, который при обновлении данных оповещал бы об этом всех подписчиков.

Моё первоначальное предложение по реализации было рабочим. Я хотел, чтобы этот список акций запрашивался на старте приложения, по аналогии с тем, как это было сделано на iOS.

Команда одобрила основную концепцию. Но тут же они подсветили «узкое горлышко»: запрос на старте мог замедлить скорость попадания пользователя в меню, а для нас это важная метрика.

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

Немного пожив с этим решением, я понял: это всё ещё полумера. Lazy отлично решал проблему «холодного старта» и лишних запросов, но архитектурно это был просто «ленивый кэш», а мне не хотелось просто «откладывать» загрузку. Мне хотелось, чтобы данные сами знали, в каком состоянии они должны находиться, опираясь на контекст приложения.

Так что я решил отказаться от хака в пользу честной реактивности на базе Kotlin Flow. Вместо того чтобы хранить список акций как статический набор данных, мы превратили Store в «микшерный пульт».

В сердце архитектуры лёг оператор combine, который в реальном времени смешивает три потока данных:

  1. «Сырые» данные — список акций с бэкенда.

  2. Глобальное состояние корзины — наш источник правды о том, применён ли сейчас промокод.

  3. Локальные состояния — ID акций, на которые пользователь нажал прямо сейчас (чтобы показать спиннер, не дожидаясь ответа сервера).

Обратите внимание на оператор .onStart на первом скриншоте. Запрос в сеть уходит ровно в тот момент, когда UI впервые подписывается на данные. Нет подписчиков — нет запросов.

Как только меняется любой из этих компонентов — прилетели данные, обновилась корзина или нажалась кнопка — combine автоматически запускает функцию mapToState. Это чистая функция. Она ничего не знает о сети или базах данных. Она просто берёт входные ингредиенты и собирает из них готовый UI-стейт, проставляя флажки isApplied и isLoading.

Если корзина изменилась в другом месте приложения, combine это увидит и сам обновит состояния акций.

Нам не нужно ждать ответа сервера, чтобы перерисовать кнопку. Мы просто закидываем ID акции в поток loadingIds, а Store мгновенно пересобирает список с нужным состоянием. UI реагирует моментально.

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

Reciever — единственный источник правды для списка персональных акций. Он передаёт подписчику отфильтрованный и всегда актуальный массив OfferVO. Подписчик отображает эти данные, проблема консистентности UI решается. Если раньше разные части UI могли быть в рассинхроне, то теперь экран всегда показывает то, что находится в источнике.

Для активации промокодов мы отказались от EventBus. Этот паттерн вносил неопределённость: событие отправлялось в никуда, и не было очевидно, кто и как его обработает. В новой архитектуре Подписчик при применении акции напрямую обращается к Activator. Поток данных стал предсказуемым и линейным, а логика — прозрачной.

От EventBus мы хотели избавиться в системе уведомлений. Notifier должен формировать и отправлять подписчику не абстрактное событие, а контекстное уведомление, привязанное к конкретной акции. Так мы будем уверены, что пользователь увидит релевантную и согласованную информацию извне.

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

Эта схема показывает работу нового подхода в масштабе всего приложения. В центре теперь находится PersonalOffersStore — единственный источник правды для всего, что связано с акциями.

Раньше каждый экран — Выгодно, Промоплатформа, Профиль — сам отвечал за загрузку и хранение данных. Теперь они превратились в пассивных подписчиков, а их задача упростилась:

  • они подключаются к PersonalOffersStore;

  • через систему фильтров (показаны цветными линиями в легенде) они указывают, какой именно срез данных им интересен. Например, Промоплатформа подписывается только на свои акции, а Корзина и Профиль — на все, кроме них;

  • всегда получают актуальные данные для отображения.

Любое изменение состояния, будь то активация промокода пользователем или получение уведомления, происходит централизованно через API стора (Activator, Notifier). Сто�� обновляет своё состояние и автоматически оповещает подписчиков, которым важны эти изменения.

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

Продажа идеи. Как убедить команду, лида и продакта

Итак, вы собрали перечень мест, где болит. Поняли, как всё это должно работать с точки зрения UX, а главное — подготовили технический концепт решения. Теперь идём к бизнесу!

Фразу «у нас тут технический долг» оставьте за дверью, а с собой лучше приносите цифры и примеры того, как рефакторинг улучшит их значение. Например, «сейчас мы тратим на разработку фичи 15 сторипоинтов, а будем — 10» или «сейчас мы не можем реализовать фичу X, а после рефакторинга сможем». Покажите бэклог с багами в конце концов и объясните, что после рефакторинга их количество уменьшится на Y%.

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

Первые вопросы от человека, который управляет ресурсами разработки, будут о том, как организовать такую работу и насколько это дорого. Для ответа на этот вопрос я подготовил уже привычную мне Mind Map, на которой подробно обрисовал план рефакторинга с оценкой его стоимости и декомпозицией задач. Мой план состоял из трёх этапов:

  • переработка слоя данных (создание реактивного хранилища);

  • унификация UI-компонента для списка акций;

  • унификация экрана с детальной информацией об акции.

Реактивный Store — сердце новой архитектуры

Это была самая фундаментальная и рискованная часть всего рефакторинга. Ошибка здесь могла затронуть абсолютно все экраны, где отображаются акции.

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

Чтобы превратить рискованный рефакторинг в контролируемый эксперимент, мы обернули новый функционал в Feature Toggle. Что мы получили:

  1. Возможность раскатывать фичу постепенно. После теста можно плавно выкатывать изменения на пользователей — сначала по городам, а потом по странам, следя за метриками и краш-репортами.

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

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

Куда меньше вопросов было к рефакторингу UI-компонента. Мы составили простой план:

  • собираем все мешающие факторы;

  • удаляем их или переписываем;

  • приводим отображение списка акций на всех экранах к единому виду.

Тут нам улыбнулась удача: всё, что мешало нам, было не нужно бизнесу. Оставалось только синхронизировать процесс удаления мешающих элементов между платформами и убедиться, что ничего не сломалось после чистки.

Впрочем, в рефакторинге главное не начать, а вовремя остановиться. В процессе декомпозиции я нашёл целый «зоопарк» из моделей и UI-компонентов для экранов с деталями акций. Руки так и чесались это исправить, но после разговора с командой я узнал, что весь этот функционал получит редизайн.

Запускать рефакторинг «вслепую», не зная целевого состояния фичи — вредная работа. Велика вероятность сделать то, что завтра придётся выбрасывать. Поэтому эти задачи мы сознательно оставили в бэклоге ждать своего часа.

Фича пошла на поправку

Наше «лечение» сработало, как мы и планировали. Мы не останавливали разработку, а проводили рефакторинг фоном от основных задач, от спринта к спринту.

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

Появление документации стало отдельным радостным событием для всей команды.

После этого мы создали единый UI-компонент для списка акций и выкатили реактивное хранилище. Теперь каждая закрытая техническая задача «пачкой» чинила несколько багов из бэклога, а фичи, которые раньше требовали костылей, стали реализовываться гораздо проще. 

Ещё одно приятное достижение, которое почувствовали и пользователи, и сервер — это тишина в эфире. Вместо хаотичных запросов с каждого экрана мы пришли к одному ленивому запросу за сессию (при первом обращении). Все дальнейшие изменения UI происходят мгновенно за счёт эффективных обновлений внутри Store, без лишних походов в сеть.

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

Да и наша история не только про код, но и про коммуникацию, и зрелый подход к разработке. Если делать выводы, они будут такими:

  1. Говорите на языке бизнеса. Переводите технический долг в измеримые метрики: сэкономленное время, сниженные риски, будущие бизнес-возможности.

  2. Декомпозируйте страх. Большой и страшный рефакторинг становится понятным и управляемым, когда он разбит на мелкие, оценимые и безопасные задачи. Mind Map, Feature Toggle и поэтапный план — ваши лучшие союзники.

  3. Самое важное: превращайте эмоции в факты. Не «этот код ужасен», а «мы провели исследование. Оно показало, почему этот код неэффективен и как можно сократить количество запросов вдвое или втрое».

Не надо быть снобом и винить во всём предшественников. Даже если вас не устраивает их решение сейчас, у них были веские причины, чтобы принять его в тот момент, а бизнес-требования и архитектура ему полностью соответствовали. Технологии цикличны. Просто примите, что и ваш «иде��льный рефакторинг» тоже когда-нибудь превратится в легаси — это нормально.

Спасибо, что дочитали статью! В моем личном Telegram-канале я пишу не только про Android, но и про IT в целом. Например, недавно я вышел из зоны комфорта и запустил челлендж: создаю свою мобильную игру на Unity. Делюсь процессом, граблями и успехами в реальном времени — присоединяйтесь!

О том, как мы развиваем IT в Додо в целом, читайте в Telegram-канале Dodo Engineering. Там мы рассказываем о жизни нашей команды, культуре и последних разработках.