instance объявлен как геттер со стрелочным телом, поэтому каждое обращение выполняет Session._() и возвращает новый объект: токен записали в объект, который никто не сохранил. Экземпляр нужно хранить один раз: static final Session instance = Session._();.
**get выполняется при каждом обращении: геттер это метод без скобок, а не закешированное значение
static final инициализируется один раз: лениво при первом чтении, дальше это один и тот же объект на всё время жизни изолята
Та же ловушка с factory Session() => Session._();: factory-конструктор возвращает один экземпляр, только если возвращает сохранённый
??= тоже работает**: static Session? _i; static Session get instance => _i ??= Session._();, но смысл в этом есть, только если тестам нужно сбрасывать _i
FlutterSenior
Паттерны проектирования
Singleton, Factory, Observer, Builder, Strategy, Command во Flutter
Закрыто вопросов
0 / 22
- 0Повторить
- 4Часто спрашивают
Вопросы
Flutter сам построен на них: дерево виджетов устроено как Composite, ChangeNotifier и Stream реализуют Observer, ScrollPhysics служит Strategy, а Actions/Intent воплощают Command. Код приложения добавляет вокруг данных и SDK Repository, Adapter, Decorator и Factory.
Composite: каждый виджет служит узлом, дети которого тоже виджеты, поэтому с Column и Text работают через один тип Widget
Observer: Listenable, ChangeNotifier, ValueNotifier, Stream; большинство библиотек стейт-менеджмента строятся на одном из них
Strategy: взаимозаменяемое поведение за одним интерфейсом: ScrollPhysics, Curve, TextInputFormatter
Factory: factory-конструктор вроде fromJson, который выбирает подтип, или функция, возвращающая реализацию под платформу
Adapter и Decorator: обернуть SDK вендора в свой интерфейс; обернуть репозиторий кешем или логированием за тем же интерфейсом
Singleton: один экземпляр на всё время жизни приложения, и с ним осторожнее всего: глобальная точка доступа прячет зависимости и протаскивает состояние между тестами, поэтому лучше зарегистрировать один экземпляр в DI
Приватный именованный конструктор плюс поле static final с экземпляром; Dart создаёт его лениво при первом обращении, а factory-конструктор может его возвращать, если вызывающим удобнее писать Analytics(). Недостатки идут от глобальной точки доступа, а не от того, что экземпляр один.
Скрытая зависимость: Analytics.instance внутри метода не видна ни в одной сигнатуре, поэтому ни читатель, ни тест не могут её увидеть или подменить
Состояние протекает между тестами: тесты одного файла делят один изолят: что записал тест 3, то прочитает тест 4, поэтому по одному они проходят, а всей пачкой падают
Не то время жизни: он живёт столько же, сколько изолят, и клиент с токеном прошлого пользователя переживает выход из аккаунта
Нет асинхронной инициализации: конструктор не умеет await, поэтому всё, что требует SharedPreferences.getInstance(), обрастает late-полями и методом init(), который все должны не забыть вызвать
Как чинить: оставить один экземпляр, но создавать его в composition root и внедрять (registerLazySingleton в get_it, провайдер Riverpod); если глобальный доступ всё же нужен, добавить сброс с @visibleForTesting и вызывать его в setUp
Репозиторий прячет источник данных за интерфейсом: приложение просит User и не знает, пришёл он из сети, из базы или из кеша. Поэтому сменить или совместить источники означает поменять один класс, а не каждый экран.
Доменный слой объявляет абстрактный класс, data-слой реализует его через удалённый и локальный источники данных, а DI соединяет их на старте.
Источник меняется свободно: переезд с REST на GraphQL или добавление офлайн-кеша меняет один класс
Тесты становятся простыми: фейковый репозиторий с фиксированными объектами заменяет всю сеть в виджет-тесте
Политика в одном месте: сначала сеть с откатом на кеш, повторы и дедупликация живут здесь, а не в пяти вью-моделях
Возвращайте доменные модели: маппинг DTO делается внутри репозитория, чтобы имена полей JSON не доходили до UI
Частая ошибка: репозиторий на каждый экран или репозиторий, отдающий HTTP-ответы: это Dio с лишними шагами
Геттер items возвращает собственный список репозитория, а не копию, поэтому removeWhere на экране удаляет товары из самой корзины: фильтр уменьшает totalCents, а скрытые товары пропадают насовсем.
Списки в Dart передаются по ссылке: геттер отдаёт тот же объект; в отличие от Array в Swift, который является value-типом, при присваивании ничего не копируется
Исправление: репозиторий возвращает List.unmodifiable(_items), а экран строит свой список через where(...).toList(), так что корзина меняется только через методы репозитория
Копия или представление: List.unmodifiable копирует при каждом чтении; UnmodifiableListView(_items) из dart:collection даёт дешёвое окно только для чтения, которое видит последующие изменения
Как поймать: со списком только для чтения старый removeWhere громко падает с Unsupported operation: Cannot remove from an unmodifiable list вместо того, чтобы молча портить состояние; это покрывает юнит-тест, который включает фильтр и затем проверяет totalCents
Внедрение зависимостей значит, что класс получает зависимости снаружи, а не создаёт их сам, поэтому тест может подсунуть ему фейк. Реальный выбор сводится к тому, чем собирать граф: constructor injection, service locator (get_it) или провайдеры Riverpod.
Constructor injection: зависимости видны в сигнатуре, компилятор проверяет связывание, тест передаёт фейки напрямую. Цена в том, что их приходится протаскивать вниз, поэтому граф обычно собирает что-то ещё
**get_it**: service locator: sl<UserRepository>() откуда угодно, без кодогенерации. Зависимость не видна в сигнатуре, забытая регистрация падает только в рантайме, а тестам приходится делать reset() глобального объекта
**injectable**: кодогенерация поверх get_it: @injectable, @lazySingleton и @Environment('dev') генерируют регистрации, и они не расходятся с кодом
Riverpod: провайдеры одновременно и DI, и состояние: зависимости приходят через ref, создаются лениво, освобождаются через autoDispose, а тест подменяет любую из них через overrides
Практическое правило: какой бы ни был контейнер, внутри классов оставляйте constructor injection, а к контейнеру обращайтесь только из composition root и из виджетов; тогда контейнер остаётся заменяемым
LoginBloc зарегистрирован как lazy singleton, но BlocProvider(create:) владеет созданным экземпляром и закрывает его при уходе со страницы. При следующем заходе get_it отдаёт тот же закрытый bloc, и add бросает Bad state: Cannot add new events after calling close.
Два владельца, два времени жизни: get_it держит экземпляр всё время жизни приложения, BlocProvider уничтожает его вместе с маршрутом; побеждает более короткое время жизни, а get_it продолжает отдавать мёртвый объект
Исправление: registerFactory для всего, что привязано к экрану (bloc, cubit, view model): каждый create получает новый экземпляр, и закрывать его при уходе со страницы правильно
Синглтоны нужны для сервисов уровня приложения: репозитории, Dio, аналитика: объекты, которые ни один экран не уничтожает
Если bloc задумывался общим: оставьте синглтон, передавайте его через BlocProvider.value, который его никогда не закрывает, и закрывайте сами, например при выходе из аккаунта
Как поймать: виджет-тест, который открывает страницу, закрывает её, открывает снова и нажимает кнопку; та же путаница с ChangeNotifierProvider(create: (_) => sl<CartModel>()) падает с «A CartModel was used after being disposed»
Facade даёт одну точку входа по форме задачи над несколькими подсистемами: он не хранит данные и не добавляет правил, а лишь знает, как части сочетаются. Repository прячет, откуда берётся один вид данных; фасад прячет, как несколько вещей соединяются ради одного действия пользователя.
Где окупается: оформление заказа, которое трогает оплату, склад и уведомления: CheckoutFacade.placeOrder(cart) прячет порядок вызовов и откат
Повторяющиеся сценарии: выбрать изображение, спросить разрешение, сжать, загрузить, показать прогресс: каждому нужен один метод, а не пять
Форма под задачу: payWithCard(order) служит фасадом, а getStripeClient() создаёт утечку абстракции
Удалите, если он пробрасывает один вызов: это косвенность без изоляции; а фасад, обрастающий несвязанными методами, на деле оказывается несколькими фасадами под одним именем
Adapter оборачивает класс с одним интерфейсом и отдаёт его через тот интерфейс, который нужен вашему приложению. Он нужен, когда форма стороннего SDK не совпадает с вашим кодом, а поменять вы не можете ни одну из сторон.
На практике он реализует ваш интерфейс, держит чужой объект полем и переводит каждый вызов.
Два SDK, один контракт: оба аналитических вендора за единым интерфейсом Analytics, выбор на старте
Кривые колбэки внутрь, чистый API наружу: типизированные колбэки платёжного SDK превращаются в Future<Result> charge(...)
Чужие типы остаются снаружи: адаптер маппит классы SDK в ваши доменные модели
Так строится anti-corruption layer: вендор выпустит ломающую v2, и изменится только адаптер
Частая ошибка: пропустить типы SDK сквозь свой интерфейс: тогда это обёртка, а не адаптер
Decorator оборачивает объект, добавляя поведение и не меняя его класс: обёртка реализует тот же интерфейс, пробрасывает каждый вызов внутреннему объекту и обкладывает его дополнительной логикой.
Для чего он: логирование, кеширование, повторы или проверки прав вокруг репозитория или HTTP-клиента
Почему не наследование: декораторы складываются в стопку (авторизация, потом кеш, потом логи), и выбирать их можно в рантайме: под окружение или под фича-флаг
Mixin: вшивает поведение в сам класс; чище, когда поведение действительно его собственное, но добавить или убрать его в рантайме нельзя
Extension: добавляет методы, не меняя тип, и не может переопределить метод интерфейса, поэтому годится для хелперов, но не для сквозной функциональности
Берите декоратор, когда: поведение опционально, композируется и должно оставаться незаметным для вызывающих за тем же интерфейсом
Два. Оба вызова проверяют кеш до того, как пришёл хоть один ответ, поэтому оба промахиваются; map заполняется только после завершения первого await. Кешировать нужно Future, а не значение, тогда второй вызов присоединится к уже идущему запросу.
Почему: проверку и запись разделяет await, а в этот промежуток выполняется другой код даже в одном изоляте
Исправление: Map<String, Future<User>>, заполняемая через putIfAbsent: первый вызов запускает запрос, все следующие получают тот же незавершённый future
Выкидывайте неудачи: закешированный упавший future будет вечно повторять ошибку, поэтому при ошибке удаляйте запись, и следующий вызов повторит запрос
Общее название: дедупликация запросов; если данные устаревают, добавьте TTL
Колбэк уже является стратегией, когда поведение сводится к одной операции без состояния и без идентичности. Интерфейс нужен, когда у поведения несколько операций, есть состояние или нужно имя.
Операций больше одной: encode и decode, canHandle и handle; три замыкания в record оказываются классом с худшей эргономикой
Есть состояние: политика повторов, считающая попытки, форматтер с локалью, кеш со своим вытеснением
Нужна идентичность: имя для логов, ключ для выбора из реестра, равенство, чтобы тест проверил, какая именно сработала
Читаемость: DiscountRule в сигнатуре что-то говорит, а bool Function(Order, User, DateTime) нет
Промежуточный вариант: typedef даёт имя типу функции без класса, а класс с методом call является и тем и другим, так что вырасти из функции в класс можно, не трогая места вызова
Сам Flutter показывает оба подхода: ScrollPhysics устроен как интерфейсная стратегия, а Comparator как обычная функция.
Command превращает операцию в объект с методом execute() и, если нужна история, с undo(). Именно хранение таких объектов вместо простых вызовов функций и делает отмену возможной.
Два стека: выполненная команда кладётся в стек undo; отмена достаёт её, зовёт undo() и кладёт в стек redo, который очищается любой новой командой
Сохраняйте состояние «до»: команда запоминает то, что нужно восстановить, в момент выполнения и никогда не вычисляет это заново внутри undo()
Отправитель остаётся глупым: кнопка знает, что отправляет команду DeleteUser, но не знает, что делает удаление
Реплей достаётся бесплатно: лог команд можно переиграть в тестах или сохранить как журнал аудита
Когда не стоит: оборачивать каждое нажатие в команду это церемония; она нужна только ради отмены, реплея или композиции
Для обычного конструирования практически никогда. Именованные аргументы со значениями по умолчанию и copyWith уже дают то читаемое, независимое от порядка и частичное конструирование, ради которого Builder придумывали.
Он окупается при накоплении: StringBuffer, BytesBuilder, построитель запросов, собирающий условия перед запуском
И при пошаговом API: когда компилятор обязан не дать вызвать build() до выполнения обязательных шагов
И при проверке целиком: когда валидация имеет смысл один раз, по всему набору входных данных
Каскады не замена: .. настраивает изменяемый объект в стиле fluent API, но отдаёт наружу недостроенный экземпляр
**builder: во Flutter относится к другому паттерну**: LayoutBuilder и ListView.builder откладывают конструирование: чтобы получить BuildContext ниже текущего виджета, чтобы принять constraints, известные только на этапе layout, или чтобы строить детей лениво по мере прокрутки
Базовый класс фиксирует порядок алгоритма и оставляет именованные шаги, которые заполняют наследники. State устроен именно так: фреймворк решает, когда вызвать initState, build и dispose, а вы только переопределяете хуки.
Хуки вы не вызываете: их вызывает фреймворк; если вызвать initState() самому, это всегда баг
**@mustCallSuper охраняет шаги базового класса**: анализатор предупреждает, если переопределение пропускает super; для dispose ещё срабатывает assert, но только в debug, а release-сборка его вырезает, поэтому пропущенный super.dispose() может тихо уехать в прод
**Где ставить super**: super.initState() первым, чтобы база была готова, super.dispose() последним, чтобы ваши ресурсы освободились раньше, чем база разберёт себя
Другие примеры: CustomPainter с paint и shouldRepaint, performLayout у RenderBox, хуки onChange и onError у Bloc
Template Method или Strategy: наследование фиксирует шаги на этапе компиляции, а Strategy передаёт шаг объектом, который можно подменить. Strategy лучше, когда меняется один шаг и выбирается он в рантайме
ChangeNotifier, ValueNotifier и Stream служат готовыми реализациями Observer: субъект хранит список слушателей и вызывает их при изменении. BLoC представляет собой Observer с правилами сверху: на вход именованные события, на выход неизменяемые состояния, и один класс переводит одно в другое.
**ChangeNotifier: синхронный и без данных: он только сообщает «что-то изменилось», а новые значения слушатели читают сами
Stream: каждое событие несёт значение или ошибку, доставка асинхронная, а каждую подписку нужно отменять
Что добавляет BLoC: фиксированную форму входа и выхода: логика экрана живёт в одном классе «события на вход, состояния на выход», который тестируют, добавляя события и проверяя состояния, ничего не рендеря
Отмена**: новое событие может отменить работу, которая ещё идёт, например трансформер restartable() на поле поиска
Когда хватает Observer: чекбокс или индекс вкладки; ValueNotifier достаточно, а bloc здесь был бы лишней церемонией
Каждый экран подписывался через addListener и не вызывал removeListener в dispose, поэтому долгоживущий ChangeNotifier через список слушателей удерживает каждый старый State; замыкание, захватившее this, тянет за собой всё поддерево.
Времена жизни асимметричны: субъект живёт всё приложение, а наблюдатель живёт один экран, и только наблюдатель знает, когда он умирает
**removeListener сравнивает через ==**: инлайновое замыкание каждый раз новый объект, поэтому удаление () => _sync() не удаляет ничего; tear-off метода работает
Релизная сборка молчит: у удерживаемого, уже уничтоженного State слушатель всё равно вызывается: в debug setState бросает «setState() called after dispose()», а в релизе эта проверка вырезана, и утечка остаётся незамеченной
Как чинить: предпочитайте ListenableBuilder, ValueListenableBuilder или context.watch: они подписываются и отписываются вместе с элементом
Если руками: подпишитесь в initState, храните ссылку в поле, отмените в dispose и уничтожайте только то, что создали сами
Оба описывают объект, который ведёт себя по-разному в каждом из нескольких режимов. State-паттерн кладёт поведение внутрь объектов-состояний, а sealed-классы Dart 3 делают состояния просто данными и заставляют компилятор требовать обработки каждого.
State-паттерн: объект делегирует полю State, которое реализует интерфейс, и состояние может само подменить себя следующим, так что переходы живут внутри состояний
Sealed-классы: sealed-родитель с конкретными подтипами; окружающий код делает switch по текущему, и switch без default перестаёт компилироваться, когда вы добавляете вариант
Где живёт логика: с sealed-классами переходы лежат в notifier, cubit или bloc, а не в самом состоянии
Берите State, когда: у каждого режима богатое собственное поведение и переходов много
Берите sealed, когда: состояние это в основном данные для UI, а в коде Flutter-приложения так в большинстве случаев
Диспетчеризуйте по полю type ровно в одной фабрике, а ветку «неизвестный тип» сделайте настоящей моделью, а не броском исключения: приложение, падающее на невиданном типе, чинится только выпуском новой версии.
Закрытый набор в одной кодовой базе: sealed-базовый класс и switch внутри единственной fromJson; тогда компилятор сам укажет на все места, которые надо обновить при добавлении варианта
Открытый набор: словарь из строки типа в функцию-парсер, заполняемый явно в composition root. Оправдано, только когда свои типы приносят отдельные модули
Саморегистрация молча не работает: статические поля в Dart ленивы, поэтому static final _registered = ... внутри класса, к которому никто не обращается, не выполнится никогда, и тип просто отсутствует в рантайме. Регистрируйте из списка, который можно прочитать глазами
Деградируйте, а не бросайте: разбирайте неизвестный тип в UnknownItem(type), логируйте один раз и пропускайте строку
Ловите ошибку на каждом элементе: одна битая строка должна стоить одной строки, а не всей ленты
**freezed напишет это за вас**: @Freezed(unionKey: 'type', fallbackUnion: 'unknown') даёт и sealed-switch, и безопасное значение по умолчанию
Это Chain of Responsibility: смоделируйте каждую стадию как функцию от запроса и следующей стадии, сверните список в один конвейер; он лучше if-ов, когда важны порядок, набор по окружениям или отдельное тестирование стадий.
Обёртывающий middleware: каждая стадия выполняет код до и после остальной цепочки: логирование, замер времени, заголовки авторизации, кеширование, маппинг ошибок. Интерцепторы Dio построены на той же идее
Первый подходящий выигрывает: обработчик либо обрабатывает запрос, либо передаёт дальше; так делают маршрутизацию deep link-ов и разбор payload-ов уведомлений
Лучше блока if-ов, когда: порядок важен и его хочется видеть одним списком, набор различается по окружениям или каждую стадию надо тестировать отдельно с заглушкой вместо next
Цена: поток управления перестаёт быть очевидным, поэтому давайте стадиям имена и логируйте цепочку в debug
Частая ошибка: стадия, забывшая вызвать next, молча выбрасывает всё, что за ней; покройте это тестом, который проверяет, что хвост выполнился
Где стоит retry: стадия повторов снова вызывает next, поэтому переигрываются только стадии после неё (логирование каждой попытки, повторное чтение токена), а не те, что перед ней; поставьте её перед auth, если каждой попытке нужен свежий токен, и после logging, если в логе нужна одна строка на запрос
Если стадий три и они не меняются, прямая функция с тремя if-ами лучше.
Спрячьте реализации за один абстрактный интерфейс и выбирайте между ними условным экспортом. Проверка kIsWeb даёт лишь ветвление в рантайме: она не убирает импорт, а веб-only импорт ломает компиляцию под мобильные.
Механизм: одна публичная библиотека пишет export 'src/create_store_stub.dart' if (dart.library.io) 'src/create_store_io.dart' if (dart.library.js_interop) 'src/create_store_web.dart';
Условие спрашивает, что существует: какая библиотека есть у текущей цели компиляции, а не на какой платформе вы запущены; первый безусловный путь это запасной вариант, и он обязан компилироваться сам по себе
Совпадение веток никто не проверяет: те же имена и те же сигнатуры, иначе сборка упадёт на той единственной платформе, которую вы не собирали
Android от iOS так не отличить: dart.library.io истинно для обоих, а ещё для десктопа и тестовой VM; это остаётся вопросом рантайма
Две стороны ломаются по-разному: dart:io компилируется в веб-сборку и бросает Unsupported operation при первом обращении; dart:js_interop и package:web под мобильные не компилируются вообще, как бы ни был защищён вызов
Проверка в рантайме не помогает ни там, ни там: if (!kIsWeb) оставляет импорт на месте: общий файл с package:web всё равно ломает сборку под Android и iOS, а с dart:io бросит исключение, как только код вне проверки до него дотронется
Оформите это как Abstract Factory: один abstract interface class SecureStore и верхнеуровневая createSecureStore() в каждом платформенном файле.
В Dart 3 по умолчанию выбирают sealed-switch: он даёт ту же проверку полноты компилятором, что и Visitor, но без шаблонных accept/visit и двойной диспетчеризации. Visitor остаётся для открытых иерархий, которые нельзя запечатать.
Размен: Visitor делает дешёвой новую операцию (один новый класс) и дорогим новый тип узла: visitX добавляется в интерфейс, и его обязан реализовать каждый визитор. Компилятор об этом сообщит, так что настоящей ценой оказываются шаблонный код accept/visit и двойная диспетчеризация
Sealed плюс switch: новая операция это обычная функция, а новый тип узла превращает каждый switch без default в ошибку компиляции: та же безопасность без церемоний
Паттерны разбирают объект прямо при диспетчеризации: case Heading(:final level, :final text) читается как таблица, а узлы остаются тупыми данными без шаблонного accept
Visitor всё ещё оправдан: когда иерархия открыта для сторонних разработчиков, которые сами реализуют accept, как AstNode и AstVisitor в analyzer, или когда сложность именно в обходе: Element.visitChildren принимает колбэк, потому что только родитель знает, как перебрать своих детей
Ловушка: подтипы sealed-класса должны лежать в его собственной библиотеке (один файл или файлы part); в другом месте это ошибка компиляции. Тихо ломает ветка default или _ в switch по нему: она проглотит следующий добавленный тип узла, поэтому её не пишут