- PVSM.RU - https://www.pvsm.ru -
Однажды ты просыпаешься и понимаешь: избыточность компонентов и рассинхронизация в твоём приложении начинают вредить пользователям. Однажды ты смотришь на написанное давным-давно ядро, плачешь горькими слезами, и приходит это некомфортное, но вместе с тем немного соблазнительное ощущение — что рефакторинг назрел. Добро пожаловать на экскурсию по рефакторингу Музыки, начиная с ресёрча и заканчивая эксплуатацией! Я покажу вам реальный код и постараюсь в деталях вспомнить, как мы формировали требования к механизмам и разрабатывали их, рисовали у себя в голове и в коде границы ядра, по одной переделывали очереди и внедряли то, что получилось, в 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) с инкрементальным процессом создания ценности, который сформировался в нашей команде.
Начинаем инициализацию.
Зафиксировав всё это в виде изначальных требований (с продуктовым уклоном) с горсткой диаграмм, можно было двигаться дальше — выявлять заинтересованных в проекте людей и общаться с ними. К счастью, этот список был в большей степени понятен: разработчики, которым предстоит учиться работать с новым механизмом, тестировщики, которым предстоит новый механизм тестировать, и руководители/менеджеры.
Пообщавшись с заинтересованными лицами о том, в чём они заинтересованы, мы поменяли изначальные требования к механизму, сделав их более полными и точными.
К этому моменту мы знали, что требуется:
На основе этой информации можно было приступить к планированию и сформировать хотя бы черновой скоуп работ.
Изначальный оптимистичный план еще до начала активных работ по проекту был таков, что за квартал новый механизм полностью заменит собой старый, и, желательно, не только в приложении, а ещё и в SDK. И что бóльшую часть остатков старого механизма мы успеем вычистить.
Реальность наш оптимизм не поддержала. Посовещавшись, решили, что за квартал и в основное приложение, и в распространяемое SDK, скорее всего не успеем. Остановились на том, что за квартал мы в первую очередь хотим увидеть хотя бы приложение на новом механизме, а работы по вычищению остатков старого механизма и внедрению в SDK нового, уже обкатанного, начнём сразу после.
Анализом назовём стадию, во время которой я вспоминал, искал и читал статьи про архитектуру модульных и, в общем смысле, распределённых систем. То есть статьи не только по мобильной разработке, но и по разработке систем в целом. Разработчики и архитекторы распределенных систем издревле занимались решением проблем согласованности, работая с асинхронными потоками данных и множеством источников. Поэтому информации и наработок на эти темы у них больше, чем у мобильщиков.
Рассмотрев различные модели согласованности, я предположил, что в конечном счёте от механизма требуется только Sequential consistency [1]. Почему?
Но есть факт: таких случаев много.
Можно использовать блокирующие средства синхронизации, вроде 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 (подробнее об этом позже). Подробное технического решение по миграции вышеупомянутого кода на новый механизм ещё предстояло спроектировать. Но главное уже было понятно: оно существует.
Итого, сформировалась новая версия требований к механизму, уже с учётом технической части.
К проектированию подход был следующим: я рисовал диаграммы, думал над обоснованием тех или иных решенией, превращал все наработки в презентацию и затем защищал её перед всей Android-командой. Этот процесс мы называем архитектурным ревью. Всего было три таких ревью, хотя третье из них уже было в большей степени не активным обсуждением, а демонстрацией получившихся результатов.
Концептуально Playback (механизм управления воспроизведением) теперь выглядит так:
Диаграмма 1
До рефакторинга Playback был частью монолита, теперь же мы вынесли его в отдельные несколько модулей. А вместе с ним — и все очереди. Каждый из этих модулей мы планируем переиспользовать в SDK, который отдаём Кинопоиску, Алисе, Навигатору и основному Поиску. Всё и сразу там, конечно, не понадобится, но итеративно будем двигаться именно в эту сторону.
В приложении было девять различных очередей, неразрывно связанных с Playback'ом. Теперь это девять модулей, которые не зависят от Playback'а и которые можно подключать к приложению независимо.
Очень большая часть логики приложения строилась на паттерне “Visitor”. Не хотелось терять все его преимущества. Пришли к такому решению:
Диаграмма 2
Внутри extension-метода мы через when проходимся по всем наследникам и пишем else-ветку. Но при этом мы пишем тест, который проверяет, что все наследники класса в этом when учтены. Таким образом мы не теряем преимуществ паттерна, заменяя compile-time-проверку на тест.
В основе механизма было решено заложить три не самые хитрые, но и не самые банальные структуры:
Так мы и должны были получить то, чего желали: настоящую sequential consistency, адекватную работу с потоками, удобный конструктор последовательностей команд и многое другое.
Верхнеуровнево схема нового Playback’а выглядит так, как на картинке. Есть модуль с ядром механизма, координирующего связь между всеми составляющими системы воспроизведения. Очереди и плееры могут находиться (и бóльшая часть уже находится) в модулях. Ещё есть несколько вспомогательных модулей, но это лишние подробности.
Диаграмма 3
Более подробная и точная схема ядра Playback’а показана на рисунке ниже. Эта схема соответствует центру диаграммы 1 и середине диаграммы 3.
Диаграмма 4
В вышеописанном виде проект перешёл на стадию разработки. В первой итерации мы должны были реализовать:
Код до конца этой итерации висел «в воздухе» и особо не был связан с приложением. Тестировщики тестировать его пока не могли, но уже можно было проходиться юнит-тестами.
На следующей (второй) итерации я начал интеграцию механизма непосредственно в приложение. На выходе хотелось иметь работающее приложение, пусть даже и с заметным количеством вновь образовавшихся ошибок. Работоспособность нового механизма обязательно нужно было подтвердить экспериментом (feature toggle), так что пользователи этих ошибок, конечно, не видели. Как только мы посчитали эту часть работ законченной, приложение было отправлено на полное регрессионное тестирование, чтобы выявить недостатки в самом новом механизме или в том, как он интегрирован.
Третьей итерацией приложение было полностью поставлено на ноги. Все известные недостатки устранили. Значит, уже можно было обкатывать ядро нового механизма на сотрудниках Яндекса.
Хорошо: ядро есть. Но оно работает со старыми очередями и старыми сущностями через адаптеры.
Четвёртая итерация. Новые очереди я разрабатывал по одной. Можно было плавно переходить на их использование благодаря экспериментам и архитектуре «конструктора», которая у нас получилась.
Так, постепенно, семь из девяти очередей я переписал. Осталось две. Их переписывание мы решили немного отложить, так как это пока менее приоритетно, чем другие работы. Оптимистичный план — добить их за второй квартал.
Во время второй и третьей итераций я параллельно — где-то вынужденно, где–то довольно хаотично — занимался переписыванием всех использований старого механизма на новый. Это долгий процесс, в него постепенно будет втягиваться вся команда, но с чего-то надо начинать. Такой рефакторинг можно было делать постепенно: не забываем требование про совместимость нового механизма со старым кодом. Но совместимость через адаптеры к старым сущностям и множественные оборачивания, разворачивания классов — это «грязновато». Будем постепенно дочищать все остатки.
Нам удалось добиться полного контроля над состоянием воспроизведения и строгим образом очертить границы ядра. Никто из разработчиков теперь не может (законно, без игр с рефлексией) стащить где-нибудь ссылку на очередь или плеер и изменить их. Все изменения проходят через процессор команд. Он, кстати, обеспечивает множество полезных семантик по эффективной отработке прилетающих команд — это не просто executor. Конечно же, у нас есть практика ревью пул-реквестов. Но на ревью размывание границ фичи очень легко пропустить. А подозрительные активности с рефлексией заметит даже начинающий ревьювер.
С новым механизмом стало легко реализовать, например, такой сценарий: пауза текущей очереди → запуск новой очереди → ожидание сходимости с плеером → запуск новой очереди. И такой сценарий будет исполняться всегда последовательно, как единое целое. Если в обработку прилетят новые команды, они встанут в очередь за этим сценарием. Внутри механизма выстраиваются чёткие цепочки команд и осуществляется регулирование их исполнения по некоторым правилам. Например, одна цепочка может «перетереть» другую, если предыдущая стала несущественной для пользователя.
Здесь работы идут полным ходом. Концептуально они похожи на работы по внедрению механизма в приложение, так что не буду дополнительно их описывать.
Интеграция нового механизма в SDK уже идёт полным ходом. Стало понятно, что внутренности SDK, касающиеся воспроизведения, будут заменены новыми модулями практически полностью — останутся только AIDL-интерфейсы для межпроцессного общения с хостами (Кинопоиском, Алисой, Навигатором и Поиском), адаптеры и небольшая специфика этих самых хостов.
Приятным бонусом интеграции нового механизма воспроизведения в SDK будет подтягивание туда полноценной реализации плеера, который используется в основном приложении: с мониторингом неполадок проигрывания и общей производительности, префетчингом следующих треков и другими функциями, которые улучшат опыт прослушивания музыки через интеграции.
Рефакторинг помог закрыть самые частые и самые раздражающие пользователей баги. Например, были проблемы с работой в фоне. Android к этому довольно критично относится, поэтому работа программистов тут как работа сапёра — без права на ошибку. Ибо если ты допустил ошибку в коде, а потом приложение из-за этого в фоновом режиме сделало что-то не то, например, отпустило wake-lock, то исход будет весьма предсказуемым:
А остановка в фоне — не самая приятная вещь. Многие пользователи слушают музыку или подкасты именно в фоне, поэтому неожиданное прекращение работы именно из-за багов, а не из-за проблем с сетью, например, приносит печальный пользовательский опыт. Это мы тоже починили при рефакторинге. Если у вас вдруг еще воспроизводится, пожалуйста, напишите мне в личку или в комментах.
Ещё были проблемы с уведомлениями — это тот самый баг, который очень сильно зависит непосредственно от устройства. Если в случае с iOS мы с вами имеем набор айфонов с плюс-минус одинаковыми ТТХ, просто у кого-то чёлка, а у кого-то — Touch ID, то в случае с Android весь зоопарк устройств даёт о себе знать. Например, на ряде устройств уведомление от плеера можно быстро и логично смахнуть, а на других устройствах — нельзя. Вдобавок некоторые девайсы время от времени предпочитали правильно отображать пользователю само уведомление, но отображать в нём старый текст. Тоже починили.
Отдельно отмечу, что рефакторинг помог решить старую проблему с синхронизацией прогресса. Та самая штука, когда вы на десктопе, например, сидели и слушали длинный подкаст, остановились на каком-то его этапе, вышли на улицу и захотели продолжить прослушивание со смартфона. А смартфон запомнил сам подкаст, но включил его с самого начала, не подтянув временную метку. Вроде бы мелочь, но раздражает.
Активная фаза разработки нового механизма заняла около трёх месяцев. Почти месяц он был включён только на сотрудников Яндекса, а затем мы постепенно подняли уровень раскатки на внешних пользователей до 50%. В тот момент нашли досадную ошибку с валидацией возможности запустить трек — сообщения о невозможности запуска стали отображаться у пользователей иначе. Раскатку механизма пришлось снова вернуть в состояние «на сотрудников».
Тем не менее, механизм показал свою состоятельность, несколько новых фич уже реализованы на его основе, а жалоб как от сотрудников, так и от внешних пользователей больше не стало. В ближайшие дни ожидаем возвращения раскатки на 50%, а далее и на 100% внешних пользователей.
Интеграция нового механизма в SDK уже идёт полным ходом. Приятным бонусом интеграции нового механизма воспроизведения в SDK будет подтягивание туда полноценной реализации плеера, который используется в основном приложении: с мониторингом неполадок проигрывания и общей производительности, префетчингом следующих треков и другими функциями, которые улучшат опыт прослушивания музыки через интеграции.
Автор: Василий Шумилов
Источник [3]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/kotlin/376015
Ссылки в тексте:
[1] Sequential consistency: https://en.wikipedia.org/wiki/Sequential_consistency
[2] Strict consistency: https://en.wikipedia.org/wiki/Consistency_model#Strict_consistency
[3] Источник: https://habr.com/ru/post/671236/?utm_source=habrahabr&utm_medium=rss&utm_campaign=671236
Нажмите здесь для печати.