Site icon AppTractor

Наиболее распространенные узкие места в производительности Android-приложений

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

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

Безопасность основного потока

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

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

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

Не предоставляйте API, которые не являются безопасными для основного потока. Это просто распределяет ответственность по всей кодовой базе — и гарантирует, что кто-то допустит ошибку.

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

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

Если вы стремитесь к 60 кадрам в секунду (FPS), вам необходимо предоставлять новый кадр каждые 16,6 мс (1 секунда / 60 кадров).

Кстати, если вас беспокоит фильтрация списка из 20–30 элементов в основном потоке — обычно это не является серьезной проблемой. Стоимость таких операций незначительна по сравнению с бюджетом в ~16 мс на кадр, даже на устройствах низкого класса. Не пытайтесь перенести все операции из основного потока в надежде на молниеносную загрузку, потому что вы можете столкнуться с другой проблемой, которую мы обсудим позже.

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

Имейте в виду, что бюджет в ~16 мс на кадр относится не только к вашему коду. Он также включает в себя измерения, компоновку, отрисовку и работу, выполняемую конвейером рендеринга.

Таким образом, если ваш код сам по себе занимает около 14 мс, вы уже слишком близки к пределу. На практике этот кадр с большой вероятностью не достигнет своего крайнего срока и приведет к рывкам. Я описал это подробнее в этой статье.

Другие проблемы многопоточности

Избыточное распараллеливание

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

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

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

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

Неправильные диспетчеры

Использование неправильного диспетчера — распространенная ошибка. Dispatchers.Default предназначен для работы, интенсивно использующей ЦП, в то время как блокирующие операции относятся к Dispatchers.IO.

Если вы запускаете блокирующие вызовы в Default, вы можете легко истощить ограниченный пул потоков ЦП. С другой стороны, запуск ресурсоемкой работы в IO может привести к чрезмерному росту пула, что приведет к избыточному распараллеливанию и дополнительному переключению контекста.  Выбор правильного диспетчера важен в обоих случаях.

Неправильное понимание концепций параллелизма

Я заметил распространенную закономерность — люди часто используют Mutex, когда хотят ограничить количество вызовов. Но он не предназначен для этого. Мьютекс — это инструмент синхронизации, он защищает доступ к критическим данным, но не контролирует как часто что-то выполняется.

Если вы получаете слишком много вызовов, проблема обычно находится выше. Что-то запускает работу чаще, чем должно. Мьютекс это не исправит — он просто сериализует вызовы и скроет реальную проблему.

В больших системах с множеством точек входа это проявляется довольно часто. В этом случае речь идет не столько о добавлении блокировок, сколько о понимании того, откуда поступает работа и почему она запускается.

Утечки памяти

Это заслуживает отдельного подробного анализа, но это одна из самых распространенных проблем — и об этом уже много написано, поэтому нет смысла повторять то же самое здесь.

Реактивный ад

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

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

Чрезмерное количество сетевых вызовов

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

Как это обычно происходит: 5–7 лет назад кто-то написал фрагмент кода. Вероятно, этот человек уже даже не работает в компании. Со временем код развивался — и вместе с ним рос и технический долг.

Затем в какой-то момент кто-то наткнулся на ошибку. Вместо того чтобы разобраться с основной проблемой, он идёт по короткому пути — запускает сетевой вызов для обновления состояния. Это работает, поэтому всё остаётся. И так начинаются эти проблемы. С годами накапливается всё больше и больше таких быстрых исправлений.

Самое сложное в том, что люди адаптируются к изменениям удивительно быстро. Если вы долго работаете над проектом и не сравниваете старую и новую версии напрямую, легко упустить из виду, насколько замедлился процесс. И это работает в обе стороны — когда я улучшал некоторые процессы на 20–30%, я часто не ощущал разницы, пока не сравнивал их напрямую.

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

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

И, конечно же, если цель — уменьшить количество сетевых запросов, убедитесь, что это число действительно уменьшилось. Такие инструменты, как Chucker, Charles или любой другой сетевой прокси, значительно упрощают эту задачу: очистите сессию, запустите именно тот поток, который хотите протестировать, и посчитайте запросы.

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

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

Кэширование

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

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

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

Иерархия представлений (дорогостоящий процесс измерения/компоновки/отрисовки)

Я бы разделил это на две отдельные проблемы.

1. Неэффективная иерархия представлений

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

Сглаживание иерархии, избегание чрезмерной вложенности и уменьшение перерисовки могут заметно улучшить ситуацию.

2. Слишком много элементов интерфейса на одном экране

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

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

Вот пример того, как разделить длинный экран оформления заказа с помощью пошагового интерфейса:

Это будет безопаснее и проще в обслуживании, чем вот это:

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

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

Вы также можете присоединиться к проекту, где эта проблема уже существует, или наблюдать, как она постепенно проявляется со временем.

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

Обновления всего экрана

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

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

Как предотвратить проблемы с производительностью

Мониторинг производительности Firebase

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

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

Создание отдельных трассировок для каждого случая обычно нецелесообразно. Передача атрибута намного проще и обеспечивает согласованность данных и облегчает их анализ.

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

StrictMode

Еще один полезный инструмент, который быстро настраивается и помогает выявлять проблемы на ранней стадии — еще до того, как ваш код попадет на проверку.

Leak Canary

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

Google Play Console

Помогает отслеживать ANR (Application Not Responding) — случаи, когда основной поток блокируется слишком долго, обычно около 5 секунд или более.

Эти инструменты сообщают вам о проблеме. Настоящая сложность заключается в понимании причины — и именно здесь начинается расследование.

Источник

Exit mobile version