Flutter представляет собой UI-фреймворк от Google для мобильных, веб- и десктопных приложений из одной кодовой базы на Dart. В отличие от React Native он не оборачивает нативные контролы, а рисует каждый пиксель сам своим движком рендеринга.
Свой рендерер: Impeller на iOS и Android (CanvasKit или skwasm в вебе) рисует интерфейс, поэтому он выглядит одинаково на любом устройстве и версии ОС
Dart: JIT в debug ради hot reload, AOT-компиляция в нативный код в release
Сравнение с другими: React Native рендерит настоящие платформенные view и управляет ими из JavaScript; .NET MAUI тоже отображается в нативные контролы; Kotlin Multiplatform делит логику и, с Compose Multiplatform, по желанию UI
Сильные стороны: одна кодовая база UI, пиксель-в-пиксель одинаковый дизайн, быстрая итерация, плавные 60/120 fps
Ограничения: приложение больше (движок добавляет несколько МБ), нативный вид и новые системные виджеты приходится воспроизводить или оборачивать, платформенные возможности нужны через плагины или platform channels, а веб-сборка слабее по SEO и первой загрузке
FlutterJunior
Основы Flutter
Дерево виджетов, StatelessWidget, StatefulWidget, BuildContext
Закрыто вопросов
0 / 24
- 0Повторить
- 15Часто спрашивают
Вопросы
Dart умеет работать и через JIT (в debug, что даёт hot reload меньше чем за секунду), и через AOT (в release, что даёт быстрый старт и предсказуемую скорость без интерпретатора). К тому же язык развивает Google, поэтому он меняется вместе с фреймворком.
GC, настроенный под UI: поколенческий сборщик мусора дёшево убирает множество короткоживущих объектов-виджетов, которые создаёт каждый кадр
Изоляты: нет потоков с общей памятью, значит нет блокировок вокруг состояния UI; тяжёлая работа уходит в другой изолят
Sound null safety: компилятор гарантирует non-nullable типы, и проверки на null не нужны повсюду
Почему не JavaScript и не Kotlin: JavaScript динамически типизирован, не компилируется заранее в быстрый нативный код, а iOS запрещает JIT в release; Kotlin здесь менее подходит: он привязан к JVM и инструментам Android, а Dart создавался для UI и несёт собственный рантайм на каждой платформе
Знакомый синтаксис: читается как Java, Kotlin или TypeScript, поэтому осваивается быстро
Framework (Dart) содержит виджеты, раскладку, жесты и анимации; engine (C++) запускает рантайм Dart и рендерит кадры через Impeller; embedder представляет собой платформенную оболочку, которая даёт движку поверхность для отрисовки, потоки, ввод и события жизненного цикла.
Framework (Dart): виджеты, rendering (RenderObject, раскладка), жесты, анимации, Material и Cupertino; обычный Dart, который можно читать и проходить в отладчике
Engine (C++): содержит рантайм Dart, раскладку текста и декодирование изображений, растеризует дерево слоёв через Impeller (CanvasKit или skwasm в вебе)
Embedder: FlutterActivity на Android, FlutterViewController на iOS, нативные хосты на десктопе; даёт поверхность, ввод и жизненный цикл, и именно здесь подключаются плагины и platform channels
В императивном UI вы шаг за шагом меняете существующие view (label.setText(...)); в декларативном вы описываете, как экран должен выглядеть при текущем состоянии, а фреймворк сам вычисляет изменения. Во Flutter такое описание возвращает build(): UI = f(state).
Как происходит обновление: вы меняете состояние через setState, Flutter снова вызывает build, сравнивает новые виджеты со старыми и обновляет только то, что изменилось
Чем это помогает: нет забытой ветки, где какой-то view всё ещё показывает устаревшие данные: экран всегда выводится из состояния
Цена: build вызывается часто, поэтому он должен быть дешёвым и без побочных эффектов
Та же идея в других местах: так же устроены Jetpack Compose, SwiftUI и React
Любая часть интерфейса (кнопка, но и отступ, выравнивание, тема или обработчик жестов) представляет собой виджет: неизменяемое описание куска экрана, а приложение образует дерево таких виджетов. Flutter делает ставку на композицию: каждый виджет делает одну работу, а раскладка получается их вложением.
Существует один класс Padding вместо аргумента padding у нескольких сотен виджетов, и работает любая комбинация (Padding вокруг Opacity вокруг Align), которую фреймворк заранее не предусматривал.
Это дёшево: виджет является описанием, а не view; стоящий за ним RenderObject создаётся один раз и переиспользуется
Цена: глубокая вложенность; лечится выделением именованных классов-виджетов, а не борьбой с вложенностью
Виджет или параметр? Если оборачивает ребёнка, это виджет; если меняет то, как виджет рисует сам себя (как TextStyle), это параметр
StatelessWidget не хранит собственных данных и перерисовывается, только когда родитель передал новые значения. StatefulWidget хранит меняющиеся данные и перерисовывает себя сам по setState.
StatefulWidget состоит из двух объектов: сам виджет это одноразовая конфигурация, а его State переживает все перестройки и хранит настоящие данные. Поэтому счётчик не теряет значение, пока виджет вокруг него пересоздаётся.
Stateless: иконка, подпись, карточка, собранная только из аргументов конструктора
Stateful: счётчик, поле ввода, анимация, данные из сети
Правило: начинайте со stateless и переводите в stateful, только когда что-то должно меняться изнутри
Почему не stateful везде: каждый добавляет долгоживущий объект State, методы жизненного цикла, которые надо написать правильно, и копию данных, которая может разойтись с источником; stateless-виджет остаётся просто функцией от входных данных, его проще тестировать и переиспользовать
BuildContext обозначает место виджета в дереве, а по сути является его Element. Каждый вызов .of(context) ищет через него вверх то, что предоставляет предок: тему, Navigator, Scaffold, провайдер.
Что он даёт: Theme.of(context), Navigator.of(context), MediaQuery.sizeOf(context), ScaffoldMessenger.of(context), значение провайдера
Поиск идёт только вверх: контекст не видит того, что ниже него; отсюда «Navigator operation requested with a context that does not include a Navigator» и «Scaffold.of() called with a context that does not contain a Scaffold»
У каждого виджета свой: context в build принадлежит этому виджету, а не детям, которых он возвращает
Решение: вынести код, которому нужен предок, в отдельный виджет или Builder, чтобы его контекст оказался ниже
**После await**: проверьте context.mounted (или mounted внутри State), прежде чем его использовать: виджета может уже не быть
createState → initState → didChangeDependencies → build, а в конце deactivate → dispose. Между ними build повторяется после каждого setState, а didUpdateWidget срабатывает на новые параметры от родителя.
**initState**: один раз при появлении State в дереве: создавайте контроллеры и подписки; context тут ещё нельзя использовать для .of(context)
**didChangeDependencies**: сразу после initState и при каждом изменении inherited-значения (тема, локаль, провайдер)
**build**: возвращает дерево виджетов; вызывается на setState, на смену зависимостей и на перестройку родителя
**didUpdateWidget(old): родитель передал новые параметры; здесь их сравнивают, например перезапрашивают данные при смене id
dispose**: удалён окончательно: отмените подписки, освободите контроллеры, super.dispose() вызывайте последним
setState не является этапом этой цепочки: он лишь помечает State «грязным», чтобы build выполнился в следующем кадре.
main() служит точкой входа Dart, которую движок вызывает первой; runApp() берёт корневой виджет, прикрепляет его к экрану и начинает выдавать кадры. Код до runApp выполняется, пока UI Flutter ещё не существует.
**runApp: при необходимости создаёт binding, растягивает корневой виджет на весь экран и планирует первый кадр
Работа до runApp: загрузка конфига, инициализация Firebase или базы данных, установка обработчиков ошибок
ensureInitialized()**: вызывайте первым, если main до runApp обращается к плагинам или platform channels (Firebase, SharedPreferences, фиксация ориентации); иначе получите «Binding has not yet been initialized»
Делайте это коротким: всё, что main ждёт через await, откладывает первый кадр, а пользователь всё это время видит только нативный сплэш
MaterialApp стоит один раз в корне и даёт то, что нужно всему приложению, а Scaffold собирает один экран. Без MaterialApp вы получите ошибки вроде «Navigator operation requested with a context that does not include a Navigator» или «No MaterialLocalizations found».
**MaterialApp даёт**: Navigator, без которого не работает Navigator.push, темы, которые читает Theme.of(context), локализации и направление текста, а также ScaffoldMessenger, благодаря которому SnackBar переживает смену экрана
**Scaffold даёт**: appBar, body, floatingActionButton, drawer, bottomNavigationBar и resizeToAvoidBottomInset, который поджимает body при появлении клавиатуры
Где их ставить: один MaterialApp у корня и один Scaffold на экран, а не на каждый виджет
Hot reload подставляет изменённый код и сохраняет состояние приложения; hot restart загружает код и заново запускает main(), а состояние теряется; полный перезапуск останавливает приложение, пересобирает и переустанавливает его.
**Hot reload (r): миллисекунды; дерево виджетов перестраивается, вы остаётесь на том же экране с теми же данными
Hot restart (R)**: секунда-две; приложение стартует с main() с пустым состоянием, нужен после правок main(), initState, глобального инициализатора или полей класса
Полный перезапуск: нужен после нативного кода, добавления или удаления плагина, правок ассетов и конфигурации сборки в pubspec.yaml
Предел: reload не выполняет заново стартовый код, поэтому заданное в main() или в глобальной переменной остаётся старым
pubspec.yaml служит манифестом проекта: имя, версия, ограничение на SDK, зависимости, dev-зависимости, а также ассеты и шрифты, которые попадут в сборку. flutter pub get разрешает его и записывает точные выбранные версии в pubspec.lock.
**dependencies и dev_dependencies**: первые уходят в приложение; вторые (тесты, линтеры, build_runner) нужны только при разработке
**^5.9.0**: синтаксис с кареткой: >=5.9.0 <6.0.0, то есть совместимые обновления подтягиваются, а ломающие не подтягиваются; для версий 0.x ^0.20.2 останавливается до 0.21.0
**version: 1.4.0+12**: до + версия, которую видит пользователь, а после него номер сборки (versionCode на Android, CFBundleVersion на iOS)
**pubspec.lock: в приложениях его коммитят, чтобы на каждой машине и в CI собирались одни и те же версии
Секция flutter:**: assets, fonts, uses-material-design и generate: true для локализации
Platform приходит из dart:io, у которого нет веб-реализации, поэтому чтение Platform.isIOS в браузере бросает UnsupportedError. Используйте kIsWeb и defaultTargetPlatform из package:flutter/foundation.dart.
**Сначала проверяйте kIsWeb**: в вебе defaultTargetPlatform сообщает нижележащую ОС, и браузер на iPhone ответит iOS: для Cupertino-стиля это верно, а для нативных API неверно
**defaultTargetPlatform**: перечисление TargetPlatform, читать безопасно везде, включая веб
Для решений по UI: берите Theme.of(context).platform: то же значение, но его можно переопределить, например зафиксировать платформу в тесте
Ловушка: kIsWeb является константой времени компиляции, поэтому ветки if (kIsWeb) вырезаются tree-shaking; сам dart:io для JS компилируется, но бросает исключение при использовании, а платформенные реализации стоит разделять условным импортом
Отлаживают брейкпоинтами в IDE, через Flutter DevTools и логи. print пишет прямо в stdout, debugPrint дробит длинный вывод, чтобы logcat на Android не терял строки, а log из dart:developer отправляет структурированные записи во вкладку Logging в DevTools.
Брейкпоинты: в VS Code или Android Studio, либо debugger() из dart:developer прямо в коде
Widget Inspector: выберите виджет на устройстве и увидите его место в дереве, ограничения и размер; самый быстрый способ разобраться с переполнением
Вкладки DevTools: Performance для подлагиваний, Memory для утечек, Network для HTTP-запросов
Помощники для раскладки: debugPaintSizeEnabled = true обводит каждый бокс; жёлто-чёрные полосы означают переполнение
Логи в release: print и debugPrint работают и в release-сборке; оборачивайте их в kDebugMode, чтобы токены и персональные данные не попадали в логи устройства
Debug запускает ваш Dart-код на VM с JIT-компилятором: hot reload работает, но приложение медленное и assert включены. Profile и release компилируются AOT в нативный машинный код: release уходит пользователям, а profile добавляет ровно столько трассировки, чтобы измерить производительность.
JIT и AOT: JIT позволяет подменять изменённый код на лету; AOT компилирует заранее, и код в рантайме не меняется
Debug: JIT, assert включены, подключены DevTools и service extensions; оценивать производительность здесь нельзя
Profile: AOT плюс трассировка таймлайна для DevTools; только на реальных устройствах, не на симуляторах и эмуляторах; в нём измеряют подлагивания и старт
Release: AOT без assert и отладочных инструментов; самая маленькая и быстрая сборка, именно она уходит в сторы
Баги только в release: assert исчезают, R8 при сжатии может выкинуть классы, к которым плагин обращается через рефлексию, меняются тайминги; всегда проверяйте release-сборку перед выпуском
В коде: kDebugMode, kProfileMode и kReleaseMode из package:flutter/foundation.dart
build() обязан быть чистой функцией от входных данных виджета: Flutter вызывает его заново на каждое изменение, иногда много раз в секунду. Всему с побочными эффектами там не место: разовой загрузке место в initState, действию пользователя в колбэке.
build вызывается: при setState, при перестройке родителя, при изменении inherited-значения (тема, поворот, отступы клавиатуры, провайдер), на каждом кадре анимации и при hot reload
**Чего в build нельзя**: запросов к API, setState, навигации, диалогов, таймеров и создания контроллеров: TextEditingController, созданный в build, пересоздаётся при каждой перестройке, а предыдущий экземпляр утекает
Куда это переносить: initState для разовой загрузки (готовый Future кладите в поле), didUpdateWidget для случая, когда изменился параметр от родителя, колбэки вроде onPressed для действий пользователя
Частая ошибка: FutureBuilder(future: fetch()) создаёт future прямо в build, поэтому каждая перестройка родителя перезапускает запрос и показывает состояние загрузки заново
Выделяйте отдельный класс-виджет. Метод-хелпер строит прямо в element родителя, поэтому его результат перестраивается вместе с родителем всегда, и пропустить это нельзя.
Своя зона перестройки: у класса-виджета есть собственный Element; StatefulWidget при setState перестраивает только себя, а const-экземпляр пропускается, когда перестраивается родитель
**Может быть const: Flutter сравнит два экземпляра, увидит, что это одно и то же, и пропустит всё поддерево
Свой контекст**: класс получает BuildContext ниже родителя, поэтому поиск идёт среди того, что построил родитель (ошибка Scaffold.of возникает от использования контекста того build, который создаёт Scaffold)
Когда хелпер уместен: маленький разовый фрагмент, который и так меняется вместе с родителем; как только у него появляются аргументы или он попадает в список, делайте виджет
Объект виджета, в котором лежит это поле, выбрасывается и создаётся заново при каждой перестройке родителя. Переживает её только State, а значит, меняющиеся данные должны храниться там.
При перестройке родитель создаёт совершенно новый Counter(), а Flutter оставляет прежний State и просто переставляет widget на новый экземпляр. Ваше поле уехало со старым объектом.
Поля виджета: final-конфигурация от родителя, читаются как widget.x
Поля State: всё, что меняется за время жизни виджета
Почему кажется случайным: значение сбрасывается, когда перестраивается любой предок (setState у родителя, смена темы или MediaQuery, hot reload); если родители не перестраиваются, в демо всё выглядит нормально
Анализатор предупреждает: не-final поле виджета подсвечивается как must_be_immutable
initState вызывается, когда виджет ещё не до конца встроен в дерево, поэтому его context пока не может читать унаследованные виджеты, а showDialog они нужны (локализации, тема, Navigator). Отложите диалог до окончания первого кадра через WidgetsBinding.instance.addPostFrameCallback.
Ошибка: в debug: «dependOnInheritedWidgetOfExactType<…>() or dependOnInheritedElement() was called before _WelcomeState.initState() completed»; обращаться к унаследованным виджетам можно начиная с didChangeDependencies
Исправление: addPostFrameCallback((_) { ... }) выполняется один раз сразу после отрисовки следующего кадра, когда дерево уже готово
**Проверяйте mounted: к моменту вызова колбэка пользователь мог уже уйти с экрана
Почему не в build: он вызывается при каждой перестройке, и диалог открывался бы снова и снова
Другие применения**: узнать размер виджета через его RenderBox, прокрутить к элементу, поставить фокус в поле после раскладки
Без ключей Flutter сопоставляет новые виджеты с существующими элементами по типу и позиции. После удаления A строка B встаёт в позицию 0 и получает State от A, а C получает отмеченный State от B. Дайте каждой строке стабильный ключ: CheckTile(key: ValueKey(item), label: item).
Почему ломаются только stateful-строки: stateless-строка перестраивается из новых аргументов; баг появляется, когда данные живут в State (чекбокс, поле ввода, анимация)
**ValueKey(id): выбор по умолчанию для строк списка; берите настоящий id, а не индекс, ведь он сдвигается так же, как позиция
ObjectKey(obj): сопоставляет по идентичности объекта, когда нет одного поля-идентификатора
UniqueKey(): никогда не совпадает, и виджет получает свежее состояние при каждой перестройке; используйте только для намеренного сброса
GlobalKey**: уникален на всё приложение и даёт доступ к State (FormState); дорогой, не для строк списка
initState вызывается один раз на State, поэтому _user загружается только для первого userId. Когда родитель перестраивает UserDetails с новым id, Flutter оставляет тот же State со старым Future, и панель продолжает показывать прежнего пользователя.
**Почему State переживает перестройку**: на том же месте в дереве стоит виджет того же типа, поэтому элемент обновляется, а не создаётся заново; меняется только ссылка widget на новый экземпляр
Исправление: переопределите didUpdateWidget(oldWidget), сравните oldWidget.userId с widget.userId и запустите новую загрузку; setState не нужен, потому что build всегда вызывается сразу после didUpdateWidget
Альтернатива: дать панели ключ по id: UserDetails(key: ValueKey(userId), …) заставляет Flutter создать новый State, и заодно сбрасываются позиция прокрутки и поля формы
Как поймать в следующий раз: виджет-тест, который сначала показывает экран с id 1, потом с 2 и ожидает имя второго пользователя; под подозрением любое значение, скопированное из widget в initState
После await экран может быть уже закрыт: его State отмонтирован, context больше нет, и ScaffoldMessenger.of(context) бросает исключение. Сразу после await, до обращения к context или вызова setState, проверьте if (!mounted) return;.
Что вы увидите: в debug assertion «This widget has been unmounted, so the State no longer has a context»; в release будет «Null check operator used on a null value» из _save, которое попадает в отчёты как необработанная асинхронная ошибка
Почему: await создаёт паузу, во время которой пользователь может нажать «Назад» или переключить вкладку; dispose отрабатывает, элемент уходит из дерева, но Future всё равно возобновляет ваш метод
Как работает исправление: mounted становится false, как только State уничтожен; само сохранение всё равно завершается, пропускается только реакция UI. В хелпере, который принимает BuildContext, проверяйте context.mounted
**Сохранить ScaffoldMessenger.of(context) до await**: рабочий способ всё же показать снекбар, но pop() должен остаться за проверкой mounted: сохранённый Navigator.pop() после ухода пользователя закроет экран под ним
Как поймать в следующий раз: линт use_build_context_synchronously из flutter_lints подсвечивает ровно эти строки; виджет-тест, который закрывает маршрут до завершения Future, воспроизводит падение
Подмешайте в свой State миксин WidgetsBindingObserver, и Flutter будет вызывать didChangeAppLifecycleState каждый раз, когда приложение уходит в фон и возвращается. Регистрируйтесь в initState через WidgetsBinding.instance.addObserver(this), снимайте регистрацию в dispose.
Состояния: resumed (видно, им пользуются), inactive (видно, но не в фокусе: звонок, переключатель приложений), hidden, paused (в фоне, кадры не рисуются), detached
Что там делать: ставьте на паузу видео, анимации, сенсоры и таймеры опроса, а на resume запускайте обратно
Сохраняйте, пока можно: paused это последний гарантированный момент: сбросьте черновик и пачку аналитики, ведь Android может убить процесс отсюда уже без колбэков
Частая ошибка: считать inactive признаком «пользователь ушёл»: он прилетает на любой системный диалог
Взвешивают команду, существующий код, степень кастомности UI и долю продукта, которая живёт в платформенных функциях; чистая скорость решает редко. Flutter подходит одной команде с брендовым UI, KMP подходит общей логике под нативным UI, а чистый натив подходит продукту на возможностях платформы.
Что решает
Команда: одна Flutter-команда выпускает обе платформы; Android-разработчики на Kotlin быстро осваивают KMP, но его iOS-часть всё равно требует iOS-инженеров; при нативе каждую фичу делают две команды
Существующий код: новый продукт быстрее всего сделать на Flutter; в существующее нативное приложение KMP входит по одному модулю, а Flutter заходит через add-to-app с целым движком
UI: один кастомный дизайн везде подходит Flutter или Compose Multiplatform: оба сами рисуют пиксели на iOS; внешний вид, который следует за каждым релизом ОС, как Liquid Glass в iOS 26, бесплатно достаётся только нативному UI
Платформенные возможности: виджеты на домашнем экране, приложения для часов, Live Activities и share extensions в любом варианте пишутся нативно, поэтому чем больше продукта живёт там, тем меньше экономит общий UI
Зрелость: Flutter стабилен с 2018 года, и экосистема UI-пакетов у него больше; Compose Multiplatform для iOS стал стабильным в мае 2025 года, так что пробелов будет больше
Чего стоит каждый вариант
Flutter: несколько мегабайт движка, platform channels или Pigeon там, где кончаются плагины, и нативный вид, который вы воспроизводите, а не получаете готовым
KMP: Swift вызывает Kotlin через сгенерированные Objective-C-заголовки, пока Swift export не дозрел, iOS-сборки становятся медленнее, а iOS-команде придётся принять Kotlin в своём репозитории
Натив: каждая фича пишется и тестируется дважды, а два приложения расходятся в поведении и датах релизов
Как ответить
Одна рекомендация и её допущение: «пять инженеров и брендовое потребительское приложение: Flutter», что изменило бы решение, и спайк на одну-две недели для самого рискованного экрана, прежде чем компания возьмёт на себя обязательства