Оберните FilledButton в небольшой StatelessWidget, который принимает label, nullable VoidCallback? onPressed и флаг loading; во время загрузки передавайте в onPressed null, чтобы кнопка сама стала неактивной, и показывайте спиннер поверх невидимого текста.
**null означает «выключена»**: это соглашение Material, поэтому тема сама делает кнопку серой и блокирует нажатия; отдельный флаг enabled не нужен
Stateless намеренно: loading принадлежит родителю, потому что именно он ждёт запрос; кнопка только отображает флаг, а неактивную кнопку нельзя нажать дважды
Размер не прыгает: невидимый текст по-прежнему занимает свою ширину, поэтому кнопка не сжимается, когда появляется спиннер
Стиль берётся из темы: никаких захардкоженных цветов, тогда работают тёмная тема и фирменные цвета; const-конструктор с super.key, как у любого переиспользуемого виджета
FlutterMiddle
Live coding на Dart
Debounce, Result-типы, LRU-кэш, backoff, ограничение параллелизма, trie, трансформеры
Закрыто вопросов
0 / 19
- 0Повторить
- 1Часто спрашивают
Вопросы
Запустите Timer.periodic на одну секунду в initState, на каждом тике читайте DateTime.now() внутри setState и отменяйте таймер в dispose: без этого таймер продолжит срабатывать на уничтоженном State, и вы получите setState() called after dispose().
Читайте часы, а не считайте тики: периодический таймер никогда не срабатывает точно вовремя, поэтому прибавление секунды на каждом тике уплывает
Дополняйте нулями: n.toString().padLeft(2, '0') или DateFormat.Hms() из package:intl, если формат должен зависеть от локали
Держите часы в отдельном маленьком виджете: тогда ежесекундный setState перестраивает один Text, а не весь экран
Альтернатива: StreamBuilder поверх Stream.periodic(const Duration(seconds: 1)); он сам отменит подписку в своём dispose
typed объявлена как локальная переменная build: присваивание ей не запускает перестройку, а каждая перестройка всё равно сбросила бы её в ''. Текст должен жить в состоянии, которое переживает build и сообщает виджету, что пора перестроиться.
**Локальные переменные build создаются заново при каждой перестройке**: всё, что должно пережить кадр, хранят в объекте State или в нотифаере
Контроллер тоже не на своём месте: созданный в build, он подменяется при каждой перестройке родителя и никогда не освобождается
Исправление: StatefulWidget, который один раз создаёт контроллер полем и освобождает его в dispose
**Перестраивайте только Text**: TextEditingController является ValueNotifier<TextEditingValue>, поэтому ValueListenableBuilder на нём обновляет подпись без setState для всего виджета
Он отклоняет настоящие адреса: теги через + вроде anna+shop@gmail.com, дефисы и цифры в домене, поддомены вроде mail.example.co.uk и любой домен верхнего уровня длиннее трёх букв. На клиенте форму адреса проверяют мягко, а настоящую проверку делает письмо с подтверждением.
Что упускает шаблон: локальная часть допускает только буквы, цифры и точки; домен ограничен одной меткой из букв; {2,3} отсекает .studio и .travel
**value! и отсутствие trim()**: пробел в конце от автодополнения клавиатуры ломает совпадение, а null роняет приложение вместо показа ошибки
Полная регулярка по RFC 5322 ответом не будет: она огромная, всё равно пропускает адреса, на которые ничего не доставить, и её невозможно проверить на ревью
Разные сообщения: «Email is required» для пустого ввода и «Enter a valid email» для кривого
Тестируйте списком: тест по валидным и невалидным адресам подтверждает оба сообщения и ловит регрессии
Пропустите тело через jsonDecode, приведите результат к List<dynamic> и превратите каждый элемент через factory Product.fromJson(Map<String, dynamic> json); toJson возвращает Map<String, dynamic>, который принимает jsonEncode.
Приводите поэлементно: jsonDecode строит List<dynamic>, поэтому as List<Map<String, dynamic>> на весь список бросает TypeError; приводите каждый элемент внутри map
Числа: JSON 10 раскодируется в int, а 10.5 в double, поэтому цену читайте как (json['price'] as num).toDouble()
Необязательные поля: json['note'] as String? покрывает и отсутствующий ключ, и явный null; обязательное поле должно падать громко, а не превращаться в ''
На большом проекте: json_serializable генерирует ровно такой же код; на лайвкодинге проверяют, умеете ли вы написать его руками
Один StatefulWidget, который владеет List<Todo> из неизменяемых элементов, TextField с контроллером для добавления и ListView.builder, где строки отмечаются через CheckboxListTile и удаляются через Dismissible. Каждое изменение проходит через setState с новым списком.
**Неизменяемый Todo с id**: отметка делается через copyWith(done: !t.done), а из id строится ключ строки
**Ключ Dismissible берите по элементу, а не по индексу: ключ-индекс передаёт состояние смахнутой строки следующему элементу, и Flutter бросает «A dismissed Dismissible widget is still part of the tree»
Проверяйте ввод**: trim(), пустой заголовок игнорируйте, затем clear() контроллера, а сам контроллер освобождайте в dispose
Сохранение, если попросят: после каждого изменения jsonEncode списка в shared_preferences и загрузка в initState; проговорите, что в настоящем приложении список уезжает из виджета в нотифаер или Bloc
Если saveName бросит исключение, все строки после await пропускаются, и _saving остаётся true: кнопка так и висит неактивной со спиннером, пока экран не откроют заново, а пользователь не узнаёт, что сохранить не удалось.
Почему: исключение из ожидаемого Future пробрасывается в точке await, и код ниже не выполняется; onPressed игнорирует возвращённый Future, поэтому ошибка попадает в PlatformDispatcher.onError как необработанная асинхронная ошибка, а не в UI
Исправление: ожидаемый сбой ловить через on Exception, сбрасывать флаг там и показывать SnackBar, чтобы пользователь мог повторить; при успехе спиннер оставить до закрытия маршрута, чтобы быстрый второй тап не запустил второе сохранение и pop (try/finally тоже подходит, если путь успеха не включает кнопку обратно)
**Проверки mounted остаются**: к концу запроса экрана может уже не быть; if (mounted) перед setState, Navigator и ScaffoldMessenger после await и есть ровно то, что проверяет линт use_build_context_synchronously
Как поймать заранее: виджет-тест с фейковым репозиторием, который бросает исключение: нажать Save, вызвать pump и проверить, что кнопка снова активна; тест «счастливого пути» эту ветку не проходит
Сверните список в Map<DateTime, List<Txn>> с ключом DateTime(y, m, d), взятым после toLocal(), а потом отсортируйте ключи; сырая метка времени как ключ никогда не сгруппирует, потому что равенство DateTime точно до микросекунды.
Свёртка: putIfAbsent(key, () => []).add(item) на каждый элемент; groupBy из package:collection делает то же самое, только с именем
Чем подставляет ключ: равенство DateTime точное до микросекунды и вдобавок сравнивает isUtc, поэтому две метки одного дня не совпадут. Обрезайте через DateTime(d.year, d.month, d.day).
Группируйте в зоне пользователя: сначала toLocal(), иначе покупка в 01:30 по местному времени уедет под вчерашний заголовок
Сортируйте ключи явно: литерал мапы создаёт LinkedHashMap, ключи выходят в порядке вставки
Разворачивайте для списка один раз: строки заголовков и элементов стройте вне build, чтобы ListView.builder получил один плоский список
Debounce ждёт конца серии вызовов и выполняет только последний; throttle по переднему фронту выполняет первый вызов и выбрасывает всё остальное, включая последнее нажатие.
Debounce: один Timer?; каждый вызов отменяет предыдущий таймер и заводит новый, поэтому действие срабатывает после delay тишины. Для поиска берут около 300 мс.
Throttle: выполнить сразу, запомнить время, игнорировать вызовы до конца окна. Подходит скроллу и жестам, но не поиску: flu уйдёт в сеть, а flutter пропадёт.
Две ловушки с временем жизни: отменяйте таймер в dispose, иначе колбэк выстрелит на уничтоженном State; и держите дебаунсер полем, ведь созданный внутри build получает новый таймер на каждую перестройку и не дебаунсит ничего
Debounce не упорядочивает ответы: медленный flu может прийти после flutter; помечайте каждый запрос растущим id и выбрасывайте ответ, чей id уже не последний
sealed class Result<T, E> с наследниками Ok и Err переносит ошибку в тип возвращаемого значения, и обработать её заставляет компилятор, а не ревью.
**sealed делает switch исчерпывающим**: наследники могут жить только в этой библиотеке, поэтому switch по Ok и Err не требует default; пометьте Ok и Err как final, чтобы внешний код не мог их расширять
Два параметра типа, а не один: E как Object даёт тот же try/catch с лишним синтаксисом; сделайте E собственной sealed-иерархией ошибок, чтобы UI мог делать по ней switch
Операторы: map преобразует значение и пропускает Err насквозь, mapError делает то же зеркально, а fold схлопывает обе ветки в одно значение, обычно в виджет
**flatMap для шага, который может упасть**: обычный map просто вложит два Result друг в друга, а ловить он ничего не умеет: бросившая функция улетит мимо Result
Оборачивайте I/O, а не баги: один try/catch в репозитории; выход за границы или нарушенный инвариант должны падать, а не уезжать в снекбар
Ключ-List сравнивается по идентичности: ['eu', 2.0] == ['eu', 2.0] даёт false, поэтому каждый вызов строит новый ключ, промахивается, считает заново и добавляет ещё одну запись. Используйте в качестве ключа запись (zone, kg), потому что у records структурные == и hashCode встроены в язык.
Почему записи работают: равенство (String, double) сравнивает поля, поэтому следующий вызов с равными аргументами находит запись
**Старая привычка '$zone|$kg': работает, пока аргумент не может содержать разделитель
putIfAbsent, а не ??=**: cache[key] ??= compute() бесконечно пересчитывает, если функция законно возвращает null: сохранённый null выглядит как промах
Мемоизируйте только чистые функции и ограничивайте мапу: если результат зависит от DateTime.now() или локали, этот вход должен быть в ключе, а неограниченная мапа в долгоживущем репозитории превращается в утечку
Храните каждое значение вместе со временем истечения и проверяйте его при чтении: истёкшая запись удаляется и считается промахом. Такое ленивое истечение обходится без таймеров, а лимит размера с вытеснением самой старой записи не даёт копиться ключам, которые больше никто не читает.
**Истечение при чтении, а не Timer на каждую запись**: тысяча таймеров обходится куда дороже одного сравнения на get, и они продолжают срабатывать, когда самого кэша уже нет
Одно ленивое истечение течёт: запись, которую больше не читают, никогда не проверяется, поэтому мапу ограничивают; Map в Dart хранит порядок вставки, так что keys.first указывает на самую старую запись, и её удаление стоит O(1)
FIFO или LRU: переставляйте ключ в конец при каждом попадании, и тот же лимит превратится в вытеснение давно не использованных (LRU)
Внедряйте часы: clock.now() из package:clock или параметр DateTime Function(), тогда тест двигает время через withClock или fakeAsync, а не спит
Проговорите, что значит «устарело» для продукта: каталог товаров может жить минуты, а баланс счёта кэшировать нельзя вовсе
put для уже существующего ключа не переносит его в конец: присваивание существующему ключу в Dart Map сохраняет его исходную позицию вставки, поэтому только что обновлённая запись по-прежнему выглядит самой давно использованной и вытесняется следующей.
Почему: Map по умолчанию реализован как LinkedHashMap: ключи упорядочены по первой вставке, а map[key] = value для существующего ключа лишь заменяет значение. get делает это правильно через remove и повторную вставку, а put так не делает
Исправление: _map.remove(key) перед присваиванием, тот же приём, что уже есть в get; обе операции остаются O(1)
Чем это оборачивается в продакшене: при capacity 2 последовательность put('anna'), put('bob'), put('anna', v2), put('carl') вытесняет anna (запись, которую трогали последней); для обновляемых ключей кэш скатывается к FIFO, и процент попаданий тихо падает без единой ошибки
Как поймать заранее: юнит-тест ровно на эту последовательность; тесты, которые только кладут и читают новые ключи, проходят с обеими версиями
Идите по обоим спискам двумя индексами, каждый раз забирая меньшую голову, и держите Set<String> уже выданных id: O(n + m), один проход, без повторной сортировки.
Два курсора: оба списка отсортированы по (sentAt, id); сравните головы через compareTo, допишите меньшую, сдвиньте её курсор; когда один список кончился, дочерпайте второй
Проговорите, почему не «склеить и отсортировать»: это O((n + m) log(n + m)) и отказ от единственного данного вам факта: оба входа уже упорядочены
**Set.add возвращает false, если id уже был**: проверка и вставка за один поиск по хешу; а out.any((m) => m.id == id) делает слияние квадратичным
Решите, чья копия выживает: копия с сервера побеждает, когда совпадают время и id; если у сообщения может измениться метка времени, сначала проиндексируйте свежую страницу по id
**Ловушка DateTime**: == сравнивает ещё и флаг isUtc, поэтому один момент в UTC и в локальной зоне не равен; сравнивайте через compareTo или isAtSameMomentAs и храните одну зону
Обход в глубину в прямом порядке с явным стеком: кладём корни в обратном порядке, снимаем узел, выдаём (comment, depth) и кладём его детей тоже в обратном порядке, чтобы они вышли в порядке чтения.
**Кладите children.reversed: стек переворачивает всё, что в него положили, и именно эту деталь проверяет задача
Почему не рекурсия**: глубину ветки задаёт пользователь, а очень глубокая рекурсия заканчивается StackOverflowError; это Error, который не стоит пытаться ловить
Глубина принадлежит элементу стека, а не модели: один и тот же комментарий имеет глубину 0 на пермалинке и 7 внутри ветки
Разворачивайте раз на изменение данных: держите плоский список в состоянии, чтобы build только брал элемент по индексу; ограничьте отступ через min(depth, 6) * 12
**Если API отдаёт parentId**: сначала сгруппируйте детей через groupBy; поиск по плоскому списку внутри цикла даёт ту самую O(n²), которая умирает на тысяче комментариев
Шина живёт всё приложение и хранит каждую подписку, а замыкание слушателя захватывает State, поэтому экран остаётся достижимым, пока не вызван cancel(); типизируйте шину классами событий и возвращайте StreamSubscription, чтобы вызывающий мог его отменить.
Форма решения: один StreamController<Object>.broadcast(), а on<T>() возвращает _ctrl.stream.where((e) => e is T).cast<T>(); события объявлены классами, поэтому опечатка становится ошибкой компиляции
Откуда утечка: колбэк замыкается на this, поэтому State и всё, на что он ссылается, живут столько же, сколько синглтон-шина
**Забытый cancel() не безобиден**: колбэк продолжает срабатывать, вызывает setState на уничтоженном State и бросает исключение на каждое событие, а каждое повторное открытие добавляет ещё одного подписчика и дублирует побочный эффект
Сделайте утечку невозможной: отдавайте Stream<T>, и пусть подпиской владеет StreamBuilder; если listen всё же нужен, отменяйте все подписки в dispose и включите линт cancel_subscriptions
Обходите обе структуры одновременно, неся с собой путь, и возвращайте первое расхождение строкой вроде $.user.tags[1] или null, если равны; == у двух map сравнивает идентичность, а DeepCollectionEquality отвечает только «да» или «нет».
Что даёт SDK: DeepCollectionEquality().equals(a, b) из package:collection корректно отвечает «равны ли», но никогда не отвечает «где», а ради этого задачу и дают
Обход: рекурсивно спускаемся в Map и List, всё остальное сравниваем через ==, а сообщаем в фиксированном порядке: несовпадение типа, ключ только с одной стороны, разная длина, затем неравенство листа
Сортируйте объединение ключей заранее: map в Dart итерируется в порядке вставки, и две по-разному собранные payload сообщили бы о разных «первых» отличиях
Значения, которые обманывают: double.nan != double.nan, а jsonDecode вернёт int для 1 и double для 1.0, поэтому num сравнивайте по значению, а всё остальное ещё и по типу
**Возвращайте null, если равны**: тогда expect(firstDiff(expected, actual), isNull) сам сделает сообщением о падении нужный путь
**expect(a, equals(b)) уже сравнивает глубоко**: он печатает строку Which:, но выводит обе payload целиком; хелпер с одним путём нужен для логов, проверок контрактов и собственных матчеров
Держите очередь уже выданных времён старта: вызов проходит сразу, пока внутри последней минуты их меньше пяти, иначе он бронируется на момент, когда самый старый выпадет из окна. Сбрасываемый счётчик пропускает десять вызовов за две секунды на стыке минут.
Скользящее окно, а не сбрасываемый счётчик: счётчик, обнуляемый раз в минуту, пропустит пять в 11:59:59 и ещё пять в 12:00:01
**Память ограничена max плюс вызовы, которые сейчас ждут: на каждой выдаче вычищайте всё старше окна, и очередь не превысит пяти плюс уже забронированные слоты
Как избежать гонки**: вычислите слот и положите его в очередь без await между этими шагами, и только потом ждите; если сначала ждать, два параллельных вызова оба увидят свободное место
Внедряйте часы: clock.now() из package:clock вместе с Future.delayed позволяют тесту на fakeAsync прокрутить всю минуту за микросекунды
Второй правильный ответ даёт token bucket: ёмкость пять, пополнение пять за окно, O(1) состояния, и он допускает короткие всплески; скажите, что именно пишете и почему
Соберите его через StreamTransformer.fromBind поверх async*-функции, чтобы состояние фильтра (последняя оставленная точка) создавалось заново на каждом bind, и сравнивайте по времени самой точки, а не по часам устройства.
**Сравнивайте с последней отданной точкой: сравнение с последней пришедшей превращает медленную прогулку в поток, который всё отбрасывает и больше ничего не отдаёт
Время проверяйте раньше расстояния: вычитание бесплатно, а гаверсинус требует нескольких тригонометрических вызовов; первая точка проходит оба фильтра
Почему не fromHandlers: его обработчики создаются один раз, поэтому захваченное ими состояние общее для всех стримов, к которым привязан трансформер, и две подписки портят друг друга
fromBind заодно даёт backpressure**: await for ставит источник на паузу, когда слушатель на паузе; результат single-subscription, так что при нескольких слушателях добавьте .asBroadcastStream()
Считайте по часам данных: после туннеля точки приходят пачкой с разницей в миллисекунды по DateTime.now(), но fix.at по-прежнему разносит их правильно, а тесты могут подать выдуманный список точек
Сначала спросите платформу: LocationSettings(distanceFilter: 10) в geolocator отсекает точки ещё до того, как они разбудят Dart; трансформер нужен, чтобы совместить оба правила и покрыть их тестами