Flutter-разработчик: Архитектура, BLoC и интеграция сервисов

Практический курс для подготовки к требованиям уровня Middle+ по разработке мобильных приложений на Flutter. Вы освоите построение многослойной архитектуры, сквозную реализацию фичей, управление состоянием с BLoC и внедрение зависимостей через get_it и injectable. Программа охватывает тонкости сетевого взаимодействия с Dio/Retrofit, обработку асинхронных состояний, работу со стримами и подключение внешних SDK.

Интеграция с Backend и внешними SDK

Интеграция с Backend и внешними SDK

Экран показывает заказ, но карта не загрузилась, статус доставки устарел, а повторное открытие приложения ничего не исправило. Где ошибка: в интерфейсе, на сервере или в стороннем сервисе? Интеграция считается законченной не тогда, когда данные однажды появились на экране, а когда приложение предсказуемо ведёт себя при успехе, задержке и сбое каждого участника.

Два источника возможностей приложения

Представьте ресторан: зал принимает заказ, кухня готовит блюдо, а курьерская служба доставляет его клиенту. Зал не должен готовить, а кухня — самостоятельно рисовать маршрут курьера. Аналогично Flutter-приложение координирует несколько самостоятельных частей.

Backend — серверная часть системы, которая хранит и обрабатывает данные, применяет правила бизнеса и отвечает на запросы приложения.

Например, Backend интернет-магазина определяет итоговую цену заказа, проверяет наличие товара и сохраняет новый адрес. Приложение показывает результат, но не должно считать окончательную стоимость по собственным, потенциально устаревшим правилам.

Чтобы посетитель ресторана не заходил на кухню и не объяснял заказ каждому повару, существует понятный способ передачи заказа. В программной системе эту роль выполняет API.

API, или application programming interface — интерфейс программирования приложений: правила, по которым одна программа отправляет другой запросы и получает ответы.

Правила определяют, какие сведения нужно передать, какой результат ожидать и как будет сообщено об ошибке. Например, приложение передаёт номер заказа, а Backend возвращает состояние доставки и предполагаемую дату прибытия.

Внешний сервис напоминает готовую курьерскую службу: команда приложения не строит собственную навигационную систему, а подключает уже созданную возможность.

SDK, или software development kit — комплект разработки программного обеспечения: готовый код и инструменты для подключения возможностей определённого сервиса или платформы.

SDK карты может показать маршрут, SDK уведомлений — связать устройство с доставкой сообщений. При этом сторонний код остаётся внешней зависимостью: его поведение, ограничения и обновления контролирует другая команда.

Вопрос Backend через API Внешний SDK
Где выполняется основная работа На удалённом сервере В приложении и связанном внешнем сервисе
Что передаёт приложение Запрос и необходимые данные Команды, настройки или данные устройства
Что может нарушить работу Недоступность сети, ошибка ответа, изменение правил Ошибка запуска, запрет пользователя, изменение версии
Кто управляет системой Команда продукта или партнёр Поставщик SDK
Что обязан сделать Flutter-код Отправить запрос и обработать любой ответ Безопасно подключить возможность и обработать её результат

Схема важна не ради стрелок, а ради границ ответственности. Backend не должен знать, как нарисован экран, SDK карты не должен определять состояние заказа, а интерфейс не должен самостоятельно придумывать серверные данные.

Как проходит одна пользовательская операция

Рассмотрим экран доставки заказа № 4821. Он должен показать статус посылки и её положение на карте.

  1. Пользователь открывает экран заказа.
  2. Приложение запрашивает через API данные заказа № 4821.
  3. Backend проверяет доступ пользователя и находит актуальный статус.
  4. Приложение получает статус «Передан курьеру» и координаты.
  5. Координаты передаются SDK карты.
  6. SDK отображает указанную точку, а Flutter-интерфейс объединяет карту со статусом.

Здесь участвуют две интеграции, но они не должны сливаться в одну неразделимую операцию. Если карта недоступна, текстовый статус заказа всё ещё полезен. Если Backend не вернул координаты, SDK карты не исправит отсутствие данных.

Такое разделение определяет поведение при частичном сбое:

  • данные заказа получены, карта не работает — показать статус и понятное сообщение вместо карты;
  • Backend недоступен — показать, что сведения временно не удалось обновить;
  • заказ не существует — не повторять запрос бесконечно, а сообщить об отсутствии заказа;
  • пользователь запретил доступ к возможности устройства — объяснить, зачем требуется разрешение, и оставить доступную часть экрана рабочей.

Договорённость важнее конкретного инструмента

Заказывая мебель, недостаточно сказать мастеру: «Сделайте шкаф». Нужно согласовать размеры, материалы и срок. В интеграции такую точность обеспечивает контракт.

Интеграционный контракт — точная договорённость о данных, допустимых запросах, результатах, ошибках и обязанностях взаимодействующих сторон.

Для API контракт отвечает как минимум на следующие вопросы:

  • какие данные приложение обязано отправить;
  • какие данные Backend возвращает при успехе;
  • какие сведения могут отсутствовать;
  • как различаются временная проблема и окончательный отказ;
  • может ли один запрос быть безопасно повторён;
  • какие изменения совместимы со старой версией приложения.

Для SDK набор вопросов немного другой:

  • в какой момент его можно запускать;
  • какие настройки ему необходимы;
  • какие разрешения может выдать или запретить пользователь;
  • что произойдёт, если внешний сервис недоступен;
  • какие результаты приложение должно сохранить;
  • как отключить возможность без поломки остального приложения.

Особенно опасно считать успешный ответ единственным сценарием. Мобильное приложение работает на устройствах с разной скоростью, настройками и качеством связи. Поэтому результат интеграции всегда следует рассматривать как один из трёх практических случаев: работа продолжается, нужно подождать или продолжить невозможно.

Ситуация Поведение приложения Что нужно сохранить для разбора
Временная проблема связи Сообщить о проблеме и разрешить повторить действие Операцию, время и причину
Backend отклонил запрос Объяснить, что пользователь может исправить Ответ Backend без личных данных
SDK не запустился Отключить только связанную возможность Этап подключения и причину
Получены неполные данные Показать доступную часть или остановить опасное действие Какие сведения отсутствовали

Записи для разбора не должны содержать пароли, личные сообщения или другие чувствительные сведения. Кроме того, секреты серверной системы нельзя помещать в мобильное приложение: установленную программу можно исследовать, поэтому спрятанная в ней строка не становится надёжно защищённой.

Практический порядок интеграции

Интеграцию лучше строить не от установки готового кода, а от пользовательского результата. Для экрана заказа работа может выглядеть так:

  1. Сформулировать результат. Пользователь видит актуальный статус заказа и, если возможно, его положение.
  2. Назначить владельца каждого решения. Backend определяет статус, SDK показывает карту, Flutter-интерфейс представляет результат.
  3. Зафиксировать контракт. Команды заранее согласуют обязательные данные, возможные ошибки и правила повторения.
  4. Собрать минимальный рабочий путь. Один реальный заказ проходит от запроса до отображения без дополнительных украшений.
  5. Добавить сценарии отказа. Отдельно проверяются отсутствие связи, неполные данные, запрет пользователя и недоступность внешнего сервиса.
  6. Добавить наблюдаемость. Команда должна понимать, на каком этапе возникла проблема, не воспроизводя её исключительно со слов пользователя.
  7. Проверить независимость частей. Сбой карты не должен уничтожать статус заказа, если тот уже получен.

На собеседовании полезно рассказывать об интеграции именно в такой логике. Фраза «я подключил SDK карты» описывает действие, но не показывает инженерного решения. Сильный рассказ объясняет цель, границы ответственности, варианты отказа, защиту данных и способ диагностики.

Когда интеграция действительно завершена

Перед выпуском функции команда должна получить ответы на несколько вопросов:

  • Кто является источником истины для каждого показанного значения?
  • Что увидит пользователь при частичном сбое?
  • Можно ли безопасно повторить неудачную операцию?
  • Не хранит ли приложение серверные секреты?
  • Можно ли определить место сбоя по техническим записям?
  • Продолжит ли основная функция работать без необязательного внешнего сервиса?
  • Согласованы ли изменения с приложениями, которые уже установлены у пользователей?

Подробную организацию кода, управление состояниями, сетевые инструменты, уведомления, карты и синхронизацию мы разберём в следующих главах курса. Пока важна общая картина: Flutter-приложение не просто соединяет два готовых блока, а управляет границами между ними.

Возвращаясь к заказу № 4821: хороший экран не обещает, что сеть, Backend и карта никогда не ошибутся. Он сохраняет полезный результат, локализует сбой и сообщает пользователю, что можно сделать дальше. Именно предсказуемость, а не единичный успешный запуск, превращает подключение сервиса в полноценную интеграцию.

Управление состоянием с помощью BLoC

Управление состоянием с помощью BLoC

Пользователь дважды быстро нажал «Добавить в корзину». Почему приложение иногда показывает три товара, иногда два, а после ответа Backend внезапно возвращается к одному? Интеграционный контракт может быть составлен правильно, но без единого центра управления состоянием разные части приложения начинают конкурировать друг с другом.

В предыдущей главе мы разделили Backend, SDK и Flutter-интерфейс. Теперь нужно организовать изменения внутри приложения так, чтобы UI не принимал бизнес-решения, а только сообщал о действиях пользователя и показывал полученный результат.

Зачем между UI и данными нужен посредник

Представьте кассу самообслуживания. Покупатель нажимает кнопку, касса проверяет допустимость действия и обновляет экран. Сам экран не решает, можно ли уменьшить количество товара ниже единицы.

BLoC, или Business Logic Component, — компонент, который получает события, применяет правила и выдаёт новое состояние для UI.

В пакете flutter_bloc такой компонент обычно наследуется от Bloc<Event, State>. Направление данных остаётся односторонним:

  1. Пользователь совершает действие в UI.
  2. UI отправляет событие в BLoC.
  3. BLoC определяет, какое правило применить.
  4. BLoC создаёт новое состояние.
  5. UI перестраивается по этому состоянию.

Это направление предотвращает ситуацию, когда кнопка, сетевой ответ и внешний SDK независимо изменяют одну и ту же переменную.

Ответственность UI BLoC
Показать текст и кнопки Да Нет
Сообщить о нажатии Да Нет
Проверить допустимость действия Нет Да
Выбрать новое состояние Нет Да
Вызвать перерисовку вручную Нет Нет

Последняя строка важна: BLoC выдаёт данные, а механизм Flutter сам перестраивает подписанную часть интерфейса.

Событие сообщает, что произошло

Кнопка дверного звонка не объясняет хозяину, что делать: она лишь сообщает о нажатии. Решение принимает тот, кто получил сигнал.

Событие — объект, сообщающий BLoC о произошедшем действии или внешнем результате.

Названия событий полезно формулировать как уже произошедшие факты: IncrementPressed, DecrementPressed, RetryPressed. Название SetQuantity слабее, потому что оно заставляет UI заранее вычислять результат и передавать готовое решение.

Для корзины определим два события:

abstract class CartEvent {}

class IncrementPressed extends CartEvent {}

class DecrementPressed extends CartEvent {}

В событии могут быть данные. Например, выбор товара мог бы передавать его идентификатор. Но событие не должно содержать виджеты, BuildContext или инструкции вроде «покажи диалог»: иначе правила приложения окажутся привязаны к экрану.

Состояние является снимком экрана

Вспомните табло электронной очереди. Оно не хранит команду «перейти к следующему номеру», а показывает актуальный результат: сейчас обслуживается номер 57.

Состояние — полный снимок данных, необходимых UI в конкретный момент.

Для счётчика корзины достаточно количества товаров и вычисляемого признака доступности кнопки уменьшения:

class CartState {
  const CartState({required this.quantity});

  final int quantity;

  bool get canDecrease => quantity > 1;
}

Старый объект состояния не изменяется. При каждом допустимом действии BLoC создаёт новый объект. Благодаря этому последовательность изменений можно читать буквально: было количество 1, появилось количество 2, затем количество 3.

Полнота состояния не означает, что в него нужно складывать всё приложение. CartState содержит данные своей области, но не настройки карты, push-уведомлений или профиля пользователя.

Как BLoC применяет правило

Главное правило примера: в корзине не может быть меньше одного товара. Если спрятать это условие внутри кнопки, другой экран или программный вызов сможет его обойти. Поэтому проверка находится в BLoC:

class CartBloc extends Bloc<CartEvent, CartState> {
  CartBloc() : super(const CartState(quantity: 1)) {
    on<IncrementPressed>((event, emit) {
      emit(CartState(quantity: state.quantity + 1));
    });

    on<DecrementPressed>((event, emit) {
      if (state.quantity > 1) {
        emit(CartState(quantity: state.quantity - 1));
      }
    });
  }
}

state обозначает текущий снимок, а emit публикует следующий. После DecrementPressed при количестве 1 новый объект не создаётся: правило отклоняет действие.

Такая схема особенно полезна при интеграциях. Ответ API или сигнал SDK тоже можно представить событием. Тогда UI, пользовательское действие и внешний источник не перезаписывают данные напрямую: их результаты последовательно обрабатывает один BLoC. Конкретную работу с асинхронными результатами разберём дальше в курсе.

Как UI подключается к состоянию

Как спортивное табло обновляет цифры после изменения счёта, UI должен перестраиваться после нового состояния, но не вычислять его самостоятельно.

BlocBuilder — виджет пакета flutter_bloc, который получает состояния BLoC и строит по ним часть интерфейса.

Пусть экземпляр CartBloc уже передан экрану через поле bloc. Его создание и время жизни позднее будут связаны с многослойной архитектурой и внедрением зависимостей.

BlocBuilder<CartBloc, CartState>(
  bloc: bloc,
  builder: (context, state) {
    return Row(
      children: [
        IconButton(
          onPressed: state.canDecrease
              ? () => bloc.add(DecrementPressed())
              : null,
          icon: const Icon(Icons.remove),
        ),
        Text('Товаров: ${state.quantity}'),
        IconButton(
          onPressed: () => bloc.add(IncrementPressed()),
          icon: const Icon(Icons.add),
        ),
      ],
    );
  },
)

Начальный результат:

Товаров: 1
Кнопка «−» отключена
Кнопка «+» доступна

После двух нажатий «+»:

Товаров: 3
Кнопка «−» доступна
Кнопка «+» доступна

Обратите внимание на разделение. UI использует canDecrease, чтобы правильно отобразить кнопку, но BLoC всё равно повторно проверяет ограничение. Отключённая кнопка улучшает взаимодействие, а проверка в BLoC защищает правило.

Типичные ошибки в BLoC

UI вычисляет следующее состояние

Проблемный вариант выглядит так: экран берёт текущее количество, прибавляет единицу и отправляет готовое значение. Если текущее состояние изменилось между чтением и отправкой, расчёт устареет.

Правильнее отправить IncrementPressed. BLoC применит действие к состоянию, актуальному в момент обработки.

Состояние изменяется без нового объекта

Если менять поле существующего объекта, история изменений становится неясной, а UI может не распознать обновление. Создание нового CartState делает результат однозначным.

Один BLoC знает обо всём приложении

Корзина, профиль, карта и уведомления имеют разные причины для изменения. Если объединить их, любое событие будет затрагивать огромный компонент, а проверка поведения станет сложнее. Границы компонентов мы уточним в главе о многослойной архитектуре.

Событие описывает интерфейс, а не намерение

ShowRedErrorDialog говорит, как рисовать результат. CheckoutFailed говорит, что произошло. Первый вариант связывает BLoC с конкретным дизайном, второй позволяет разным экранам показать результат по-своему.

Собираем картину целиком

Вернёмся к двум быстрым нажатиям из начала главы. UI дважды отправляет IncrementPressed. BLoC последовательно получает события: из состояния 1 создаёт состояние 2, затем из состояния 2 — состояние 3. BlocBuilder показывает последний снимок.

Устойчивость возникает не из-за самой библиотеки, а из-за дисциплины:

  • UI сообщает о действиях, но не принимает бизнес-решения.
  • Событие описывает произошедшее.
  • BLoC применяет правило к актуальному состоянию.
  • Новое состояние полностью описывает то, что должен показать UI.
  • BlocBuilder превращает состояние в интерфейс.

Именно это ожидается под «уверенным опытом работы с BLoC»: разработчик умеет не только написать классы событий и состояний, но и провести чёткую границу между отображением, решением и результатом.

Многослойная архитектура и разделение ответственности

Многослойная архитектура и разделение ответственности

Почему изменение одной подписи на экране иногда требует исправить пять файлов, а замена поля в ответе Backend внезапно ломает BLoC? Обычно проблема не в масштабе задачи, а в том, что код не разделён по причинам изменения. Наша цель — спроектировать отмену заказа № 7314 так, чтобы интерфейс, правила бизнеса и API могли меняться независимо.

В предыдущей главе BLoC уже отделил события UI от состояния. Но BLoC сам по себе не отвечает на следующий вопрос: где должны находиться правила отмены и работа с Backend? Если поместить всё внутрь BLoC, он будет одновременно обрабатывать нажатия, принимать бизнес-решения и разбирать серверные данные.

Архитектура как разделение причин изменения

В квартире кухня, спальня и ванная выполняют разные функции. Стены нужны не ради количества помещений, а чтобы запах готовящейся еды не определял устройство спальни.

Многослойная архитектура — организация приложения, при которой код разделён на части с разными обязанностями и разными причинами изменения.

Слой — не просто папка. Если файл лежит в каталоге domain, но импортирует Flutter-виджеты и разбирает JSON, разделения ответственности нет. Граница становится полезной только тогда, когда ограничивает знания одной части приложения о другой.

Для фичи отмены заказа причины изменения различаются:

  • дизайнер меняет кнопку и текст подтверждения;
  • аналитик меняет правило: после передачи курьеру отмена запрещена;
  • Backend переименовывает поле can_cancel в cancellation_allowed.

Эти изменения не должны затрагивать один и тот же участок кода. Для этого выделим три слоя.

Слой представления: общение с пользователем

Кассовое окно принимает просьбу покупателя и показывает результат, но кассир не устанавливает правила возврата товара и не управляет складской системой.

Слой представления — часть приложения, которая получает действия пользователя и превращает состояние приложения в видимый интерфейс. В нашей фиче к нему относятся экран заказа, кнопка отмены и BLoC.

Слой представления отвечает за вопросы:

  • какую часть экрана построить из текущего состояния;
  • какое событие отправить после нажатия;
  • нужно ли отключить кнопку;
  • какое сообщение показать пользователю.

Но он не должен самостоятельно решать, разрешена ли отмена заказа со статусом «Передан курьеру». Это правило должно сохраняться, даже если вместо Flutter-экрана появится другой интерфейс.

Доменный слой: правила, которые переживают интерфейс

Судья применяет правила игры независимо от того, показывают матч по телевизору или описывают по радио. Способ представления меняется, но решение о нарушении остаётся тем же.

Доменный слой — часть приложения, содержащая правила и операции предметной области, которые не зависят от Flutter, формата серверного ответа и конкретного экрана.

Для заказа № 7314 правило можно сформулировать так:

  • статус «Собирается» — отмена разрешена;
  • статус «Передан курьеру» — отмена запрещена;
  • уже отменённый заказ нельзя отменить повторно.

BLoC передаёт доменному слою намерение отменить заказ. Доменный слой проверяет правило и либо разрешает продолжить операцию, либо возвращает понятный результат: «Заказ уже передан курьеру».

Здесь важно не путать поведение интерфейса и правило бизнеса. Отключённая кнопка — поведение интерфейса. Запрет отмены после передачи курьеру — правило бизнеса. Даже если кнопка по ошибке останется активной, правило всё равно должно остановить операцию.

Слой данных: общение с внешним миром

Сотрудник пункта выдачи знает, как запросить коробку со склада, но покупателю не нужно знать номер складской ячейки или формат внутренней накладной.

Слой данных — часть приложения, которая получает и сохраняет данные через Backend, локальное хранилище или другие внешние источники и преобразует их в форму, понятную остальному приложению.

Для отмены заказа этот слой:

  • отправляет запрос к API;
  • разбирает успешный ответ;
  • переводит серверную ошибку в результат, понятный приложению;
  • скрывает от других слоёв имена серверных полей.

Например, доменный слой спрашивает: «Отмени заказ 7314». Ему не нужно знать, что слой данных отправляет запрос на /orders/7314/cancel и получает поле cancellation_allowed.

Слой Что знает Что делает Чего не делает
Представление События UI и состояние BLoC Показывает кнопку, принимает нажатие Не разбирает серверный ответ
Доменный Правила работы с заказом Проверяет допустимость отмены Не строит виджеты
Данные API и формат внешних данных Отправляет запрос и преобразует ответ Не решает, как выглядит экран

Сквозной путь отмены заказа

Теперь проследим одну операцию целиком, не забегая в техническую реализацию следующей главы.

  1. Пользователь открывает заказ № 7314 со статусом «Собирается».
  2. Слой представления показывает активную кнопку «Отменить заказ».
  3. Нажатие превращается в событие для BLoC.
  4. BLoC обращается к доменному слою с намерением отменить заказ.
  5. Доменный слой проверяет текущий статус и разрешает операцию.
  6. Слой данных отправляет запрос через API и получает подтверждение.
  7. Результат возвращается в BLoC.
  8. BLoC выдаёт новое состояние, а экран показывает статус «Отменён» и отключённую кнопку.

Конкретный результат можно проверить: после успешной операции на экране заказа № 7314 виден статус «Отменён», а повторная отмена недоступна.

Если заказ уже получил статус «Передан курьеру», путь останавливается на пятом шаге. Запрос к API не отправляется, а UI получает результат, из которого показывает сообщение: «Заказ уже нельзя отменить».

Направление обращений идёт от представления к правилам, а затем к данным; детали Backend не должны протекать обратно в UI.

Проверка архитектуры изменениями

Хорошая архитектура проявляется не на схеме, а при следующем изменении требований.

Изменился дизайн

Кнопку перенесли в нижнюю панель, а подтверждение теперь показывается в отдельном окне. Меняется слой представления. Правило отмены и запрос к API остаются прежними.

Изменилось правило бизнеса

Теперь заказ разрешено отменять ещё пять минут после передачи курьеру. Меняется доменный слой. Экран по-прежнему отправляет то же намерение, а слой данных выполняет ту же операцию.

Изменился Backend

API начинает возвращать объект cancellation вместо поля can_cancel. Меняется слой данных, который преобразует новый ответ в уже понятную приложению форму. BLoC и экран не должны знать о переименовании.

Типичная ошибка выглядит так: BLoC читает response['can_cancel'], сравнивает строковый статус с "courier" и одновременно выбирает текст сообщения. После изменения API приходится переписывать BLoC, хотя пользовательское действие и правило бизнеса не изменились.

Исправленная структура распределяет причины изменения:

  • BLoC координирует действие и состояние;
  • доменный слой принимает решение по правилу;
  • слой данных скрывает формат API;
  • UI только отображает полученный результат.

Практический критерий для нового кода

Перед добавлением класса или функции задайте вопрос: из-за какого требования этот код изменится?

  • Из-за нового вида экрана — представление.
  • Из-за нового правила бизнеса — доменный слой.
  • Из-за нового формата API или способа хранения — слой данных.

Если ответ включает сразу три причины, код соединяет слишком много обязанностей. Его следует разделить до того, как фича начнёт расширяться.

Так решается исходная проблема: изменение подписи больше не ведёт к исправлению серверного преобразования, а новое поле Backend не заставляет переписывать BLoC. Многослойная архитектура ценна не количеством каталогов, а тем, что каждое изменение получает ограниченное и предсказуемое место.

Сквозная разработка фичи от UI до данных

Сквозная разработка фичи от UI до данных

Кнопка уже нарисована, BLoC принимает событие, а запрос к Backend успешно работает отдельно. Почему же фича всё ещё не готова? Потому что пользователь не взаимодействует со слоями по отдельности: ему нужен один непрерывный результат. Нажатие «Повторить заказ» должно пройти через всё приложение и закончиться созданием нового заказа — либо понятным отказом.

Мы разберём этот путь на конкретной задаче: пользователь открывает доставленный заказ № 9052, нажимает «Повторить заказ» и получает новый заказ № 9187.

Начинаем не с кнопки, а с результата

Когда человек заказывает кофе, его цель — не нажать кнопку терминала, а получить выбранный напиток. Так же и в приложении: действие в UI имеет смысл только как часть законченного результата.

Пользовательский сценарий — последовательность действий и результатов, через которую пользователь достигает конкретной цели.

Для нашей фичи сценарий звучит так:

  1. Пользователь открывает заказ № 9052 со статусом «Доставлен».
  2. Видит кнопку «Повторить заказ».
  3. Нажимает её.
  4. Приложение просит Backend создать новый заказ с теми же доступными товарами.
  5. Backend возвращает идентификатор нового заказа — 9187.
  6. Приложение открывает экран заказа № 9187.
  7. Если повторение невозможно, пользователь видит причину, а не остаётся на экране без объяснения.

Эта формулировка сразу связывает уже знакомые части: слой представления отвечает за действие и отображение результата, BLoC управляет состоянием, доменный слой выражает операцию, а слой данных выполняет интеграционный контракт.

Превращаем ожидание в проверяемые условия

Перед поездкой вы можете проверить короткий список: ключи взяты, дверь закрыта, адрес известен. Такой список превращает расплывчатое «я готов» в наблюдаемые признаки готовности.

Критерий приёмки — проверяемое условие, по которому можно определить, реализовано ли требуемое поведение.

Для повторения заказа критерии удобно записать до реализации:

Ситуация Ожидаемый результат
Заказ доставлен Кнопка «Повторить заказ» доступна
Backend создал заказ № 9187 Открывается экран нового заказа № 9187
Один товар больше не продаётся Пользователь получает сообщение с причиной и доступным дальнейшим действием
Backend временно недоступен Текущий экран сохраняется, показывается понятная ошибка
Пользователь быстро нажал дважды Не создаются два одинаковых заказа

Последняя строка особенно важна. Требование «кнопка работает» её не покрывает, хотя именно здесь нередко появляется дорогостоящая ошибка.

Строим вертикальный срез

Если разрезать слоёный торт сверху вниз, в одном куске окажутся все его слои. Отдельная тарелка крема или коржа ещё не даёт понять, каким получился торт целиком.

Вертикальный срез — минимальная законченная часть фичи, проходящая через все необходимые слои приложения и дающая наблюдаемый пользовательский результат.

Для первого среза не нужно сразу реализовывать каждую ошибку. Сначала можно провести один успешный сценарий через всю систему:

  1. Кнопка создаёт событие RepeatOrderPressed с идентификатором 9052.
  2. BLoC принимает событие и вызывает доменную операцию повторения заказа.
  3. Доменный слой проверяет применимое правило: повторять можно заказ в допустимом статусе.
  4. Слой данных отправляет запрос согласно API.
  5. Ответ Backend преобразуется в данные, понятные приложению.
  6. BLoC выпускает новое состояние с идентификатором 9187.
  7. Слой представления открывает соответствующий экран.

Сквозная фича похожа на электрическую цепь: наличие батарейки, лампы и проводов по отдельности недостаточно. Результат появляется только тогда, когда все части соединены.

Сквозная фича — пользовательская возможность, реализованная и проверенная по всей цепочке от интерфейса до источника данных и обратно.

Минимальный вертикальный срез уже позволяет нажать кнопку и увидеть экран нового заказа. После этого срез расширяется: добавляются недоступные товары, сетевой отказ и защита от повторного запуска. Подробное моделирование промежуточных асинхронных состояний будет отдельной темой курса.

Распределяем работу по причинам изменения

Для одной фичи нужен не один большой обработчик, а несколько согласованных решений. У каждого слоя остаётся своя причина измениться.

Часть Что реализуем для повторения заказа Из-за чего эта часть может измениться
Слой представления Кнопка, сообщение об отказе, переход к заказу № 9187 Новый дизайн или навигация
BLoC Приём RepeatOrderPressed, запуск операции, выпуск состояния Изменение поведения экрана
Доменный слой Операция повторения и правило допустимости Новое бизнес-правило
Слой данных Запрос к API и преобразование ответа Изменение серверного контракта

Предположим, Backend принимает запрос на повторение заказа и отвечает идентификатором нового заказа. Слой данных не должен решать, на какой экран переходить: это знание интерфейса. BLoC не должен разбирать серверные поля: внешние форматы принадлежат слою данных. Виджет не должен самостоятельно выполнять запрос: иначе пользовательское действие обойдёт BLoC и доменное правило.

Типичная ошибка выглядит так: обработчик кнопки вызывает API, получает число 9187 и сразу запускает навигацию. Успешный случай работает, но правило допустимости оказывается в UI, серверная ошибка обрабатывается рядом с виджетом, а повторное нажатие запускает второй запрос. Формально части присутствуют, но разделение ответственности разрушено.

Реализуем от устойчивого контракта к экрану

Порядок написания файлов не обязан повторять направление пользовательского действия. Удобнее сначала зафиксировать связи между частями:

  1. Уточнить интеграционный контракт. Какие данные отправляются? Что возвращается при успехе? Как представлены недоступные товары и временный отказ?
  2. Сформулировать доменный результат. Приложению нужны не серверные поля, а понятный итог: новый заказ создан либо повторение отклонено по известной причине.
  3. Реализовать слой данных. Он выполняет запрос и переводит внешний ответ в форму приложения.
  4. Подключить доменную операцию. Она применяет правило и обращается к слою данных, не зная деталей HTTP.
  5. Добавить событие и состояние BLoC. Событие несёт факт нажатия и идентификатор заказа; состояние сообщает UI результат.
  6. Подключить UI. Кнопка отправляет событие, а интерфейс реагирует на новое состояние.
  7. Пройти критерии приёмки. Проверить не только успех, но и каждый отказ из таблицы.

Создание и передача конкретных объектов между этими частями будут организованы через DI в следующей главе. Сейчас важнее, чтобы зависимости были направлены правильно и не смешивали обязанности.

Проверяем цепочку по контрольным границам

Проверка только последнего экрана плохо объясняет источник сбоя. Полезнее идти по тому же маршруту, что и команда:

  • UI действительно отправил RepeatOrderPressed с числом 9052;
  • BLoC вызвал операцию ровно один раз;
  • доменный слой допустил заказ в статусе «Доставлен»;
  • слой данных сформировал запрос по контракту;
  • ответ преобразовался в идентификатор 9187;
  • BLoC выпустил состояние с этим идентификатором;
  • UI открыл экран заказа № 9187.

Если кнопка не реагирует, но событие не появилось, проблема находится у границы UI и BLoC. Если событие принято, но запрос не ушёл, следует проверить доменную операцию и её связь со слоем данных. Если Backend вернул 9187, а приложение пытается открыть заказ с нулевым идентификатором, наиболее вероятна ошибка преобразования ответа.

Такая диагностика показывает практическую ценность архитектуры: слои не просто раскладывают файлы по папкам, а создают проверяемые границы.

Когда фича действительно закончена

Вернёмся к исходному вопросу. Кнопка, BLoC и сетевой запрос по отдельности не делают возможность готовой. Фича закончена, когда пользовательский сценарий проходит через все слои, критерии приёмки наблюдаемы, а сбои дают предусмотренный результат.

Для заказа № 9052 итог можно сформулировать буквально: одно нажатие создаёт один новый заказ № 9187 и открывает его экран; запрещённое или неудачное повторение оставляет приложение в понятном состоянии. Именно способность довести такую цепочку от UI до данных и обратно обычно подразумевается в вакансии под словами «реализовывать фичу целиком».

Внедрение зависимостей: get_it и injectable

Внедрение зависимостей: get_it и injectable

Представьте, что экран адресов доставки работает, но для его открытия нужно вручную создать семь объектов в правильном порядке. После добавления нового сервиса этот порядок приходится менять в трёх виджетах и двух тестах. Как сделать так, чтобы фича знала, что ей нужно, но не решала сама, где и как это создавать?

В предыдущей главе мы провели фичу через UI, BLoC, доменный слой и слой данных. Теперь нужно соединить эти части, не разрушив разделение ответственности.

Почему прямое создание объектов становится проблемой

Когда настольная лампа подключена к розетке, она зависит от электричества, но ей не нужно строить электростанцию. Аналогично AddressBloc может использовать AddressRepository, но не должен самостоятельно создавать его реализацию.

Зависимость — объект или возможность, необходимые другому объекту для выполнения его работы.

Передача зависимости через конструктор выглядит так:

class AddressBloc extends Bloc<AddressEvent, AddressState> {
  AddressBloc(this._repository) : super(const AddressState.initial());

  final AddressRepository _repository;
}

Из конструктора сразу видно: без AddressRepository этот BLoC создать нельзя. При этом AddressBloc не знает, откуда появился объект и какие внешние компоненты потребовались для его настройки.

Проблемный вариант выглядит иначе:

class AddressBloc extends Bloc<AddressEvent, AddressState> {
  AddressBloc()
      : _repository = AddressRepositoryImpl(),
        super(const AddressState.initial());

  final AddressRepository _repository;
}

Теперь BLoC связан с конкретной реализацией. Для теста нельзя просто передать управляемую тестовую реализацию. Если конструктору AddressRepositoryImpl понадобится ещё один объект, придётся изменять сам BLoC, хотя его правила не поменялись.

Ситуация становится ещё хуже, если реализация создаётся внутри UI:

BlocProvider(
  create: (_) => AddressBloc(
    AddressRepositoryImpl(),
  ),
  child: const AddressPage(),
)

Виджет начинает знать устройство слоя данных. Изменение способа получения адресов затрагивает экран, хотя внешний вид и действия пользователя остались прежними.

Внедрение зависимостей меняет направление решения

В кафе посетитель выбирает блюдо, но не собирает для повара плиту, ножи и холодильник. Всё необходимое подготовлено до начала выполнения заказа. Такой же принцип применяется при сборке объектов приложения.

Внедрение зависимостей, или Dependency Injection, — способ передавать объекту необходимые зависимости извне вместо их создания внутри этого объекта.

После такого изменения BLoC принимает готовый контракт:

class AddressBloc extends Bloc<AddressEvent, AddressState> {
  AddressBloc(this._repository) : super(const AddressState.initial());

  final AddressRepository _repository;
}

А решение о конкретной реализации принимается отдельно:

final repository = AddressRepositoryImpl();
final bloc = AddressBloc(repository);

Это сохраняет направление архитектуры: AddressBloc использует контракт AddressRepository, а сборка приложения выбирает AddressRepositoryImpl.

Практическая проверка проста: если заменить AddressRepositoryImpl тестовой реализацией, код AddressBloc не должен измениться. Если приходится редактировать его методы или события, зависимость отделена недостаточно хорошо.

Ручная сборка через get_it

Телефонная книга связывает имя человека с номером: вы спрашиваете имя и получаете нужный контакт, не запоминая номер. Похожую задачу выполняет get_it, только вместо контактов он хранит правила получения объектов.

get_it — пакет для регистрации и получения объектов по их типам из единого места сборки приложения.

Создадим общую точку доступа:

final getIt = GetIt.instance;

Затем зарегистрируем объекты:

void configureDependencies() {
  getIt.registerLazySingleton<AddressRepository>(
    () => AddressRepositoryImpl(),
  );

  getIt.registerFactory<AddressBloc>(
    () => AddressBloc(
      getIt<AddressRepository>(),
    ),
  );
}

Здесь зафиксированы два разных решения:

  • AddressRepository создаётся при первом обращении, после чего используется тот же объект;
  • AddressBloc создаётся заново при каждом запросе;
  • зависимость BLoC от репозитория собирается в одном месте;
  • UI не знает, какой класс реализует работу с адресами.

Экран получает уже настроенный BLoC:

BlocProvider(
  create: (_) => getIt<AddressBloc>(),
  child: const AddressPage(),
)

Важно не превращать get_it в глобальный источник объектов для любого класса. Такой код скрывает реальные требования:

class AddressBloc extends Bloc<AddressEvent, AddressState> {
  AddressBloc() : super(const AddressState.initial());

  final AddressRepository repository = getIt<AddressRepository>();
}

Конструктор снова притворяется пустым, хотя BLoC зависит от репозитория. Для чтения кода и тестирования лучше, когда обычные классы получают зависимости явно, а get_it используется на границе сборки приложения.

Что автоматизирует injectable

Если в офисе десять сотрудников, список пропусков можно вести вручную. При сотнях сотрудников ручной список становится источником пропусков и повторов. В большом Flutter-проекте похожая проблема возникает с регистрациями get_it.

injectable — пакет, который по пометкам в коде создаёт конфигурацию регистраций для get_it.

Классы по-прежнему явно принимают зависимости:

@injectable
class AddressBloc extends Bloc<AddressEvent, AddressState> {
  AddressBloc(this._repository) : super(const AddressState.initial());

  final AddressRepository _repository;
}

Реализацию контракта также связывают с требуемым типом:

@LazySingleton(as: AddressRepository)
class AddressRepositoryImpl implements AddressRepository {
  AddressRepositoryImpl();
}

В точке запуска остаётся вызов созданной конфигурации:

final getIt = GetIt.instance;

@InjectableInit()
void configureDependencies() {
  getIt.init();
}

injectable анализирует конструкторы и пометки, после чего создаёт регистрации. Если AddressBloc требует AddressRepository, конфигурация связывает его с AddressRepositoryImpl.

При этом обязанности инструментов различаются:

Инструмент Ответственность
get_it Хранит регистрации и выдаёт запрошенные объекты
injectable Создаёт код регистраций по пометкам и конструкторам
Конструктор класса Явно объявляет необходимые зависимости
Точка запуска Настраивает зависимости до построения UI

injectable не исправляет архитектуру автоматически. Если AddressBloc напрямую требует конкретный внешний клиент вместо понятного ему контракта слоя данных, автоматическая регистрация лишь быстрее закрепит нежелательную связь.

Сквозная сборка фичи адресов

Теперь исходную проблему можно решить последовательно.

  1. AddressPage отвечает за отображение списка и действия пользователя.
  2. AddressBloc принимает AddressRepository через конструктор.
  3. AddressRepositoryImpl выполняет работу слоя данных.
  4. injectable создаёт код, связывающий контракт с реализацией.
  5. get_it выдаёт собранный AddressBloc при открытии экрана.

Конкретный результат: при вызове getIt<AddressBloc>() приложение получает новый BLoC, внутри которого уже находится зарегистрированная реализация AddressRepository. В тесте можно создать AddressBloc напрямую и передать ему тестовую реализацию, вообще не запуская общую конфигурацию.

Так снимается исходный парадокс: фича знает свои потребности, но не занимается сборкой собственного окружения. get_it отвечает за получение объектов, injectable сокращает ручные регистрации, а явные конструкторы сохраняют зависимости видимыми. Именно это позволяет добавлять интеграции и менять реализации, не распространяя детали сборки по UI, BLoC и доменным правилам.

Сетевое взаимодействие: Dio и Retrofit

Сетевое взаимодействие: Dio и Retrofit

Карточка товара № 42 должна показать название и цену, но сервер отвечает через восемь секунд, возвращает поле другого типа или сообщает, что товар удалён. Как сделать так, чтобы эти различия не просочились в BLoC и не превратили его в набор сетевых проверок? Для этого сетевое взаимодействие нужно разделить на две части: выполнение запросов и описание API.

Dio управляет выполнением запроса

Когда вы заказываете доставку, курьерская служба отвечает за маршрут, ожидание и повторную попытку связи, но не решает, какой товар вы хотели купить. Похожую техническую роль выполняет Dio.

Dio — библиотека для Dart, которая отправляет запросы к API, получает ответы и управляет техническими параметрами сетевого соединения.

Обычно один объект Dio настраивают при запуске приложения и передают в сетевые клиенты через уже знакомое внедрение зависимостей. На нём задают адрес Backend, допустимое время ожидания и общие заголовки.

Если столик в ресторане держат для гостя только десять минут, ожидание имеет явную границу. Сетевому запросу такая граница тоже необходима.

Timeout — максимальное время ожидания сетевой операции, после которого она прерывается с ошибкой.

Без ограничения экран может оставаться в ожидании неопределённо долго. Ограничение не гарантирует быстрый ответ Backend, но позволяет приложению перейти к контролируемому сценарию ошибки.

Retrofit описывает контракт в коде

Бланк заказа фиксирует: какой товар нужен, куда его доставить и какие сведения должен предоставить покупатель. Сотруднику не приходится каждый раз составлять эти правила заново. Retrofit выполняет похожую работу для API.

Retrofit — библиотека, которая по декларативному описанию методов API генерирует реализацию сетевого клиента на основе Dio.

Dio умеет выполнить произвольный запрос. Retrofit позволяет один раз описать конкретные операции: путь, параметры и ожидаемый результат. Рассмотрим загрузку товара № 42.

import 'package:dio/dio.dart';
import 'package:retrofit/retrofit.dart';

part 'product_api.g.dart';

@RestApi()
abstract class ProductApi {
  factory ProductApi(Dio dio, {String? baseUrl}) = _ProductApi;

  @GET('/products/{id}')
  Future<Map<String, dynamic>> getProduct(
    @Path('id') int id,
  );
}

class Product {
  const Product({
    required this.id,
    required this.title,
    required this.price,
  });

  final int id;
  final String title;
  final int price;

  @override
  String toString() {
    return 'Product(id: $id, title: $title, price: $price)';
  }
}

class ProductRemoteDataSource {
  ProductRemoteDataSource(this.api);

  final ProductApi api;

  Future<Product> getById(int id) async {
    final json = await api.getProduct(id);

    return Product(
      id: json['id'] as int,
      title: json['title'] as String,
      price: json['price'] as int,
    );
  }
}

При ответе Backend {"id":42,"title":"Термокружка","price":2490} слой данных возвращает:

Product(id: 42, title: Термокружка, price: 2490)

Запись @GET('/products/{id}') означает получение данных по указанному пути, а @Path('id') подставляет идентификатор в адрес. Реализацию _ProductApi создаёт генератор; файл product_api.g.dart вручную не редактируют.

После изменения описания API запускают:

dart run build_runner build --delete-conflicting-outputs

При успешной генерации команда завершается без ошибок и создаёт или обновляет файл:

product_api.g.dart

Важна архитектурная граница: ProductApi знает формат сетевого ответа, а ProductRemoteDataSource превращает его в объект приложения. Поэтому изменение серверного поля title на display_name потребует исправления слоя данных, но не BLoC.

Задача Dio Retrofit
Установить базовый адрес Backend Да Да
Ограничить время ожидания Да Нет
Описать путь /products/{id} Нет Да
Подставить параметр id Нет Да
Выполнить сгенерированный запрос Да Использует Dio
Преобразовать ответ в объект приложения Нет Обычно выполняет слой данных

Общие действия выполняет interceptor

На входе в офис охрана проверяет каждого посетителя в одном месте. Было бы ненадёжно поручать каждому отделу самостоятельно проверять пропуск. Так же не стоит вручную добавлять токен доступа в каждый сетевой метод.

Interceptor — обработчик Dio, который перехватывает запросы, ответы и сетевые ошибки для выполнения общего действия.

Через interceptor можно добавлять токен, вести технический журнал или централизованно реагировать на истёкшую авторизацию. Конкретное правило бизнеса туда помещать не следует: например, решение о доступности скидки относится к доменному слою.

Dio createDio(String accessToken) {
  final dio = Dio(
    BaseOptions(
      baseUrl: 'https://api.example.com/v1',
      connectTimeout: const Duration(seconds: 5),
      receiveTimeout: const Duration(seconds: 8),
    ),
  );

  dio.interceptors.add(
    InterceptorsWrapper(
      onRequest: (options, handler) {
        options.headers['Authorization'] = 'Bearer $accessToken';
        handler.next(options);
      },
    ),
  );

  return dio;
}

Функция возвращает настроенный объект:

Dio с baseUrl https://api.example.com/v1, ожиданием подключения 5 секунд, ожиданием ответа 8 секунд и заголовком Authorization

Такую настройку регистрируют через get_it как общий объект. Затем на её основе создают ProductApi, а его передают в ProductRemoteDataSource. UI и BLoC не должны обращаться к Dio напрямую.

Ошибки должны менять смысл на границе слоя данных

Для курьера фраза «подъезд закрыт» — техническая причина. Для покупателя важен результат: заказ пока нельзя доставить. Аналогично DioException полезен сетевому коду, но BLoC должен получить ошибку, понятную сценарию приложения.

Future<Product> getProduct(int id) async {
  try {
    return await remoteDataSource.getById(id);
  } on DioException catch (error) {
    if (error.response?.statusCode == 404) {
      throw ProductNotFound(id);
    }

    throw NetworkUnavailable();
  }
}

Возможные результаты этого метода:

Успешный ответ: Product(id: 42, title: Термокружка, price: 2490)
Ответ 404: ProductNotFound(42)
Нет соединения или превышено время ожидания: NetworkUnavailable

Здесь Retrofit описывает вызов, Dio сообщает техническую причину сбоя, а слой данных переводит её на язык приложения. BLoC получает либо Product, либо понятную ошибку и не зависит от устройства сети.

Итоговая цепочка

Для карточки товара № 42 получается последовательность:

  1. BLoC запрашивает товар у repository.
  2. Repository обращается к ProductRemoteDataSource.
  3. Источник данных вызывает метод ProductApi.
  4. Сгенерированная Retrofit реализация формирует запрос.
  5. Dio добавляет общие настройки и отправляет его.
  6. Ответ преобразуется в Product, а техническая ошибка — в ошибку приложения.
  7. BLoC получает результат, не зная ни сетевого пути, ни формата ответа, ни типа исключения Dio.

Исходная проблема решена разделением обязанностей: Dio управляет выполнением запросов, Retrofit фиксирует операции API, а слой данных защищает остальное приложение от внешнего формата. То, как BLoC покажет ожидание, ошибку и полученные данные, будет следующим уровнем этой цепочки.

Обработка асинхронных состояний (Loading, Error, Data)

Обработка асинхронных состояний (Loading, Error, Data)

Почему экран иногда бесконечно показывает индикатор загрузки, хотя Dio уже вернул ошибку? Обычно проблема не в сети, а в модели состояния: приложение умеет начать запрос, но не описывает все варианты его завершения. Исправим это на фиче выбора способа доставки для индекса 190000.

Что именно происходит во время ожидания

Заказывая еду, вы отправляете заказ и продолжаете пользоваться телефоном, пока ресторан готовит ответ. Приложение тоже не должно «замерзать», ожидая Backend.

Асинхронная операция — действие, результат которого появляется не сразу, поэтому приложение продолжает работу и позднее обрабатывает успех или ошибку.

Запрос через Dio — типичный пример такой операции. После отправки запроса BLoC ждёт результат, а Flutter продолжает строить кадры, обрабатывать нажатия и показывать текущий интерфейс.

Однако одного вызова await недостаточно. Пользователь должен понимать, что происходит в каждый момент:

  1. Запрос начался — ожидание.
  2. Ответ получен — показ результата.
  3. Запрос завершился неудачно — объяснение и доступное следующее действие.

Эти варианты нужно представить полноценными состояниями BLoC, а не разрозненными флагами вроде isLoading и hasError.

Три состояния одного запроса

Loading: результат ещё не известен

Когда вы ждёте лифт после нажатия кнопки, горящий индикатор сообщает: вызов принят, но кабина ещё не приехала. Он не обещает успешного результата, а подтверждает, что ожидание уже началось.

Loading — состояние, в котором асинхронная операция выполняется, а её итоговые данные ещё не получены.

На экране выбора доставки Loading может показывать индикатор и текст «Ищем варианты доставки». Это лучше пустого экрана: пользователь видит, что нажатие или открытие страницы было обработано.

Loading должно появляться непосредственно перед началом ожидания. Если установить его после await, UI увидит его слишком поздно или не увидит совсем.

Data: операция успешно завершена

На табло вокзала может появиться список поездов, а может появиться сообщение «Рейсов нет». В обоих случаях табло исправно получило данные; различается только их содержимое.

Data — состояние успешного завершения операции, содержащее результат, необходимый UI.

Для индекса 190000 Backend может вернуть два варианта:

  • курьерская доставка — 390 RUB;
  • самовывоз — бесплатно.

Пустой список также относится к Data. Он означает: запрос выполнен успешно, но подходящих вариантов доставки нет. UI должен показать осмысленное сообщение, например «По этому адресу доставка недоступна», а не сообщение о сетевой ошибке.

Error: операция не дала результата

Если банкомат сообщает «Нет связи с банком», это не то же самое, что нулевой баланс. В первом случае данные получить не удалось, во втором — они успешно получены и имеют конкретное значение.

Error — состояние неуспешного завершения операции, содержащее понятное приложению описание проблемы и позволяющее UI выбрать дальнейшее действие.

Error не должен передавать пользователю технический текст исключения Dio. Слой данных уже преобразует технические ошибки в понятные приложению результаты, а BLoC формирует сообщение для конкретного экрана: «Не удалось загрузить способы доставки».

Модель состояния без противоречий

Три независимых поля создают невозможные сочетания:

  • isLoading равно true;
  • error содержит сообщение;
  • options одновременно содержит список.

UI вынужден угадывать, чему верить. Отдельные классы состояний исключают такую неоднозначность:

abstract class ShippingState {}

final class ShippingLoading extends ShippingState {}

final class ShippingData extends ShippingState {
  ShippingData(this.options);

  final List<ShippingOption> options;
}

final class ShippingError extends ShippingState {
  ShippingError(this.message);

  final String message;
}

В каждый момент BLoC выдаёт один объект состояния. Значит, экран либо ждёт, либо показывает результат, либо сообщает о проблеме. Противоречивое сочетание Loading и Error невозможно представить случайно.

Обработчик события сначала сообщает UI о начале ожидания, а затем гарантированно завершает цепочку состоянием Data или Error:

on<ShippingRequested>((event, emit) async {
  emit(ShippingLoading());

  try {
    final options = await repository.getOptions(event.postcode);
    emit(ShippingData(options));
  } catch (_) {
    emit(ShippingError(
      'Не удалось загрузить способы доставки',
    ));
  }
});

Здесь важна не конструкция try сама по себе, а завершённость перехода. После каждого пути выполнения BLoC покидает Loading. Типичная ошибка выглядит так:

emit(ShippingLoading());
final options = await repository.getOptions(event.postcode);
emit(ShippingData(options));

Если репозиторий завершится ошибкой, ShippingData не будет выполнено, а нового состояния не появится. На экране останется бесконечный индикатор.

Как UI отображает результат

BlocBuilder не запускает запрос и не решает, какие варианты доставки допустимы. Он только превращает полученное состояние в виджет:

BlocBuilder<ShippingBloc, ShippingState>(
  builder: (context, state) {
    if (state is ShippingLoading) {
      return const Center(
        child: CircularProgressIndicator(),
      );
    }

    if (state is ShippingError) {
      return ErrorPanel(
        message: state.message,
        onRetry: () {
          context.read<ShippingBloc>().add(
                ShippingRequested('190000'),
              );
        },
      );
    }

    final data = state as ShippingData;

    if (data.options.isEmpty) {
      return const Text(
        'По этому адресу доставка недоступна',
      );
    }

    return ShippingOptionsList(
      options: data.options,
    );
  },
)

Обратите внимание на две разные ветви: пустой успешный результат отображается из ShippingData, а сбой — из ShippingError. Если объединить их, пользователь не поймёт, нужно ли менять адрес или повторять запрос.

Кнопка «Повторить» отправляет то же событие в BLoC. UI не обращается к репозиторию напрямую: иначе логика загрузки окажется одновременно в экране и BLoC.

Сквозной результат для пользователя

Соберём поведение фичи по шагам.

  1. Пользователь открывает выбор доставки для индекса 190000.
  2. UI отправляет ShippingRequested.
  3. BLoC выдаёт ShippingLoading.
  4. Экран показывает индикатор и блокирует повторное нажатие.
  5. Репозиторий получает два варианта доставки.
  6. BLoC выдаёт ShippingData со списком.
  7. Экран показывает «Курьер — 390 RUB» и «Самовывоз — бесплатно».

При проблеме на пятом шаге BLoC вместо Data выдаёт ShippingError. Пользователь увидит сообщение и кнопку «Повторить», а не бесконечную загрузку. После нажатия последовательность начинается заново: Loading, затем Data при успешном ответе.

Корректная асинхронная цепочка не заканчивается на отправке запроса: каждый её путь обязан привести UI из Loading либо в Data, либо в Error.

На собеседовании такую реализацию важно объяснять через ответственность компонентов: слой данных получает и преобразует результат, BLoC управляет переходами, а UI отображает текущее состояние и отправляет события. Случаи, когда несколько запросов завершаются не в порядке запуска, мы свяжем с подписками и синхронизацией состояний в главе о реактивной модели.

Интеграция внешних SDK: Push-уведомления и карты

Интеграция внешних SDK: Push-уведомления и карты

Пользователь нажимает уведомление «Курьер будет через 10 минут», приложение запускается, открывает доставку № 6260 — но карта не загружается. Что должен увидеть пользователь: пустой экран, бесконечный Loading или адрес и статус доставки без карты? Правильный ответ зависит не от возможностей SDK, а от того, насколько аккуратно приложение отделяет внешний сигнал, свои данные и интерфейс.

Мы уже умеем проводить фичу через UI, BLoC, доменный слой и слой данных. Теперь к этой цепочке подключаются два внешних участника: Push SDK сообщает о событии, а Map SDK визуализирует координаты. Они не должны становиться центром архитектуры.

Push-уведомление — сигнал, а не источник истины

Звонок в дверь сообщает, что кто-то пришёл, но не доказывает личность посетителя. Аналогично Push-уведомление — сообщение, доставленное устройству внешней системой, которое информирует пользователя или запускает обработку события в приложении.

Для доставки № 6260 Backend может отправить такие данные:

  • тип события: order_status_changed;
  • идентификатор заказа: 6260;
  • новый статус для текста уведомления: «Курьер выехал»;
  • путь назначения: /orders/6260.

Из этого не следует, что приложению нужно без проверки записать новый статус в своё состояние. Уведомление могло задержаться, а заказ — уже перейти на следующий этап. Поэтому Push обычно приносит идентификатор и повод обновиться, после чего приложение запрашивает актуальные данные через уже существующий слой данных.

Push сообщает: «что-то произошло». Backend после запроса отвечает: «вот текущее состояние».

Так сохраняется единый источник данных. Текст уведомления помогает пользователю понять событие, но экран строится по свежему ответу Backend.

Device token связывает установку приложения с доставкой

Номерок в гардеробе указывает на конкретно принятую вещь, но не описывает её владельца целиком. Device token — выданный Push SDK идентификатор установки приложения, по которому служба уведомлений доставляет сообщения на конкретное устройство.

Типичный маршрут выглядит так:

  1. Приложение запрашивает разрешение на уведомления в подходящий момент.
  2. Push SDK выдаёт device token.
  3. Слой данных отправляет token на Backend вместе с данными текущего пользователя.
  4. Backend сохраняет связь пользователя с одной или несколькими установками приложения.
  5. При изменении доставки Backend отправляет сообщение зарегистрированным установкам.

Device token нельзя считать постоянным. SDK может выдать новое значение, поэтому приложение должно передать обновление на Backend. При выходе пользователя из аккаунта связь также нужно удалить или отключить — иначе уведомление может получить следующий человек, вошедший на том же устройстве.

BLoC экрана доставки не должен регистрировать token самостоятельно. Регистрация относится к интеграции приложения и учётной записи, а не к отображению одного экрана.

Адаптер удерживает SDK на границе

В поездке электрический переходник позволяет подключить знакомую вилку к незнакомой розетке, не переделывая весь прибор. Адаптер — компонент, который преобразует типы, вызовы и ошибки внешнего SDK в формы, понятные приложению.

Push SDK разных поставщиков может передавать данные разными объектами. Map SDK также использует собственные классы координат, меток и ошибок. Если эти классы попадут в BLoC и доменный слой, смена поставщика потребует изменений по всему проекту.

Граница должна работать в обе стороны:

  • Push-адаптер превращает данные SDK в событие BLoC, например OrderOpened;
  • приложение передаёт карте обычные широту и долготу;
  • Map-адаптер создаёт из них объект, требуемый конкретным SDK;
  • технические ошибки преобразуются в понятные приложению результаты;
  • объекты внешних пакетов не выходят за интеграционную границу.

Например, адаптер может принять словарь из уведомления и создать уже знакомое BLoC событие:

class OrderOpened {
  const OrderOpened(this.orderId);

  final int orderId;

  @override
  String toString() => 'OrderOpened(orderId: $orderId)';
}

class PushAdapter {
  OrderOpened? convert(Map<String, Object?> data) {
    if (data['type'] != 'order_opened') return null;

    final orderId = int.tryParse('${data['order_id']}');
    if (orderId == null) return null;

    return OrderOpened(orderId);
  }
}

void main() {
  final adapter = PushAdapter();
  final event = adapter.convert({
    'type': 'order_opened',
    'order_id': '6260',
  });

  print(event);
}
OrderOpened(orderId: 6260)

Некорректное уведомление не должно открывать экран с идентификатором 0 или вызывать падение. Адаптер отклоняет данные, если тип неизвестен или идентификатор нельзя прочитать.

Deep link сохраняет намерение пользователя

Адрес квартиры позволяет прийти сразу в нужное место, а не останавливаться у входа в город. Deep link — адрес внутри приложения, который описывает конкретный экран или действие, например /orders/6260.

Он особенно важен, когда пользователь нажимает уведомление, а процесс приложения ещё не запущен. В этот момент нельзя рассчитывать, что нужный BLoC уже существует. Приложение сначала получает адрес назначения, запускает необходимые зависимости, восстанавливает вход пользователя и только затем открывает экран.

Для уведомления о доставке последовательность такова:

  1. Push-адаптер извлекает /orders/6260.
  2. Приложение проверяет, что адрес имеет поддерживаемый формат.
  3. Система навигации открывает экран доставки.
  4. Экран создаёт BLoC и отправляет событие загрузки заказа № 6260.
  5. Слой данных получает актуальный статус и координаты.
  6. UI показывает данные, а Map SDK получает координаты для визуализации.

Если пользователь не авторизован, намерение открыть доставку нужно временно сохранить. После успешного входа приложение продолжает переход, а не отправляет пользователя на безымянный главный экран.

Одно нажатие не должно создавать несколько одинаковых экранов. Причиной дублирования часто становятся два независимых обработчика: один реагирует на запуск приложения, другой — на открытие уведомления. Они должны сходиться в одном механизме обработки deep link.

Карта — часть представления, а не владелец доставки

Бумажная карта показывает место встречи, но не определяет, состоится ли сама встреча. Так и Map SDK показывает координаты доставки, но не владеет статусом заказа, адресом или правилами доступности действий.

Для доставки № 6260 состояние экрана может независимо содержать:

  • данные заказа;
  • координаты курьера;
  • признак готовности карты;
  • понятное сообщение о недоступности карты.

Не следует заменять всё состояние общим Loading, пока карта инициализируется. Данные заказа уже могут быть получены: пользователь должен увидеть статус «Курьер выехал», адрес и ожидаемое время, даже если карта ещё загружается.

Если Map SDK сообщает об ошибке, UI скрывает интерактивную карту или показывает нейтральную заглушку, но сохраняет остальные данные. Ошибка карты не превращается в ошибку загрузки заказа. Это две асинхронные операции с разными последствиями.

Разрешение на доступ к местоположению пользователя тоже не требуется для показа точки курьера, если координаты пришли с Backend. Запрашивать доступ «на всякий случай» не стоит: разрешение нужно только функции, которая действительно использует положение устройства, например построению пути от пользователя.

Сквозной результат интеграции

Готовая фича разделяет обязанности следующим образом:

Участник Ответственность
Push SDK Доставить сообщение устройству и сообщить о нажатии
Push-адаптер Проверить внешние данные и преобразовать их в событие или deep link
Backend и слой данных Вернуть актуальную доставку № 6260
BLoC Управлять загрузкой и состоянием экрана
Map-адаптер Преобразовать координаты в типы конкретного Map SDK
UI Показать статус, адрес, карту или безопасную замену карты

Проверка фичи должна охватывать не только успешное нажатие. Нужны запуск приложения из завершённого состояния, неизвестный тип уведомления, отсутствующий идентификатор, повторное получение одного сообщения, сменившийся device token и отказ карты при доступных данных заказа.

Исходный вопрос теперь решается однозначно: при недоступной карте пользователь всё равно видит адрес и актуальный статус доставки № 6260. Push привёл его к нужной фиче, Backend подтвердил состояние, BLoC подготовил данные, а сбой одного SDK остался локальной проблемой. Синхронизацию нескольких изменяющихся источников состояния подробнее разберём дальше в реактивной модели.

Реактивная модель: стримы, подписки и синхронизация состояний

Реактивная модель: стримы, подписки и синхронизация состояний

В 14:03 Push сообщает, что доставка № 8043 завершена. В 14:04 заканчивается запрос, запущенный двумя минутами ранее, и приносит старый статус «Курьер в пути». Почему экран вдруг возвращает заказ в прошлое, хотя каждый источник по отдельности работает правильно?

Проблема возникает не в отображении и не в запросе. Приложение получает результаты в разное время, поэтому должно определять, какие обновления принимать, как собирать их в одно состояние и когда прекращать их обработку.

От одного результата к последовательности

Заказывая такси, вы не ожидаете получить один ответ с окончательным положением автомобиля. Точка водителя изменяется снова и снова, а экран реагирует на каждое изменение.

Поток данных, или Stream, — последовательность значений или ошибок, которые появляются во времени и могут обрабатываться по мере поступления.

Обычная асинхронная операция даёт один итог: например, загрузка карточки доставки. Поток подходит для данных, которые продолжают меняться: координат, состояния соединения, сообщений или результатов работы внешнего SDK.

Характеристика Одиночная операция Stream
Число результатов Один успех или одна ошибка Ноль, одно или много значений
Завершение После результата Может продолжаться до явной остановки
Пример Загрузить сведения о доставке Получать координаты курьера
Действие BLoC Обработать итог Реагировать на каждое обновление

Для доставки № 8043 первоначальный запрос может вернуть адрес, статус и текущую координату. После этого поток передаёт новые точки: в 14:01, 14:02 и 14:03. Каждая точка превращается в событие BLoC, а BLoC выпускает новый полный снимок состояния.

Подписка означает ответственность

Уведомления от банковского приложения приходят только после того, как пользователь разрешил их получать. Если он отключил уведомления, новые сообщения больше не должны влиять на устройство.

Подписка — активное подключение обработчика к потоку, благодаря которому код получает новые значения, ошибки и сигнал завершения.

В Dart вызов listen создаёт объект StreamSubscription. Он похож на талон управления подключением: через него подписку можно приостановить, возобновить или отменить.

StreamSubscription — объект Dart, представляющий конкретную подписку и позволяющий управлять её жизненным циклом.

Для BLoC особенно важна отмена. Пользователь может открыть доставку № 8043, а через секунду перейти к доставке № 8091. Если первая подписка останется активной, её координаты способны попасть на новый экран.

Ниже BLoC хранит только одну активную подписку. Каждому запуску назначается номер сессии, поэтому запоздавшее событие от прежнего подключения игнорируется.

class TrackingBloc extends Bloc<TrackingEvent, TrackingState> {
  TrackingBloc(this.repository) : super(const TrackingInitial()) {
    on<TrackingStarted>(_onStarted);
    on<TrackingSnapshotArrived>(_onSnapshotArrived);
    on<TrackingStreamFailed>(_onStreamFailed);
    on<TrackingStopped>(_onStopped);
  }

  final TrackingRepository repository;

  StreamSubscription<TrackingSnapshot>? _subscription;
  int _session = 0;

  Future<void> _onStarted(
    TrackingStarted event,
    Emitter<TrackingState> emit,
  ) async {
    final session = ++_session;

    await _subscription?.cancel();
    if (session != _session) return;
    emit(TrackingLoading(event.deliveryId));

    _subscription = repository
        .watchTracking(event.deliveryId)
        .listen(
          (snapshot) {
            add(TrackingSnapshotArrived(session, snapshot));
          },
          onError: (Object error, StackTrace stackTrace) {
            add(TrackingStreamFailed(session, error));
          },
        );
  }

  void _onSnapshotArrived(
    TrackingSnapshotArrived event,
    Emitter<TrackingState> emit,
  ) {
    if (event.session != _session) return;

    emit(TrackingData(event.snapshot));
  }

  void _onStreamFailed(
    TrackingStreamFailed event,
    Emitter<TrackingState> emit,
  ) {
    if (event.session != _session) return;

    emit(TrackingError(event.error));
  }

  Future<void> _onStopped(
    TrackingStopped event,
    Emitter<TrackingState> emit,
  ) async {
    _session++;
    await _subscription?.cancel();
    _subscription = null;
  }

  @override
  Future<void> close() async {
    await _subscription?.cancel();
    return super.close();
  }
}

Отмена выполняется в трёх ситуациях:

  1. Началось наблюдение за другой доставкой.
  2. Экран больше не нуждается в обновлениях.
  3. Сам BLoC уничтожается через close.

Одной отмены недостаточно: значение могло уже попасть в очередь событий. Поэтому номер сессии служит второй линией защиты. Событие от старой доставки физически может дойти до BLoC, но состояние не изменит.

Кто собирает единое состояние

На кухне несколько часов могут показывать немного разное время. Если повар для каждого действия выбирает случайные часы, последовательность рецепта нарушается. Нужны общие правила: какие показания считать актуальными и как обновлять всю картину.

Синхронизация состояний — согласование обновлений из нескольких источников так, чтобы UI всегда получал непротиворечивый снимок данных.

На экране доставки одновременно действуют три источника:

  • Backend возвращает статус и сведения о заказе.
  • Push сообщает, что данные могли измениться.
  • Map SDK передаёт координаты или ошибку карты.

Push не должен напрямую устанавливать статус «Доставлено»: он лишь запускает обновление из Backend. Map SDK не должен изменять статус заказа. BLoC, в свою очередь, не должен выпускать три независимых состояния, где одно случайно стирает данные другого.

Практичнее передавать в BLoC полный TrackingSnapshot, содержащий:

  • идентификатор доставки;
  • статус, подтверждённый Backend;
  • последнюю доступную координату;
  • доступность карты;
  • время серверного обновления.

Слой данных собирает результаты интеграций и соблюдает их контракт, а BLoC решает, какой полный снимок станет состоянием UI. При ошибке карты статус и адрес сохраняются. При Push выполняется повторное получение серверных данных. При переключении доставки старая сессия перестаёт влиять на экран.

Виджет показывает главный риск: порядок запуска операций не гарантирует порядок их завершения. Если старый ответ приходит позже нового, приложение не обязано его принимать. Идентификатор текущей доставки, номер сессии и серверное время позволяют проверить актуальность до изменения состояния.

Практический результат для доставки № 8043

Соберём цепочку по времени:

  1. В 14:00 пользователь открывает доставку № 8043. BLoC отменяет прежнее подключение и создаёт сессию 7.
  2. Поток выдаёт полный снимок: статус «Курьер в пути», координата доступна, карта работает.
  3. В 14:03 приходит Push «Доставка завершена». Он запускает обновление, но сам статус не меняет.
  4. Backend подтверждает статус «Доставлено». Слой данных формирует новый полный снимок, а BLoC передаёт его UI.
  5. В 14:04 приходит координата из сессии 6. BLoC сравнивает номера и игнорирует её.
  6. Пользователь закрывает экран. Подписка отменяется, поэтому дальнейшие координаты не обрабатываются.

Конкретный результат: экран остаётся в состоянии «Доставлено», старая точка курьера его не изменяет, а после закрытия экрана приложение не продолжает ненужную работу.

Что демонстрировать на собеседовании

Уверенное владение реактивной моделью — не просто умение вызвать listen. Важно показать, что вы контролируете всю временную цепочку:

  • выбираете Stream, только когда результатов действительно несколько;
  • храните и отменяете StreamSubscription;
  • не позволяете старой подписке обновить новую фичу;
  • преобразуете значения потока в события BLoC;
  • выпускаете полное состояние, а не несвязанные фрагменты;
  • сохраняете полезные данные при частичном отказе;
  • проверяете порядок поступления результатов, а не полагаетесь на порядок запуска.

Ответ на исходный вопрос теперь ясен: экран возвращается в прошлое, когда принимает запоздалый результат без проверки его актуальности. Реактивная архитектура решает эту проблему не самим фактом использования потоков, а управлением подписками и едиными правилами синхронизации состояния.