Люди спрашивают нас в Twitter «почему мобильные приложения такие большие» и мы в Emerge разбираемся с ними. Это нечто вроде нашего бэт-сигнала.
Обычно мы разбираем приложения для iOS, поскольку они кажутся на порядок больше своих аналогов для Android. И почти всегда кто-то указывает на размер Android по сравнению с iOS.
На первый взгляд это правда! Размер, который мы видим в магазине приложений для iOS, почти всегда в разы больше размера Android-аналога в Google Play Store.
Но что, если я скажу вам, что размеры приложений для Android больше, чем может показаться на первый взгляд?
В этой статье мы расскажем, почему приложения для Android больше, чем кажется, откуда берутся размеры на обеих платформах и действительно ли приложения для iOS намного больше, чем для Android.
Магазины приложений
Давайте начнем с самого начала — что показывают магазины приложений? В качестве примера мы возьмем Linear, поскольку это полностью нативное* (здесь есть один большой файл index.html для того, что, скорее всего, является способом запуска веб-кода в приложении) приложение для iOS и Android.
Магазин приложений iOS (слева) и Google Play Store (справа) дают нам дико разные цифры для размера:
iOS показывает расплывчатое число «размер», а Android — «размер загрузки». На первый взгляд, разница шокирует.
iOS показывает 88.3 МБ против 9.52 МБ у Android — это означает, что приложение Linear для iOS в ~9.25 раза больше, чем его аналог для Android!
Неужели Linear что-то напутала с iOS? Не совсем.
Неужели приложения для iOS настолько больше, чем для Android? Не в 9.25 раза.
Размер установки по сравнению с размером загрузки
Во-первых, давайте уточним, что такое «размер» в iOS. В магазине приложений iOS отображается Install Size (размер установки), в то время как в Google Play — Download Size (размер скачивания). Из блога Spotify о размере приложений:
Сравнение скриншотов соответствующих магазинов приложений — это не сравнение яблок с яблоками. Размер установки всегда будет больше, чем размер загрузки, поскольку загрузка сжимается для оптимизации передачи данных.
Из анализа размера приложения Linear для iOS, проведенного Emerge, мы можем определить, что размер загрузки Linear составляет 33.6 МБ (размер загрузки для публичных IPA не может быть точным на 100%, но он дает хорошую оценку). Но подождите, 33.6 МБ (iOS) против 9.52 МБ (Android) — это все еще ~3.5 раза больше!? Как такое возможно?
Размер, который Android «скрывает» от вас
Давайте поговорим об ART (Android Runtime). Нативные приложения для Android обычно пишутся на Kotlin или Java. Kotlin/Java компилируется в байткод, в частности в байткод Dex (dex) на Android, и он оптимизирован для работы в ограниченных средах, таких как телефон или планшет.
Android может выполнять Dex напрямую, но не без ущерба для производительности. Чтобы избежать этих потерь, ART предварительно компилирует части кода, чтобы ускорить его выполнение. За это приходится платить увеличением размера устанавливаемого приложения.
Как выполняется код
Нативный код
Компьютеры выполняют нативный код — необработанные инструкции, которые указывают процессору, что делать, например, загрузить что-то в память или выполнить вычисления. Несмотря на то, что для человеческого глаза dex (и другой байткод) выглядит как «нативный код», это не так Чтобы стать нативным кодом, dex должен пройти еще один этап.
В приведенном ниже фрагменте мы видим простую функцию Sort, которая иллюстрирует написанный нами код (Kotlin) в сравнении со скомпилированным байткодом dex и, наконец, нативным кодом, который выполняет процессор:
Важным моментом является то, что код на Kotlin/Java, который мы пишем, не является тем, что выполняется в Android. Даже после того, как он «скомпилирован» в байткод dex, он все равно должен стать нативным кодом, чтобы Android мог его запустить.
И этот скомпилированный нативный код довольно большой! Больше, чем наш исходный код Kotlin и наш байткод dex.
Компиляция по сравнению с интерпретацией
Компилируемый язык непосредственно преобразуется в нативный код компилятором (C++/ Swift). После компиляции компьютер напрямую выполняет инструкции. Недостатком является то, что скомпилированный нативный код работает только на той платформе, для которой его создал компилятор (например, x86, ARM64).
Интерпретируемый язык использует интерпретатор для преобразования исходного кода в нативный код по одной инструкции за раз (Python/Javascript). Интерпретированный код не зависит от компилятора и обеспечивает большую гибкость, поскольку не компилируется под конкретную платформу. Недостатком является снижение производительности из-за необходимости «перевода» из исходного кода в нативный.
Как Android выполняет код
Kotlin и Java не относятся к какой-то одной категории. Они «компилируются» в dex на Android. Если не делать никаких оптимизаций, то dex интерпретируется, то есть каждая инструкция байткода dex переводится в нативный код по очереди по мере выполнения программы.
Интерпретация кода выполняется медленно, особенно на устройствах с ограниченными возможностями, таких как низкобюджетные телефоны на Android. Чтобы ускорить выполнение байткода, ART использует такие тактики, как компиляция «точно в срок» (JIT) и «с опережением» (AOT). JIT происходит во время выполнения, и скомпилированный код не сохраняется, что не влияет на размер.
Компиляция с опережением времени (AOT)
С помощью AOT ART предварительно компилирует dex в нативный код и сохраняет нативный код для последующего использования.
Полная AOT-компиляция приложения может иметь значительные последствия для пространства (и времени). Тут начинается profile-guided оптимизация..
Оптимизация с помощью профилей (Profile-guided optimization, PGO)
Оптимизация с помощью профилей использует профили (статистические данные о коде) для определения часто используемого кода. Эти профили используются для AOT-компиляции наиболее часто используемого кода (а не всего dex), оптимизируя производительность и дисковое пространство.
Если вы являетесь разработчиком Android, то, возможно, уже слышали о базовых профилях — базовые профили являются разновидностью PGO!
PGO ускоряет выполнение кода, компилируя наиболее используемые части байткода dex в нативный код и сохраняя нативный код для последующего (повторного) использования. Это повышает производительность при оптимальных затратах дискового пространства, предварительно компилируя только самые важные части dex.
Цена, которую приходится платить: размер установки
Размер установки на Android
Поскольку Android предварительно компилирует байткод dex в нативный код, этот скомпилированный нативный код нужно где-то хранить.
Давайте вернемся к Linear.
Мы можем принудительно очистить весь нативный код с помощью adb shell cmd compile -reset app.linear
(слева) и сравнить его со свежей установкой (справа), чтобы увидеть заметную разницу:
Приложение без скомпилированного нативного кода занимает 17.86 МБ, а после свежей установки — 25.81 МБ! Откуда взялись эти ~8 МБ? Ответ — базовые профили.
ART использует PGO для предварительной компиляции наиболее используемых частей кода.
После проверки на устройстве мы можем подтвердить, что это AOT-компилированный .odex
, который является нашим нативным кодом.
# /data/app/.../app.linear-.../oat/arm64 $ ls -l -rw-r--r-- 1 system all_a6089 0 2024-09-20 16:10 base.art -rw-r--r-- 1 system all_a6089 8064752 2024-09-20 16:10 base.odex -rw-r--r-- 1 system all_a6089 273892 2024-09-20 16:10 base.vdex
Этот нативный код генерируется ART во время установки с помощью компилятора на устройстве под названием dex2oat. Мы не будем вдаваться в подробности, но если вам интересно, то вот дальнейшая информация.
Размер установки iOS
Нативные приложения для iOS, с другой стороны, полностью компилируются в нативный код во время сборки. Это означает, что iOS получает производительность нативного кода, но размер бинарного файла больше, чем у Android (где только часть кода компилируется в нативный). Здесь нет ручки для управления тем, сколько кода компилируется в машинный код, как в Android.
Чтобы перейти от исходного кода Swift к машинному коду, нужно выполнить несколько шагов. Сначала фронтенд компилятора Swift преобразует ваш код в язык Swift Intermediate Language (SIL), что позволяет, помимо прочего, выполнить некоторые пользовательские операции по оптимизации. Затем SIL «опускается» в более общее промежуточное представление LLVM (IR), и на его основе LLVM может генерировать машинный код для различных целевых архитектур.
Когда вы собираете свое приложение в Xcode, вы можете увидеть, какие именно команды отправляются компилятору Swift:
Покопавшись в этой гигантской команде сборки, мы в конце концов увидели, где Xcode выводит нативный код для архитектуры arm64:
/Users/User/Library/Developer/Xcode/DerivedData/HackerNews-dzukkzbeqbfejeapaejefshbwqhx/Build/Intermediates.noindex/HackerNews.build/Debug-iphonesimulator/HackerNews.build/Objects-normal/arm64/
Открыв эту папку, вы увидите все объектные файлы .o, которые мы ожидаем увидеть в исходном файле. Эти объектные файлы затем соединяются вместе в конечный бинарный результат, который упаковывается для пользователей. Если вы посмотрите на двоичный файл, то обнаружите большую часть машинного кода приложения в сегментах __TEXT и __DATA.
Яблоки к яблокам — сравнение размера установки Linear на Android с размером установки на iOS
При «свежей установке» обоих приложений iOS все еще в ~3.5 раза больше (87.6 МБ против 25.1 МБ). Но что, если мы заставим Android вести себя как iOS?
adb shell cmd package compile -m speed -f app.linear
Выполнение этой команды AOT полностью компилирует приложение Linear для Android, генерируя весь возможный нативный код из байткода dex. Стоит отметить, что полная AOT-компиляция приложения для Android, скорее всего, никогда не произойдет в природе, но это забавное упражнение для сравнения iOS и Android.
Проверка размера установки дает нам результаты, которые гораздо ближе друг к другу, чем наши оригинальные цифры из магазинов приложений:
Ну вот, это уже больше похоже на правду. 87.6 МБ на iOS против 56.08 МБ на Android, размер полностью скомпилированной установки AOT всего в ~1.5 раза больше (31.5 МБ).
Не поймите меня неправильно, 1.5x — это все еще большой разрыв! Но давайте вспомним, с чего мы начали:
Шаг | iOS | Android | Разница |
---|---|---|---|
App store | 88.3 MB («size») | 9.52 MB | ~9.25x (78.78 MB) |
Download size (Emerge) | 33.6 MB | 9.52 MB | ~3.5x (23.94 MB) |
On-device Install | 87.6 MB | 25.81 MB (нормальная установка) | ~3.5x (61.59 MB) |
On-device Install | 87.6 MB | 56.08 MB (полная AOT компиляция) | ~1.5x (31.5 MB) |
А как насчет дополнительной разницы в размере?
Между приложениями Linear для iOS и Android все еще существует несущественная разница в размере ~31.5 МБ. Давайте рассмотрим некоторые причины этого.
Пакет приложений и разделенные APK
Android еще больше оптимизирует размер приложений, используя формат AAB (App bundle). Формат AAB разделяет приложение на несколько более мелких модульных APK, предназначенных для конкретного устройства пользователя, а не на один универсальный APK, содержащий все ресурсы, архитектуру и языки, которые поддерживает приложение.
Таким образом, Play может поставлять минимальное количество языков, кода и ресурсов, чтобы обеспечить оптимальный размер загрузки и установки.
У Apple есть похожая функция под названием App Thinning», которая создает несколько вариантов приложения для отправки пользователям в зависимости от типа их устройства. Это означает, что изображение, масштабированное для iPad, не будет загружено на iPhone.
Хотя у Apple есть механизм уменьшения размера неиспользуемых изображений, он не оптимизирует локализации. При загрузке приложение для iOS содержит все поддерживаемые им локализации, даже если пользователь, скорее всего, использует только одну. Приложение Gmail для iOS содержит ~130 МБ локализаций, что больше, чем размер всего несжатого приложения Gmail для Android 🤯.
Swift
Swift — это современный язык с расширенными возможностями и безопасностью памяти. Мы в Emerge любим Swift, но за все эти возможности приходится расплачиваться сгенерированным результатом. Двоичные файлы Swift по своей природе более раздуты, чем (в качестве крайнего примера) у языка вроде C, где вы сами управляете памятью.
Синтез компилятора для автоматического соответствия типам также становится популярным для все большего количества функций, которые теперь обобщаются с добавлением макросов. В некоторых случаях это может привести к неожиданному объему кода, например, для приведения сложного типа в соответствие с Codable, и большая часть этого сгенерированного кода попадает в конечный бинарник.
Мы рекомендуем вам поиграть с таким инструментом, как Godbolt, чтобы убедиться в этом! Эта простая 6-строчная программа генерирует 1936 строк ассемблера:
import Foundation struct JSONTest: Codable { let firstName: String let lastName: String }
iOS может быть сложной
Если вы следите за нами в Twitter, то знаете, что в приложениях для iOS есть множество источников раздутости, которой можно избежать. Даже функция App Thinning, о которой мы говорили выше, может быть реализована разработчиками «неправильно».
Дублирующиеся файлы
17% линейного размера приложений для iOS приходится на дубликаты файлов.
Вы можете создать динамический фреймворк для совместного использования кода и ресурсов, однако правильно модулировать и разделять код в приложении для iOS сложнее, чем могло бы быть. Хотя дублирование может происходить и на Android, оно далеко от масштабов iOS.
Раздувание бинарных символов
Чтобы отладчик мог работать с нативным кодом, компилятор Swift вставляет в двоичный файл различные отладочные символы DWARF. Когда происходит отладка кода и срабатывает точка останова, с помощью этих символов Xcode преобразует адреса памяти функций и переменных в человекочитаемые имена.
Эти символы нужны только для разработки. К сожалению, их очень легко включить случайно, и это распространенный источник раздутости в приложениях для iOS. Отладочные символы также присутствуют в DEX-файлах Android, но Android Studio очень хорошо относится к их удалению по умолчанию в релизных сборках.
Вот как часто это происходит 👇.
Забавно, но приложение Linear на самом деле является жертвой другого типа раздувания символов в iOS — ненужных бинарных попыток. Это 6.6 МБ, которые можно сократить с помощью настроек в Xcode.
Сам факт того, что разработчики iOS имеют множество способов получения ненужных символов, неслыханен для Android.
Мы могли бы продолжить перечислять другие особенности iOS, например, как FedEx использует 56 МБ комментариев и Unicode в своих локализациях или как «уровни» в Candy Crush Saga занимают на 30 МБ больше на iOS, чем на Android, из-за минимального размера файла. Но на этом мы остановимся.
Другие различия в размерах
Кратко рассмотрим другие источники различий в размерах
- Оптимизации R8/Proguard: R8 и Proguard — это инструменты, используемые для минификации байткода dex, который содержат приложения для Android. В этих инструментах используется множество оптимизаций (например, древовидная перестройка, обфускация и т.д.), которые идут гораздо дальше, чем аналогичные функции на iOS.
- Динамические функции (Android) против ресурсов по требованию (iOS): Android поддерживает динамические функции, позволяя загружать части приложения по требованию, например, определенные библиотеки или функции. Аналог Apple, «Ресурсы по требованию», поддерживает только такие активы, как видео или файлы игр, но не полные функции приложения.
Подведение итогов
Фух. Размер приложений может быть сложным.
Если подвести итог, то приложения для Android больше, чем вы думаете:
- Размер, показанный в Google Play, — это размер загрузки. Это сжатое приложение, в то время как Apple показывает размер установки (несжатое приложение).
- Android предварительно компилирует часть приложения в нативный код. Этот нативный код хранится вместе с dex-байткодом приложения, что увеличивает размер установки.
Но приложения для iOS все равно больше, чем для Android, потому что:
- Приложения для iOS компилируются как полностью нативные по сравнению с Android, которые компилируются лишь частично.
- Swift — более многословный язык, чем Kotlin, и автоматически генерирует больше нативного кода, чем Android
- Инструментарий Apple позволяет легче получить раздутый код, чем Android
Обе платформы имеют широкий спектр механизмов и функций, которые разработчики могут использовать для уменьшения размера приложений и которые связаны с историей платформ. iOS, с жестким контролем над аппаратным и программным обеспечением, поставляет именно тот нативный код, который будет работать на вашем iPhone. С другой стороны, Android, где OEM-производители могут поставлять любое оборудование, которое они захотят, развил продвинутое разделение APK для загрузки только тех частей приложения, которые нужны для конкретного устройства.
Попробуйте загрузить свое приложение сегодня, чтобы узнать, что скрывается за цифрами.
Отдельное спасибо Гектору Дирману, Ною Мартину и Максу Топольски за помощь в написании статьи.