Мобильная разработка Производительность мобильного: старт, плавность, батарея, трафик
0%

Производительность мобильного: старт, плавность, батарея, трафик

Производительность мобильного: старт, плавность, батарея, трафик

В вебе производительность — это в основном про то, как быстро страница станет интерактивной. Метрики LCP, INP и CLS описывают один сеанс: человек пришёл, посмотрел, ушёл. Мобильное приложение живёт на устройстве месяцами и открывается по десять раз в день, поэтому у слова «медленно» здесь минимум четыре разных значения, и оптимизации под них конфликтуют.

Пользователь говорит «тормозит», имея в виду одно из:

  1. долго открывается — между тапом по иконке и полезным экраном проходит секунды;
  2. дёргается — прокрутка спотыкается, анимация рвётся, клавиатура появляется рывком;
  3. садит батарею и греется — телефон тёплый в кармане, к вечеру 20 % заряда;
  4. жрёт трафик — тариф кончился на десятый день, а приложение всего лишь показывало ленту.

Это четыре разных бюджета с разными единицами измерения, разными инструментами и разными владельцами внутри команды. Хуже того, они связаны: агрессивный префетч ускоряет открытие экрана, но тратит трафик и энергию; большой кэш картинок делает прокрутку плавной, но раздувает память, из-за чего систему быстрее убивает ваш процесс в фоне — и человек чаще платит холодным стартом. Оптимизация мобильного — не поиск «узкого места», а управление балансом между четырьмя бюджетами.

Общий контекст (энергия, серая зона сети, чужой жизненный цикл) разобран в обзоре трека; здесь мы переводим его в измеримые числа и код. Если вы приходите из веба, полезно рядом держать статью о веб-производительности — многие принципы совпадают, но границы бюджетов сдвинуты.

Четыре бюджета и что их связывает

Бюджет Единица Кто ограничивает Типичная цель Как ломается
Старт миллисекунды до полезного экрана ожидание пользователя, 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 мс» с разрезом по модели устройства и типу сети.

Старт: самая заметная метрика

Старт — единственная метрика производительности, которую пользователь измеряет сознательно. Он помнит, что «карты открываются мгновенно, а ваше приложение думает». Поэтому начинать оптимизацию почти всегда стоит отсюда.

Анатомия холодного старта: pre-main, ваш код, первый кадр и полезный экран

Три вида старта и почему память влияет на скорость

Система различает запуск с нуля и возврат к живому процессу, и разница между ними — порядок величины. Ваша задача — не только ускорить холодный старт, но и сделать так, чтобы он случался реже. А случается он тем чаще, чем больше памяти вы держите: подробности лежат в разделе про память ниже.

Отдельная ловушка — восстановление после убийства процесса. Пользователь свернул приложение на третьем экране навигационного стека, вернулся через час, а его встретил главный экран. Формально это не «медленно», но воспринимается ещё хуже. Восстановление стека разобрано в статье про 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 МБ — часы. Для пользователя это выражается ровно в одном: как часто он видит холодный старт вместо мгновенного возврата.

Состав footprint приложения и порядок завершения процессов при нехватке памяти

Что важно понимать про завершение по памяти:

  • Это не крэш. Стека нет, символов нет, ваш обработчик исключений не сработал. В обычных отчётах о крэшах такие события отсутствуют, и команда годами не знает о проблеме. Искать надо в MXAppExitMetric (iOS) и ActivityManager.getHistoricalProcessExitReasons() (Android).
  • Предупреждения (didReceiveMemoryWarning, onTrimMemory) — вежливость системы, а не контракт. Между сигналом и убийством может не пройти ни одного вашего кадра.
  • На Android лимит Java-кучи (ActivityManager.getMemoryClass(), обычно 128–512 МБ) — не то же самое, что общий footprint: native-аллокации и графические буферы в него не входят, но учитываются системой при выборе жертвы.

Практический чек-лист:

  1. Изображения — источник 80 % проблем. Декодированный битмап занимает ширина × высота × 4 байта независимо от размера файла. Всегда декодируйте под целевой размер (downsample выше, BitmapFactory.Options.inSampleSize на Android, ResizeImage во Flutter).
  2. Кэш должен быть ограничен и реагировать на давление. NSCache сбрасывается системой автоматически; LruCache надо чистить в onTrimMemory руками.
  3. Ищите удержания, а не «утечки». В мире ARC и GC настоящих утечек мало, зато полно циклов ссылок (замыкание держит контроллер, который держит замыкание) и подписок, переживших экран. Инструменты: Leaks и Memory Graph Debugger на iOS, LeakCanary и Memory Profiler на Android.
  4. Проверяйте 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» (удержание дольше часа). Попадание в эти отчёты — реальный риск для видимости приложения, а не абстрактная гигиена.

Трафик: чужие деньги и чужая сеть

Мобильный трафик стоит денег пользователю и энергии — устройству. Плюс сеть в поле не «есть или нет», а «есть, но пакеты теряются»: подробно про серую зону — в статье о данных и оффлайне.

Что даёт эффект, по убыванию:

  1. Не запрашивать. Кэш с уважением к ETag и Cache-Control, дельта-синхронизация по курсору вместо полной выдачи, отмена запросов при уходе с экрана. Самый дешёвый байт — не отправленный.
  2. Картинки. Обычно 80–95 % трафика приложения. Отдавайте с сервера размер под конкретный экран и плотность, используйте современные форматы (AVIF, WebP, HEIC), не тяните оригинал 4000 пикселей в аватарку 40 на 40 точек.
  3. Сжатие и формат. Gzip или Brotli на JSON — обязательны и почти бесплатны. Переход на бинарный формат (protobuf, CBOR) даёт ещё 20–40 %, но стоит инфраструктуры и обычно не окупается, пока не сделаны пункты 1 и 2.
  4. Транспорт. HTTP/2 убирает лишние соединения за счёт мультиплексирования; HTTP/3 (QUIC) заметно лучше на потерях и умеет мигрировать соединение при смене Wi-Fi на сотовую сеть — на мобильном это происходит постоянно. Подробности — в статье о транспортной безопасности и TLS.
  5. Уважение к лимитному соединению. Тяжёлые загрузки — только по неограниченной сети. Флаги есть на обеих платформах: 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)
        }
    }
}

Три правила, чтобы это работало, а не мигало красным:

  1. Одно и то же устройство и релизная конфигурация. Бенчмарк на эмуляторе в общем CI-раннере бесполезен: дисперсия больше измеряемого эффекта. Нужна ферма устройств — см. тестирование мобильного.
  2. Порог по перцентилю с запасом. Падать при росте p90 старта на 15 %, а не при любом шуме.
  3. Поле важнее лаборатории. 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 и без телеметрии в поле деградирует за два спринта.

Источники

Что дальше

Быстрое приложение, из которого утекают данные, — плохая сделка. В следующей статье разбираем, где на устройстве действительно можно хранить секреты, как устроена биометрия, зачем нужен пиннинг сертификатов и что видит человек, который разобрал ваш APK или IPA: Безопасность мобильного: хранение секретов, биометрия, пиннинг, реверс.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов