Каждый ребилд должен затрагивать как можно меньшую часть дерева: помечайте неизменные поддеревья const, держите состояние в том маленьком виджете, который его использует, и подписывайтесь на одно нужное поле, а не на весь объект.
**const-конструкторы**: const-виджет остаётся одним и тем же экземпляром при каждом билде, поэтому фреймворк видит идентичный виджет и пропускает всё поддерево
Выносите в виджеты, а не в методы: метод _buildHeader() перестраивается вместе с родителем; отдельный виджет может быть const, поэтому его пропускают, а его собственный setState или слушатель перестраивает только его
Опускайте состояние вниз: setState в корне страницы перестраивает всю страницу; то же состояние внутри переключателя перестраивает только переключатель
Слушайте узко: context.select, BlocSelector, ref.watch(provider.select(...)) или ValueListenableBuilder перестраивают только при изменении выбранного значения
**Передавайте готовый child**: AnimatedBuilder, ValueListenableBuilder и Consumer принимают child, который строится один раз и переиспользуется
Сначала измерьте: счётчики ребилдов в DevTools показывают, что на самом деле перестраивается слишком часто; ребилд дешёвого виджета чинить не стоит
FlutterSenior
Производительность
Конвейер рендеринга, jank, утечки памяти, инструменты профилирования
Закрыто вопросов
0 / 18
- 0Повторить
- 8Часто спрашивают
Вопросы
Ребилд заново вызывает build и обновляет виджеты и элементы; перерисовка заново записывает команды рисования рендер-объектов. RepaintBoundary выделяет поддереву собственный слой, поэтому перерисовка внутри него не перерисовывает соседей, и наоборот.
Они независимы: CustomPaint с repaint: controller перерисовывается каждый кадр без единого ребилда, а ребилд, не меняющий ничего видимого, не вызывает перерисовки
Почему перерисовка расползается: рендер-объекты без границы между ними рисуют в один общий слой, поэтому один «грязный» объект перерисовывает его целиком
Где это окупается: спиннер, видео, тикающие часы или живой график рядом со статичным дорогим контентом
Где Flutter уже добавляет границу: роуты и каждый элемент ListView (addRepaintBoundaries: true)
Цена: каждая граница означает лишний слой и лишнюю память GPU; если обернуть всё подряд, приложение станет медленнее, а не быстрее
Как проверить: debugRepaintRainbowEnabled = true показывает, какие слои перерисовываются
Запустите flutter run --profile на реальном, желательно дешёвом устройстве, повторяйте одно и то же действие и откройте график кадров во вкладке Performance в DevTools. У каждого кадра есть столбик UI и столбик raster (работа GPU); тот, что вышел за бюджет, подсказывает, куда смотреть.
Бюджет: около 16 мс на кадр при 60 Гц и 8 мс при 120 Гц для каждого потока; потоки параллельно работают над разными кадрами
Только profile-режим: debug выполняет JIT-код с ассертами и бывает в разы медленнее; release вырезает средства профилирования
UI вышел за бюджет: виноват ваш Dart-код: большие ребилды, тяжёлый build, синхронный парсинг; откройте flame chart кадра и CPU-профайлер
Raster вышел за бюджет: виновато то, что вы попросили нарисовать GPU: эффекты с saveLayer, клипы, размытие, слишком большие изображения
Подписывайте свою работу: оберните подозрительный код в Timeline.timeSync('parse feed', () {...}) из dart:developer, и он появится в таймлайне под этим именем
Детали по запросу: «Track widget builds» и «Track layouts» во вкладке Performance добавляют события по каждому виджету ценой дополнительной нагрузки
Типичная ошибка: замерить на флагмане и решить, что с приложением всё хорошо
Стройте его лениво через ListView.builder (или SliverList внутри CustomScrollView), чтобы существовали только видимые строки и небольшая область кэша, и делайте каждую строку дешёвой в построении, раскладке и отрисовке.
**Никогда ListView(children: [...]) для длинных данных: он строит всех детей сразу
Фиксированная или эталонная высота**: itemExtent или prototypeItem позволяют списку не измерять строки, что сильно удешевляет скролл и jumpTo
**Никакого shrinkWrap: true внутри другого скролла: он раскладывает всех детей и уничтожает ленивость; комбинируйте слайверы
Дешёвые строки**: const где возможно, миниатюры декодируются в размере отображения, никаких Opacity и intrinsic-размеров в каждой строке
Стабильные ключи: ValueKey(item.id) сохраняет состояние за нужной строкой после вставок и удалений
Обновляйте строку, а не список: когда меняется один элемент, пусть его слушает сама строка, а не весь список перестраивается сверху
Пагинация: запрашивайте следующую страницу за экран-два до конца списка
Каждый вызов itemBuilder фильтрует и сортирует все 5 000 заказов ради одной строки, поэтому каждая новая строка стоит полной сортировки за O(n log n), и несколько строк за кадр выходят за бюджет в 16 мс. Видимый список нужно вычислить один раз, вне билдера.
Почему так происходит: ListView.builder ленив в отношении виджетов, но не кода внутри itemBuilder: билдер вызывается для каждой строки, которая попадает в видимую область или в область кэша, поэтому каждая строка стоит фильтрации и сортировки вместо обращения по индексу, а itemCount заново проходит весь список при каждом ребилде
Цифры: в AOT-сборке на ноутбуке построение 15 строк занимало примерно 15-30 мс с этим кодом и 1-2 мс с исправлением; телефон среднего класса в разы медленнее, так что бюджет превышается уже при 2-3 строках за кадр флинга
Исправление: постройте visible один раз в build, а itemBuilder пусть только берёт элемент по индексу; build не выполняется на каждом кадре скролла, а только при ребилде родителя или смене входных данных; если родитель перестраивается часто, кэшируйте visible в поле State и пересчитывайте в didUpdateWidget, когда меняются orders или query
Дальше: если запрос меняется на каждое нажатие клавиши, пересчитывайте только при изменении (с debounce); сортировку десятков тысяч элементов вынесите в Isolate.run
Как поймать: в CPU-профайлере DevTools на медленном кадре под itemBuilder виден List.sort; на ревью itemBuilder должен только достать элемент и вернуть виджет
Слушатель вызывает setState на каждом тике, поэтому весь build (вместе с HeavyReport) выполняется 60 раз в секунду только ради поворота одной иконки. Пусть за контроллером следит transition-виджет или AnimatedBuilder, тогда реагирует только вращающаяся часть.
**RotationTransition(turns: _ctrl)**: transition сам слушает анимацию; на каждом тике выполняется только его маленький build, страница и HeavyReport не перестраиваются (FadeTransition не перестраивается совсем)
**AnimatedBuilder для своих эффектов**: его builder вызывается на каждом тике, но то, что передано как child, строится один раз и переиспользуется
**Никакого setState: анимации, которую ведёт контроллер, он не нужен; контроллер сам управляет виджетами, которые его слушают
Второй баг**: _ctrl не освобождается через dispose, поэтому в debug страница падает с «was disposed with an active Ticker», а в release тикер продолжает работать
Как убедиться: счётчики ребилдов в DevTools для страницы падают с 60 в секунду до нуля
shouldRepaint возвращает true, поэтому каждый ребилд, который передаёт CustomPaint новый painter, перерисовывает график. Сравнивайте входные данные и возвращайте false, если они совпадают, и перестаньте копировать список через toList(), иначе проверка по идентичности никогда не сработает.
Как это устроено: CustomPaint получает новый painter при каждом билде и вызывает shouldRepaint(old); только true помечает рендер-объект для перерисовки
Для неизменяемых данных хватит идентичности: !identical(old.points, points) ничего не стоит; со свежими копиями понадобится listEquals, который обходит весь список на каждом билде
Перерисовка без ребилда: передайте Listenable как repaint: в конструктор painter, и график будет перерисовываться по его сигналу вообще без ребилдов
Изолируйте: RepaintBoundary вокруг CustomPaint не даёт его перерисовкам расползаться на остальной экран
Семантика тоже: shouldRebuildSemantics по умолчанию повторяет shouldRepaint, так что true заодно перестраивает данные для accessibility
Они могут вызывать saveLayer: поддерево рисуется в offscreen-буфер и смешивается обратно, что стоит времени raster-потока и памяти пропорционально площади. Избегайте этого: альфа в цвете, FadeTransition и Clip.hardEdge.
**Opacity**: для текста и иконок задайте альфу в цвете; для изображений используйте параметр opacity у Image, он принимает Animation<double>, например const AlwaysStoppedAnimation(0.5); для анимации берите FadeTransition, который меняет рендер-объект каждый кадр без ребилда поддерева
**BackdropFilter**: самый дорогой из всех, потому что читает обратно то, что уже нарисовано под ним; делайте его маленьким, ограничивайте ClipRect и никогда не ставьте в каждую строку списка
Клипы: медленным режимом считается Clip.antiAliasWithSaveLayer; Clip.hardEdge или Clip.antiAlias обычно выглядят так же, а скруглённому BoxDecoration клип вообще не нужен
**ShaderMask и ColorFiltered: тоже рисуют через слой
Как убедиться**: raster-столбик выходит за бюджет, а UI в норме; checkerboardOffscreenLayers: true у MaterialApp подсвечивает каждый offscreen-слой на экране
Impeller представляет собой движок рендеринга Flutter. Skia компилировал шейдеры в рантайме при первом появлении эффекта, из-за чего на свежей установке случались разовые подёргивания; Impeller компилирует небольшой фиксированный набор шейдеров заранее, при сборке движка, и этих рывков больше нет.
Где он по умолчанию: на iOS (Skia там больше недоступен), Android с Vulkan и десктопных платформах в актуальном Flutter; старые Android-устройства без Vulkan откатываются на legacy-рендерер OpenGL
Каким был прогрев: шейдеры записывали через --cache-sksl, упаковывали через --bundle-sksl-path и компилировали при старте; результат зависел от устройства и легко ломался
Сейчас: эти флаги относятся к Skia и под Impeller ничего не делают; рывки при первом запуске теперь указывают на ваш код, а не на шейдеры
Другие отличия: время кадра стабильнее, но некоторые эффекты (большие размытия, сложные пути) рисуются немного иначе, поэтому после обновления сравнивайте экраны
Skia никуда не делся: на нём построен рендерер CanvasKit во Flutter web
Tree shaking означает, что компилятор release-сборки выбрасывает все классы, функции и константы, до которых программа никогда не дойдёт, поэтому неиспользуемый код библиотек не попадает в приложение. Flutter делает то же со шрифтами иконок, но только для глифов, которые видны как константы времени компиляции.
Обычный код: AOT-компилятор идёт от main и оставляет только достижимое; импорт большого пакета стоит ровно столько, сколько вы из него вызываете
Ветки только для debug: kDebugMode и kReleaseMode объявлены как константы, поэтому блоки if (kDebugMode) исчезают из release-сборки
Шрифты иконок: tree shaking иконок включён в release по умолчанию и уменьшает MaterialIcons примерно с 1,6 МБ до нескольких КБ используемых глифов
**Неконстантный IconData**: иконка, собранная из code point в рантайме, роняет сборку с ошибкой «This application cannot tree shake icons fonts»; используйте const-иконки или константную таблицу
Что он не уберёт: ассеты из pubspec.yaml, нативные SDK, которые тянут плагины, и код, всё ещё достижимый через map или switch, ссылающийся на всё подряд
initState новой страницы, её первый build, раскладка и декодирование изображений выполняются в тех же кадрах, что анимируют переход, поэтому любая тяжёлая работа там вычитается прямо из бюджета анимации.
**Не блокируйте initState: открытие базы, чтение большого блока настроек или декодирование закэшированного JSON там останавливает кадр; запустите работу и покажите плейсхолдер
Декодируйте изображения заранее**: вызовите precacheImage на предыдущем экране, чтобы главная картинка уже была в кэше
Упростите первый кадр: нарисуйте скелетон и заполните его после окончания перехода
Дождитесь анимации: слушайте ModalRoute.of(context)?.animation и запускайте тяжёлую работу, когда её статус станет completed (читайте её в didChangeDependencies, добавьте слушатель статуса и уберите его в dispose)
Проверьте по таймлайну: потерянные кадры стоят в начале push и прекращаются, когда переход заканчивается
Под jank понимают кадр, не уложившийся в бюджет (около 16 мс при 60 Гц, 8 мс при 120 Гц), поэтому он отбрасывается и движение дёргается. Сначала выясните, какой поток вышел за бюджет (UI или raster), потому что причины и исправления у них разные.
UI-поток вышел за бюджет
Слишком много перестраивается за кадр: setState высоко в дереве или вся страница слушает провайдер; лечится const, мелкими виджетами и select
Тяжёлая синхронная работа: декодирование JSON, сортировка, обработка изображений в главном изоляте; перенесите её в Isolate.run
Дорогая раскладка: intrinsic-размеры, списки с shrinkWrap, неленивые списки на сотни детей
Raster-поток вышел за бюджет
**Эффекты с saveLayer**: Opacity, ShaderMask, BackdropFilter, Clip.antiAliasWithSaveLayer
Огромные изображения: декодированные в полном разрешении; используйте cacheWidth или ResizeImage
Большие области перерисовываются каждый кадр: изолируйте анимированную часть через RepaintBoundary
Порядок действий
Воспроизведите в --profile на самом слабом поддерживаемом устройстве, возьмите худший кадр, уберите его главную статью расходов и замерьте снова
Закрепите результат: перф-тест в CI и метрики кадров из продакшена
Почти за любой утечкой во Flutter стоит disposable-объект, который забыли освободить, или долгоживущий объект (синглтон, глобальный стрим, статическое поле), всё ещё держащий ссылку на State закрытого экрана.
Обычные подозреваемые
StreamSubscription, Timer, AnimationController, TextEditingController, ScrollController, FocusNode
Слушатель, добавленный в глобальный ChangeNotifier и не удалённый; его замыкание захватывает this и держит в памяти весь State
BuildContext или GlobalKey, сохранённые в статическом поле или синглтоне
Как найти
DevTools Memory: откройте и закройте подозрительный экран десять раз, снимите heap snapshot и поищите десять экземпляров его State; retaining path покажет, кто их держит
**leak_tracker**: включите LeakTesting в виджет-тестах, и забытый dispose будет ронять тест
Не любой рост памяти означает утечку
ImageCache по замыслу хранит до 1000 изображений и 100 МБ
Декодированные битмапы и видеобуферы лежат в нативной памяти и никогда не видны в Dart-куче
Выведите первый кадр как можно быстрее, а всё остальное делайте после него: в main() не должно быть сети и тяжёлой работы с диском, покажите нативный сплэш, необязательные сервисы запускайте лениво. Первый запуск медленнее, потому что кэши, базы и миграции создаются с нуля.
Куда уходит время
Нативный запуск до появления движка, затем старт движка и Dart VM, затем ваш main() и первый build
flutter run --profile --trace-startup записывает реальные тайминги, включая время до первого кадра
Что менять
**В main ждите (await) только то, без чего не нарисовать первый экран**: часто это ничего; остальное запускайте после runApp
Откладывайте необязательные сервисы: аналитика, crash reporting, remote config и фича-флаги стартуют после первого кадра или при первом обращении
Показывайте кэшированные данные сразу: обновляйте их в фоне вместо спиннера, ждущего сеть
Нативный сплэш: flutter_native_splash заполняет брендингом паузу до первой отрисовки Flutter, вместо пустого экрана
Почему первый запуск медленнее
Пустые HTTP- и image-кэши, создание базы и миграции, первичная настройка плагинов
На Android платформенная часть (embedding и плагины) ещё не оптимизирована ART; Dart-код уже скомпилирован AOT
Сначала измерьте: соберите с --analyze-size, откройте результат в DevTools App Size и атакуйте самые крупные статьи (обычно это ассеты, нативные SDK из плагинов и отладочные символы), а называйте тот размер, который реально отдаёт стор.
Меряйте правильное число: Play Console и App Store Connect показывают размер загрузки для конкретного устройства, он намного меньше универсального APK или сырого IPA
Сравнивайте релизы: загрузите два снапшота размера в DevTools и увидите, что именно добавил релиз
Ассеты: WebP вместо PNG, убрать неиспользуемые варианты разрешений, крупные медиа и видео онбординга грузить по сети
Вынесите отладочную информацию: --split-debug-info вместе с --obfuscate выносит таблицы символов из бинарника; сохраните эти файлы, чтобы символизировать краши
Шрифты и иконки: делайте сабсет своих шрифтов и не отключайте tree shaking иконок
Зависимости: плагин, тянущий целый нативный SDK (карты, ML, видео), часто становится сюрпризом; поищите более лёгкую альтернативу
Deferred components: на Android импорты deferred as можно превратить в модули Play Feature Delivery, скачиваемые по требованию; на iOS аналога нет
Большая часть памяти Flutter-приложения приходится не на Dart-объекты. Декодированные битмапы, текстуры GPU, кэши движка и аллокации плагинов живут в нативной памяти, которую график Dart-кучи никогда не показывает.
Смотрите на правильную линию: в DevTools Memory Dart-куча считает только Dart-объекты, а RSS считает весь процесс; ответ лежит в разнице между ними
Изображения доминируют: фото 4000×3000 занимает в RAM около 48 МБ, каким бы маленьким ни был JPEG, а ImageCache держит до 1000 записей и 100 МБ на всё приложение
Декодируйте меньше: cacheWidth/cacheHeight или ResizeImage везде, плюс пониженные лимиты кэша на экранах с крупными фото
Ищите за пределами Dart: видеоплееры, камера, тайлы карт и ML-модели аллоцируют нативно
Используйте инструменты платформ: Xcode Instruments на iOS и memory profiler в Android Studio показывают то, чего не видит DevTools
Почему убивают в фоне: ОС освобождает память по размеру процесса, поэтому именно нативная память и приводит к убийству приложения
Собирайте тайминги кадров и метрики старта с release-сборок на реальных устройствах и смотрите перцентили по классам устройств: либо через колбэки FrameTiming, которые отправляете сами, либо через инструмент вроде Firebase Performance Monitoring или Sentry.
**SchedulerBinding.instance.addTimingsCallback**: отдаёт пачки FrameTiming и в release-сборках, с временем build, raster и общим временем каждого кадра
Firebase Performance: из коробки старт приложения и сетевые трейсы, плюс кастомные трейсы, которые вы добавляете вокруг Flutter-сценариев; его автоматические screen traces работают по нативным экранам, поэтому Flutter-приложение видно как один экран; добавляйте кастомные трейсы по роутам
Sentry: медленные и замёрзшие кадры, старт приложения и спаны по роутам через его navigator observer; Crashlytics отвечает за краши, а не за скорость
Перцентили, а не средние: p90 и p99 времени кадра и доля janky-кадров; среднее полностью прячет jank
Не смешивайте build и raster: у них разные исправления
Режьте по модели устройства и ОС: jank концентрируется в самой дешёвой трети устройств; сравнивайте каждый релиз с предыдущим
Опишите сценарий как интеграционный тест, запускайте его в profile-режиме на фиксированном физическом устройстве в CI, суммируйте тайминги кадров и роняйте джобу, когда перцентиль хуже закоммиченного baseline больше чем на оговорённый порог.
Записывайте нужный отрезок: IntegrationTestWidgetsFlutterBinding.traceAction пишет таймлайн ровно для интересующего вас взаимодействия
Суммируйте: TimelineSummary.summarize(timeline) пишет JSON со средним, p90 и p99 времени build и raster и числом кадров за бюджетом
Сравнивайте с baseline: падайте на относительной регрессии, скажем 15% по p90, а не на абсолютном числе, чтобы шум устройства не ломал сборки
Закрепите железо: один и тот же физический телефон при каждом прогоне; цифры эмулятора ничего не значат
Типичная ошибка: гонять бенчмарк в debug-режиме, где цифры бессмысленны