Примечание. Хотя стратегии, изложенные в этой статье, не зависят от платформы, мы используем конкретные примеры из Android, чтобы показать их выполнение.
Создание фреймворка производительности
Весной 2020 года мы начали работу по повышению производительности мобильных приложений Lyft, первоначально сосредоточив внимание на времени запуска приложения (также известном как время до взаимодействия или TTI, Time to Interact). В Lyft было много возможностей для улучшений в пространстве TTI, и мы были уверены, что с небольшими инвестициями мы сможем добиться значительного эффекта. Успех этого проекта помог проложить путь для дальнейших инвестиций в улучшение быстродействия приложений в Lyft.
Переход от работы над TTI к целостному плану повышения мобильного быстродействия в Lyft означал, что нам нужно было выйти за рамки одной метрики. При этом мы также хотели установить ключевые области, чтобы не «кипятить океан» и не работать над слишком многими способами улучшения. Используя документацию Google по производительности Android, мы сосредоточили наши инвестиции в Mobile Performance на трех показателях с наибольшими возможностями для улучшения.
- Время до взаимодействия (запуск приложения): дальнейшее сокращение времени запуска приложения, работа над которым началось в 2020 году.
- Стабильность: снижение количества сбоев и ANR (приложение не отвечает) при любом пользовательском опыте.
- Производительность рендеринга: поддержание высокой стабильности частоты кадров
При оценке приоритетности вышеуказанных метрик мы создали таблицу с приблизительными оценками возможности улучшения каждой метрики, действенности и влияния на пользователя. Мы присвоили каждой области вес и умножили их, чтобы получить оценку. Вес «Насколько действенно» (How Actionable) определяется уровнем инженерных усилий, необходимых для первого улучшения. Вес «Влияния на клиента» (Customer Impact) определяется дополнительным временем и усилиями, которые пользователи должны тратить во время взаимодействия с приложением из-за возникающих проблем с производительностью. Ниже приведен отрывок, конкретно описывающий приложение Android Lyft для водителей.
Основываясь на приведенной выше оценке, мы смогли определить, что стабильность приложения является явным кандидатом на приоритетность. В стабильности есть две подкатегории: сбои и ANR-ошибки. Отчеты о сбоях обычно содержат больше информации для отладки по сравнению с отчетами ANR, поэтому мы решили сначала разобраться со сбоями приложений.
Приоритет № 1: сбои приложений
Сбои могут возникать в любом месте кода и различаться по сложности и влиянию. Понимание причин сбоев может потребовать глубоких знаний базовых систем и фреймворков. А это может привести как к погружению в “кроличьи норы” расследований, так и к получению огромного количества информации, на основе которой нужно действовать. Было важно начать с наиболее очевидных “низко висящих плодов”, которые потенциально могли принести результаты, из которых мы могли извлечь уроки.
Мы начали со сбора релевантной статистики с помощью общепризнанных инструментов наблюдения: нашей собственной аналитики отчетов о стабильности, Bugsnag и Google Play Console. Синтез этой информации привел к нескольким ключевым выводам:
- Нативные сбои не учитывались во внутреннем отслеживании частоты сбоев Lyft. Нативные сбои — это сбои, возникающие на слое самой ОС. Они перехватываются и сообщаются иначе, чем обычные сбои Java. Мы использовали Bugsnag для общих отчетов о сбоях, но никогда не включали функцию отчетов о нативных сбоях.
- Топ-10 основных сбоев дали 53% от общего их числа. Учитывая масштаб приложения Lyft, сбоев может быть много. Мы были удивлены, обнаружив, что на 10 наиболее частых сбоев приходится более 50% их общего объема. На круговой диаграмме ниже перечислены основные сбои и их процент.
- Эти главные сбои были длительными и «неисправимыми». Каждый сбой в топ-10 занимал эту позицию не менее 6 месяцев. Это было связано с тем, что для исправления этих сбоев требовалось слишком много времени. Некоторые также медленно увеличивались с течением времени, ускользая от стандартного обнаружения и исправления.
Основные креши можно разделить на три группы:
- Сбои, вызванные сторонними SDK: картографирование и навигация внесли наибольший вклад.
- Сбои OutOfMemory: у Instabug есть хороший обзор этих сбоев здесь.
- Нативные сбои: происходят на уровне Native/C++ операционной системы Android.
Нам нужно было принять решение: на какие сбои мы должны нацелиться в первую очередь? Объемы сбоев были почти одинаковыми, поэтому возможность улучшения зависела от возможности их исправления в основных категориях.
Сбои в Google Maps SDK не могли быть исправлены, потому что мы не можем контролировать сторонние библиотеки, наш единственный вариант — сообщить о них в Google и работать с их инженерами над их устранением. Точно так же у нас не было достаточного количества инструментов для выявления нативных причин сбоев, поэтому они не могли быть немедленно исправлены без предварительного создания инструментов наблюдения. К счастью, для некоторых сбоев OutOfMemory (OOM) имелись действенные трассировки стека, поэтому мы решили поработать над ними.
Глубокое погружение в OOM сбои
Просматривая довольно много трассировок стека OOM сбоев, мы обнаружили много вызовов API блокировки RxJava2 (например, blockingGet()) при синхронном чтении значений с диска.
При чтении данных с диска внутреннее хранилище Lyft всегда создает новый поток ввода-вывода, подписываясь на планировщик ввода-вывода, считывая и кэшируя данные в PublishRelay и используя функцию blockingGet() из RxJava2.
Есть несколько проблем с этим подходом в отношении сбоев OOM. В документации RxJava отмечается, что планировщик ввода-вывода может создавать неограниченное количество рабочих потоков. Поскольку планировщик ввода-вывода использует CachedThreadPool, планировщик не сразу удаляет простаивающие потоки. Вместо этого планировщик поддерживает потоки в течение примерно 60 секунд, прежде чем очистить их. Вместо того, чтобы повторно использовать потоки, он создает 1000 новых потоков, если в минуту происходит 1000 операций чтения. Каждый поток может занимать как минимум 1–2 МБ памяти, что приводит к исключениям OOM.
Мы профилировали самые популярные операции чтения с диска для приложений Lyft и, к счастью, обнаружили, что большая часть операций чтения с диска происходила только в двух местах в кодовой базе, где количество операций чтения было исключительно высоким — больше 2 тыс. раз в минуту! Так мы нашли первопричину.
Поскольку новые потоки создавались только при чтении данных с диска, исправление было относительно простым. Когда приложение запускалось в холодном старте и данные читались с диска в первый раз, мы могли кэшировать данные в локальной памяти. Это позволяет выполнять последующие чтения из кэша и предотвращает создание множества потоков при чтении с диска.
Результаты эксперимента показали, что решение не только уменьшило количество сбоев OOM, но и уменьшило число сбоев на 53%. Мы не ожидали, что исправление окажет такое сильное влияние на нативные сбои, но очевидно, что причиной многих нативных сбоев была нехватка памяти приложения.
Расширяемся на ANR
Мы не смогли сразу определить с помощью дополнительных отчетов о сбоях Bugsnag другие действенные направления работы, поэтому, используя наш фреймворк приоритизации, мы перешли ко второй подкатегории стабильности приложения — ANR (приложение не отвечает).
ANR возникает, когда поток пользовательского интерфейса блокируется более 5 секунд, а операционная система предлагает пользователю закрыть приложение. ANR обычно трудно выявить без соответствующей трассировки стека. К счастью Bugsnag, инструмент, который Lyft использует для мониторинга стабильности приложений, не только предоставляет трассировки стека для ANR, но и группирует связанные ANR вместе, когда трассировки стека похожи. Отсортировав отчеты ANR в порядке убывания, мы обнаружили, что снова главный виновник — наш слой сохранения данных. В частности, использование нами SharedPreferences вызывало большинство ошибок ANR.
Мы хотели узнать больше о том, как SharedPreferences может вызывать ошибки ANR, поэтому мы изучили документацию Google. Google рекомендует вызывать SharedPreferences.apply() для асинхронной записи и редактирования данных. Под капотом SharedPreferences.apply() добавляет операции записи на диск в очередь вместо немедленного выполнения этих операций. Для нескольких событий жизненного цикла, включая Activity.onStop(), Service.onStartCommand() и Service.onDestroy(), SharedPreferences синхронно выполняет все операции записи на диск для очереди в основном потоке (эта статья содержит немного больше подробностей). В результате, если в очереди много операций, их синхронное выполнение может заблокировать основной поток на достаточно длительный период времени, чтобы вызвать ANR.
Мы снова хотели посмотреть, чем это вызывается в кодовой базе Lyft, поэтому мы профилировали операции записи на диск и обнаружили, что частота записи на диск для приложений Lyft достигает 1.5 тыс. раз в минуту. В некоторых случаях одно и то же значение записывалось на диск несколько раз в секунду, что приводило к трате ценных ресурсов. В целом мы смогли определить, что внутренний фреймворк хранения Lyft абстрагировал базовый механизм хранения, то есть дисковое хранилище и хранилище в памяти использовали один и тот же интерфейс, поэтому разработчики непреднамеренно рассматривали диск и хранилище в памяти как одно и то же.
Чтобы смягчить это, сначала мы поработали с продуктовыми командами, чтобы удалить все ненужные записи на диск из их функций. Затем мы добавили логгирование для аудита избыточных операций записи на диск. Затем на уровне функций мы создали кеш памяти для каждой из этих операций записи на диск и записывали только в кеш памяти. Наконец, мы синхронизировали кеш памяти и дисковое хранилище с разумной частотой в зависимости от варианта использования. Мы также отделили интерфейс дискового хранилища от интерфейса памяти, чтобы избежать путаницы.
После проведения эксперимента в течение нескольких месяцев это решение привело к снижению ANR на 21%.
Долгосрочная стратегия
Основываясь на результатах двух экспериментов, приведенных выше, мы поняли, что дисковое хранилище играет гораздо более важную роль в стабильности нашего приложения, чем мы изначально думали. После решения этих тактических вопросов мы также предложили долгосрочную стратегию, основанную на том, что мы уже узнали:
- Отслеживать частоту чтения/записи на диск. Фактическая частота чтения/записи на диск нашей системы хранения данных должна контролироваться и поддерживаться в разумных пределах независимо от стратегии реализации, потому что, как мы обнаружили, избыточное прямое чтение/запись на диск может вызвать ненужные проблемы с производительностью.
- Обеспечить правильную абстракцию. Операции с диском выполняются намного медленнее, чем операции с памятью. Наличие синхронных интерфейсов для дисковых операций неправильно и предоставляет разработчикам неправильную абстракцию, поэтому современные дисковые решения, такие как DataStore (Google) и SimpleStore (Uber), используют асинхронные интерфейсы.
- Глубоко погрузиться в фреймворки, которые влияют на все сообщество разработчиков. Существует множество решений для хранения данных на дисках, и у каждого решения есть свои плюсы и минусы. Понимание их перед использованием предотвратит возможные проблемы. В нашем примере мы могли бы избежать ANR, если бы понимали SharedPreferences на гораздо более глубоком уровне.
Выводы
В нашем первой статье в блоге мы описали, как наши небольшие первоначальные инвестиции в TTI получили достаточную внутреннюю поддержку для инвестирования в долгосрочную стратегию повышения производительности мобильных устройств.
В этой статье мы описали, как мы начали разработку этой долгосрочной стратегии, сначала определив области вложения сил и средств, а затем определяя структуру приоритизации. Используя эту структуру приоритизации, мы смогли решить самые проблемные и системные проблемы, от которых страдает наша кодовая база, и значительно улучшили общую стабильность приложения.
Хотя это была жизнеспособная первоначальная стратегия, сама природа “низко висящих плодов” заключается в том, что со временем они становятся все более редкими. В нашем следующем сообщении мы обсудим, как мы улучшили решение проблем в области производительности, увеличив инвестиции в наблюдаемость и отладку.