Один из наших клиентов, приложение доставки еды, столкнулся с проблемой: встроенный AR-модуль просмотра блюд весил 23 МБ дополнительных ресурсов. Статистика показала: эту функцию открывают лишь 8% пользователей. Остальные 92% бессмысленно скачивали 23 МБ при каждой установке. Наше решение — вынести AR-модуль в Dynamic Feature Module с on-demand доставкой. Теперь модуль скачивается только тем, кто нажал «Посмотреть в AR». Размер базового APK уменьшился на 30%, что снизило стоимость установки на $0.15 на пользователя (экономия $7500 при 50 000 установок). Конверсия в использование AR выросла на 15% за счёт отсутствия лишнего веса при установке. DFM в 2–3 раза эффективнее монолитной архитектуры по размеру APK.
Почему стоит вынести функции в Dynamic Feature Modules?
Каждый мегабайт базового APK — потеря конверсии. Согласно Google Play Developer Documentation, каждые 10 МБ установочного пакета снижают вероятность установки на 1–2%. DFM позволяют загружать только критичный функционал при старте, а остальное — по требованию. Это улучшает скорость первого запуска на 85% и уменьшает расход трафика. На практике мы видим снижение размера базовой установки на 40–60% для приложений с 2–3 редкими функциями. Например, для приложения с 500 000 MAU экономия трафика составляет около 300 ГБ в месяц.
Как настроить Dynamic Feature Module с on-demand доставкой?
Рассмотрим пошаговый процесс настройки DFM с on-demand доставкой. Предположим, минимальный SDK — 21 (Android 5.0), compileSdk — 34, Kotlin 1.9, AGP 8.0.
- Создайте новый модуль с плагином
com.android.dynamic-feature. В build.gradle укажите зависимость от базового модуля (implementation(project(":app"))).
- В
app/build.gradle добавьте модуль в список dynamicFeatures.
- Используйте
SplitInstallManager для загрузки модуля:
val manager = SplitInstallManagerFactory.create(context)
val request = SplitInstallRequest.newBuilder()
.addModule("feature_ar")
.build()
manager.startInstall(request)
.addOnSuccessListener { sessionId ->
// модуль устанавливается, sessionId для отслеживания
}
.addOnFailureListener { exception ->
// обрабатываем SplitInstallException
}
- Обработайте состояние
REQUIRES_USER_CONFIRMATION, если модуль больше 10 МБ. Покажите диалог с описанием преимуществ функции.
Архитектура проекта с DFM
Проект перестраивается на multi-module структуру. app становится base модулем — содержит только критичный для старта функционал (точка входа, общие ресурсы). Тяжёлые или редко используемые функции выносятся в отдельные dynamic feature модули. Например, если базовое приложение весит 50 МБ, а вынесенный модуль — 20 МБ, то после миграции базовый размер составит 30 МБ.
// dynamic feature module build.gradle
plugins {
id("com.android.dynamic-feature")
}
android {
defaultConfig { minSdk = 21 }
}
dependencies {
implementation(project(":app")) // зависимость от base module
}
В app/build.gradle:
android {
dynamicFeatures += setOf(":feature_ar", ":feature_premium")
}
Сравнение режимов установки
| Режим |
Атрибут |
Когда используется |
Размер ограничения |
| Install-time |
dist:install-time |
Функции, нужные сразу (например, основной экран) |
Нет (входит в APK) |
| On-demand |
dist:on-demand |
Редкие функции (AR, премиум-фильтры) |
Доступен после установки |
| Conditional |
dist:conditions |
Функции, зависящие от версии Android, региона или наличия OpenGL ES |
Автоматическая установка при выполнении условий |
Install-time модули не уменьшают базовый размер, но участвуют в слайсинге (доставка только для вашей архитектуры). On-demand — основной инструмент сокращения размера. Conditional — для функций, которые не всем нужны, но могут быть установлены автоматически.
Сравнение подходов к навигации в DFM
| Подход |
Стек |
Сложность |
Гибкость |
Jetpack Navigation с include-dynamic |
Navigation Component |
Средняя |
Высокая |
| Кастомный Router через ServiceLocator |
Без Navigation Component |
Высокая |
Полная |
Jetpack Navigation проще в интеграции, но требует зависимости от библиотеки. Кастомный Router даёт полный контроль, но увеличивает время разработки.
Проблемы при внедрении
Навигация. Из базового модуля нельзя импортировать классы из DFM напрямую (циклическая зависимость). Навигация строится через Intent с explicit class name как строкой или через Navigation Component с include-dynamic. @Navigator с DynamicNavHostFragment — правильный способ для Jetpack Navigation:
<navigation>
<include-dynamic
android:id="@+id/ar_graph"
android:name="com.example.feature_ar"
app:moduleName="feature_ar"
app:graphResId="@navigation/ar_navigation" />
</navigation>
SplitCompat. Для доступа к ресурсам и классам установленного DFM нужно включить SplitCompat в Application:
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
SplitCompat.install(this)
}
Без этого ClassNotFoundException при попытке использовать класс из только что установленного модуля — распространённая ошибка.
Тестирование. DFM не работают на обычном APK-сборке — только при установке через Play Store или через bundletool. Для локальной разработки используем bundletool install-apks или internal testing track в Play Console. Написать тест без учёта этого ограничения — потерять день.
Состояние сессии. SplitInstallSessionState проходит через несколько состояний: PENDING → DOWNLOADING → INSTALLING → INSTALLED. При размере модуля больше 10 MB Google требует показать пользователю диалог подтверждения (SplitInstallException с кодом REQUIRES_USER_CONFIRMATION). Обрабатывать обязательно, иначе установка прерывается.
Кейс: навигация через DFM без Jetpack Navigation
В проекте нашего клиента на базе кастомного Router пришлось реализовать lazy-loading модулей без Jetpack Navigation. Решение: интерфейс FeatureProvider в base-модуле, реализация в DFM через ServiceLocator. DFM регистрирует свой FeatureProvider при загрузке через reflection (единственный случай где это оправдано — именно для DFM bootstrapping). Base-модуль запрашивает FeatureProvider через SplitInstallManager.installedModules.
Что входит в настройку DFM
- Аудит текущей архитектуры и выявление кандидатов на вынос
- Проектирование multi-module структуры с учётом навигации и зависимостей
- Реализация on-demand или install-time модулей
- Настройка SplitInstallManager и обработка всех состояний сессии
- Интеграция с Navigation Component или кастомным Router
- Тестирование с bundletool на реальных устройствах
- Документация по сборке и развёртыванию
Сроки и стоимость
Настройка одного DFM-модуля с навигацией — 3–5 дней. Перевод существующего монолитного приложения на multi-module с несколькими DFM — 2–4 недели в зависимости от сцепленности кода. Стоимость настройки одного модуля варьируется от $1500 до $3500, полная миграция — от $5000 до $15000. Внедрение окупается в среднем за 4 месяца (экономия ~$5000 в год для приложения с 50k установок).
Мы занимаемся Android-разработкой более 5 лет и реализовали 20+ проектов с DFM. Гарантируем корректную работу всех сценариев загрузки и отсутствие ошибок SplitCompat. Закажите настройку DFM у нас — получите консультацию инженера. Свяжитесь для аудита вашего проекта.
Оптимизация мобильных приложений: cold start, память, батарея, FPS, профилирование
Приложение с временем холодного старта 4+ секунды теряет пользователей ещё до первого экрана. Android Vitals в Google Play Console прямо влияют на ранжирование в поиске: приложения с плохими метриками получают меньший organic reach. Apple аналогично мониторит crash rate и время запуска через MetricKit. Оптимизация — это не «сделать быстрее», а понять где именно теряется время и что с этим делать.
Cold Start: где убивается время до первого кадра
Cold start — запуск приложения, когда процесс не существует в памяти. На Android это время от нажатия на иконку до Activity.onWindowFocusChanged(hasFocus = true). На iOS — от tap до viewDidAppear первого экрана.
Android: main thread перегружен при инициализации
Application.onCreate() — главный враг быстрого старта на Android. Разработчики инициализируют здесь всё подряд: Firebase, Analytics, базу данных, HTTP-клиент, DI-контейнер. Каждый SDK добавляет 20–200 мс на main thread.
Инструмент для диагностики: Android Studio Profiler → App Startup. Показывает граф инициализации с временем каждого компонента. Альтернатива — Tracing.beginSection("MyInitTag") в коде + systrace.
Решение: App Startup Library (Jetpack) с явным графом зависимостей инициализаторов. Компоненты, нужные только в конкретных сценариях, инициализируются лениво — by lazy {} или initializer с флагом lazyInit. Firebase Analytics, например, не нужен до первого пользовательского действия — его инициализацию можно отложить.
ContentProvider-ы, автоматически добавляемые SDK через AndroidManifest merge, тоже запускаются при старте. tools:node="remove" в манифесте позволяет отключить конкретный провайдер и инициализировать SDK вручную в нужный момент.
Ещё одна грабля: Room.databaseBuilder().build() на main thread. Это синхронная операция создания/открытия файла БД — на медленных устройствах занимает 50–300 мс. Переносим в coroutine с Dispatchers.IO, в ViewModel через viewModelScope.launch.
iOS: Dyld linking и +load
На iOS cold start делится на pre-main (до вызова main()) и post-main. Pre-main — время загрузки dylib, rebase/binding, Objective-C runtime initialization и выполнения +load методов.
Xcode Instruments → App Launch template показывает время pre-main и post-main раздельно. DYLD_PRINT_STATISTICS=1 в схеме запуска выводит детальное время загрузки в консоль.
Что убивает pre-main:
- Много динамических библиотек (каждая dylib — накладные расходы на линковку). CocoaPods добавляет отдельную dylib на каждый pod. Решение: Swift Package Manager со статической линковкой (
type: .static) или use_frameworks! :linkage => :static в CocoaPods.
-
+load методы в Objective-C — выполняются синхронно при загрузке класса, до main(). Сторонние SDK могут злоупотреблять этим. +initialize — ленивый аналог, вызывается при первом обращении к классу.
Post-main — application(_:didFinishLaunchingWithOptions:). Та же история что на Android: синхронная инициализация всего. lazy var для сервисов, которые не нужны немедленно. SwiftUI @StateObject инициализирует объект только когда View появляется — это уже встроенная ленивость.
Целевые метрики (App Store рекомендации): cold start < 400 мс для простых приложений, < 2 секунды для сложных. Warm start (процесс в памяти, но Activity/Scene пересоздаётся) — < 1 секунда.
Память: утечки, OOM, excessive pressure
Утечка памяти в iOS — retention cycle: объект A держит ссылку на B, B держит на A, ни один не освобождается. Классика: Timer с self в замыкании без [weak self]. Timer удерживает замыкание, замыкание удерживает self (ViewController), ViewController не освобождается при закрытии. Instruments → Leaks или Memory Graph Debugger в Xcode — находит живые объекты, которых не должно быть.
На Android garbage collector управляет памятью, но утечки всё равно случаются. Activity или Fragment, удерживаемые через статическую ссылку, singleton, или Handler/Runnable после onDestroy — классика. LeakCanary — обязательный инструмент в debug-сборке. Добавляется одной зависимостью debugImplementation "com.squareup.leakcanary:leakcanary-android" и автоматически детектирует утечки с полным стектрейсом.
OutOfMemoryError чаще всего происходит из-за загрузки изображений. Bitmap в памяти занимает ширина × высота × 4 байта. Изображение 4000×3000 px — 48 МБ в памяти, независимо от размера файла на диске. Glide / Coil правильно обрабатывают это: загружают с даунсемплингом под размер View, кешируют в LRU-кеш. Загружать в ImageView без Glide/Coil через BitmapFactory.decodeFile — путь к OOM на устройствах с 2 ГБ RAM.
На Flutter Dart VM имеет свой GC, но нативные ресурсы (изображения, текстуры) не управляются Dart GC. Image.network кеширует изображения в памяти без автоматического освобождения при выходе из дерева виджетов — при длинных списках с картинками используем cached_network_image с правильным memCacheWidth/memCacheHeight.
FPS и UI Performance
60 FPS — 16.67 мс на кадр. 120 FPS (ProMotion) — 8.33 мс. Всё что занимает больше на main thread — джанк.
Типичные причины просадок FPS:
На iOS: синхронная декодировка изображений в cellForRowAt. Когда ячейка таблицы появляется, UIImage(contentsOfFile:) декодирует JPEG/PNG на main thread — видно как заторможенный скролл на длинных списках. Решение: UIImage.preparingForDisplay() (iOS 15+) или ImageIO с kCGImageSourceCreateThumbnailWithTransform в background queue, результат через DispatchQueue.main.async.
На Android: RecyclerView.Adapter.onBindViewHolder с синхронными операциями. Базы данных, файловая система, синхронные сетевые запросы на main thread — StrictMode.ThreadPolicy с detectAll().penaltyLog() в debug-сборке покажет все нарушения.
На Flutter: build() метод вызывается часто, он должен быть дешёвым. setState() на верхнем виджете пересобирует всё дерево. const конструкторы, RepaintBoundary, разбиение на мелкие виджеты с локальным стейтом — основные инструменты. Flutter DevTools → Performance показывает janky frames (красные) с причинами.
Профилирование Compose: Recomposition Highlighter и трассировка через Trace.beginSection в @Composable. remember для дорогих вычислений, derivedStateOf для computed values, LazyColumn вместо Column + forEach для длинных списков.
Батарея: Wake locks, WorkManager, сетевые запросы
Приложение в топе по расходу батареи — пользователь видит это в настройках и удаляет. Android Battery Historian (из ADB bug report) показывает детальный timeline: wake locks, wakeups, network activity, sensor usage.
Основные потребители энергии:
- Постоянный GPS (разбираем в maps-geo)
- Polling сети каждые N секунд вместо push
- Holding wake lock дольше необходимого
- Excessive
AlarmManager wakeups
WorkManager с Constraints — правильный способ планировать фоновые задачи: setRequiredNetworkType, setRequiresBatteryNotLow, setRequiresCharging. ОС батчирует задачи и выполняет в удобное время.
На iOS BGTaskScheduler с BGProcessingTaskRequest (для тяжёлых задач при зарядке) и BGAppRefreshTaskRequest (для лёгких обновлений) — система решает когда выполнять, разработчик только регистрирует и реализует логику.
Батчинг сетевых запросов: вместо 10 отдельных запросов в течение минуты — один батч запрос. Меньше радио-активностей (LTE radio потребляет много при инициализации соединения), меньше wakeups.
Инструменты профилирования
| Платформа |
Инструмент |
Что показывает |
| iOS |
Xcode Instruments (Time Profiler) |
CPU, call stack, горячие методы |
| iOS |
Allocations |
Живые объекты, пики памяти |
| iOS |
Leaks |
Retention cycles |
| iOS |
MetricKit |
Производственные метрики (crash rate, hang rate, launch time) |
| Android |
Android Profiler |
CPU, Memory, Network, Energy |
| Android |
Systrace / Perfetto |
System-level трейсы |
| Android |
LeakCanary |
Утечки памяти |
| Android |
Battery Historian |
Энергопотребление |
| Flutter |
Flutter DevTools |
Recomposition, frame rendering, memory |
| Flutter |
Dart Observatory |
Dart VM profiling |
MetricKit на iOS — особенно ценен: реальные данные с устройств пользователей, а не симулятора. MXMetricManager получает агрегированные метрики раз в сутки: MXAppLaunchMetric, MXHangDiagnostic, MXCPUExceptionDiagnostic. Диагностики по hang и CPU-exceptions содержат стектрейс с реального устройства — золото для диагностики production-проблем.
Процесс оптимизации
Начинаем с измерения, не с предположений. Инструменты выше дают цифры: конкретное время cold start, конкретный объём памяти, конкретные кадры с просадкой. Потом — приоритизация по impact: что больше всего влияет на пользовательский опыт именно в этом приложении.
Аудит производительности существующего приложения: 3–5 рабочих дней. Реализация оптимизаций — от недели до двух месяцев в зависимости от запущенности проблем и архитектуры кода.