Start Debugging

Как профилировать jank во Flutter-приложении с помощью DevTools

Пошаговое руководство по поиску и устранению jank во Flutter 3.27 с DevTools: profile mode, Performance overlay, вкладка Frame Analysis, CPU Profiler, raster vs поток UI, прогрев шейдеров и особенности Impeller. Проверено на Flutter 3.27.1, Dart 3.11, DevTools 2.40.

Краткий ответ: соберите приложение с flutter run --profile (никогда не debug), откройте DevTools, перейдите на вкладку Performance, воспроизведите jank и изучите график Frame Analysis. Кадры, превышающие бюджет (16.67 ms при 60 Гц, 8.33 ms при 120 Гц), окрашиваются. Если просроченный столбец красный на потоке UI, переходите в CPU Profiler и смотрите свой код на Dart; если он красный на потоке raster, узкое место - GPU, и решение обычно состоит в прогреве шейдеров, уменьшении изображений или отказе от дорогих эффектов. Это руководство проводит через каждое из этих решений на Flutter 3.27.1, Dart 3.11 и DevTools 2.40.

Почему jank нельзя профилировать в режиме debug

Сборки debug медленные намеренно. Они выполняют неоптимизированный JIT-код, тащат каждый assert и пропускают пайплайн AOT. Сам фреймворк рисует поверх приложения текст "This is a debug build", чтобы об этом напомнить. Цифры, собранные в debug, обычно в 2-10 раз хуже, чем в release, поэтому любой jank, который вы там “найдёте”, в продакшене может вообще отсутствовать. Хуже того, реальный jank можно пропустить, потому что в debug на некоторых Android-устройствах используется более низкая частота кадров по умолчанию.

Профилируйте всегда с flutter run --profile на реальном устройстве. Симулятор и iOS Simulator не отражают реального поведения GPU, особенно в части компиляции шейдеров. Profile mode сохраняет хуки DevTools (события timeline, отслеживание аллокаций, observatory), но компилирует Dart через пайплайн AOT, так что цифры отличаются от release лишь на несколько процентов. Документация Flutter по производительности приложений на этот счёт высказывается прямо.

# Flutter 3.27.1
flutter run --profile -d <your-device-id>

Если устройство подключено по USB, можно также использовать --profile --trace-startup для записи стартовой timeline в файл build/start_up_info.json - это полезно специально для измерения jank на холодном старте.

Откройте DevTools и выберите нужную вкладку

Когда flutter run --profile запустится, в консоли появится URL DevTools вида http://127.0.0.1:9100/?uri=.... Откройте его в Chrome. Вкладки, важные для jank, в порядке полезности:

  1. Performance: timeline кадров, Frame Analysis, raster cache, переключатели enhance tracing.
  2. CPU Profiler: сэмплирующий профайлер с видами bottom-up, top-down и деревом вызовов.
  3. Memory: отслеживание аллокаций и события GC. Полезно, если jank коррелирует с GC.
  4. Inspector: дерево виджетов. Полезно для подтверждения шторма rebuild-ов.

“Performance overlay”, который также включается изнутри запущенного приложения (P в терминале или WidgetsApp.showPerformanceOverlay = true в коде), - это уменьшенная версия тех же данных, нарисованная поверх UI. Она хороша для обнаружения jank в реальном времени на устройстве, но из неё нельзя углубиться в конкретный кадр. Используйте overlay, чтобы найти сценарий с jank, а затем зафиксируйте его в DevTools.

Как читать график Frame Analysis

В Performance верхний график показывает по столбцу на каждый отрисованный кадр. Каждый столбец имеет два сегмента, уложенных горизонтально: нижний сегмент - поток UI (ваш проход build, layout, paint на Dart), верхний - поток raster (где движок растеризует дерево слоёв на GPU). Если любой из сегментов превышает бюджет кадра, столбец становится красным.

Бюджет кадра равен 1000 ms / refresh_rate. На устройстве 60 Гц это 16.67 ms всего, но эти 16.67 ms нельзя потратить на каждом потоке. Кадр успевает только если и UI, и raster уложатся в свой бюджет, что на практике означает примерно по 8 ms на каждый (остальное время - накладные расходы движка и выравнивание по vsync). На устройстве 120 Гц всё уменьшается вдвое.

Кликните по красному кадру - нижняя панель переключится в “Frame Analysis”. Это самая полезная вкладка в DevTools 2.40. Она показывает:

Если подсказка говорит о потоке UI, исправление лежит в вашем коде на Dart. Если она указывает на поток raster - исправление в форме дерева виджетов, в шейдерах, в изображениях или в эффектах.

Когда узкое место - поток UI

Jank на потоке UI - это ваш код, выполняющийся слишком долго в одном кадре. Самые частые источники:

Перейдите на вкладку CPU Profiler в момент, когда происходит взаимодействие с jank. Установите “Profile granularity” в “high” для коротких всплесков и начните запись. Остановите запись после кадров с jank. Вид bottom-up (“Heaviest frames at the top”) обычно за секунды показывает виновника.

// Flutter 3.27.1, Dart 3.11
class ProductList extends StatelessWidget {
  const ProductList({super.key, required this.json});
  final String json;

  @override
  Widget build(BuildContext context) {
    // Bad: parses a 4 MB JSON blob on every rebuild on the UI thread.
    final products = (jsonDecode(json) as List)
        .map((e) => Product.fromJson(e as Map<String, dynamic>))
        .toList();

    return ListView.builder(
      itemCount: products.length,
      itemBuilder: (_, i) => ProductTile(product: products[i]),
    );
  }
}

Решение - вынести работу с потока UI: либо одиночным вызовом compute(...), либо, для повторяющейся CPU-bound работы, долгоживущим isolate. Полное прохождение обоих вариантов есть в отдельном руководстве по написанию isolate Dart для CPU-bound работы.

Менее очевидная стоимость на потоке UI - чрезмерная перестройка. Заверните часть, которая действительно меняется, в маленький виджет, чтобы только его build запускался при setState. Переключатель Inspector “Highlight Repaints” (в Performance > More options) рисует цветную рамку вокруг каждого слоя, который перерисовывается, и это самый быстрый способ увидеть Container рядом с корнем, перестраивающий весь экран.

Когда узкое место - поток raster

Jank на потоке raster означает, что движок выполняет слишком много работы на GPU для дерева слоёв, которое создают ваши виджеты. Решение редко звучит как “купите телефон побыстрее”. Обычно это одно из:

  1. Jank от компиляции шейдеров: эффекты при первом появлении (переходы между страницами, градиенты, blur, custom painter) компилируют шейдеры посреди кадра, что взрывает время raster. Виден как один-два экстремальных кадра при первом открытии экрана.
  2. Off-screen-слои: Opacity, ShaderMask, BackdropFilter и ClipRRect с antiAlias: true могут заставить движок отрендерить поддерево в текстуру и скомпоновать. Для одного элемента это нормально, для списка таких - дорого.
  3. Слишком большие изображения: 4k JPEG, декодированный в Image.asset, покрывает экран телефона гораздо большим количеством пикселей, чем вы видите. Используйте cacheWidth / cacheHeight, чтобы уменьшить разрешение прямо при декодировании.
  4. Вызовы saveLayer: характерный шаблон в timeline движка. saveLayer - это то, что Opacity использует внутри. Замена Opacity(opacity: 0.5, child: ...) на AnimatedOpacity или дочерний виджет, рисующий с заранее применённой альфой, его избегает.

DevTools 2.40 показывает это напрямую. В Performance > “Enhance Tracing” включите “Track widget builds”, “Track layouts” и “Track paints” для большей детализации в timeline. Frame Analysis также подсвечивает панель “Raster cache”: если в ней высокое отношение “raster cache hits / misses”, движок не кеширует слои, которые мог бы.

Прогрев шейдеров на Impeller и Skia

Это самый частый вопрос про производительность Flutter: “при первом открытии этот экран подёргивается”. Причина - компиляция шейдеров. Решение зависит от рендер-бэкенда.

Impeller - это современный рендерер движка. Начиная с Flutter 3.27, Impeller включён по умолчанию на iOS и является дефолтным на Android (с Skia в качестве fallback на старых устройствах). Impeller компилирует все шейдеры заранее, поэтому на устройствах только с Impeller jank от компиляции шейдеров не должен возникать в принципе. Если на Impeller вы всё ещё видите jank на первом кадре, значит, дело в декодировании изображений или подготовке слоёв, а не в шейдерах.

На пути Skia (старый Android, web, desktop) компиляция шейдеров по-прежнему происходит во время выполнения. Традиционный поток flutter build --bundle-sksl-path использовал кеш SkSL, но начиная с Flutter 3.7 движок объявил его устаревшим, потому что Impeller сделал его ненужным. Если сегодня вам всё-таки нужно поставлять на устройство со Skia, рекомендуемый путь:

Какой рендерер активен, можно увидеть в логах запущенного приложения (flutter run печатает Using the Impeller rendering backend) или во вкладке “Diagnostics” DevTools.

Повторяемый рабочий процесс, который действительно работает

Это цикл, который использую я, по порядку:

  1. flutter run --profile -d <real-device>. Любые измерения jank из симулятора - в корзину.
  2. Воспроизведите jank. Включите внутри приложения Performance overlay (P в терминале), чтобы видеть столбцы UI vs raster в реальном времени. Убедитесь, что jank настоящий и воспроизводимый.
  3. Откройте DevTools > Performance. Нажмите “Record” перед jank, воспроизведите его, нажмите “Stop”.
  4. Кликните по самому худшему красному кадру. Прочитайте Frame Analysis. Решите: UI или raster.
  5. Если UI: откройте вкладку CPU Profiler, запишите тот же сценарий, провалитесь bottom-up в самую тяжёлую функцию. Вынесите работу с потока UI или сократите площадь rebuild-ов.
  6. Если raster: включите “Track paints” и “Highlight Repaints”, ищите saveLayer, слишком большие изображения и события компиляции шейдеров. Заменяйте, уменьшайте разрешение или прогревайте.
  7. Проверьте исправление на том же устройстве. Зафиксируйте бюджет в бенчмарке, чтобы регрессии не пропускались.

Для шага 7: package:flutter_driver объявлен устаревшим начиная с Flutter 3.13 в пользу package:integration_test с IntegrationTestWidgetsFlutterBinding.framework.allReportedDurations. Руководство по тестированию производительности от команды Flutter показывает, как это собрать и выгрузить JSON-файл, который можно сравнивать в CI. Если вы держите CI-матрицу версий SDK Flutter, тот же стенд встаёт в пайплайн с несколькими версиями Flutter.

Кастомные события timeline для сложных случаев

Иногда событий движка недостаточно, и хочется видеть в timeline собственный код. Библиотека dart:developer предоставляет API синхронной трассировки, которое DevTools подхватывает автоматически:

// Flutter 3.27.1, Dart 3.11
import 'dart:developer' as developer;

List<Product> parseCatalog(String json) {
  developer.Timeline.startSync('parseCatalog');
  try {
    return (jsonDecode(json) as List)
        .map((e) => Product.fromJson(e as Map<String, dynamic>))
        .toList();
  } finally {
    developer.Timeline.finishSync();
  }
}

Теперь parseCatalog появится отмеченным span-ом в timeline потока UI, и Frame Analysis сможет относить к нему время напрямую. Используйте умеренно: каждый Timeline.startSync имеет небольшую, но не нулевую стоимость, поэтому не оборачивайте им горячий внутренний цикл. Применяйте на крупных границах (парсинг, обработчик ответа сети, метод контроллера), где стоимость пренебрежимо мала по сравнению с измеряемой работой.

Для асинхронной работы используйте Timeline.timeSync для синхронных участков внутри async-функций, либо Timeline.startSync('name', flow: Flow.begin()) в паре с Flow.step и Flow.end, чтобы нарисовать линию потока, сшивающую связанные события между потоками. Панель Frame Analysis может показать этот поток при выборе кадра.

Давление на память может выглядеть как jank

Если вы видите периодические заминки на 50-100 ms, всплывающие на потоке UI, но не совпадающие ни с каким кодом в стеке вызовов, причина часто - крупная сборка мусора. Откройте вкладку Memory и посмотрите на линию маркеров GC. Частые сборки старого поколения коррелируют с аллокацией большого числа короткоживущих объектов на кадр.

Типичные виновники:

Выносите константы за пределы build (const TextStyle(...) на уровне файла - ваш друг) и предпочитайте растущие списки, которые вы мутируете, вместо перестройки. Функция “Profile Memory” вкладки Memory снимает профиль аллокаций кучи и точно указывает, какой класс производит мусор.

Вызов нативного кода - отдельная задача профилирования

Если приложение использует platform channels (MethodChannel, EventChannel), Dart видит эти вызовы как обычные Future, но настоящая работа выполняется в платформенном потоке. DevTools показывает ожидание со стороны Dart, но не может заглянуть в нативный обработчик. Если кадр тормозит из-за медленной реализации на Kotlin или Swift, придётся подключить нативный профайлер (CPU Profiler в Android Studio или Xcode Instruments) к тому же процессу.

Другая особенность: синхронные вызовы по platform channel в современном Flutter недопустимы (они падают с Synchronous platform messages are not allowed), так что любая блокировка - это асинхронная блокировка на стороне Dart. Если MethodChannel.invokeMethod занимает 200 ms, это 200 ms, в течение которых await возвращает управление и кадр успевает закрыться, но всё, что прицеплено к результату, попадает в более поздний кадр, что выглядит как пропущенные кадры. Решение - проектировать канал так, чтобы UI никогда не зависел от одного round-trip для рендера. Подробнее в руководстве по platform channels.

Распространённые ложные срабатывания

Кадр не “janky” просто потому, что он длинный. Несколько паттернов, которые выглядят как jank, но им не являются:

Если сомневаетесь, воспроизведите jank дважды на свежем flutter run --profile и доверяйте только тому, что воспроизводится в обоих запусках.

Связанное

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад