Мы сталкиваемся с этой проблемой на каждом втором проекте: приложение загружает изображение за изображением, а память растёт до OOM. Или — более мягкий сценарий — пользователи на iPhone 12 в режиме Low Data Mode ждут 4–6 секунд до появления первого изображения, потому что загружается оригинал 4K вместо превью. Из нашей практики: заказчик обратился с жалобой на вылеты при просмотре галереи из 200 фото. Android Profiler показывал Bitmap allocation 4, 8, 14, 23 MB… Типичный корень — отсутствие параметризованных URL ресайза и неправильный кэш. Мы внедряем связку: CDN-ресайз + disk-кэш + BlurHash. Оптимизация загрузки изображений мобильного приложения требует комплексного подхода.
Какие проблемы решает оптимизация загрузки изображений?
Неправильный размер изображения
Сервер отдаёт оригинал 2400×3200, ImageView — 80×80 dp. Glide, Kingfisher или Coil делают downsampling, но сначала эти 29 MB приходят по сети и декодируются в памяти. Параметризованные URL ресайза (?w=160&h=160&fit=crop) решают проблему до загрузки. Это снижает трафик на 30-50% и ускоряет отображение.
Отсутствие disk-кэша
По умолчанию SDWebImage кэширует на диск, но если кто-то установил SDWebImageOptions.refreshCached для «свежести» данных — каждый запуск приложения загружает все изображения заново. На экранах с аватарами пользователей это означает 20–30 лишних сетевых запросов при каждом открытии. Правильный лимит кэша (500 MB) позволяет загружать изображения один раз.
Последовательная загрузка вместо параллельной
Кастомные реализации через URLSession.dataTask нередко создают очередь, где следующий запрос стартует после завершения предыдущего. В списке из 10 элементов — ждём суммарно все 10 RTT вместо максимального из них.
OOM при загрузке галереи
Пагинация с окном видимости ±2 страницы, параметризованные URL с размером под ImageView и ограничение кэша decoded images до 20–100 MB решают проблему. В carousel с 50+ изображениями — lazy loading и предзагрузка только текущей и соседних.
Почему важна оптимизация загрузки изображений?
Оптимизация загрузки изображений напрямую влияет на пользовательский опыт и метрики приложения. Медленная загрузка — причина до 40% отказов на мобильных устройствах. Внедрение методов, описанных выше, окупается за счёт снижения расходов на CDN и повышения удержания пользователей. В одном из проектов мы сократили время загрузки с 8 до 1.2 секунды, что привело к увеличению конверсии на 15%.
Почему важно использовать WebP?
WebP обеспечивает сжатие на 25–35% лучше JPEG при том же визуальном качестве. На Android Glide поддерживает WebP из коробки, на iOS Kingfisher — через установку флага .webpConversion. Для максимальной эффективности передавайте параметр формата в URL — ?format=webp. Это уменьшает трафик и ускоряет загрузку, экономя бюджет на хостинг изображений.
Как мы оптимизируем загрузку изображений?
Наш подход к оптимизации загрузки изображений включает стек: на iOS — Kingfisher для Swift-проектов, SDWebImage для Obj-C legacy. Kingfisher удобен KFImage в SwiftUI и нативной поддержкой @MainActor. Ключевые настройки:
KingfisherManager.shared.cache.diskStorage.config.sizeLimit = 500 * 1024 * 1024 // 500 MB
KingfisherManager.shared.cache.memoryStorage.config.totalCostLimit = 100 * 1024 * 1024 // 100 MB
Для прогрессивной загрузки JPEG — ImageDataProcessor с ProgressiveJPEGAddon. Пользователь видит размытое изображение сразу, а не плейсхолдер 2 секунды.
На Android: Coil для Compose-проектов (нативная AsyncImage), Glide для View-based. Glide умеет thumbnail(0.1f) — загружает 10% от оригинального размера как placeholder пока грузится полная версия. Для WebP-конвертации на сервере Glide справляется из коробки, Coil требует SvgDecoder / VideoFrameDecoder через отдельные зависимости.
Обязательный паттерн для обоих платформ: параметризованные URL с размером под конкретный ImageView. Если бэкенд на Cloudinary или imgproxy — передаём ?width={viewWidthDp * density}&format=webp&quality=80.
Placeholder стратегия
Пустой серый прямоугольник — плохо. BlurHash или ThumbHash — хорошо. Это компактный (20–30 байт) хэш изображения, который рендерится локально в виде цветного размытого превью до загрузки реального контента. На iOS — библиотека BlurHash, на Android — io.github.nicklockwood:thumbhash. Данные хэша приходят вместе с JSON-ответом API — нулевые сетевые затраты на превью.
Кейс: carousel с 50 изображениями (из нашей практики)
Клиент реализовал UIScrollView с UIImageView через page control. При открытии экрана — сразу загружал все 50 изображений. На медленном 3G WKWebView закрывался по OOM через 3–4 листания.
Решение: lazy loading через UIPageViewController с окном видимости ±2 страницы. NSCache с лимитом 20 MB для decoded images. Остальные — только URL в памяти. Время до первого взаимодействия сократилось с 8 до 1.2 секунды, а затраты на CDN уменьшились на 40%.
Сравнение подходов
| Метод |
Экономия трафика |
Время загрузки |
Сложность внедрения |
| Ресайз на сервере |
30–50% |
-1.5 с |
Низкая (изменить URL) |
| BlurHash |
0% (хэш) |
На 1.5 с быстрее |
Средняя (добавить хэш) |
| Disk-кэш 500 MB |
До 80% при повторах |
-0.7 с |
Низкая (настройка лимита) |
| Прогрессивный JPEG |
0% |
-0.3 с на превью |
Средняя (добавить процессор) |
| Типичная ошибка |
Последствие |
Исправление |
| Загрузка оригинала без ресайза |
OOM, 4-6 с загрузки |
Параметризованный URL |
| Отсутствие disk-кэша |
Многократные запросы |
Настройка лимита кэша |
| Последовательная загрузка |
Суммарное ожидание |
Параллельная загрузка |
Настройка параметризованных URL
Для imgproxy используйте формат: /rs:fit:320:320/plain/https://example.com/image.jpg. Cloudinary: /c_fit,w_320,h_320/f_webp,q_80. Параметры должны учитывать плотность экрана (density).
Что входит в работу
- Аудит текущей схемы загрузки изображений (сетевая библиотека, кэш, размеры)
- Интеграция Kingfisher/Glide/Coil с оптимальными настройками
- Настройка параметризованных URL ресайза (CDN)
- Внедрение BlurHash или ThumbHash превью
- Оптимизация пагинации и lazy loading
- Тестирование на реальных устройствах и в условиях плохого соединения
- Документация по архитектуре и рекомендации для поддержки
Сроки
Аудит загрузки изображений и настройка библиотеки — 1–3 дня. Если нужна интеграция CDN-ресайза и BlurHash по всему приложению — 1 неделя. Стоимость рассчитывается индивидуально.
Гарантируем, что после оптимизации OOM не повторится, а время загрузки сократится минимум вдвое. Наш опыт — более 5 лет работы с мобильными приложениями, более 50 успешных проектов. Получите консультацию — мы ответим на вопросы и предложим план улучшений. Закажите аудит или свяжитесь с нами, чтобы начать оптимизацию.
Оптимизация мобильных приложений: 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 рабочих дней. Реализация оптимизаций — от недели до двух месяцев в зависимости от запущенности проблем и архитектуры кода.