Узнайте, какие мифы о производительности Android выдержали испытание бенчмарком

В преддверии старта курса "Android Developer. Basic" приглашаем всех желающих посмотреть открытый урок по теме "Unit-тестирование в Android".

А также делимся переводом полезного материала.


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

В этой статье мы собираемся проверить эти мифы на прочность в духе MythBusters (Разрушители легенд). Для развенчания мифов мы используем реальные примеры и инструменты, которые вы тоже можете использовать. Мы ориентируемся на превалирующие шаблоны использования: то, что вы, как разработчики, вероятнее всего, делаете в своем приложении. Но стоит озвучить одно предостережение: помните, что очень важно сначала производить измерения, прежде чем принимать решение об использовании той или иной практики по соображениям производительности. Тем не менее, давайте приступим к разрушению мифов!

Миф 1: Приложения на Kotlin больше и медленнее, чем приложения на Java

Команда Google Drive перенесла свое приложение с Java на Kotlin. Этот перенос затронул более 16 000 строк кода и 170 файлов, охватывающих более 40 таргетов сборки. Среди показателей, которые отслеживала команда, одним из важнейших было время запуска.

Как видите, переход на Kotlin не возымел существенного влияния.

Даже больше, команда не заметила разницы в производительности во всем наборе тестов. Они действительно отметили небольшое увеличение времени компиляции и размера скомпилированного кода, но примерно на 2%, что не играет особой роли.

С другой стороны, команда добилась сокращения количества строк кода на 25%. Их код стал чище, понятнее и проще в обслуживании.

Следует отметить одну вещь касательно Kotlin: вы можете и должны использовать инструменты сжатия кода, такие как R8, в котором даже есть специальные оптимизации для Kotlin.

Миф 2: Геттеры и сеттеры обходятся дорого

Некоторые разработчики из соображений производительности предпочитают использовать public поля вместо сеттеров и геттеров. Распространенный шаблон кода, где getFoo служит в качестве геттера, выглядит примерно так:

public class ToyClass {
   public int foo;
   public int getFoo() { return foo; }
}
ToyClass tc = new ToyClass();

Мы сравнили это с использованием public поля tc.foo, когда код нарушает инкапсуляцию объекта для прямого доступа к полям.

Мы протестировали это с помощью библиотеки Jetpack Benchmark на Pixel 3 с Android 10. Библиотека бенчмарков предоставляет фантастический способ легко протестировать ваш код. Среди особенностей библиотеки - то, что она предварительно разгоняет код, поэтому результаты представляют собой стабильные показатели.

Так что же показали бенчмарки?

Версия с геттером работает так же хорошо, как и версия, с прямым доступом к полю. Этот результат неудивителен, поскольку среда выполнения Android (ART) инлайнит в ваш код все тривиальные методы доступа. Таким образом, код, выполняемый после компиляции JIT или AOT, одинаков. Даже больше, когда вы обращаетесь к полю в Kotlin — в этом примере tc.foo — вы обращаетесь к этому значению с помощью геттера или сеттера в зависимости от контекста. Однако, поскольку мы инлайним все методы доступа, ART выручит нас: разницы в производительности нет.

Если вы не используете Kotlin и если только у вас нет веской причины сделать поля public, вам не следует ломать хорошие практики инкапсуляции. Скрывать private данные вашего класса — хорошая идея, и вам не следует изобличать их только по соображениям производительности. Используйте геттеры и сеттеры.

Миф 3: Лямбда-выражения медленнее, чем внутренние классы

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

Возьмем код, в котором мы суммируем значения некоторых внутренних полей из массива объектов. Сначала используя потоковые API с операцией map-reduce.

ArrayList<ToyClass> array = build();
int sum = array.stream().map(tc -> tc.foo).reduce(0, (a, b) -> a + b);

Здесь первая лямбда преобразует объект в целое число, а вторая суммирует два полученных значения.

Это можно сравнить с определением эквивалентных классов для лямбда-выражений.

ToyClassToInteger toyClassToInteger = new ToyClassToInteger();
SumOp sumOp = new SumOp();
int sum = array.stream().map(toyClassToInteger).reduce(0, sumOp);

Есть два вложенных класса: один - это toyClassToInteger, который преобразует объекты в целое число, а второй - это операция суммирования sum.

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

Однако что насчет различий в производительности? Мы снова использовали библиотеку Jetpack Benchmark на Pixel 3 с Android 10 и не обнаружили разницы в производительности.

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

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

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

Миф 4: Аллоцирование объектов — дорогое удовольствие, лучше использовать пулы

Android использует самые современные функции выделения памяти и сборки мусора. Аллоцирование объектов улучшалось почти в каждом релизе, как можно увидеть на следующем графике.

Сборка мусора также значительно улучшилась от релиза к релизу. Сегодня сборка мусора не влияет на сбои или плавность работы приложения. На следующем графике показано улучшение, которое мы сделали в Android 10 для сбора объектов с коротким жизненным циклом с параллельным Gen-CC. Улучшения, которые также заметны в новом релизе Android 11.

Пропускная способность существенно выросла в бенчмарках сборки мусора, таких как H2, более чем на 170%, а в реальных приложениях, таких как Google Sheets, — на 68%.

Так как же это влияет на решения в написании, например, аллоцировать ли объекты с помощью пулов?

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

Pool<A> pool[] = new Pool<>[50];
void foo() {
   A a = pool.acquire();
   …
   pool.release(a);
}

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

Чтобы проверить это, мы реализовали микробенчмарк для измерения двух вещей: накладных расходов на стандартную аллокацию и извлечения объекта из пула и накладных расходов ЦП, чтобы выяснить, влияет ли сборка мусора на производительность приложения.

Для этого сценария мы использовали Pixel 2 XL с Android 10, выполняя код аллокации тысячи раз в очень жестком цикле. Мы также смоделировали объекты разных размеров, путем добавления дополнительных полей, потому что производительность может быть разной для маленьких и больших объектов.

Вот результаты накладных расходов на аллокацию объектов:

Вот результаты накладных расходов ЦП на сборку мусора:

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

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

Итак, разрушен ли этот миф? Не совсем. То, будут ли пулы объектов более эффективными, зависит от потребностей вашего приложения. Во-первых, помимо сложности кода, помните о других недостатках использования пулов:

  • Может иметь более высокий объем памяти.

  • Риск сохранения объектов в живых дольше, чем необходимо.

  • Требуется очень эффективная реализация пула

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

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

Миф 5: Профилирование моего отлаживаемого приложения — это хорошая идея

Профилирование вашего приложения, пока оно является отлаживаемым (debuggable), было бы действительно удобно, ведь, в конце концов, обычно вы пишете код в режиме отладки. И даже если профилирование в отлаживаемом режиме немного неточно, возможность выполнять итерацию быстрее должна компенсировать это. К сожалению, нет.

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

На некоторых тестах, например на десериализации, это не отражается. Однако для других существует 50% или более регрессии эталонного теста. Мы даже нашли примеры, которые были на 100% медленнее. Это связано с тем, что среда выполнения очень мало оптимизирует ваш код, когда он является отлаживаемым, т.е. код, который пользователи запускают на производственных устройствах, сильно отличается.

Результатом профилирования в отлаживаемом режиме является то, что вы можете быть неправильно перенаправлены на горячие точки в своем приложении и будете в итоге тратить время на оптимизацию того, что не требует оптимизации.

Странные дела

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

Странность 1: Multidex: влияет ли это на производительность моего приложения?

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

Вопрос в том, сколько методов - это слишком много? И есть ли влияние на производительность, если приложение имеет большое количество dex-файлов? Может быть даже не из-за того, что ваше приложение слишком велико, вы можете просто разделить dex-файлы dex на основе функций для упрощения разработки.

Чтобы исследовать влияние множества dex-файлов на производительность, мы взяли калькулятор. По умолчанию это приложение с одним dex-файлом. Затем мы разбили его на пять dex-файлов на основе границ его пакета, чтобы имитировать разбиение по функциям.

Затем мы протестировали несколько аспектов производительности, начиная со времени запуска.

Разделение dex-файла на это не повлияло. Для других приложений могут возникнуть небольшие накладные расходы в зависимости от нескольких факторов: насколько велико приложение и как оно было разделено. Однако, если вы разбиваете dex-файл разумно и не добавляете их сотни, влияние на время запуска должно быть минимальным.

 А как насчет размера APK и памяти?

Как видите, немного увеличился и размер APK, и объем памяти во время работы приложения. Это связано с тем, что, когда вы разделяете приложение на несколько dex-файлов, каждый dex-файл содержит некоторые дублированные данные для таблиц символов и кешей.

Однако вы можете минимизировать этот прирост, уменьшив зависимости между dex-файлами. В нашем случае мы не пытались его минимизировать. Если бы мы попытались минимизировать зависимости, мы бы обратились к инструментам R8 и D8. Эти инструменты автоматизируют разделение dex-файлов, помогают избежать распространенных ошибок и минимизировать зависимости. Например, эти инструменты не создадут больше dex-файлов, чем необходимо, и не поместят все startup-классы в основной файл. Однако, если вы делаете разбиение dex-файлов самостоятельно, всегда измеряйте то, что вы разбиваете.

Странность 2: Мертвый код

Одно из преимуществ использования среды выполнения с JIT-компилятором, например ART, заключается в том, что среда выполнения может профилировать код, а затем оптимизировать его. Существует теория, что если код не профилируется интерпретатором/JIT-системой, он, вероятно, также не выполняется. Чтобы проверить эту теорию, мы исследовали профили ART, созданные приложением Google. Мы обнаружили, что значительная часть кода приложения не профилируется ART интерпретатором JIT-системы. Это показатель того, что большая часть кода на самом деле никогда не будет выполняться на устройствах.

Есть несколько типов кода, которые не могут быть профилированы:

  • Код обработки ошибок, который, надеюсь, выполняется нечасто.

  • Код для обратной совместимости, код, который не выполняется на всех устройствах, особенно на устройствах с Android 5 или более поздней версией.

  • Код для редко используемых функций.

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

Быстрый, простой и бесплатный способ удалить ненужный код — минимизировать его с помощью R8. Затем, если вы еще этого не сделали, преобразовать ваше приложение для использования Android App Bundle и Play Feature Delivery. Они позволяют улучшить взаимодействие с пользователем, устанавливая только те функции, которые используются.

Выводы

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

Существует множество инструментов, которые помогут вам измерить и решить, что лучше всего подходит для вашего приложения. Например, в Android Studio есть профилировщики для нативного и неродного кода, в нем даже есть профилировщики для использования батареи и сети. Есть инструменты, которые могут копнуть глубже, например Perfetto и Systrace. Эти инструменты могут предоставить очень подробное представление о том, что происходит, например, во время запуска приложения или сегмента вашего выполнения.

Библиотека Jetpack Benchmark устраняет все сложности, связанные с измерениями и сравнительным анализом. Мы настоятельно рекомендуем вам использовать его в вашей непрерывной интеграции, чтобы отслеживать производительность и видеть, как ваши приложения ведут себя, когда вы добавляете в них дополнительные функции. И последнее, но не менее важное: не выполняйте профилирование в режиме отладки.

Java является зарегистрированным товарным знаком Oracle и/или ее дочерних компаний.


Узнать подробнее о курсе "Android Developer. Basic".

Посмотреть открытый урок по теме "Unit-тестирование в Android".

ЗАБРАТЬ СКИДКУ