Производительность мобильного: старт, плавность, батарея, трафик
В вебе производительность — это в основном про то, как быстро страница станет интерактивной. Метрики LCP, INP и CLS описывают один сеанс: человек пришёл, посмотрел, ушёл. Мобильное приложение живёт на устройстве месяцами и открывается по десять раз в день, поэтому у слова «медленно» здесь минимум четыре разных значения, и оптимизации под них конфликтуют.
Пользователь говорит «тормозит», имея в виду одно из:
- долго открывается — между тапом по иконке и полезным экраном проходит секунды;
- дёргается — прокрутка спотыкается, анимация рвётся, клавиатура появляется рывком;
- садит батарею и греется — телефон тёплый в кармане, к вечеру 20 % заряда;
- жрёт трафик — тариф кончился на десятый день, а приложение всего лишь показывало ленту.
Это четыре разных бюджета с разными единицами измерения, разными инструментами и разными владельцами внутри команды. Хуже того, они связаны: агрессивный префетч ускоряет открытие экрана, но тратит трафик и энергию; большой кэш картинок делает прокрутку плавной, но раздувает память, из-за чего систему быстрее убивает ваш процесс в фоне — и человек чаще платит холодным стартом. Оптимизация мобильного — не поиск «узкого места», а управление балансом между четырьмя бюджетами.
Общий контекст (энергия, серая зона сети, чужой жизненный цикл) разобран в обзоре трека; здесь мы переводим его в измеримые числа и код. Если вы приходите из веба, полезно рядом держать статью о веб-производительности — многие принципы совпадают, но границы бюджетов сдвинуты.
Четыре бюджета и что их связывает
| Бюджет | Единица | Кто ограничивает | Типичная цель | Как ломается |
|---|---|---|---|---|
| Старт | миллисекунды до полезного экрана | ожидание пользователя, watchdog ОС | холодный старт до 1 с, первый кадр до 400–500 мс | инициализация всех SDK в первом колбэке |
| Кадр | миллисекунды на кадр | частота дисплея: 16,7 / 11,1 / 8,3 мс | менее 1 % кадров дольше бюджета | работа на главном потоке |
| Энергия | мВт·ч за сеанс, пробуждения в час | ёмкость батареи, тепловой троттлинг | не попадать в отчёты ОС о расходе | опрос сервера, wakelock, GPS |
| Трафик | байты и число активаций радио | тариф пользователя, качество канала | лента открывается на 200 КБ | JSON без сжатия, картинки в полном разрешении |
Связывающий эти бюджеты закон один: энергия равна средней мощности, умноженной на время. Всё, что вы делаете — вычисления, отрисовка, сеть, — превращается в тепло. Отсюда два неочевидных следствия.
Первое: «медленно» и «прожорливо» — часто одно и то же. Плохо написанный парсер, который держит процессор нагруженным полсекунды вместо пятидесяти миллисекунд, тратит на порядок больше энергии на ту же работу. Оптимизация под скорость обычно даёт оптимизацию под батарею бесплатно.
Второе: race to idle. Современные SoC умеют глубоко засыпать, но только когда работы нет совсем. Десять маленьких порций работы, размазанных по секунде, обходятся дороже, чем одна порция той же суммарной длительности: между ними ядро не успевает уйти в глубокий сон, а радио не успевает отпустить канал. Поэтому в мобильном «сделать быстро и заснуть» почти всегда лучше, чем «сделать плавно и понемногу» — за исключением работы в кадре, где как раз наоборот.
Правило номер ноль: меряйте в поле, а не на своём телефоне
Устройство разработчика — худший из возможных стендов. Оно новое, у него горячий кэш, полный заряд, Wi-Fi в двух метрах от роутера и debug-сборка, которая ведёт себя не так, как релизная. Реальный пользователь сидит на трёхлетнем устройстве среднего сегмента, в метро, при 12 % заряда и с включённым режимом энергосбережения, который урезает частоту процессора.
Отсюда три требования к измерениям.
Перцентили, а не среднее. Среднее время старта прячет длинный хвост. Смотрите p50, p90 и p99 отдельно; регрессии почти всегда приходят в p90+, потому что задевают слабые устройства и плохие сети. Медиана может улучшаться, пока приложение становится непригодным для трети аудитории.
Разрез по классам устройств и версий ОС. Одна и та же цифра «старт 1,2 с» складывается из 0,6 с на новых флагманах и 3,5 с на бюджетниках трёхлетней давности. Оптимизировать надо второе. Разрез по модели даёт Xcode Organizer на iOS и Android Vitals в Play Console.
Поле и лаборатория — разные инструменты для разных задач. Лаборатория (профайлер, бенчмарк на конкретном устройстве) отвечает на вопрос «почему медленно». Поле (телеметрия с реальных устройств) отвечает на вопрос «медленно ли вообще и у кого». Если у вас есть только лаборатория, вы будете оптимизировать то, что не болит.
| Задача | iOS | Android | Кроссплатформа |
|---|---|---|---|
| Профиль CPU и главного потока | Instruments: Time Profiler | Android Studio Profiler, Perfetto | Flutter DevTools, Hermes profiler |
| Разбор старта | Instruments: App Launch, DYLD_PRINT_STATISTICS |
Macrobenchmark StartupTimingMetric, am start -W |
замер поверх нативного хоста |
| Пропуски кадров | Animation Hitches, hitch ratio | JankStats, FrameTimeline в Perfetto |
DevTools timeline, --profile |
| Память | Allocations, VM Tracker, Leaks | Memory Profiler, dumpsys meminfo |
наблюдение через нативные средства |
| Энергия | Energy Log, MetricKit MXCPUMetric |
Battery Historian, dumpsys batterystats |
нативные средства |
| Поле | MetricKit + Xcode Organizer | Android Vitals, Firebase Performance | те же, плюс свои трейсы |
Практический минимум, который должен быть в любом продакшн-приложении: подписка на MetricKit на iOS, Android Vitals в Play Console, плюс собственные события «экран X стал полезным за N мс» с разрезом по модели устройства и типу сети.
отложить инициализаторы SDK] C1 -->|Нет| C3[Убрать диск, БД и сеть с главного потока,
показать каркас экрана раньше данных] D --> D1{Главный поток занят} D1 -->|Да| D2[Декодирование, парсинг, вычисления в фон] D1 -->|Нет| D3[GPU: overdraw, тени, размытия,
offscreen-проходы, лишние слои] E --> E1[Свести будильники и запросы в пачки,
отдать работу планировщику ОС] F --> F1[Сжатие, формат картинок, дельта вместо полной выдачи,
кэш и отмена лишних запросов] C2 --> G[Повторный замер: то же устройство, релизная сборка] C3 --> G D2 --> G D3 --> G E1 --> G F1 --> G G --> H{p90 в поле улучшился} H -->|Нет| B H -->|Да| I[Зафиксировать бюджет тестом в CI]
Старт: самая заметная метрика
Старт — единственная метрика производительности, которую пользователь измеряет сознательно. Он помнит, что «карты открываются мгновенно, а ваше приложение думает». Поэтому начинать оптимизацию почти всегда стоит отсюда.
Три вида старта и почему память влияет на скорость
Система различает запуск с нуля и возврат к живому процессу, и разница между ними — порядок величины. Ваша задача — не только ускорить холодный старт, но и сделать так, чтобы он случался реже. А случается он тем чаще, чем больше памяти вы держите: подробности лежат в разделе про память ниже.
Отдельная ловушка — восстановление после убийства процесса. Пользователь свернул приложение на третьем экране навигационного стека, вернулся через час, а его встретил главный экран. Формально это не «медленно», но воспринимается ещё хуже. Восстановление стека разобрано в статье про UI и навигацию.
Что измерять и чем
На Android есть две канонические точки: TTID (Time To Initial Display) — первый кадр активности,
и TTFD (Time To Full Display) — момент, когда экран стал полезным; о нём вы обязаны сообщить сами.
Без вызова reportFullyDrawn() система считает стартом первый кадр с индикатором загрузки, и
метрика будет красивой при отвратительном опыте.
class FeedActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val state by viewModel.state.collectAsStateWithLifecycle()
FeedScreen(state)
// TTFD: экран стал полезным только когда пришли реальные данные,
// а не когда отрисовался скелетон. Вызов идемпотентен.
if (state is FeedState.Loaded) {
LaunchedEffect(Unit) { reportFullyDrawn() }
}
}
}
}
Замер из терминала — самый дешёвый способ поймать регрессию руками:
# Android: гарантированно холодный старт
adb shell am force-stop com.example.app
adb shell am start-activity -W -n com.example.app/.MainActivity
# TotalTime — до первого кадра активности (TTID); строка Displayed в logcat — то же самое
# Полная системная трасса на 10 секунд: видно и ваш код, и работу системы
adb shell perfetto -o /data/misc/perfetto-traces/trace.pb -t 10s \
sched freq idle am wm gfx view binder_driver hal dalvik
# iOS: запись трассы запуска без открытия GUI
xcrun xctrace record --template 'App Launch' \
--device-name 'iPhone 12' --launch com.example.app --output launch.trace
На iOS первый шаг — понять, сколько времени уходит до main(). Переменная окружения
DYLD_PRINT_STATISTICS=1 в схеме Xcode печатает разбивку по фазам динамического компоновщика:
маппинг образов, rebase/binding, инициализаторы. Если там сотни миллисекунд, оптимизировать
свой код бессмысленно — надо сокращать число динамических фреймворков (объединять их или
линковать статически) и убирать +load и статические конструкторы C++.
Дальше нужны собственные метки. OSSignposter даёт интервалы, которые видны прямо в Instruments
рядом с системными событиями:
import OSLog
enum Launch {
static let signposter = OSSignposter(subsystem: "com.example.app", category: "launch")
}
@main
struct App: UIApplicationDelegate {
func application(_ app: UIApplication,
didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let state = Launch.signposter.beginInterval("didFinishLaunching")
defer { Launch.signposter.endInterval("didFinishLaunching", state) }
// Критично: только то, без чего нельзя отрисовать первый кадр.
Container.registerCoreDependencies()
// Всё остальное — после первого кадра, в фоновом приоритете.
Task.detached(priority: .utility) {
await Analytics.shared.start()
await RemoteConfig.shared.refresh()
await Database.shared.runPendingMigrations()
}
return true
}
}
Поле измеряет MetricKit — раз в сутки система присылает агрегированные гистограммы с реальных устройств. Это единственный способ увидеть p90 старта у пользователей, а не у себя:
import MetricKit
final class MetricsReceiver: NSObject, MXMetricManagerSubscriber {
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
// Гистограмма времени до первой отрисовки по реальным устройствам
if let launch = payload.applicationLaunchMetrics {
report(histogram: launch.histogrammedTimeToFirstDraw, as: "launch.ttfd")
}
// Доля времени заминок на секунду прокрутки
if let animation = payload.animationMetrics {
report(value: animation.scrollHitchTimeRatio, as: "hitch.ratio")
}
}
}
// Здесь же приходят зависания и завершения по памяти — их нет в отчётах о крэшах
func didReceive(_ payloads: [MXDiagnosticPayload]) {
payloads.flatMap { $0.hangDiagnostics ?? [] }.forEach(report(hang:))
}
}
Что реально ускоряет старт
Ленивая инициализация. Инвентаризуйте всё, что запускается на старте, и для каждого пункта
ответьте: нужно ли это до первого кадра? Обычно из двадцати SDK критичны два. На Android
библиотеки любят самозапускаться через ContentProvider ещё до Application.onCreate —
их надо явно отключать в манифесте и запускать вручную через App Startup.
Baseline Profile на Android. Список горячих методов, который кладётся в APK и позволяет среде выполнения скомпилировать их в машинный код заранее, до первого запуска. Это одна из немногих оптимизаций, которая даёт двузначный процент ускорения старта и прокрутки почти бесплатно: профиль генерируется автоматически прогоном Macrobenchmark-теста.
Каркас раньше данных. Первый кадр должен рисоваться из того, что уже есть локально. Если источник истины — локальная БД (см. данные и оффлайн), экран показывает вчерашние данные мгновенно и обновляет их, когда придёт сеть.
Ничего синхронного на главном потоке. Чтение файла настроек, разбор JSON конфигурации, открытие базы, миграция схемы, обращение в Keychain или Keystore — всё это на старте на слабом устройстве стоит сотни миллисекунд.
Меньше кода на пути. Гигантский граф зависимостей DI, собираемый на старте, — типичный источник 150–300 мс. Ленивые провайдеры решают проблему без переписывания архитектуры; см. архитектуру мобильного приложения.
Плавность: бюджет одного кадра
Кадр — это жёсткий дедлайн. При 60 Гц у вас 16,7 мс, при 90 Гц — 11,1 мс, при 120 Гц — 8,3 мс на всё: обработку события, пересчёт состояния, компоновку, отрисовку и передачу композитору. Не уложились — дисplay показывает предыдущий кадр ещё раз, и глаз это замечает. Один пропуск незаметен, серия пропусков во время прокрутки читается как «дешёвое приложение».
Важная асимметрия с вебом: в мобильном приложении почти вся работа UI идёт на одном потоке, и он же обслуживает жесты. Джанк во время прокрутки — не косметика, а потеря управляемости: палец уехал, а список не поехал.
Метрики, которые считают платформы:
- Android Vitals помечает приложение проблемным, если больше 25 % кадров сеанса длиннее бюджета («медленная отрисовка») или больше 0,1 % кадров длиннее 700 мс («зависшие кадры»).
- Apple считает hitch ratio — миллисекунды заминок на секунду прокрутки. Ориентир хорошего экрана — менее 5 мс/с; выше 10 мс/с пользователь уже видит рывки.
Куда уходит время кадра
Три типичных источника, и лечатся они по-разному.
Главный поток занят вашим кодом. Декодирование картинки, разбор JSON, регулярка по длинной
строке, синхронный запрос к БД внутри ячейки списка, форматирование дат через тяжёлый
DateFormatter, создаваемый на каждую строку. Лечение — вынести в фон и кэшировать.
Главный поток занят системой из-за вас. Слишком глубокая иерархия вью, пересчёт компоновки из-за нестабильных ключей списка, лишние рекомпозиции в Compose и SwiftUI из-за нестабильных типов состояния. Лечение — стабильные идентификаторы, точечная подписка на состояние, плоские иерархии.
GPU не успевает. Overdraw (несколько слоёв рисуют один и тот же пиксель), тени и размытия, маски и скругления, требующие offscreen-прохода, огромные растровые слои. Лечение — убрать эффекты со скроллящихся элементов, растеризовать статику.
Списки — источник большинства проблем. Правильная виртуализация меняет асимптотику:
| Подход | Время на кадр | Память | Комментарий |
|---|---|---|---|
| Полный список во вью-дереве | O(n) | O(n) | На 5000 строк неприемлемо всегда |
| Виртуализация без ключей | O(k) + пересоздание | O(k) | k — видимые строки; ключи спасают от лишней работы |
| Виртуализация со стабильными ключами и фиксированной высотой | O(k) | O(k) | Целевое состояние |
| Виртуализация + предварительная подготовка данных вне кадра | O(k), меньше константа | O(k + кэш) | Форматирование и декодирование заранее |
Здесь k — число видимых строк, обычно 8–15, и оно не растёт с размером данных. Именно поэтому переход от «отрисовать всё» к виртуализации даёт не проценты, а порядки.
Код: одно и то же правило в четырёх стеках
Kotlin и Compose — избежать перерисовки на каждый кадр прокрутки:
@Composable
fun Feed(items: List<Post>, listState: LazyListState) {
// derivedStateOf: рекомпозиция происходит при смене булева значения,
// а не на каждое изменение firstVisibleItemIndex во время прокрутки
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 5 }
}
LazyColumn(state = listState) {
// Стабильный ключ: при вставке в середину не пересоздаётся весь список
items(items, key = { it.id }, contentType = { it.kind }) { post ->
PostRow(post)
}
}
if (showScrollToTop) ScrollToTopButton()
}
Swift — самая частая мобильная ошибка вообще: полноразмерное декодирование изображения:
import ImageIO
import UIKit
/// Декодирует изображение сразу в нужный размер.
/// В памяти окажется превью, а не битмап исходного разрешения на 46 МБ.
func downsample(imageAt url: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage? {
// ShouldCache: false — не держим полный битмап в кэше ImageIO
let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else { return nil }
let maxPixel = max(pointSize.width, pointSize.height) * scale
let options = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
// Декодируем здесь, в фоновом потоке, а не лениво внутри кадра
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixel
] as CFDictionary
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options) else { return nil }
return UIImage(cgImage: cgImage)
}
Dart и Flutter — тяжёлый разбор уходит в отдельный изолят, UI-изолят продолжает рисовать:
// compute() поднимает изолят, гоняет функцию и возвращает результат.
// Функция должна быть верхнеуровневой или статической: изоляты не разделяют память.
Future<List<Post>> parseFeed(String body) => compute(_decodeFeed, body);
List<Post> _decodeFeed(String body) =>
(jsonDecode(body) as List).map((e) => Post.fromJson(e as Map<String, dynamic>)).toList();
// Список: фиксированная высота убирает пересчёт компоновки,
// RepaintBoundary ограничивает перерисовку одной строкой
ListView.builder(
itemCount: posts.length,
itemExtent: 96,
itemBuilder: (context, i) => RepaintBoundary(
child: PostRow(key: ValueKey(posts[i].id), post: posts[i]),
),
);
TypeScript и React Native — JS-поток отделён от UI-потока, и это одновременно защита и ловушка:
// Без getItemLayout список измеряет каждую строку — это работа на UI-потоке.
// renderItem обязан быть стабильной ссылкой, иначе пересоздаются все строки.
const renderItem = useCallback(({ item }: { item: Post }) => <PostRow post={item} />, []);
<FlatList
data={posts}
keyExtractor={(p) => p.id}
renderItem={renderItem}
getItemLayout={(_, index) => ({ length: ROW_HEIGHT, offset: ROW_HEIGHT * index, index })}
initialNumToRender={8}
windowSize={5}
removeClippedSubviews
/>;
// Тяжёлую работу — после завершения анимации перехода,
// иначе она конкурирует с ней за JS-поток
useEffect(() => {
const task = InteractionManager.runAfterInteractions(() => analytics.flush());
return () => task.cancel();
}, []);
Порционная работа под бюджет кадра
Иногда работу нельзя вынести в фон: она обязана трогать UI. Тогда её режут на порции, каждая из которых укладывается в остаток бюджета кадра. Классический приём для «применить 5000 изменений к открытому документу».
Псевдокод:
процедура ОбработатьПорциями(элементы, бюджет_мс):
i ← 0
пока i < длина(элементы):
начало ← сейчас()
пока i < длина(элементы) и (сейчас() − начало) < бюджет_мс:
обработать(элементы[i])
i ← i + 1
уступить_кадру() # вернуть управление циклу отрисовки
Реализация на Python (логика та же в любом языке с событийным циклом):
import time
from typing import Callable, Iterable, Iterator, TypeVar
T = TypeVar("T")
def process_in_frame_budget(
items: Iterable[T],
handle: Callable[[T], None],
budget_ms: float = 8.0,
) -> Iterator[None]:
"""Обрабатывает элементы порциями, каждая — не длиннее budget_ms.
Генератор отдаёт управление после каждой порции: вызывающий код
планирует следующий шаг на следующий кадр (requestAnimationFrame,
Handler.post, DispatchQueue.main.async и т. п.).
"""
it = iter(items)
exhausted = False
while not exhausted:
start = time.perf_counter()
while (time.perf_counter() - start) * 1000.0 < budget_ms:
try:
handle(next(it))
except StopIteration:
exhausted = True
break
yield # уступаем кадру
Сложность: по времени O(n) — каждый элемент обрабатывается ровно один раз, накладные расходы на замер времени постоянны на элемент. По дополнительной памяти O(1): состояние — это позиция итератора. Что меняется — не асимптотика, а распределение задержки: вместо одной паузы в 400 мс получаем 50 кадров с задержкой 8 мс, то есть плавную прокрутку и растянутое завершение работы. Это осознанный размен: суммарно операция станет чуть медленнее, зато интерфейс не умрёт.
Память: это тоже производительность
На мобильном память — не про «не переполнить», а про время жизни процесса. Мобильная ОС не свопит на диск, как десктоп: под давлением она убивает процессы. Приложение, которое держит 400 МБ, живёт в фоне минуты; приложение на 80 МБ — часы. Для пользователя это выражается ровно в одном: как часто он видит холодный старт вместо мгновенного возврата.
Что важно понимать про завершение по памяти:
- Это не крэш. Стека нет, символов нет, ваш обработчик исключений не сработал. В обычных
отчётах о крэшах такие события отсутствуют, и команда годами не знает о проблеме. Искать надо
в
MXAppExitMetric(iOS) иActivityManager.getHistoricalProcessExitReasons()(Android). - Предупреждения (
didReceiveMemoryWarning,onTrimMemory) — вежливость системы, а не контракт. Между сигналом и убийством может не пройти ни одного вашего кадра. - На Android лимит Java-кучи (
ActivityManager.getMemoryClass(), обычно 128–512 МБ) — не то же самое, что общий footprint: native-аллокации и графические буферы в него не входят, но учитываются системой при выборе жертвы.
Практический чек-лист:
- Изображения — источник 80 % проблем. Декодированный битмап занимает ширина × высота × 4
байта независимо от размера файла. Всегда декодируйте под целевой размер (
downsampleвыше,BitmapFactory.Options.inSampleSizeна Android,ResizeImageво Flutter). - Кэш должен быть ограничен и реагировать на давление.
NSCacheсбрасывается системой автоматически;LruCacheнадо чистить вonTrimMemoryруками. - Ищите удержания, а не «утечки». В мире ARC и GC настоящих утечек мало, зато полно циклов ссылок (замыкание держит контроллер, который держит замыкание) и подписок, переживших экран. Инструменты: Leaks и Memory Graph Debugger на iOS, LeakCanary и Memory Profiler на Android.
- Проверяйте footprint под нагрузкой, а не после старта. Прокрутите ленту на 500 элементов, откройте и закройте экран двадцать раз: если график памяти монотонно растёт и не возвращается к базовому уровню — у вас удержание.
Общая теория виртуальной памяти, dirty/clean страниц и вытеснения лежит в статье об управлении памятью; здесь важна только мобильная особенность — цена ошибки не тормоза, а внезапная смерть процесса.
Батарея: физика, а не магия
Энергопотребление складывается из работы конкретных подсистем, и их «цены» отличаются на порядки. Ориентировочная иерархия, от дорогого к дешёвому: сотовый радиомодуль в активном состоянии и GPS — сильно дороже; экран (особенно яркий и с частотой 120 Гц) — дорого; Wi-Fi — дешевле сотовой связи при том же объёме; процессор на полной частоте — дорого, но коротко; процессор в глубоком сне — почти бесплатно.
Отсюда следуют три правила, которые дают почти весь эффект.
Первое: пакетируйте сеть. Активация радиомодуля — самая дорогая часть обмена, а не сами байты. После последнего пакета радио ещё несколько секунд держит канал (tail energy), тратя энергию впустую. Десять запросов по одному в минуту разбудят радио десять раз; один запрос раз в десять минут — один раз.
Второе: не будите устройство сами — просите систему. Собственные таймеры и точные будильники не дают процессору уйти в глубокий сон. Планировщики ОС существуют именно для того, чтобы объединять работу всех приложений в общие окна пробуждения.
// Правильная фоновая синхронизация: система сама выберет момент,
// когда устройство на зарядке/Wi-Fi и всё равно проснулось для других задач
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) // только не-лимитная сеть
.setRequiresBatteryNotLow(true)
.build()
val sync = PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork("feed-sync", ExistingPeriodicWorkPolicy.KEEP, sync)
// iOS: discretionary-загрузка отдаётся системе — она сама выберет
// момент по батарее и сети, а приложение может быть даже не запущено
let config = URLSessionConfiguration.background(withIdentifier: "com.example.app.sync")
config.isDiscretionary = true // не срочно: система решает когда
config.allowsExpensiveNetworkAccess = false // не лезть в сотовую сеть и хотспот
config.allowsConstrainedNetworkAccess = false // уважать режим экономии данных
config.sessionSendsLaunchEvents = true
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
Детали ограничений фона, Doze, бюджетов запуска и пушей — в статье о фоновой работе.
Третье: выключайте то, что не нужно прямо сейчас. Подписка на геолокацию с максимальной точностью, оставленная активной на всём экране; акселерометр, опрашиваемый на 100 Гц ради анимации; видео, которое продолжает декодироваться под перекрывающим его экраном; анимация индикатора загрузки, которая крутится в невидимой вкладке. Правило простое: любая подписка должна быть привязана к жизненному циклу экрана, а не к жизни приложения.
За чем следят сторы: Android Vitals отдельно считает «избыточные пробуждения» (в среднем более 10 пробуждений в час) и «застрявшие частичные wakelock» (удержание дольше часа). Попадание в эти отчёты — реальный риск для видимости приложения, а не абстрактная гигиена.
Трафик: чужие деньги и чужая сеть
Мобильный трафик стоит денег пользователю и энергии — устройству. Плюс сеть в поле не «есть или нет», а «есть, но пакеты теряются»: подробно про серую зону — в статье о данных и оффлайне.
Что даёт эффект, по убыванию:
- Не запрашивать. Кэш с уважением к
ETagиCache-Control, дельта-синхронизация по курсору вместо полной выдачи, отмена запросов при уходе с экрана. Самый дешёвый байт — не отправленный. - Картинки. Обычно 80–95 % трафика приложения. Отдавайте с сервера размер под конкретный экран и плотность, используйте современные форматы (AVIF, WebP, HEIC), не тяните оригинал 4000 пикселей в аватарку 40 на 40 точек.
- Сжатие и формат. Gzip или Brotli на JSON — обязательны и почти бесплатны. Переход на бинарный формат (protobuf, CBOR) даёт ещё 20–40 %, но стоит инфраструктуры и обычно не окупается, пока не сделаны пункты 1 и 2.
- Транспорт. HTTP/2 убирает лишние соединения за счёт мультиплексирования; HTTP/3 (QUIC) заметно лучше на потерях и умеет мигрировать соединение при смене Wi-Fi на сотовую сеть — на мобильном это происходит постоянно. Подробности — в статье о транспортной безопасности и TLS.
- Уважение к лимитному соединению. Тяжёлые загрузки — только по неограниченной сети.
Флаги есть на обеих платформах:
ConnectivityManager.isActiveNetworkMeteredиNetworkCapabilitiesна Android,NWPath.isExpensiveиisConstrainedна iOS.
Натив против кроссплатформы: честный счёт по производительности
Это место, где спор чаще всего ведётся лозунгами. Попробуем по фактам.
Где кроссплатформа объективно платит.
Размер и старт. Любой рантайм добавляет вес и время инициализации. Flutter тащит собственный движок отрисовки, React Native — движок JavaScript. Это единицы мегабайт к сборке и десятки миллисекунд к холодному старту — обычно терпимо, но заметно на бюджетных устройствах.
Память. Второй рантайм и его куча — это дополнительный расход, а память, как мы разобрали выше, конвертируется в частоту холодных стартов.
Кадр. У React Native классическое место риска — занятый JS-поток: пока он считает, жесты и анимации на нативной стороне продолжаются, но новые кадры содержимого не приходят. Новая архитектура с JSI и Fabric убрала асинхронный мост и сильно улучшила ситуацию, а Hermes с предкомпилированным байткодом ускорил старт, но модель «UI зависит от отдельного потока» осталась. У Flutter риск другой: раньше это была компиляция шейдеров на первом показе анимации (та самая «первая прокрутка дёргается»); переход на Impeller с заранее скомпилированными шейдерами закрыл этот класс проблем.
Доступ к системным API. Тонкие вещи вроде фоновых режимов, точной работы с камерой или специфичных энергосберегающих API приходят в кроссплатформенные фреймворки с задержкой и через плагины разного качества.
Где разницы нет или она в пользу кроссплатформы.
Kotlin Multiplatform не трогает UI: экраны остаются нативными, общей делается бизнес-логика. Бюджет кадра при этом ровно нативный. Flutter при аккуратной работе стабильно держит кадр, потому что рисует всё сам и не зависит от особенностей платформенных вью. И самое главное: подавляющее большинство проблем с производительностью не про фреймворк. Синхронный запрос в БД на главном потоке, полноразмерная картинка в аватарке, десять SDK в старте и опрос сервера раз в тридцать секунд одинаково убивают приложение на Swift, Kotlin, Dart и TypeScript.
| Критерий | Натив | Flutter | React Native | KMP |
|---|---|---|---|---|
| Холодный старт | база | +десятки мс на движок | +десятки мс, Hermes сглаживает | как натив |
| Память | база | +рантайм и своя куча | +рантайм JS | как натив |
| Плавность кадра | потолок платформы | близко к потолку, свой рендер | зависит от загрузки JS-потока | как натив |
| Профилирование | зрелые системные инструменты | DevTools + нативные трассы | Hermes profiler + нативные трассы | нативные инструменты |
| Стоимость команды | две команды, дороже | одна команда | одна команда | одна логика, два UI |
| Наём | много специалистов, дорогие | рынок меньше, растёт | много людей из веба | узкий рынок, нужен Kotlin |
| Когда выбор оправдан | производительность и системные API — суть продукта | много экранов, важна визуальная идентичность | есть веб-команда и общий продукт | сложная логика, важен нативный UX |
Практический вывод: выбирать стек из-за производительности стоит только если ваш продукт — камера, карта, игра, редактор или что-то ещё, где вы упираетесь в аппаратный потолок. Во всех остальных случаях решают стоимость команды, наём и скорость поставки, а производительность достигается дисциплиной, а не выбором фреймворка. Полный разбор цены решения — в статье о кроссплатформе.
Регрессии: бюджет, зафиксированный тестом
Разовая оптимизация деградирует за два спринта. Единственный рабочий способ — превратить бюджет в тест, который падает.
На Android это Macrobenchmark: тест запускает релизную сборку на реальном устройстве и меряет старт и кадры. Он же генерирует Baseline Profile.
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
@get:Rule val rule = MacrobenchmarkRule()
@Test
fun coldStartup() = rule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(StartupTimingMetric()),
iterations = 10,
startupMode = StartupMode.COLD,
// Меряем ровно ту компиляцию, что получит пользователь
compilationMode = CompilationMode.Partial(
baselineProfileMode = BaselineProfileMode.Require
),
) {
pressHome()
startActivityAndWait()
}
}
На iOS — метрики XCTest, которые умеют падать при выходе за базовую линию:
final class LaunchPerformanceTests: XCTestCase {
func testColdLaunch() {
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
}
func testFeedScrollHitches() {
let app = XCUIApplication()
app.launch()
measure(metrics: [XCTOSSignpostMetric.scrollDecelerationMetric]) {
app.tables.firstMatch.swipeUp(velocity: .fast)
}
}
}
Три правила, чтобы это работало, а не мигало красным:
- Одно и то же устройство и релизная конфигурация. Бенчмарк на эмуляторе в общем CI-раннере бесполезен: дисперсия больше измеряемого эффекта. Нужна ферма устройств — см. тестирование мобильного.
- Порог по перцентилю с запасом. Падать при росте p90 старта на 15 %, а не при любом шуме.
- Поле важнее лаборатории. CI ловит грубые регрессии, но окончательный вердикт выносит телеметрия после поэтапной раскатки — см. релиз и сторы. Смежные подходы к нагрузочным измерениям описаны в статье о тестировании производительности.
Типичные ошибки
- Оптимизировать на флагмане. Проблема живёт на четырёхлетнем устройстве среднего сегмента; на новом телефоне вы её просто не увидите.
- Мерить debug-сборку. Отключённые оптимизации компилятора, лишние проверки и отладочные надстройки искажают картину в разы; во Flutter debug-режим вообще не показателен, нужен profile.
- Смотреть среднее. Регрессия приходит в хвост распределения и в среднем незаметна.
- Начинать с микрооптимизаций. Ускорять парсер дат, когда экран блокируется на 900 мс синхронной миграцией БД.
- Кэшировать без ограничения. Безлимитный кэш картинок «для плавности» превращается в завершение процесса по памяти и в тот самый холодный старт при каждом возврате.
- Игнорировать завершения по памяти. Их нет в отчётах о крэшах, поэтому команда искренне считает, что «всё стабильно», пока пользователи жалуются на «вылеты».
- Опрашивать сервер по таймеру. Polling каждые 30 секунд — гарантированный расход батареи; для доставки изменений существуют пуши.
- Грузить всё на старте. Инициализация двадцати SDK «на всякий случай» и синхронный запрос за конфигурацией до первого кадра.
- Считать, что фреймворк виноват. В девяти случаях из десяти виновата работа на главном потоке, а не Flutter, React Native или SwiftUI.
- Забывать про тепловой троттлинг. Приложение, которое греет устройство, через пять минут начинает работать на пониженных частотах — и «внезапно тормозит» без изменений в коде.
Мини-итог
- У мобильной производительности четыре бюджета: старт, кадр, энергия, трафик. Они связаны через память и через физику: работа превращается в тепло, а память — во время жизни процесса.
- Старт делится на часть до вашего кода (dyld, zygote, инициализаторы SDK) и вашу часть.
Меряйте обе:
DYLD_PRINT_STATISTICSи MetricKit на iOS,am start -W, Macrobenchmark иreportFullyDrawn()на Android. - Кадр — жёсткий дедлайн в 16,7 / 11,1 / 8,3 мс. Всё, что не обязано быть на главном потоке, должно уйти с него; списки обязаны быть виртуализированы со стабильными ключами.
- Память — это не «не упасть», а «как долго процесс проживёт в фоне». Изображения дают почти весь расход; завершения по памяти не видны в отчётах о крэшах.
- Батарея экономится пакетированием сети, передачей фоновой работы планировщику ОС и отключением подписок вместе с экраном.
- Трафик режется в порядке: не запрашивать → уменьшить картинки → сжать → сменить транспорт.
- Кроссплатформа платит размером, стартом и памятью; кадр при аккуратной работе сопоставим. Выбирать стек ради производительности стоит только на границе аппаратного потолка.
- Любая оптимизация без теста-бюджета в CI и без телеметрии в поле деградирует за два спринта.
Источники
- Apple. Improving your app’s performance — https://developer.apple.com/documentation/xcode/improving-your-app-s-performance
- Apple. Reducing your app’s launch time — https://developer.apple.com/documentation/xcode/reducing-your-app-s-launch-time
- Apple. MetricKit — https://developer.apple.com/documentation/metrickit
- Android Developers. App startup time — https://developer.android.com/topic/performance/vitals/launch-time
- Android Developers. Slow rendering — https://developer.android.com/topic/performance/vitals/render
- Android Developers. Baseline Profiles — https://developer.android.com/topic/performance/baselineprofiles/overview
- Android Developers. Macrobenchmark — https://developer.android.com/topic/performance/benchmarking/macrobenchmark-overview
- Android Developers. JankStats — https://developer.android.com/topic/performance/jankstats
- Android Developers. Overview of memory management — https://developer.android.com/topic/performance/memory-overview
- Android Developers. Optimize for battery life — https://developer.android.com/topic/performance/power
- Perfetto. System tracing documentation — https://perfetto.dev/docs/
- Flutter. Performance best practices — https://docs.flutter.dev/perf/best-practices
- Flutter. Impeller rendering engine — https://docs.flutter.dev/perf/impeller
- React Native. Performance overview — https://reactnative.dev/docs/performance
- Ilya Grigorik. High Performance Browser Networking, глава о мобильных сетях — https://hpbn.co/mobile-networks/
- Brendan Gregg. Systems Performance, 2nd edition — https://www.brendangregg.com/systems-performance-2nd-edition-book.html
- RFC 9114. HTTP/3 — https://www.rfc-editor.org/rfc/rfc9114.html
Что дальше
Быстрое приложение, из которого утекают данные, — плохая сделка. В следующей статье разбираем, где на устройстве действительно можно хранить секреты, как устроена биометрия, зачем нужен пиннинг сертификатов и что видит человек, который разобрал ваш APK или IPA: Безопасность мобильного: хранение секретов, биометрия, пиннинг, реверс.