Оптимизация сетевых запросов: как сократить время загрузки экрана?
Главный экран приложения делает 14 параллельных запросов при открытии. Казалось бы — параллельно, значит быстро. Но HTTP/1.1 ограничен 6 соединениями к одному хосту, и 8 запросов стоят в очереди. На слабом LTE с RTT 180 мс — суммарное ожидание до готовности экрана превышает 2 секунды. Каждая секунда задержки снижает конверсию на 7%, а удержание — на 20%. Потери выручки от медленных экранов могут составлять миллионы рублей ежемесячно.
Переход на HTTP/2 с мультиплексированием или агрегация запросов на BFF-слое (Backend for Frontend) решает это без изменений в клиентском коде. HTTP/2 — протокол, который мультиплексирует запросы в одном соединении, устраняя блокировку очереди (head-of-line blocking). Наши инженеры применяют оба подхода в зависимости от архитектуры.
Где теряется время?
Лишние запросы. Самое частое — отсутствие кэширования на клиенте. URLSession на iOS по умолчанию уважает Cache-Control заголовки, но только если сервер их выставляет. Если API возвращает Cache-Control: no-store «для надёжности» — каждое обращение к справочным данным (категории, настройки, конфигурация) идёт по сети. URLCache с лимитом 50 MB и ручная установка URLRequest.cachePolicy = .returnCacheDataElseLoad для read-only эндпоинтов работает как быстрый патч.
Избыточные payload. REST-эндпоинт для списка пользователей возвращает 40 полей, из которых UI использует 4. На списке из 100 элементов это лишние 60–80 KB JSON на каждый запрос. GraphQL решает это на уровне протокола, но если GraphQL нет — ?fields=id,name,avatar_url как query-параметр фильтрации хотя бы частично спасает ситуацию.
Повторные запросы при ротации экрана. На Android ViewModel + LiveData/StateFlow держат результат запроса и не перезапускают его при пересоздании Activity. Но если запрос живёт в Fragment.onViewCreated без проверки — при каждой ротации идёт новый сетевой вызов. Диагностируется через Charles Proxy или OkHttp EventListener с логированием.
Инструменты и решения: какой подход выбрать?
iOS (URLSession / Alamofire / Moya)
Alamofire RequestInterceptor — удобное место для retry-логики с exponential backoff:
func retry(_ request: Request, for session: Session, dueTo error: Error,
completion: @escaping (RetryResult) -> Void) {
let delay = min(pow(2.0, Double(request.retryCount)), 30.0)
completion(.retryWithDelay(delay))
}
URLSession с waitsForConnectivity = true — запрос автоматически ждёт восстановления сети вместо немедленной ошибки. Критично для offline-first приложений.
Android (OkHttp / Retrofit)
OkHttp CacheInterceptor уже встроен, достаточно передать Cache при создании клиента:
val cache = Cache(context.cacheDir, 50L * 1024 * 1024)
val client = OkHttpClient.Builder().cache(cache).build()
Retrofit + suspend fun — автоматическая отмена запроса при смерти coroutine scope. Главное — привязывать scope к viewModelScope, а не к GlobalScope.
Дедупликация запросов
Если несколько компонентов одновременно запрашивают один ресурс — выполнять запрос один раз. На iOS — Combine с share() оператором на Publisher. На Android — StateFlow в Repository: первый подписчик запускает запрос, остальные получают результат из того же flow. Это соответствует рекомендациям Apple Human Interface Guidelines по отзывчивости.
Request prioritization
На iOS URLSession поддерживает URLRequest.networkServiceType: .responsiveData для пользовательских действий, .background для аналитики и prefetch. Система приоритизирует трафик соответственно — аналитика не конкурирует за bandwidth с пользовательским запросом.
На Android WorkManager с NetworkType.CONNECTED и приоритетом EXPEDITED vs стандартным — для фоновой синхронизации данных без блокировки основного потока запросов.
Как настроить кэширование: пошаговая инструкция
- Определите read-only эндпоинты (справочники, категории, конфигурация).
- Проверьте, выставляет ли сервер Cache-Control. Если нет — настройте клиентское кэширование принудительно.
- На iOS установите URLCache с лимитом 50 MB и укажите cachePolicy = .returnCacheDataElseLoad.
- На Android создайте OkHttp Cache размером 50 MB и передайте в OkHttpClient.
- Для динамических данных используйте ETag или Last-Modified: клиент отправляет If-None-Match, сервер возвращает 304 Not Modified, экономя трафик.
Сравнение HTTP/1.1 и HTTP/2
| Характеристика |
HTTP/1.1 |
HTTP/2 |
| Количество соединений на хост |
6 (обычно) |
1 (мультиплексирование) |
| Head-of-line blocking |
Да (очередь запросов) |
Нет (потоки внутри соединения) |
| Сервер push |
Нет |
Да (server push) |
| Сжатие заголовков |
Нет (HPACK) |
Нет (HPACK) |
| Время загрузки на слабом LTE (14 запросов) |
>2 с |
~600 мс |
HTTP/2 даёт выигрыш в 3-4 раза на слабых сетях за счёт устранения очередей.
Кейс: GraphQL N+1 на мобиле
Из нашей практики: приложение использовало GraphQL, но запросы строились «как удобно» — отдельный query на каждую карточку в списке при детальном просмотре. 20 карточек = 20 запросов. Внедрение DataLoader-паттерна на клиенте через @defer directive (Apollo iOS / Apollo Android поддерживают) позволило батчить запросы. Время загрузки детального экрана — с 2.8 с до 0.6 с. Опыт нашей команды показывает, что такой подход применим и для REST через BFF.
Сравнение стратегий кэширования
| Подход |
iOS |
Android |
Снижение трафика |
Сложность |
| URLCache / OkHttp Cache |
URLCache + cachePolicy |
Cache в OkHttp |
до 70% |
Низкая |
| Disk-based + memory |
+ |
+ |
до 90% |
Средняя |
| ETag / Last-Modified |
URLSession по умолчанию |
OkHttp по умолчанию |
до 50% при 304 |
Низкая |
| Pragma / Cache-Control |
Настраивается |
Cache-Control |
до 80% |
Средняя |
Типичные ошибки при оптимизации сетевых запросов
- Забывают настроить Cache-Control на сервере, из-за чего кэширование не работает.
- Используют один и тот же HTTP-клиент для всех запросов без учёта приоритетов.
- Не проверяют поведение на слабых сетях — эмулятор LTE обязателен.
- Делают запросы при каждой отрисовке экрана, а не при создании ViewModel.
Что входит в работу?
- Аудит текущего сетевого слоя с замером времени и анализом трафика.
- Внедрение HTTP/2 или агрегации на BFF.
- Настройка кэширования (URLCache, OkHttp Cache, ETag).
- Дедупликация и батчинг запросов.
- Оптимизация payload (GraphQL или field-фильтрация).
- Retry-логика и prioritization.
- Документация и рекомендации по дальнейшей поддержке.
Свяжитесь с нами, чтобы заказать аудит сетевого слоя — оценим ваш проект за 1 день.
Почему стоит обратиться к нам?
У нас 5+ лет опыта в оптимизации мобильных приложений для iOS и Android. Мы выполнили более 50 проектов по ускорению загрузки, кэшированию и снижению трафика. Гарантируем, что после аудита вы получите конкретные рекомендации с измеримыми метриками. Оставьте заявку на аудит и получите детальный отчет с метриками.
Сроки
Аудит сетевого слоя и точечные оптимизации — 3–5 дней. Внедрение кэширования, retry-логики и дедупликации по всему приложению — 1–2 недели. Получите консультацию — мы рассчитаем точные сроки под ваш проект.
Оптимизация мобильных приложений: 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 рабочих дней. Реализация оптимизаций — от недели до двух месяцев в зависимости от запущенности проблем и архитектуры кода.