В 2019 году команда Yelp Core Android начала работу по повышению производительности навигации в приложении Yelp для потребителей. Мы перешли от создания экранов с несколькими отдельными Активити к использованию фрагментов внутри одной Активити. В этой статье мы расскажем о нашем решении, о нашем подходе к миграции, и поделимся выводами, сделанными на этом пути, а также достигнутыми успехами.
С чего мы начали в 2018 году
Навигация между экранами в приложении для Android часто является моментом, когда приложение и устройство испытывают наибольшую нагрузку. Создается новый экран и все его зависимости, что может привести к медленной работе или пропуску кадров. До 2019 года почти каждая страница в потребительском приложении Yelp находилась в своей собственной Активити. Переход от одной страницы к другой не всегда был плавным, а пользовательский интерфейс часто заметно лагал, что при навигации было видно даже невооруженным глазом.
Нажатие на одну из нижних закладок означало, что каждый раз при переходе к экрану пользователь создавал все заново. В краткосрочной перспективе мы решили эту проблему, выводя Активити на передний план вместо того, чтобы создавать ее заново, если она присутствовала в стеке. Хотя это и помогло, но все равно не привело к той плавности переходов, на которую мы рассчитывали.
Чтобы понять, где мы можем оказать наибольшее влияние, и сфокусировать наши усилия, мы сначала провели несколько локальных тестов, а затем проверили данные о производительности реальных пользователей, чтобы подтвердить нашу гипотезу о медленной работе навигации.
Локальные бенчмарки
В 2018 году мы провели несколько базовых тестов навигации на устройстве Pixel под управлением Android 7.1.1 и измерили время, прошедшее с момента нажатия кнопки на экране 1 до завершения жизненного цикла метода onCreate()
экрана 2. Следующие цифры — это среднее значение, полученное в результате 10 итераций для каждого сценария:
Scenario | 1st Time Navigation to Page (ms) | 2nd Time Navigation to Page (ms) |
---|---|---|
Plain Activity with no animation | 152 | 65 |
Plain YelpActivity with no animation | 420 | 116 |
Мы задались вопросом, почему базовая Активити Yelp настолько медленная? Оказалось, что это было сочетание многих вещей.
- Мы создавали Navigation Drawer сразу после создания Активити, а не лениво, только когда пользователь открывал ее. Это было справедливо даже для Активити из нижних вкладок.
- Иерархия макета была глубокой, содержала неиспользуемые и ненужные слои, которые можно было удалить.
- Каждая страница должна была создавать всю иерархию макета, а не только содержимое над нижней панелью навигации.
- У нас было несколько медленных вызовов во время
onCreate()
, таких как вызовы аналитики и случайные дисковые вводы и выводы, которые должны были выполняться в фоновом потоке. - У нас было много мелких, дешевых объектов, которые настраивались на каждом экране для простоты разработки, и их совокупность составляла значительную часть замедления.
Производственные данные
Мы также собрали некоторые данные о производительности навигации у реальных пользователей за пять месяцев. Ниже приведены данные для двух потоков с высокой посещаемостью:
Flow | Average (ms) | P99 (ms) |
---|---|---|
Home to Search Overlay | ~200 | ~1000 |
Search List to the Business Page | ~240 | ~380 |
Мы выяснили, что производительность была недостаточной, проверили наши цифры в локальных бенчмарках и доказали нашу гипотезу о навигационных переходах, видную невооруженным глазом.
Основываясь на этих исследованиях, команда Yelp Core Android решила, что решение этой проблемы будет бесценным. Рекомендуемая архитектура для Bottom Tab экрана — это использование «единой» Активити с несколькими фрагментами или представлениями. Теория заключается в том, что создание фрагмента или представления намного быстрее и дешевле, чем создание Активити. Однако существует множество способов реализовать это, поэтому нам предстояло принять важное решение.
Фрагменты против представлений
Чтобы определить, из чего должны состоять экраны в нашей новой системе с одной Активити, мы провели еще несколько локальных бенчмарков. Эти измерения также проводились на устройстве Pixel под управлением Android 7.1.1.
Scenario | 1st Time Navigation to Page (ms) | 2nd Time Navigation to Page (ms) |
---|---|---|
Plain View with no animation | 6 | 3 |
Plain View with animation | 6 | 3 |
Plain Fragment with no animation | 14 | 12 |
Plain Fragment with animation | 15 | 11 |
Мы обнаружили, что любое из этих решений обеспечивает значительно более высокую производительность навигации, чем использование Активити. Мы также провели сравнительный анализ с переходом между экранами с помощью общих элементов и без них, чтобы оценить их влияние на производительность, и обнаружили, что они оказывают незначительное негативное воздействие.
Исходя из всего вышесказанного, view были явно самыми быстрыми по времени, но эти цифры не раскрывают всей истории. Во-первых, разница в таймингах не видна невооруженным глазом, и обе они представляют собой значительный прирост производительности по сравнению с текущим положением. Во-вторых, помимо производительности, мы также должны были учитывать опыт разработки для нашего Android-сообщества и постоянную поддержку, которую мы сможем получить в будущем от любого решения, которое мы выберем для создания нашей новой единой Активити.
Подход 1: Навигация на основе View с Conductor
В то время Google напрямую не поддерживал навигацию на основе представлений (с тех пор она стала возможной благодаря навигации на основе Compose). Чтобы использовать представления, нам нужно было либо найти существующую библиотеку архитектуры навигации на основе представлений, либо создать ее самостоятельно. Было несколько перспективных решений с открытым исходным кодом, таких как Scoop от Lyft, Flow & Mortar от Square и библиотека Conductor от BlueLine Lab. Однако сторонние библиотеки с открытым исходным кодом имеют свои риски и проблемы, например, их могут со временем отменить или они могут устареть, как это произошло с двумя из упомянутых выше библиотек.
Мы оценили Conductor, и у него было много преимуществ, таких как:
- Молниеносные переходы
- Отличный API
- Поддержка переходов между общими элементами «из коробки»
- Поддержка RxLifecycle и других архитектурных компонентов с помощью дополнительных библиотек
Однако в конечном итоге мы сочли, что риск использования сторонней библиотеки для навигации слишком велик. Хотя технически представления были быстрее, но, приняв все во внимание, мы решили использовать фрагменты.
Для использования фрагментов существует множество старых и новых вариантов, предлагаемых Google. В отличие от представлений, фрагменты предназначены для использования в качестве экранов в рамках потока одной Активити. Поэтому, выбрав фрагменты в качестве решения, мы получаем всю сопутствующую поддержку, такую как документация, тестирование и управление жизненным циклом.
Подход 2: Jetpack Navigation Library от Google
Первым решением, основанным на фрагментах, которое мы оценили, была библиотека Google Jetpack Navigation Library. Библиотека была довольно новой, но, похоже, она должна была подойти для наших нужд. Разработчики задают навигационный граф в XML, а библиотека автоматически генерирует код для упрощения навигации между экранами, заданными в графе. Однако мы быстро обнаружили различные ограничения и препятствия для использования этой библиотеки.
Препятствие №1: Модули функций
Сборка Yelp для Android построена по модульному принципу: каждая фича находится в собственном модуле Gradle. Чтобы сохранить скорость сборки, мы не позволяем модулям Gradle, находящимся на одном уровне иерархии сборки, зависеть друг от друга. Это позволяет модулям собираться параллельно, что открывает множество преимуществ в производительности сборки.
Определение навигационных маршрутов в XML-файле означало, что навигационный граф всего приложения должен находиться в иерархии сборки выше, чем слой функций. Идентификаторы фрагментов также должны были быть объявлены ниже по иерархии, чтобы они были доступны во всех модулях и обеспечивали межмодульную навигацию.
Препятствие № 2: масштабируемость
Объявление всех экранов в одном XML-файле также привело бы к серьезной проблеме масштабируемости, когда у нас был бы один огромный и трудночитаемый файл, который все команды часто пересматривали бы. Кроме того, XML недостаточно динамичен для наших целей. Из-за проблем с производительностью в Android Gradle Plugin время сборки также увеличилось в три раза при попытке объявить идентификаторы фрагментов в модуле нижнего уровня. Наконец, даже при использовании вышеуказанного подхода межмодульная навигация становилась сложной и сводила на нет большинство преимуществ, которые давала библиотека.
После пяти лет усовершенствований библиотека навигации Jetpack может работать с большим количеством вариантов использования. Теперь можно динамически создавать навигационный граф на языке Kotlin, что должно помочь решить некоторые из проблем, с которыми мы столкнулись. Мы регулярно проводим переоценку этой возможности и, возможно, позже перейдем на ее использование. В целом, это отличная библиотека навигации, и в настоящее время мы используем ее для небольших потоков внутри большого экрана.
Выбранный подход: обычные старые фрагменты
Мы решили использовать обычные фрагменты без использования навигационной библиотеки Jetpack. Фрагменты являются хорошо поддерживаемой частью экосистемы Android и знакомы большинству разработчиков. Используя простую навигацию по фрагментам, мы смогли добиться желаемой производительности, получить визуально приятные переходы и решить проблему кросс-модульной навигации, с которой мы столкнулись при использовании библиотеки Jetpack Navigation.
SingleActivityNavigator — долгожданный уровень абстракции
Android предоставляет FragmentTransaction API для отображения и скрытия фрагментов, что мы и используем под капотом. Однако мы добавили слой абстракции, который скрывает FragmentTransaction и другой код, специфичный для навигации, от функций. Мы используем слои абстракций, когда можем, с большим успехом. Это дает нам в будущем (спасибо, нам!) большое преимущество, позволяя при необходимости менять реализацию, но без обновления каждой навигационной точки в приложении. Этот слой абстракции существует в виде интерфейса, который мы образно назвали «SingleActivityNavigator».
Переход с одного экрана на другой в одиночной Активити требует создания экземпляра нового фрагмента и последующего вызова displayInSingleActivity
, что требует просто наличия контекста Android и тега фрагмента.
Нижняя панель навигации
Мы создали нижние вкладки как обычную функцию, используя нашу MVI-библиотеку auto-mvi, которая является одновременно и производительной, и легко тестируемой. Теперь в одной Активити есть только один экземпляр нижней панели вкладок, и он разделяется между многими экранами. Это ускоряет создание фрагментов, и фрагментам в единой Активити нужно воспроизводить только содержимое над нижней панелью вкладок.
Navigation Drawer
Мы убрали Navigation Drawer, поскольку на тот момент он уже был устаревшей тенденцией в дизайне Android, и вместо этого переместили контент в «More Tab», доступные через нижнюю панель навигации. Это повысило производительность как фрагментов внутри отдельной Активити, так и самой Активити, поскольку она больше не требовалась на каждом экране.
Навигационные хуки для настройки свойств экрана
Мы позволяем каждому фрагменту нашей единой Активити настраивать свойства на уровне экрана через интерфейс SingleActivityNavigator. Эти свойства настраивают каждое свойство, необходимое при отображении фрагмента, и перенастраивают свойства экрана на требования последнего фрагмента при переходе назад. Настраиваемые параметры включают: цвет строки состояния, цвет значка строки состояния, должно ли содержимое фрагмента находиться под строкой состояния и цвет фона окна.
Перекрестная навигация по Gradle-модулям
Мы используем инъекцию зависимостей для получения фрагментов на основе ключа строки инъекции зависимостей. Это позволяет нам хранить фрагменты в отдельных функциональных модулях и получать их экземпляры из любого другого места в приложении. Одно из преимуществ использования интерфейса SingleActivityNavigator заключается в том, что, хотя мы в основном используем инъекцию зависимостей для получения фрагментов, это не является жестким требованием. Мы можем получать фрагменты и другими способами, что для нашего случая было важно, чтобы обеспечить обратную совместимость с некоторым устаревшим кодом.
Еще одним преимуществом этого подхода является то, что он позволяет сократить время сборки с помощью нашей модульной сборки Android Gradle.
Работа с глубокими ссылками
В приложении Yelp Consumer каждая внешняя глубокая ссылка сначала проходит через Активити, единственная цель которой — обработать параметры URL глубокой ссылки и решить, является ли URL безопасным и/или корректным. Эти действия называются URLCatcherActivity. Для каждого пункта назначения глубокой ссылки существует своя собственная Активити URLCatcherActivity. После обработки URL и разбора всех необходимых данных эта Активити отвечает за переход к целевому пункту в приложении. Хотя эти промежуточные действия во время запуска приложения не лучшим образом сказываются на времени холодного старта нашего приложения, мы выигрываем от отсутствия монолитного класса обработки глубоких ссылок и улучшения читабельности и тестирования.
Это подводит нас к тому, как мы добавили поддержку навигации по глубоким ссылкам во фрагменты. Основываясь на предыдущем разделе, мы знаем, что можем использовать инъекцию зависимостей для получения фрагмента на основе строкового ключа. Ключ используется для получения экземпляра фрагмента из графа зависимостей. Для перехода к фрагменту на основе глубокой ссылки мы используем дополнительный интент, обозначающий фрагмент для отображения. После разбора данных из URL мы передаем их в интент. Затем отдельная Активити использует это дополнительное намерение для получения экземпляра фрагмента из графа зависимостей. Она передает данные из URL в аргументы фрагмента и, наконец, отображает фрагмент.
Хотя это решение удовлетворяет нашим требованиям в соответствии с ограничениями (требуется URLCatcherActivity), дальнейшее повышение производительности стало возможным после внедрения единственной Активити. Чтобы еще больше улучшить навигацию по глубоким ссылкам и производительность холодного старта, мы теперь можем напрямую переходить по глубоким ссылкам к одиночной Активити и отображать фрагмент, что является значительным улучшением по сравнению с предыдущим состоянием.
Путь миграции
Переход на фрагменты состоит из трех этапов. Прежде чем приступить к миграции на фрагменты, сначала нужно было разобраться с Navigation Drawer и переместить его на вкладку «More Tab». Затем мы перенесли каждую Активити во фрагмент, оставив при этом исходные Активити на месте. На данный момент эти Активити были в основном пустыми оболочками и использовали уже существующий код навигации. Затем мы постепенно развернули версию, в которой каждый фрагмент использовался в одном Активити. Наконец, мы проследили за производительностью навигации, чтобы проверить, достигли ли мы ожидаемых улучшений.
Результаты
При записи результатов измерений на продакшене мы сосредоточились на отслеживании экранов в приложении с наибольшей посещаемостью. Мы отслеживали только первый переход в каждом сеансе, поскольку именно в этом случае изменения наиболее ощутимы и заметны. Мы обнаружили, что после того, как экраны уже созданы, переход между ними происходит исключительно быстро. Поэтому помните, что следующий результат включает в себя и создание представлений фрагментов.
В среднем по всем версиям Android и моделям устройств (low и high end) мы наблюдали ~30% прирост производительности навигации. Иногда мы наблюдали до ~60% улучшения времени навигации. Увеличение производительности зависит от экрана, его работы и внутренней структуры.
Заключение
Мы узнали, что несколько фрагментов в одной Активити выполняются гораздо быстрее, чем несколько отдельных Активити. Мы добились заметно лучшей плавности анимации между экранами, оставив наши фрагменты в отдельных функциональных модулях. Подобная постепенная и безопасная миграция вполне достижима.
Хотя производительность базовых компонентов Android — активити/фрагмента/представления — довольно сильно различалась, прирост производительности в любом проекте всегда зависит от конкретного кода и уже имеющихся решений. Вот почему в команде Core Android мы стараемся решать проблему производительности комплексно, используя performant-by-default решения, когда и где это возможно.
Наша реализация единой Активити хорошо работает уже много лет, и многие команды, работающие над приложением для потребителей, переняли этот шаблон для своих экранов. Наше приложение для владельцев бизнеса также последовало этому примеру и перешло на единую Активити на основе фрагментов. Хотя наши приложения теперь работают более плавно, мы сохраняем надежду, что навигационная библиотека Jetpack когда-нибудь решит все наши проблемы.