Рефакторинг приложения с десятилетним легаси за три месяца. Опыт Яндекс Музыки
Однажды ты просыпаешься и понимаешь: избыточность компонентов и рассинхронизация в твоём приложении начинают вредить пользователям. Однажды ты смотришь на написанное давным-давно ядро, плачешь горькими слезами, и приходит это некомфортное, но вместе с тем немного соблазнительное ощущение — что рефакторинг назрел. Добро пожаловать на экскурсию по рефакторингу Музыки, начиная с ресёрча и заканчивая эксплуатацией! Я покажу вам реальный код и постараюсь в деталях вспомнить, как мы формировали требования к механизмам и разрабатывали их, рисовали у себя в голове и в коде границы ядра, по одной переделывали очереди и внедряли то, что получилось, в SDK.
Из чего состоит Музыка
Приложение Музыки — это ансамбль из разных и довольно самостоятельных сущностей. Как и во всяком уважающем себя ансамбле, все его части должны работать сообща. У нас эти части выглядят так:
- сущность самого плеера,
- логика переключения очередей,
- сущности плейлистов, альбомов, страниц исполнителей, поисковой выдачи,
- Моя волна,
- плеер в шторке уведомлений,
- медиасессия,
- аудиофокус,
- несколько штук, о которых мы пока не можем вам рассказать.
Собственно, как раз плеер, очереди и всё, что связывает эти сущности в единое целое, я и переделывал. Главной проблемой всегда была именно связность, ядро приложения писалось довольно давно (по меркам сервиса), функциональность приложения нарастала внутри него годами, и для адекватного рефакторинга пришлось довольно тщательно всё это распутывать. Все годы, любые требования, которые ровно не укладывались в изначальную реализацию механизма управления воспроизведением, скрупулезно раскладывались по всему приложению, чтобы достичь нужного эффекта. В этом вопросе ни о каких принципах SOLID и архитектуре в целом речи, в общем-то, и не шло. Так часто бывает с God-сущностями, которых все стараются избегать, но всё равно время от времени дописывают.
На схеме изображено далеко не всё, но и без того видно, как сущность раньше была перегружена. Различные условные переходы в каждом методе здесь также не изображены.
А ещё состояние плеера и очередей исторически было рассинхронизировано. Было много гонок. Например, в пульт Алисы или Chromecast (устройство Google, через которое можно транслировать контент на ТВ или умные колонки) мог ненадолго попасть прошлый или следующий трек. Что-то могло криво нарисоваться в UI.
В коде можно было временами встретить что-то такое (упрощено):
combine(queueFlow, playerFlow) { queueState, playerState ->
if (queueState.current == playerState.playable) {
Pair(queueState, playerState)
} else {
null
}
}
И это, в общем-то говоря, не такой уж и плохой код, ведь он сосредоточен в одном месте, и ничего страшного внешне не делает. То, что таких мест больше одного, что у такого кода есть нежелательные побочные эффекты, это уже совсем другая история. Писать его и думать над ним каждый раз в любом случае неприятно. Но ведь бывает и так, что разные части подобной синхронизации оказываются в разных местах.
Теперь представьте, что будет, если внести в подобные надёжные решения больше двух переменных… Конечно, изредка wake-lock и вырывался на свободу, вынуждая разработчиков порождать еще больше разных if
и synchronized
, чтобы загнать его обратно.
У нас был план, и мы его придерживались
Все работы разделилась на три крупных этапа.
Первый этап — исследование и защита проекта
Небольшой оффтоп. Я не случайно называю этот рефакторинг проектом. Уже при первичной оценке объёма работ — на основе того, что знаем, — было понятно, что рефакторинг будет нетривиальным, сопряженным с существенными рисками. Поэтому и подходить к его исполнению нужно было серьезно. Некоторые стандарты по управлению проектами (из того самого PMBOK и не только) всплыли в моей голове. Формировать план проекта с уставом и ТЗ по ГОСТ«у, я, конечно, не собирался, но соблюдение базовых последовательностей показалось весьма полезным.Итак, образовалась, казалось бы, банальная, но выверенная временем последовательность выполнения: инициализация, планирование, анализ, проектирование, разработка, тестирование, ввод в эксплуатацию.
Всё это не конфликтует (и не должно — just to clarify) с инкрементальным процессом создания ценности, который сформировался в нашей команде.
Начинаем инициализацию.
- Нужно было тщательно изучить текущий код.
- Выявить все сущности приложения, зависимые от старого механизма.
- Выявить среди них все те места, куда старый механизм пролез, но точно не должен был.
- Выявить все те сущности, которые в старый механизм пролезли, но не должны были.
- Собрать список известных проблем, которые мы долгое время не можем починить.
- Понять, какие задачи решает старый механизм.
Зафиксировав всё это в виде изначальных требований (с продуктовым уклоном) с горсткой диаграмм, можно было двигаться дальше — выявлять заинтересованных в проекте людей и общаться с ними. К счастью, этот список был в большей степени понятен: разработчики, которым предстоит учиться работать с новым механизмом, тестировщики, которым предстоит новый механизм тестировать, и руководители/менеджеры.
Пообщавшись с заинтересованными лицами о том, в чём они заинтересованы, мы поменяли изначальные требования к механизму, сделав их более полными и точными.
К этому моменту мы знали, что требуется:
- Перенять новым механизмом всю функциональность старого: координацию того самого ансамбля разнородных сущностей, которые делают Музыку такой, какая она есть.
- Повысить качество приложения и сервиса в целом. Исправить ошибки, которые ранее исправить не могли. Оценивать общее качество мы сможем по количеству положительных и отрицательных отзывов во внешних и внутренних каналах коммуникации, крешам, ANR«ам, показателям производительности и прочему.
- Раскатывать новый механизм постепенно. Иначе качество приложения на заметное время сильно просядет и наши пользователи расстроятся. Или вообще временно не сможем выпускать релизы. Ошибки в первоначальной реализации нового механизма такого масштаба и его интеграции со старым кодом, как показывает опыт, неизбежны.
- Повысить качество кода: сделать его гибким, расширяемым, понятным, предсказуемым, тестируемым — в общем, хорошим. Должно получиться. Согласуем моё собственное видение и накопленную экспертизу с видением и экспертизой команды, обсуждая реализацию на архитектурных ревью, рабочих местах и за кружкой кофе на кофепоинте.
- Получить новые возможности по внесению изменений в ту часть функциональности, которая относится к воспроизведению музыки. Учесть потребности параллельно разрабатывающихся и планирующихся проектов. Упростить поддержку текущей функциональности и внедрение новой.
- Переиспользовать новый механизм в приложении и распространяемом SDK. Значит, сделать новый механизм в форме набора модулей. И, желательно, с этими модулями лишние зависимости в SDK не притащить, чтобы сильно не увеличить его размер.
На основе этой информации можно было приступить к планированию и сформировать хотя бы черной скоуп работ.
Изначальный оптимистичный план еще до начала активных работ по проекту был таков, что за квартал новый механизм полностью заменит собой старый, и, желательно, не только в приложении, а ещё и в SDK. И что бóльшую часть остатков старого механизма мы успеем вычистить.
Реальность наш оптимизм не поддержала. Посовещавшись, решили, что за квартал и в основное приложение, и в распространяемое SDK, скорее всего не успеем. Остановились на том, что за квартал мы в первую очередь хотим увидеть хотя бы приложение на новом механизме, а работы по вычищению остатков старого механизма и внедрению в SDK нового, уже обкатанного, начнём сразу после.
Анализом назовём стадию, во время которой я вспоминал, искал и читал статьи про архитектуру модульных и, в общем смысле, распределённых систем. То есть статьи не только по мобильной разработке, но и по разработке систем в целом. Разработчики и архитекторы распределенных систем издревле занимались решением проблем согласованности, работая с асинхронными потоками данных и множеством источников. Поэтому информации и наработок на эти темы у них больше, чем у мобильщиков.
Рассмотрев различные модели согласованности, я предположил, что в конечном счёте от механизма требуется только Sequential consistency. Почему?
Можно использовать блокирующие средства синхронизации, вроде mutex«ов. В уже изученных реалиях это зазвучало крайне неэффективно и неудобно для использования в конечных точках, да ещё и не решало толком никаких фундаментальных проблем, справиться с которыми был неспособен старый механизм (об этом позже). А любые, даже безобидные, которые не должны занимать много времени, обращения к такому механизму из главного потока всё так же могут затормозить UI, если попадут на блокировку из параллельной долгой операции.
А как реализовывать отмену долгих операций? Что делать со старым Java-кодом, в котором нет тех самых корутин? В общем, реализация со строгой консистентностью на блокировках не выглядела для нас хорошим вариантом.
К этому моменту для себя я твёрдо решил — обеспечения строгой консистентности нужно постараться избежать. Обеспечить её должным образом мы не сможем.
Остриём встал вопрос: что делать с кодом, который на строгую консистентность рассчитывает? И не забываем: он вызывается из главного потока. Такой, например (упрощён для понимания):
val contentType = playbackControl.getCurrentQueue().getContentType()
if (contentType is SomeConentType) {
val currentPlayable = playbackControl.getCurrentPlayable()
val duration = playbackControl.getCurrentPlayableDuration()
val position = playbackControl.getCurrentPlaybackPosition()
val shouldRewind = someControl.shoudRewind(duration, position, currentPlayable)
if (shouldRewind) {
playbackControl.seekTo(position - 2.seconds)
}
}
Решить проблему можно преобразованием этого кода в некую команду или транзакцию и постановкой этой сущности в некую очередь на исполнение.
Здесь, к слову, скрывается и другая проблема, помимо вопроса о строгой консистентности. В старом механизме возможен рассинхрон между тем, что окажется в поле contentType, и тем, что окажется в поле currentPlayable (подробнее об этом позже). Подробное технического решение по миграции вышеупомянутого кода на новый механизм ещё предстояло спроектировать. Но главное уже было понятно: оно существует.
Итого, сформировалась новая версия требований к механизму, уже с учётом технической части.
- Реализовать новый механизм нужно таким образом, чтобы он был совместим со старым кодом. Раскатка нового механизма должна проходить постепенно. Иначе шанс застопорить релизный процесс на несколько недель (а может, и больше) и расстроить множество пользователей стремился бы к 100% из-за допущенных при реализации ошибок.
- Из первого требования плавно вытекает второе. Новый механизм должен перенять всю функциональность старого: координацию того самого ансамбля разнородных сущностей, которые делают Музыку такой, какая она есть. Всё перечислять не буду: это задачи от реагирования на нажатие кнопки плей/пауза до реагирования на переключение треков через Яндекс Станцию (в режиме сопряжения с колонкой).
- Из первого требования плавно вытекает и третье. Раз весь код приложения ходит в старый механизм воспроизведения из главного потока, ходить таким образом в новый тоже должно быть допустимо и безопасно. Более того, новый механизм должен быть синхронизирован так, чтобы любой поток мог к нему обратиться и не породить этим новые баги.
- Обеспечить в новом механизме sequential consistency. То есть сделать так, чтобы выдаваемое наружу состояние ансамбля координируемых механизмом сущностей было согласованным. Но не так, чтобы результат желаемых операций по изменению состояния механизма был всегда виден при последующем чтении состояния незамедлительно.
- Новый механизм должен быть многомодульным и собираться как конструктор. Это значит, что каждая очередь, каждый плеер, непосредственно ядро и прочие вспомогательные сущности, должны, в идеале, быть вынесены в модули. Пульт от Алисы, Chromecast, различные модификаторы для немузыкального контента (подкастов, книг и прочего) тоже должны быть лишь частями конструктора. Это требуется для сборки различающихся по функциональности версий механизма для SDK и основного приложения. В SDK на текущий момент требуются не все типы очередей, не все типы плееров и тому подобного. К тому же SDK, просто-напросто будучи распространяемой библиотекой, а не приложением, обладает некоторой собственной спецификой, и это нужно учитывать.
- Должно быть легко реализовать, например, такой продуктовый сценарий, происходящий по желанию пользователя или автоматически: постановка текущего трека на паузу → запуск новой очереди с конкретного трека и конкретной секунды → начало воспроизведения этого трека. То есть требуется некая возможность конструировать произвольные последовательности операций и выполнять их. И, конечно, нужно сохранять отзывчивость на действия пользователя, не портить UX. Но всё должно работать согласованно, и ни в какой комбинации действий не приводить к тому, что пользователь увидел или услышал то, чего не ожидал.
К проектированию подход был следующим: я рисовал диаграммы, думал над обоснованием тех или иных решенией, превращал все наработки в презентацию и затем защищал её перед всей Android-командой. Этот процесс мы называем архитектурным ревью. Всего было три таких ревью, хотя третье из них уже было в большей степени не активным обсуждением, а демонстрацией получившихся результатов.
Концептуально Playback (механизм управления воспроизведением) теперь выглядит так:
Диаграмма 1
До рефакторинга Playback был частью монолита, теперь же мы вынесли его в отдельные несколько модулей. А вместе с ним — и все очереди. Каждый из этих модулей мы планируем переиспользовать в SDK, который отдаём Кинопоиску, Алисе, Навигатору и основному Поиску. Всё и сразу там, конечно, не понадобится, но итеративно будем двигаться именно в эту сторону.
В приложении было девять различных очередей, неразрывно связанных с Playback’ом. Теперь это девять модулей, которые не зависят от Playback’а и которые можно подключать к приложению независимо.
Очень большая часть логики приложения строилась на паттерне «Visitor». Не хотелось терять все его преимущества. Пришли к такому решению:
Диаграмма 2
Внутри extension-метода мы через when проходимся по всем наследникам и пишем else-ветку. Но при этом мы пишем тест, который проверяет, что все наследники класса в этом when учтены. Таким образом мы не теряем преимуществ паттерна, заменяя compile-time-проверку на тест.
В основе механизма было решено заложить три не самые хитрые, но и не самые банальные структуры:
- Расширяемый дополнительными executor«ами процессор команд, где команда содержит только параметры для её исполнения, но не содержит логики исполнения, как это предполагается в распространенном паттерне «Команда». Так обеспечим модульную расширяемость и возможность конструировать произвольные последовательности команд.
- Буфер команд, благодаря которому мы можем не беспокоиться о том, с какого потока обращаться к механизму. Бонусом получаем возможность осознанно приоритизировать команды (и их последовательности), управлять их отменой, а также избавляться от вырождающих друг друга команд.
- Разделение команд и executor«ов на две иерархии: те, что запускают новые очереди, и те, что не запускают. Для Музыки это очень большая разница. Для Playback’а тоже. Чуть позже расскажу, почему.
- Машину состояний, которая управляет состоянием механизма и обеспечивает консистентность внешнего состояния при любых входных данных. Входными данными являются потоки событий и состояний от плеера, очередей и разных вспомогательных сущностей.
Так мы и должны были получить то, чего желали: настоящую sequential consistency, адекватную работу с потоками, удобный конструктор последовательностей команд и многое другое.
Верхнеуровнево схема нового Playback«а выглядит так, как на картинке. Есть модуль с ядром механизма, координирующего связь между всеми составляющими системы воспроизведения. Очереди и плееры могут находиться (и бóльшая часть уже находится) в модулях. Ещё есть несколько вспомогательных модулей, но это лишние подробности.
Диаграмма 3
Более подробная и точная схема ядра Playback«а показана на рисунке ниже. Эта схема соответствует центру диаграммы 1 и середине диаграммы 3.
Диаграмма 4
Второй этап — разработка и внедрение в основное приложение
В вышеописанном виде проект перешёл на стадию разработки. В первой итерации мы должны были реализовать:
- Стейт-машину состояний.
- Реестр очередей.
- Владельцев состояния очередей, Playback’а и ещё пары менее значительных сущностей. Коротко говоря, это такие классы, которые в удобном виде поставляют информацию наружу.
- Процессор команд.
Код до конца этой итерации висел «в воздухе» и особо не был связан с приложением. Тестировщики тестировать его пока не могли, но уже можно было проходиться юнит-тестами.
На следующей (второй) итерации я начал интеграцию механизма непосредственно в приложение. На выходе хотелось иметь работающее приложение, пусть даже и с заметным количеством вновь образовавшихся ошибок. Работоспособность нового механизма обязательно нужно было подтвердить экспериментом (feature toggle), так что пользователи этих ошибок, конечно, не видели. Как только мы посчитали эту часть работ законченной, приложение было отправлено на полное регрессионное тестирование, чтобы выявить недостатки в самом новом механизме или в том, как он интегрирован.
Третьей итерацией приложение было полностью поставлено на ноги. Все известные недостатки устранили. Значит, уже можно было обкатывать ядро нового механизма на сотрудниках Яндекса.
Хорошо: ядро есть. Но оно работает со старыми очередями и старыми сущностями через адаптеры.
Четвёртая итерация. Новые очереди я разрабатывал по одной. Можно было плавно переходить на их использование благодаря экспериментам и архитектуре «конструктора», которая у нас получилась.
Так, постепенно, семь из девяти очередей я переписал. Осталось две. Их переписывание мы решили немного отложить, так как это пока менее приоритетно, чем другие работы. Оптимистичный план — добить их за второй квартал.
Во время второй и третьей итераций я параллельно — где-то вынужденно, где–то довольно хаотично — занимался переписыванием всех использований старого механизма на новый. Это долгий процесс, в него постепенно будет втягиваться вся команда, но с чего-то надо начинать. Такой рефакторинг можно было делать постепенно: не забываем требование про совместимость нового механизма со старым кодом. Но совместимость через адаптеры к старым сущностям и множественные оборачивания, разворачивания классов — это «грязновато». Будем постепенно дочищать все остатки.
Нам удалось добиться полного контроля над состоянием воспроизведения и строгим образом очертить границы ядра. Никто из разработчиков теперь не может (законно, без игр с рефлексией) стащить где-нибудь ссылку на очередь или плеер и изменить их. Все изменения проходят через процессор команд. Он, кстати, обеспечивает множество полезных семантик по эффективной отработке прилетающих команд — это не просто executor. Конечно же, у нас есть практика ревью пул-реквестов. Но на ревью размывание границ фичи очень легко пропустить. А подозрительные активности с рефлексией заметит даже начинающий ревьювер.
С новым механизмом стало легко реализовать, например, такой сценарий: пауза текущей очереди → запуск новой очереди → ожидание сходимости с плеером → запуск новой очереди. И такой сценарий будет исполняться всегда последовательно, как единое целое. Если в обработку прилетят новые команды, они встанут в очередь за этим сценарием. Внутри механизма выстраиваются чёткие цепочки команд и осуществляется регулирование их исполнения по некоторым правилам. Например, одна цепочка может «перетереть» другую, если предыдущая стала несущественной для пользователя.
Третий этап — внедрение в SDK
Здесь работы идут полным ходом. Концептуально они похожи на работы по внедрению механизма в приложение, так что не буду дополнительно их описывать.
Интеграция нового механизма в SDK уже идёт полным ходом. Стало понятно, что внутренности SDK, касающиеся воспроизведения, будут заменены новыми модулями практически полностью — останутся только AIDL-интерфейсы для межпроцессного общения с хостами (Кинопоиском, Алисой, Навигатором и Поиском), адаптеры и небольшая специфика этих самых хостов.
Приятным бонусом интеграции нового механизма воспроизведения в SDK будет подтягивание туда полноценной реализации плеера, который используется в основном приложении: с мониторингом неполадок проигрывания и общей производительности, префетчингом следующих треков и другими функциями, которые улучшат опыт прослушивания музыки через интеграции.
Закрытые баги
Рефакторинг помог закрыть самые частые и самые раздражающие пользователей баги. Например, были проблемы с работой в фоне. Android к этому довольно критично относится, поэтому работа программистов тут как работа сапёра — без права на ошибку. Ибо если ты допустил ошибку в коде, а потом приложение из-за этого в фоновом режиме сделало что-то не то, например, отпустило wake-lock, то исход будет весьма предсказуемым:
А остановка в фоне — не самая приятная вещь. Многие пользователи слушают музыку или подкасты именно в фоне, поэтому неожиданное прекращение работы именно из-за багов, а не из-за проблем с сетью, например, приносит печальный пользовательский опыт. Это мы тоже починили при рефакторинге. Если у вас вдруг еще воспроизводится, пожалуйста, напишите мне в личку или в комментах.
Ещё были проблемы с уведомлениями — это тот самый баг, который очень сильно зависит непосредственно от устройства. Если в случае с iOS мы с вами имеем набор айфонов с плюс-минус одинаковыми ТТХ, просто у кого-то чёлка, а у кого-то — Touch ID, то в случае с Android весь зоопарк устройств даёт о себе знать. Например, на ряде устройств уведомление от плеера можно быстро и логично смахнуть, а на других устройствах — нельзя. Вдобавок некоторые девайсы время от времени предпочитали правильно отображать пользователю само уведомление, но отображать в нём старый текст. Тоже починили.
Отдельно отмечу, что рефакторинг помог решить старую проблему с синхронизацией прогресса. Та самая штука, когда вы на десктопе, например, сидели и слушали длинный подкаст, остановились на каком-то его этапе, вышли на улицу и захотели продолжить прослушивание со смартфона. А смартфон запомнил сам подкаст, но включил его с самого начала, не подтянув временную метку. Вроде бы мелочь, но раздражает.
Итог
Активная фаза разработки нового механизма заняла около трёх месяцев. Почти месяц он был включён только на сотрудников Яндекса, а затем мы постепенно подняли уровень раскатки на внешних пользователей до 50%. В тот момент нашли досадную ошибку с валидацией возможности запустить трек — сообщения о невозможности запуска стали отображаться у пользователей иначе. Раскатку механизма пришлось снова вернуть в состояние «на сотрудников».
Тем не менее, механизм показал свою состоятельность, несколько новых фич уже реализованы на его основе, а жалоб как от сотрудников, так и от внешних пользователей больше не стало. В ближайшие дни ожидаем возвращения раскатки на 50%, а далее и на 100% внешних пользователей.
Интеграция нового механизма в SDK уже идёт полным ходом. Приятным бонусом интеграции нового механизма воспроизведения в SDK будет подтягивание туда полноценной реализации плеера, который используется в основном приложении: с мониторингом неполадок проигрывания и общей производительности, префетчингом следующих треков и другими функциями, которые улучшат опыт прослушивания музыки через интеграции.