Урок 17 из 25 · Месяц 5. Clean Architecture и MVVM — интернет-магазин
Flutter Inspector, DevTools и ошибки отрисовки
Содержание урока
- Что такое DevTools
- Как открыть
- Режимы сборки
- Flutter Inspector
- Overflow: жёлто-чёрные полосы
- Случай 1. Длинный текст в Row
- Случай 2. Экран не помещается по высоте
- Случай 3. Ряд тегов или фильтров
- Случай 4. ListView внутри Column
- Лишние rebuild
- Как найти
- Пример проблемы
- const: бесплатная оптимизация
- RepaintBoundary
- Вкладка Performance: ищем тормоза
- Типичные ошибки
- Практика
- Итоги
Магазин уже работает, но на реальном телефоне всплывают проблемы: жёлто-чёрные полосы у длинного названия товара, подтормаживание ленты, нагрев телефона на экране с анимацией. В этом уроке научимся находить такие проблемы инструментами Flutter Inspector и Flutter DevTools и исправлять их: разберём overflow, лишние перерисовки (rebuild), const и RepaintBoundary.
Что такое DevTools
Flutter DevTools — набор инструментов для отладки, который открывается в браузере или прямо в IDE. Аналогия — приборная панель и диагностический сканер в автосервисе: машина едет, а механик видит обороты, температуру и ошибки датчиков.
Основные вкладки:
| Вкладка | Для чего |
|---|---|
| Inspector | Дерево виджетов, размеры и отступы, поиск ошибок вёрстки |
| Performance | Время отрисовки кадров, поиск «тормозов» (jank) |
| CPU Profiler | Какие функции съедают процессор |
| Memory | Расход памяти и утечки |
| Network | Все HTTP-запросы: адрес, заголовки, ответ |
| Logging | Логи приложения и фреймворка |
Как открыть
- VS Code: запустите приложение в режиме отладки, затем
Ctrl+Shift+P(Cmd+Shift+Pна macOS) → Flutter: Open DevTools. - Android Studio: панели Flutter Inspector и Flutter Performance справа; полный DevTools открывается кнопкой в панели запуска.
- Терминал: после
flutter runв консоли появится ссылка на DevTools — откройте её в браузере.
Режимы сборки
flutter run # debug: горячая перезагрузка, Inspector, но работает медленнее
flutter run --profile # profile: скорость как в релизе, доступна вкладка Performance
Flutter Inspector
Inspector показывает дерево виджетов вашего экрана, как оно есть в данный момент.
Главные инструменты:
- Select Widget Mode — включите и нажмите на элемент в приложении. Inspector подсветит его в дереве и покажет свойства: размер, отступы, ограничения (constraints).
- Layout Explorer — для
Row,ColumnиFlexнаглядно показывает, сколько места занимает каждый ребёнок и как работаютflex,mainAxisAlignment. - Show Guidelines — рисует рамки и отступы всех виджетов прямо на экране.
- Highlight Repaints — каждая перерисованная область обводится рамкой нового цвета. Мигает весь экран — что-то перерисовывается лишний раз.
- Highlight Oversized Images — подсвечивает картинки, загруженные в гораздо большем размере, чем показаны.
Рамки и подсветку можно включить и из кода:
import 'package:flutter/rendering.dart';
void main() {
debugPaintSizeEnabled = true; // то же, что Show Guidelines
debugRepaintRainbowEnabled = true; // то же, что Highlight Repaints
runApp(const ShopApp());
}
Не забудьте убрать эти строки после отладки.
Overflow: жёлто-чёрные полосы
Самая частая ошибка вёрстки — overflow («переполнение»): виджету не хватило места. В debug Flutter рисует жёлто-чёрную полосу и пишет в консоль:
A RenderFlex overflowed by 42 pixels on the right.
The relevant error-causing widget was:
Row lib/features/products/presentation/widgets/product_tile.dart:18:12
Чтобы понимать причины, запомните главное правило вёрстки Flutter:
Ограничения идут вниз, размеры идут вверх, позицию задаёт родитель.
Родитель говорит ребёнку: «ты можешь быть шириной от 0 до 360». Ребёнок выбирает размер в этих пределах и сообщает его родителю. Но Row даёт детям бесконечную ширину — и если дети в сумме шире экрана, получается overflow.
Случай 1. Длинный текст в Row
// ❌ Название товара не помещается — overflow справа
Row(
children: [
Image.network(product.imageUrl, width: 56),
const SizedBox(width: 12),
Text(product.title),
Text(product.price.asPrice),
],
)
// ✅ Текст занимает оставшееся место и обрезается многоточием
Row(
children: [
Image.network(product.imageUrl, width: 56),
const SizedBox(width: 12),
Expanded(
child: Text(
product.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
Text(product.price.asPrice),
],
)
Expanded говорит: «отдай этому ребёнку всё оставшееся место, но не больше». Теперь у Text есть конечная ширина, и он переносится на вторую строку или обрезается.
Случай 2. Экран не помещается по высоте
Форма входа на маленьком телефоне: открылась клавиатура — и снизу overflow.
// ✅ Содержимое можно прокрутить
Scaffold(
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// поля логина и пароля, кнопка
],
),
),
)
Случай 3. Ряд тегов или фильтров
Категории «Косметика», «Смартфоны», «Мебель» в Row не влезут. Wrap переносит детей на новую строку:
Wrap(
spacing: 8,
runSpacing: 8,
children: [for (final c in categories) Chip(label: Text(c))],
)
Случай 4. ListView внутри Column
Vertical viewport was given unbounded height.
Column даёт детям бесконечную высоту, а ListView хочет занять всю доступную высоту — то есть бесконечность. Решение — ограничить высоту:
Column(
children: [
const SearchField(),
Expanded(child: ListView.builder(/* ... */)), // ✅ занимает остаток экрана
],
)
Лишние rebuild
Rebuild — повторный вызов метода build. Сам по себе он дешёвый, но если при нажатии на одну кнопку перестраивается весь экран с лентой из 50 карточек, лента начинает подтормаживать.
Как найти
Самый простой способ — debugPrint в build:
@override
Widget build(BuildContext context) {
debugPrint('ProductTile ${product.id} rebuild');
// ...
}
В DevTools для этого есть отслеживание перестроений: на вкладке Performance включите Track widget builds (в разделе Enhance Tracing), и на временной шкале появятся события build для каждого виджета. В новых версиях DevTools есть также статистика перестроений (Rebuild Stats) — список виджетов с количеством вызовов build.
Пример проблемы
// ❌ Таймер акции обновляется раз в секунду — и перестраивает ВЕСЬ экран
import 'dart:async'; // для Timer
class _HomePageState extends State<HomePage> {
Duration _left = const Duration(hours: 2);
late final Timer _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
setState(() => _left -= const Duration(seconds: 1));
});
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('До конца акции: $_left'),
const Expanded(child: CatalogList()), // каталог тоже под ударом
],
);
}
}
setState перестраивает весь build текущего State. Решение — опустить состояние вниз: вынести таймер в отдельный маленький виджет.
// ✅ Раз в секунду перестраивается только этот виджет
class SaleTimer extends StatefulWidget {
const SaleTimer({super.key});
@override
State<SaleTimer> createState() => _SaleTimerState();
}
class _SaleTimerState extends State<SaleTimer> {
Duration _left = const Duration(hours: 2);
late final Timer _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
setState(() => _left -= const Duration(seconds: 1));
});
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => Text('До конца акции: $_left');
}
// HomePage стал StatelessWidget:
Column(
children: const [
SaleTimer(),
Expanded(child: CatalogList()),
],
)
Другие способы уменьшить rebuild:
BlocSelector,buildWhen,ref.watch(p.select(...))— перерисовывать только при изменении нужного поля (см. урок про BlocSelector).- Выносить части экрана в отдельные классы-виджеты, а не в методы
Widget _buildHeader(). У отдельного класса свойbuild, и Flutter может пропустить его перестройку; метод вызывается заново всегда вместе с родителем. - Не создавать тяжёлое в
build.FutureBuilder(future: repository.getProducts())вbuildзапускает новый запрос при каждой перерисовке. Создавайте Future вinitStateили используйте Bloc/Riverpod.
const: бесплатная оптимизация
const говорит Dart: «этот объект известен уже при компиляции и никогда не изменится». Такой объект создаётся один раз и переиспользуется.
const SizedBox(height: 16)
const Icon(Icons.shopping_cart)
const Text('Каталог')
const EdgeInsets.all(16)
Почему это ускоряет: когда родитель перестраивается, Flutter сравнивает старый и новый виджет. Если это тот же самый const-экземпляр, Flutter понимает, что ничего не поменялось, и не вызывает его build вообще, а значит, не перестраивает и всё поддерево.
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Итого: $total'), // зависит от данных — без const
const Divider(), // никогда не меняется
const CheckoutHints(), // свой виджет тоже может быть const
],
);
}
Чтобы ваш виджет можно было создавать с const, у него должен быть const-конструктор и только final-поля:
class CheckoutHints extends StatelessWidget {
const CheckoutHints({super.key});
// ...
}
Не расставляйте const вручную — включите линтер. В analysis_options.yaml:
include: package:flutter_lints/flutter.yaml
linter:
rules:
prefer_const_constructors: true
prefer_const_literals_to_create_immutables: true
Теперь IDE подсветит, где можно добавить const, а команда dart fix --apply расставит их автоматически.
RepaintBoundary
После build идёт paint — рисование пикселей. По умолчанию соседние виджеты рисуются на одном «холсте» (слое), и если один из них меняется 60 раз в секунду, перерисовывается и всё, что рядом.
RepaintBoundary выделяет виджет на отдельный слой. Аналогия — прозрачная плёнка поверх рисунка: рисуете на плёнке сколько угодно, основной рисунок не трогается.
ListView(
children: [
const RepaintBoundary(
child: PromoBannerCarousel(), // автоматически листается каждые 3 секунды
),
for (final p in products) ProductTile(product: p),
],
)
Как проверить: включите Highlight Repaints. До RepaintBoundary при каждом перелистывании баннера рамка меняется у всей ленты, после — только у баннера.
Вкладка Performance: ищем тормоза
Для плавной картинки при 60 Гц у Flutter есть около 16 мс на кадр (при 120 Гц — около 8 мс). Не успел — кадр пропущен, пользователь видит рывок (jank).
На вкладке Performance (в режиме --profile) каждый кадр — столбик:
- UI — время на build и layout вашего Dart-кода;
- Raster — время на отрисовку на GPU.
Красные столбики — кадры, которые не уложились в лимит. Нажмите на такой кадр, и ниже появится временная шкала: какой виджет или функция заняли больше всего времени. Высокий UI — ищите лишние rebuild и тяжёлые вычисления в build. Высокий Raster — сложные эффекты: тени, Opacity, ClipRRect, огромные картинки.
Быстро посмотреть столбики прямо на телефоне можно через showPerformanceOverlay:
MaterialApp(
showPerformanceOverlay: true,
home: const CatalogPage(),
)
Типичные ошибки
- Проверка скорости в debug. Всегда
flutter run --profileна реальном устройстве. shrinkWrap: trueдля больших списков. ИспользуйтеExpandedили slivers.setStateвысоко в дереве. Опускайте состояние в маленький виджет.- Методы
_buildX()вместо виджетов-классов там, где важна производительность. - Создание Future или контроллеров в
build. Только вinitStateили в Bloc/Notifier. RepaintBoundaryвезде. Только вокруг часто меняющихся частей.
Практика
- Откройте DevTools для вашего магазина. С помощью Select Widget Mode найдите в дереве карточку товара и посмотрите её размеры и constraints.
- Создайте overflow: поставьте длинное название в
RowбезExpanded. Прочитайте сообщение в консоли, найдите виджет по указанной строке и исправьте черезExpandedиTextOverflow.ellipsis. - Поместите
ListViewвColumnбез ограничений, получите ошибкуunbounded heightи исправьте черезExpanded. - Добавьте на главный экран таймер акции через
setStateиdebugPrintвbuildкарточек. Убедитесь, что карточки перестраиваются раз в секунду, затем вынесите таймер вSaleTimer— сообщения должны прекратиться. - Включите
prefer_const_constructors, выполнитеdart fix --apply. Затем запустите--profile, включите Highlight Repaints и оберните автолистающийся баннер вRepaintBoundary. Ожидаемый результат: подсветка мигает только у баннера.
Итоги
- DevTools — приборная панель приложения: Inspector, Performance, CPU, Memory, Network, Logging.
- Скорость измеряем в
--profileна реальном устройстве; лимит кадра — около 16 мс. - Overflow лечится
Expanded/Flexible,TextOverflow.ellipsis,SingleChildScrollView,Wrap;ListViewвColumn— черезExpanded. - Лишние rebuild уменьшаем: опускаем состояние вниз, выносим виджеты в классы, используем селекторы.
const-виджеты не перестраиваются; линтерprefer_const_constructorsрасставит их за вас.RepaintBoundaryвыносит часто меняющуюся часть на отдельный слой, но ставится точечно.