Почему AI-агенты буксуют на Flutter: 7 пробелов, которые закрывает опыт

Коротко. Это не аргумент против того, чтобы AI писал Flutter. Агенты пишут большую часть нашего кода, и мы осознанно выстроили процесс вокруг этого. Это аргумент о том, что происходит, когда ими никто не управляет. Мы выпускаем Flutter вместе с агентами — и делаем аудит Flutter, который агенты написали без присмотра. Между этими двумя вещами лежат одни и те же семь пробелов. Агент без присмотра пересчитывает производные данные везде, вместо того чтобы держать единый источник правды; не пишет ни тестов, ни бенчмарков, поэтому единственным барьером остаётся код-ревью, а регрессии уезжают в прод свободно; сваливает сложную асинхронность в пирамиды из if и switch поверх разрозненных флагов вместо стрима; держит дизайн-токены константами в constants.dart вместо ThemeData, который фреймворк уже даёт; изобретает новый UX-язык на каждом экране, потому что не видит приложение; склеивает строки там, где правила плюрализации ICU обязательны; и упрощает равномерно, выравнивая ровно те части домена, где сложность и есть смысл. Ничего из этого не является проблемой Flutter, и ничего из этого не делает код бесполезным. Это значит, что решения уровня всей системы по-прежнему наши.
Почему Flutter наказывает за это сильнее других стеков
Каждая из этих ошибок встречается на любом языке. Flutter просто делает их громче — по четырём структурным причинам.
Интерфейс — это код, до самого низа. Нет стилей, живущих снаружи исходников и удерживающих единообразие, нет каскада, нет общего CSS-файла, который второй разработчик вынужден заметить. Каждый пиксель — это выражение на Dart. Если единообразие не закреплено внутри кода, его не закрепляет ничто.
Всё компилируется. Flutter предлагает примерно десять разумных способов сделать что угодно, и все десять работают. Нет ошибки компиляции «здесь надо было взять тему», нет предупреждения «это третий раз, когда ты выводишь эту сумму», нет линта «этот экран грузится не так, как остальные тридцать девять». Обратной связи, которая нужна агенту, просто не существует — пока её кто-то не построит.
Агент никогда не видит кадр. Он пишет экран, получает чистый flutter analyze и рапортует об успехе — на вёрстке, которая переполняется на 320pt, прячет кнопку отправки за клавиатурой и проваливает контраст в тёмной теме. Человек замечает все три за две секунды. У агента нет глаз, пока вы их не дадите.
В обучающих данных — десятилетие смешанного Flutter. Фреймворк движется быстро, корпус — нет. Поэтому агенты уверенно пишут RaisedButton (удалён в 3.0), WillPopScope (заменён на PopScope после 3.12), MediaQuery.of(context).textScaleFactor (заменён на TextScaler в 3.16), MaterialStateProperty (переименован в WidgetStateProperty в 3.19), Color.withOpacity (задеприкейчен в пользу withValues в 3.27) и ThemeData.accentColor, который удалён в 3.7 и не существует уже несколько лет. Дело не в том, что модель ошибается, — она отвечает за версию Flutter, которую никто больше не выпускает, и половина этого до сих пор компилируется с предупреждением, которое никто не читает.
Теперь сами семь.
1. Единый источник правды или пересчёт на каждом углу
Что делает агент. Он выводит одно и то же значение в каждом месте, где оно понадобилось. Сумма корзины считается сворачиванием на странице корзины. Потом ещё раз — в прилипшем футере оформления. И третий раз — в сводке заказа, причём там скидка применяется до налога, а не после. По отдельности ни один файл не выглядит неправильным; просто три расходятся между собой, и расхождение невидимо ровно до момента, когда с покупателя списали не ту сумму.
Флаттерная версия хуже — это зеркалирование состояния в виджет.
class _CartPageState extends State<CartPage> {
double _total = 0; // копия правды №2
@override
void initState() {
super.initState();
_total = widget.items.fold(0, (sum, i) => sum + i.price * i.qty);
}
// ...и теперь _total никогда не обновится вслед за корзиной
}
Классическая копия в initState. В демо работает, потому что в демо корзина не меняется после открытия страницы. Ломается при первом же изменении количества из боттом-шита.
Что делаем мы. Значение выводится ровно один раз, в слое состояния, и все виджеты его читают. Если что-то можно вычислить из состояния — это не состояние.
// Один вывод. Страница корзины, футер и сводка читают именно его.
Stream<Money> get total => _cart.map(
(c) => c.items.fold(Money.zero, (sum, i) => sum + i.subtotal),
);
Обратите внимание на тип Money вместо double. Агенты хватаются за double для денег постоянно, а 0.1 + 0.2 != 0.3 перестаёт быть академическим примером, когда это чей-то баланс.
Почему агент так делает. Найти существующий вывод стоит контекста; перегенерировать его инлайн не стоит ничего. Каждый запрос начинается с чистого листа, поэтому вопрос «а это уже где-то посчитано?» он структурно не может задать. Каждая копия локально разумна. Расхождение — это цена, которую вы платите.
2. Тесты и бенчмарки или надежда
Что делает агент. Выкатывает фичу и останавливается. Ни виджет-тестов, ни голденов, ни бенчмарка. Определение готовности — «компилируется и экран выглядит правдоподобно по тому описанию, которое я сам написал».
Единственным барьером качества остаётся код-ревью — и вот эту часть недооценивают. Ревью было узким местом ещё тогда, когда код писали люди с человеческой скоростью. Направьте на кодовую базу трёх агентов, и объём диффа, приходящего на этот барьер, вырастет на порядок, а количество людей, способных его отревьюить, останется равным одному. Ревью не масштабируется. Исполняемые проверки — масштабируются.
Что делаем мы. Пишем тесты и бенчмарки первыми — не из соображений добродетели, а как ту самую обратную связь, которой агенту не хватает.
- Виджет-тесты на поведение: кнопка блокируется на время отправки, ошибка сбрасывается при новом вводе, список подгружается на нужном скролле.
- Golden-тесты на внешний вид. Это самое рычажное, что вообще можно дать агенту, работающему с UI, потому что голден — ближайший аналог зрения.
matchesGoldenFileпревращает вопрос «сломал ли редизайн пустое состояние на 320pt в тёмной теме при масштабе текста 200%?» в проверку, которая проходит в CI за четыре секунды. Без голденов на этот вопрос отвечает только человек, открывший приложение, — то есть иногда. - Бенчмарки на то, что тихо гниёт. Бюджет кадра — 16.6 мс; список, который начинает дёргаться после того, как кто-то добавил тень и
Opacityвнутрь билдера элемента, — это регрессия, которую не ловит ни один юнит-тест. Тайминги кадров на profile-сборке, сверенные с бюджетом, ловят. - Регрессионный тест на каждый исправленный баг, чтобы агент, который его починил, не расчинил его тремя запросами позже.
Паттерн, который стоит усвоить: агент отлично умеет удовлетворять проверку, которую может запустить, и беспомощен там, где проверки нет. Дайте ему flutter test — он будет итерироваться до зелёного. Не дайте ничего — он скажет, что уверен в результате. Что происходит с кодовой базой через год по второму сценарию, мы разобрали в материале о находках аудита.
3. Стримы или пирамида из флагов
Что делает агент. Представляет асинхронное состояние как набор параллельных полей и потом ветвится по ним.
bool _isLoading = false;
String? _error;
List<Hit> _items = [];
// build():
if (_isLoading) return const CircularProgressIndicator();
if (_error != null) return Text(_error!);
if (_items.isEmpty) return const Text('Ничего нет');
return ListView(/* ... */);
Три поля, восемь представимых комбинаций, четыре из них бессмысленны. Что отрисуется, если _isLoading истинно и _error не пуст? То, в каком порядке случайно оказались if. Потом появляется поиск, и агент добавляет Timer для дебаунса, счётчик _requestId, чтобы отбрасывать устаревшие ответы, и проверку mounted перед каждым setState — самодельную и тонко сломанную реализацию switchMap. Рядом разрастается utils.dart со статическими хелперами, потому что логике структурно негде жить.
Форма проблемы именно такая: дело не в том, что агенты избегают switch, а в том, что они переключаются по флагам, которые сами же и выставляют, вместо типа, который может проверить компилятор.
Что делаем мы. Состояние — запечатанная иерархия, поэтому недопустимые комбинации непредставимы, а таймингом занимается стрим.
sealed class SearchState {}
final class Idle extends SearchState {}
final class Loading extends SearchState {}
final class Failed extends SearchState { Failed(this.error); final AppError error; }
final class Loaded extends SearchState { Loaded(this.hits); final List<Hit> hits; }
// build() — компилятор требует обработать каждый случай
return switch (state) {
Idle() => const SearchHint(),
Loading() => const AppSpinner(),
Failed(:final error) => AppErrorView(error),
Loaded(:final hits) when hits.isEmpty => const EmptyResults(),
Loaded(:final hits) => HitList(hits),
};
И асинхронная часть — debounceTime, switchMap и startWith это расширения Stream из rxdart, а не из dart:async, — там, где версия на флагах тихо гоняется сама с собой:
// Дебаунс и отмена — декларативны. Устаревший запрос не может победить,
// потому что switchMap уже отменил его подписку.
Stream<SearchState> get states => _query
.debounceTime(const Duration(milliseconds: 300))
.switchMap(_search)
.startWith(Idle());
Добавьте новое состояние — скажем, RateLimited — и компилятор перечислит каждый switch, который нужно обновить. В версии на флагах добавление состояния означает найти все цепочки if руками и понадеяться.
Почему агент так делает. Императивные флаги — самый частый паттерн в корпусе и самый удобный для генерации по строчке за раз. Стрим требует держать в голове всю машину состояний целиком, а это ровно то, что хуже всего даётся генератору с ограниченным контекстом. Это тот же сбой, который мы описывали как callback hell в аудитах, просто в дартовом костюме.
4. Знание фреймворка: ThemeData — это не файл констант
Здесь знание фреймворка проявляется нагляднее всего.
Что делает агент. Создаёт constants.dart, набивает его строчками static const primary = Color(0xFF6750A4), а потом пишет стили инлайн в каждой точке вызова.
Text(
'Баланс',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
)
В этой одной строке четыре отдельные проблемы, и очевидна только первая:
- Тёмная тема превращается в ручной
if.AppColors.textPrimary— это один цвет. Поддержка второй темы означает тернарник поTheme.of(context).brightnessв каждой из 200 точек — что агенты потом и пишут, по одному экрану за раз. - Ребрендинг — это дифф на 400 файлов. Дизайнер, меняющий типографскую шкалу, должен править один файл. Здесь это поиск с заменой по всему приложению, и разъехавшиеся места (
fontSize: 15на двух экранах, потому что та генерация округлила иначе) будут пропущены. - Системное масштабирование текста ломает вёрстку. Захардкоженные размеры вместе с захардкоженными отступами означают, что пользователь с масштабом 200% получит переполнение, а не перекомпоновку.
Theme.of(context)перестаёт работать как переопределение. Обернуть поддерево в другую тему — тёмная секция на светлом экране, брендированный чекаут внутри нейтрального приложения — не даст ничего, потому что никто в поддереве не спрашивает тему ни о чём.
Что делаем мы. Дизайн-система живёт в ThemeData, один раз, в корне.
MaterialApp(
theme: AppTheme.light, // ColorScheme.fromSeed + TextTheme + темы компонентов
darkTheme: AppTheme.dark,
// ...
)
// Каждый виджет спрашивает фреймворк, а не файл констант:
Text('Баланс', style: Theme.of(context).textTheme.titleMedium)
Темы компонентов (FilledButtonThemeData, CardThemeData, InputDecorationThemeData) означают, что кнопка выглядит правильно по умолчанию, — и агент, делающий двадцатый экран, не сможет ошибиться, даже если попытается. Для брендовых токенов, под которые в Material нет слота, — палитра графиков, градиент, семантическая пара «рост/падение» в финтехе, — ThemeExtension держит их внутри того же механизма, а не рядом с ним:
final class BrandColors extends ThemeExtension<BrandColors> {
const BrandColors({required this.positive, required this.negative});
final Color positive;
final Color negative;
// copyWith / lerp ...
}
// использование
final brand = Theme.of(context).extension<BrandColors>()!;
Тёмная тема после этого стоит одной дополнительной ThemeData, а не тернарника на виджет. В Arcana вся кастомная дизайн-система выражена именно так — поэтому правка дизайна там даёт дифф, который читается за одно ревью.
Почему агент так делает. Константа локально корректна и мгновенно приносит удовлетворение: цвет появился, экран собрался. Протаскивание дизайн-системы через фреймворк окупается только на множестве экранов и второй теме — а окупаемость-на-масштабе-приложения и есть та ось, которую пооперационный оптимизатор увидеть не может.
5. Глаза и десять лет практики
Что делает агент. Строит сорок экранов, каждый из которых по отдельности защитим, а вместе они ощущаются как приложение, собранное из сорока туториалов, — потому что в некотором смысле так и есть.
Загрузка — это CircularProgressIndicator на главной, шиммер-скелет в ленте и кастомная анимация логотипа в профиле. Ошибки — SnackBar здесь, красный текст под полем там и модальный AlertDialog на третьем экране. Подтверждение удаления — AlertDialog на одном экране и боттом-шит на следующем. У пустых состояний три разные иллюстрации, две разные интонации и одно, где просто написано «Пусто». Одни формы валидируются на каждое нажатие, другие — по сабмиту, одна — по потере фокуса. Главные кнопки на всю ширину в одном флоу и по содержимому — в другом.
Каждый выбор по отдельности разумен. Проблема в том, что пользователь учит приложение один раз. Когда одно и то же действие выглядит по-разному в трёх местах, он перестаёт доверять своему пониманию того, что сейчас произойдёт, — и эта цена всплывает в обращениях в поддержку, а не в отчёте линтера.
Дальше идёт класс дефектов, которые существуют только на экране:
RenderFlex overflowed by 27 pixelsна любом устройстве уже, чем агент вообразил- области нажатия меньше 44/48dp — в тестах отлично, реальным большим пальцем промахиваешься постоянно
- клавиатура, закрывающая поле, в которое печатают, потому что ничего не скроллится
- текст, обрезанный при системном масштабе 200%
- контент под вырезом камеры или за индикатором home, потому что
SafeAreaне был частью запроса - контраст, который проходит в светлой теме и проваливается в тёмной
flutter analyze чист по каждому пункту.
Что делаем мы. Определяем язык взаимодействия один раз — как выглядит загрузка, как всплывают ошибки, как подтверждаются разрушительные действия, когда валидируются формы, как ведёт себя навигация, что написано в пустом состоянии, — и дальше каждый экран реализует его. Десять с лишним лет практики в основном покупают знание о том, какие паттерны у пользователя уже в пальцах, потому что их туда положили тысячи других приложений. Знакомое выигрывает у остроумного практически всегда, а проверяется это тем, что приложение открывают на живом устройстве, при реальном масштабе текста, в обеих темах и пальцем.
Почему агент так делает. Он действительно не видит. Он генерирует правдоподобный экран по описанию, без памяти о тридцати девяти предыдущих экранах и без картинки того, что только что произвёл. Локальное правдоподобие — единственная доступная ему цель.
6. ICU не опционален, а склейка строк — не локализация
Мы делаем приложения для рынков, где ошибка здесь видна в первом же предложении первого экрана. Агенты ошибаются здесь по умолчанию.
Что делает агент.
Text('$count файлов'); // 1 файлов
Text('$count ${count == 1 ? "файл" : "файла"}'); // «умно» и всё равно сломано
Text('Привет, ' + name + '!'); // порядок слов зашит в код
Text('${d.day}.${d.month}.${d.year}'); // неверно для половины мира
Text('\$${amount.toStringAsFixed(2)}'); // не тот разделитель, не то место
Тернарник для множественного числа — самый показательный, потому что выглядит так, будто разработчик подумал. Он зашивает предположение, истинное в английском и ложное почти везде: что у языка ровно две формы множественного числа. В русском их четыре категории (one, few, many, other) — 1 файл, 2 файла, 5 файлов, — поэтому тернарник неверен для большинства чисел на экране. В арабском их шесть. В японском одна, и любая плюрализация читается как поломка. Никакая вложенность тернарников это не чинит, потому что правило — это данные конкретного языка, а не условие, которое вы пишете.
Дальше то же самое. Склейка зашивает английский синтаксис в исходники, и переводчик не может переставить части предложения. Десятичный разделитель, разделитель разрядов и позиция символа валюты — это локальные данные (1 234,56 € против $1,234.56). Порядок компонентов даты — локальные данные. А в RTL-раскладках EdgeInsets.only(left: 16), который агенты пишут рефлекторно, кладёт отступ не с той стороны — там, где EdgeInsetsDirectional.only(start: 16) был бы корректен.
Что делаем мы. Сообщения живут в ARB-файлах с ICU MessageFormat, из которых gen_l10n генерирует типизированные аксессоры:
{
"unreadMessages": "{count, plural, =0{Нет новых сообщений} one{{count} новое сообщение} few{{count} новых сообщения} many{{count} новых сообщений} other{{count} новых сообщения}}",
"@unreadMessages": { "placeholders": { "count": { "type": "int" } } }
}
В английском ARB тот же ключ обходится формами one/other — в формате есть место под язык, а фреймворк выбирает правильную ветку по данным CLDR. NumberFormat.currency и DateFormat.yMMMd(locale) закрывают остальное, а направленные отступы держат RTL честным. Точка вызова превращается в Text(l10n.unreadMessages(count)) — короче тернарника и при этом корректно.
Почему агент так делает. Интерполяция строк — кратчайший путь к тексту на экране, и она визуально корректна на том языке, на котором был написан запрос. Сбой проявляется только в локали, о которой агента не спросили, и на числе, которое он не пробовал.
7. Знать, где упрощать — и где нельзя
Последний пункт — про суждение, и его передать труднее всего.
Что делает агент. Применяет упрощение равномерно, что даёт два противоположных сбоя в одной кодовой базе.
Он выравнивает сложность, которая была несущей:
try {
await api.charge(order);
} catch (_) {
// проглочено: сетевая ошибка, отказ банка и конфликт идемпотентности
// стали одним событием, а ретрай теперь спишет деньги дважды
}
Логика ретраев и идемпотентности схлопывается в один вызов, потому что «выглядело избыточным». Гонка при обновлении токена получает простой await, потому что мьютекс «вроде не использовался». Разные режимы отказа сливаются в одно «Что-то пошло не так» — ровно то сообщение, которое делает баг в платежах невоспроизводимым. Токены отмены выбрасываются, потому что на них не ссылался ни один тест.
И он переабстрагирует то, что было в порядке: AppButton с четырнадцатью опциональными именованными параметрами и шестью булевыми флагами, generic-иерархия BaseRepository<T, ID> поверх трёх эндпоинтов, AbstractBaseViewModel, от которого нельзя отнаследоваться, не прочитав его целиком, и неизбежный utils.dart, куда сорок несвязанных статических функций приходят умирать.
Что делаем мы. Сложность — это бюджет, и он тратится там, где домен действительно сложен: платежи, офлайн-синхронизация и разрешение конфликтов, обновление токенов авторизации, идемпотентность, всё, что касается денег и личности. Эти места остаются явными, многословными и намеренно скучными: каждый режим отказа назван, каждый ретрай осознан. Везде остальное схлопываем агрессивно: пять почти одинаковых элементов списка становятся одним виджетом, экран настроек не получает никакого слоя абстракции, а репозиторий поверх одного эндпоинта — это просто функция.
Почему агент так делает. «Какие части этого домена сделают кому-то больно, если окажутся неверными?» — это не та информация, которая есть в коде. Она берётся из продукта, из регуляторного контекста и из того, что вы когда-то были на разборе инцидента. Агент, оптимизирующий читаемые минимальные диффы, обращается с ретраем платежа и с переключателем в настройках одинаково — потому что синтаксически это одно и то же.
Что такое оркестрация на самом деле
Прочитайте семь пунктов вместе — и форма становится очевидной: каждый из них является свойством всей системы: единообразие, покрытие, модель состояния, дизайн-система, язык взаимодействия, модель локализации, бюджет сложности. Ни одно из них не может возникнуть по одному запросу за раз, потому что ни один запрос не видит целого. Это не изъян, который лечится промптом, — это и есть окно контекста.
Поэтому работа инженера уезжает вверх по стеку, и состоит она в основном из четырёх вещей.
Задать инварианты до того, как появится код. Тема, модель состояния, настройка локализации, структура папок, таксономия ошибок. Как только это есть, у фразы «делай как в существующем коде» появляется на что указывать — а следовать уже лежащему в репозитории паттерну агенты умеют по-настоящему хорошо. Первые два дня определяют следующие полгода.
Построить обратную связь, которой у агента нет. Строгий analysis_options.yaml с линтами, поднятыми до ошибок. flutter test с виджет- и golden-покрытием. Бюджет времени кадра, проверяемый на profile-сборках. Каждая из этих вещей превращает вопрос суждения в проверку, которую агент может сам запустить и под которую может итерироваться, — а это единственный способ сделать его скорость активом, а не обязательством.
Ревьюить решения, а не строки. Это та же модель состояния, что и во всём приложении? Это стилизовано из темы? Это тот самый единственный вывод значения? Эта строка переживёт русский язык? Построчное ревью выхлопа агентов не масштабируется и не будет; ревью на уровне решений — масштабируется, потому что решений мало.
Открыть приложение. На устройстве, при масштабе текста 200%, в тёмной теме, на обоих языках, с придушенной сетью. Это десять минут, и это находит весь класс дефектов, о которых ни одна статическая проверка никогда не сообщит.
Вот в чём реальная разница между Flutter, написанным агентом, и Flutter, написанным командой, которая агентами управляет. Не в коде — код часто нормальный. В рамках вокруг него.
Частые вопросы
Могут — внутри кодовой базы, в которой уже есть структура: тема, модель состояния, настроенная локализация и тесты, под которые можно итерироваться. Чего они не могут, так это создать эту структуру, потому что каждый её элемент является свойством всей системы, а агент оптимизирует каждый запрос локально. Если есть паттерн, которому надо следовать, выхлоп агента действительно силён. Если есть пустой репозиторий и никаких рамок, получаются сорок экранов, каждый из которых работает, но которые не складываются в одно приложение.
Если у вас Flutter-приложение, написанное агентом
Если вы узнали свою кодовую базу в этом списке — это не признак того, что использовать AI было ошибкой. Так выглядит выхлоп агентов без присмотра на Flutter, каждый раз, и это чинится без переписывания.
Мы так и работаем: агенты пишут большую часть кода, а наши инженеры владеют семью решениями выше. Посмотрите, как мы делаем разработку на Flutter, прогоните существующую кодовую базу через аудит AI-кода или запишитесь на бесплатную оценку — и мы скажем, какой из семи пунктов обходится вам дороже всего.

Илья Никсан
Основатель и ведущий разработчик
Илья основал Nerdy Production и руководит инженерной командой. До этого он был CTO QIWI — одной из крупнейших платёжных платформ России, — где он руководил примерно 12 инженерными командами: от веб-продуктов до карточного процессинга, зоны PCI-DSS и бесконтактных платежей, включая бесконтактную оплату картой на Android через Host Card Emulation поверх ISO/IEC 14443, с платёжным протоколом EMV Contactless (Visa PayWave). Он также был принципал-разработчиком в Яндексе, где работал над Яндекс.Авто, уводя нативный Android глубоко в автомобиль, с серьёзной интеграцией по шине CAN через собственный CAN-шилд. Он также был принципалом в Эвоторе, чьи кассовые устройства работают на форке AOSP, что дало ему низкоуровневый взгляд на Android, недоступный большинству прикладных разработчиков. Он занимается разработкой с 2010 года и выпускает продакшн-приложения на Flutter с 2018-го, а сейчас ведёт поставку флагманских приложений агентства — от насыщенного графиками финтех-интерфейса ExtraETF до полностью кастомной дизайн-системы Arcana. Он пишет большую часть материалов этого блога и поддерживает open-source агентства, включая движок dxpdf для конвертации DOCX в PDF. Работает с Flutter, Go, Rust, TypeScript, Kotlin, Kubernetes и Docker; его фокус — архитектура приложений, кросс-платформенная поставка и построение команд, которые доводят продукт до релиза.
Ещё от Илья Никсан