Как я собирал Flutter-клиент, который не разваливается вне happy path

от автора

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

Есть Bloc, есть Dio, есть go_router, есть get_it. Где-то рядом лежат репозитории, модели, пара экранов и слайд со стрелками. На демо это звучит убедительно: “вот UI-слой, вот data-слой, вот state management”. Кажется, что если взять правильный набор пакетов, дальше система почти сама соберётся.

У меня так не вышло.

Я делаю Flutter-приложение для изучения языков. Это не pet-проект на три экрана, а полноценный клиент: авторизация через несколько провайдеров, анонимный вход, перевод, распознавание, генерация контента, учебные капсулы, локальные настройки, офлайн-поведение, WebSocket-события, длинные фоновые операции. И основные проблемы там начинаются не на уровне Flutter, а в тот момент, когда продуктовая логика перестаёт помещаться в учебные примеры.

Почти все полезные инженерные решения в проекте появились не из любви к “красивой архитектуре”, а после вполне приземлённых сбоев в реальных сценариях:

  • пользователь зашёл анонимно, потом решил зарегистрироваться и не должен потерять данные;

  • сеть на телефоне формально есть, но realtime-слой умер;

  • тяжёлая серверная операция крутится десятки секунд, а UI не должен выглядеть подвисшим;

  • приложение надо уметь не просто логинить, а аккуратно переживать смену identity;

  • после logout нельзя надеяться, что десяток старых Cubit-ов сами как-нибудь очистятся.

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

Что за проект и почему вообще пришлось что-то изобретать

Технически стек у меня стандартный:

  • flutter_bloc

  • dio

  • go_router

  • get_it

  • Firebase Auth

  • WebSocket

  • shared_preferences

  • flutter_secure_storage

Проблема не в том, что Flutter бедный и под него ничего нет. Под него как раз есть почти всё. Проблема в другом: готовые библиотеки закрывают отдельные кирпичи, но не собирают здание.

Например:

  • go_router даёт маршрутизацию, но не решает, как у тебя ведёт себя приложение при глубоко вложенной навигации и tab-shell;

  • dio отлично ходит в API, но ничего не знает о серверных задачах, которые стартуют по HTTP, а завершаются через WebSocket;

  • Firebase Auth хорошо умеет identity, но не заменяет серверный access-layer, если backend живёт своей жизнью;

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

Вместо “идеальной архитектуры” у меня получился набор прагматичных решений. Они и оказались самыми полезными.

1. Firebase я оставил для identity, а backend-токен сделал отдельным

Это решение одним из первых сделало проект взрослым.

Обычно мобильную авторизацию тянут в одну из двух крайностей. Либо полностью отдают всё Firebase и пытаются строить продуктовую логику вокруг него. Либо вообще не используют Firebase и делают полностью собственную auth-схему.

Я пошёл по промежуточному пути.

Firebase у меня отвечает за identity:

  • email/password,

  • Google,

  • Apple,

  • anonymous sign-in,

  • linking анонимного пользователя с постоянным аккаунтом.

Но после успешной аутентификации приложение не работает напрямую на Firebase-токене. Оно получает свой backend JWT.

Идея простая: Firebase подтверждает, кто пользователь, а backend уже решает, как этот пользователь живёт в прикладной системе.

Схема здесь простая:

  1. пользователь логинится через Firebase;

  2. клиент получает Firebase ID token;

  3. отправляет его на backend;

  4. backend отдаёт уже собственный API JWT;

  5. дальше REST и WebSocket работают через него.

У этого решения было несколько причин.

Во-первых, backend не зависит жёстко от конкретного mobile auth provider. Сегодня это Firebase, завтра может быть другой внешний identity provider, а внутренний серверный контракт не ломается.

Во-вторых, это сильно упрощает унификацию транспорта. Один и тот же JWT потом уходит и в HTTP, и в WebSocket, и в пользовательские backend-права.

В-третьих, на таком слое очень удобно жить с anonymous flow. Пользователь может сначала анонимно попробовать приложение, потом привязать к себе Google или email-аккаунт, и это не выглядит как “сейчас мы создадим нового пользователя, а всё старое потеряем”.

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

Да, такая схема усложняет auth-цепочку. Появляются два токена и две зоны ответственности:

  • Firebase-token как proof of identity;

  • backend-token как прикладной access token.

Но эта сложность окупается.

Здесь обычно сразу спрашивают: “а почему не жить вообще без backend JWT и не пускать в API сразу по Firebase?”. Ответ простой: у backend свои права, свои правила доступа и свой жизненный цикл сессии. Мне было важнее иметь прикладную auth-модель на стороне сервера, чем формально упростить клиент.

2. Я перестал “чистить состояние” и начал полностью пересоздавать приложение

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

На практике это почти всегда означает одно: где-то ты обязательно забудешь кусок старого состояния.

Особенно неприятно это проявляется в сценариях:

  • logout -> login под другим пользователем;

  • anonymous -> linked account;

  • протухший токен -> восстановление сессии;

  • возврат из глубоко вложенного экрана с уже неактуальным состоянием.

Я быстро понял, что в приложении с большим количеством Cubit-ов ручная зачистка становится ловушкой. Ты вроде бы контролируешь процесс, но постепенно обрастаешь списком специальных случаев.

В какой-то момент я сделал наоборот: вместо точечной санитарной уборки начал пересоздавать всё дерево провайдеров целиком.

Технически это реализовано грубо, но надёжно:

  • в AppInitializer есть собственный ключ;

  • при смене критического auth-состояния ключ меняется;

  • subtree с MultiBlocProvider собирается заново;

  • Cubit-ы регистрируются заново через GetIt.

Идея простая: если сменился “мир пользователя”, не нужно пытаться спасать старое состояние. Нужно поднять новый runtime-контекст.

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

Да, у решения есть побочные эффекты:

  • надо аккуратно следить за singleton-ами;

  • нельзя бездумно смешивать долгоживущие сервисы и экранные состояния;

  • появляется дополнительный слой управления DI.

Но взамен я получил главное: предсказуемость. После logout/login приложение ведёт себя как новый экземпляр пользовательской сессии, а не как старый дом, в котором попытались переставить мебель. Для такого сценария это важнее формальной элегантности.

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

Кусок кода: reset всего дерева провайдеров через новый ключ
final GlobalKey<_AppInitializerState> appContextKey =    GlobalKey<_AppInitializerState>();class _AppInitializerState extends State<AppInitializer> {  Key _appKey = UniqueKey();  void resetApp() {    setState(() {      _appKey = UniqueKey();    });  }  @override  Widget build(BuildContext context) {    return MultiBlocProvider(      key: _appKey,      providers: ProvidersManager.getAllProviders(),      child: widget.child,    );  }}

3. Долгие серверные операции я перестал ждать по HTTP и перевёл в схему HTTP + WebSocket

Пока сервер отвечает быстро, всё прекрасно:

final response = await dio.post(...);

Но как только появляются операции вроде:

  • анализа текста,

  • генерации,

  • распознавания,

  • фоновой обработки,

начинаются проблемы.

Хорошо, когда про такие операции знаешь заранее и сразу проектируешь их как отдельные async-сценарии. Тогда у тебя с самого начала есть специальные эндпоинты, нормальный контракт под долгую задачу и понятный UX.

Но в реальной жизни обычный endpoint часто становится тяжёлым уже после релиза. Пока фича маленькая, она спокойно живёт в формате “отправили запрос, получили ответ”. Потом бизнес-логика разрастается, добавляется новая обработка, интеграции, генерация, анализ, и прежний endpoint уже не укладывается в старую модель ожидания.

В этот момент особенно неприятно выяснить, что под утяжеление backend-логики нужно полностью перекраивать клиент.

Если ждать результат в одном длинном HTTP-запросе, проблемы почти гарантированы:

  • таймауты,

  • нервный UX,

  • сложная диагностика,

  • странное поведение при сворачивании приложения,

  • повышенная чувствительность к сетевым скачкам.

Поэтому мне была важна не только сама схема фонового выполнения, но и то, что клиент может к ней адаптироваться без полного рефакторинга. Если endpoint стал тяжёлым, я не хочу переписывать половину приложения. Я хочу иметь транспортный слой, который позволяет перевести такую операцию в background-режим с минимальными изменениями в клиентском коде.

Схема такая:

  1. клиент стартует задачу по HTTP, передавая специальный хедер X-Async-Background: true;

  2. сервер сразу возвращает task_id;

  3. клиент подписывается на событие завершения по WebSocket;

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

Уточнение: X-Async-Background в моём случае не является “магическим хедером из мобильного клиента”. За ним стоит отдельное backend-решение, которое умеет принимать обычный HTTP-запрос и перенаправлять его в фоновое выполнение с возвратом идентификатора задачи. Клиент здесь только пользуется уже подготовленной серверной инфраструктурой, а не изобретает async-механику сам.

HTTP здесь остаётся стартовой точкой и контейнером контракта.

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

А финальный REST-запрос даёт:

  • нормальную трассировку,

  • повторяемость,

  • удобную обработку ошибок,

  • понятную точку для ретраев.

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

Под это пришлось собрать собственный WSService. Не потому что хотелось “свой сокетный велосипед”, а потому что приложение живёт не в вакууме.

Нужны были:

  • reconnect;

  • heartbeat;

  • message queue;

  • реакция на lifecycle приложения;

  • раздельные потоки статуса, данных и ошибок.

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

В итоге сокет у меня стал не “каналом для уведомлений”, а нормальной инфраструктурной частью приложения. После этого с ним стало проще работать как с системным слоем, а не как с набором исключений.

Про сам backend-механизм с X-Async-Background могу отдельно рассказать. Там важна не только клиентская часть: как именно запрос уходит в background, как возвращается task_id, и как потом клиенту безопасно забирать результат без грязного polling.

Кусок кода: старт задачи по HTTP, завершение через WebSocket
Future<Response> postAsync(String path, {dynamic data}) async {  final response = await dio.post(    path,    data: data,    options: Options(headers: {"X-Async-Background": "true"}),  );  String? taskId = response.data?['meta']?['async_request_id'] as String?;  if (taskId == null) {    throw Exception('Missing async_request_id in response');  }  final completer = Completer<Response>();  Future<void> handleCompletion() async {    final result = await get(      '/api/v1/async_background_response/$taskId/?full_response=true',    );    if (!completer.isCompleted) {      completer.complete(result);    }  }  StreamSubscription? subscription;  subscription = WSService()      .dataStream      .where((m) => m['action'] == 'async_bg' && m['task_id'] == taskId)      .listen((m) async {    if (m['status'] == 'completed' || m['status'] == 'failed') {      await handleCompletion();      await subscription?.cancel();    }  });  await handleCompletion();  return completer.future;}

4. Офлайн для меня начинается не там, где пропал Wi-Fi, а там, где умерла реальная связность системы

Во многих приложениях online/offline определяется очень формально:

  • есть wifi или mobile,

  • значит online;

  • нет, значит offline.

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

  • формально интернет есть;

  • REST ещё может отвечать;

  • а realtime уже умер;

  • фоновые задачи не доходят;

  • сокет не переподключился;

  • экран выглядит живым, но система на самом деле уже частично ослепла.

Поэтому я сделал более жёсткую, но честную модель: приложение считается по-настоящему online только если одновременно живы и connectivity, и WebSocket.

То есть online у меня не равен “у телефона есть интернет”. Online равен “приложение может полноценно работать”.

Такая трактовка ближе к реальному UX, а не к системному API. Да, для некоторых продуктов это было бы слишком строго. Но в приложении, где завязаны фоновые операции и realtime-сигналы, это честнее по отношению к пользователю.

Поверх этого я собрал базовый CachedRepository, чтобы офлайн-слой не расползался по проекту хаотично.

Внутри набор идей простой, но полезный:

  • единый шаблон кэш-ключей;

  • версия приложения внутри ключа;

  • TTL;

  • fallback на локальные данные, если сеть недоступна;

  • fallback на кэш, если сервер временно умер;

  • хеширование длинных аргументов запроса.

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

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

А когда появляется общий слой, можно уже обсуждать:

  • что кэшируем,

  • как инвалидируем,

  • как ведём себя после релиза,

  • как чистим пользовательские данные после logout.

После этого офлайн-поведение становится не случайным, а проектируемым.

Сразу оговорюсь про хранилище — “почему SharedPreferences, а не SQLite/Isar/Drift?”. Ответ прагматичный: в моём случае это кэш сравнительно небольших JSON-ответов, а не полноценная локальная база приложения. Если бы речь шла о тяжёлом offline-first сценарии, больших коллекциях или сложных локальных запросах, я бы в эту сторону не смотрел.

Кусок кода: online = connectivity + живой WebSocket
class NetworkState extends Equatable {  final bool isConnectivity;  final bool isWsConnected;  bool get isOnline => isConnectivity && isWsConnected;  const NetworkState(this.isConnectivity, this.isWsConnected);}class NetworkCubit extends Cubit<NetworkState> {  NetworkCubit() : super(NetworkState(true, true)) {    Connectivity().onConnectivityChanged.listen((results) async {      final isConnectivity = _checkConnectivity(results);      if (isConnectivity) {        await GetIt.I<AppCubit>().loadData();        await WSService().reconnect();      }      emit(NetworkState(isConnectivity, state.isWsConnected));    });    WSService().statusStream.listen((status) {      emit(NetworkState(        state.isConnectivity,        status == WSStatus.connected,      ));    });  }}
Кусок кода: базовый репозиторий с кэшем и fallback-логикой
abstract class CachedRepository<T> {  final String baseKey;  final Duration cacheExpiry;  CachedRepository({    required this.baseKey,    this.cacheExpiry = const Duration(hours: 24),  });  String _generateCacheKey(Map<String, dynamic>? args) {    if (args == null || args.isEmpty) {      return 'cached_repo_${F.version}_$baseKey';    }    final sortedKeys = args.keys.toList()..sort();    var argsString =        sortedKeys.map((key) => '$key:${args[key]}').join('_');    if (argsString.length > 50) {      argsString = sha256.convert(utf8.encode(argsString))          .toString()          .substring(0, 16);    }    return 'cached_repo_${F.version}_${baseKey}_$argsString';  }  Future<dynamic> fetchFromRemote([Map<String, dynamic>? args]);  T fromJson(dynamic data);  Future<T> getData({bool forceRemote = true, Map<String, dynamic>? args}) async {    final cachePath = _generateCacheKey(args);    if (!GetIt.I<NetworkCubit>().isOnline) {      final cached = await _loadFromCache(cachePath);      if (cached != null) return fromJson(cached);    }    final remote = await fetchFromRemote(args);    await _saveToCache(cachePath, remote);    return fromJson(remote);  }}

5. Вместо бесконечного спиннера я сделал слой длинного прогресса с доменными анимациями

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

Стандартные спиннеры начинают раздражать. Не потому что они некрасивые, а потому что они ничего не говорят. Особенно если серверная операция длится 5, 10, 20 секунд.

Для таких сценариев я сделал свой LongProgressCubit.

У него есть:

  • оценочная длительность операции;

  • набор стадий;

  • тексты для каждой стадии;

  • прогресс-значение;

  • защита от гонок через operationId, чтобы старые таймеры не перетирали новый прогресс.

Важный момент: это не “настоящий серверный процент”, а управляемая клиентская модель длинной операции. Для меня это нормальный компромисс, если backend не отдаёт реальный progress.

Потому что между двумя вариантами:

  • “вечный спиннер без объяснений”;

  • “оценочные стадии с понятным текстом и завершением”,

второй почти всегда лучше для живого интерфейса.

Здесь тоже есть тонкая грань. Такой progress нельзя превращать в враньё. Если клиент рисует красивую шкалу, а потом минуту висит на 90%, пользователь это чувствует мгновенно. Поэтому staged-progress нормально работает только там, где оценки более-менее совпадают с реальным временем и где UI честно признаёт, что это именно стадии ожидания, а не точный серверный процент.

Поверх этого слоя я сделал не универсальный абстрактный loader “на все случаи”, а несколько визуально разных анимаций через CustomPainter. И это решение оказалось не декоративным, а вполне прикладным.

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

Когда идёт медиа-распознавание, уже другую.

Когда операция завершилась, success-стадия тоже анимируется отдельно, а не просто исчезает.

То есть long-running операция в UI начинает восприниматься не как “экран думает”, а как “система сейчас выполняет конкретную работу”.

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

Кусок кода: staged progress с защитой от гонок
class ProgressStage {  final String message;  final double progressValue;  const ProgressStage({    required this.message,    required this.progressValue,  });}abstract class LongProgressCubit extends Cubit<LongProgressState> {  final Stopwatch _stopwatch = Stopwatch();  final List<Timer> _activeTimers = [];  int _currentOperationId = 0;  Duration? get approximateDuration;  List<ProgressStage> get progressStages;  int loading() {    _cancelActiveTimers();    _currentOperationId++;    final operationId = _currentOperationId;    _stopwatch      ..reset()      ..start();    emit(LongProgressLoadingState(progressStages.first));    for (int i = 1; i < progressStages.length - 1; i++) {      final delay = Duration(        milliseconds: (approximateDuration!.inMilliseconds *                progressStages[i].progressValue)            .toInt(),      );      _activeTimers.add(Timer(delay, () {        if (operationId != _currentOperationId) return;        emit(LongProgressLoadingState(progressStages[i]));      }));    }    return operationId;  }}
Кусок кода: конкретные этапы для операции перевода
class TranslateProgressCubit extends LongProgressCubit {  @override  Duration? get approximateDuration => const Duration(seconds: 4);  @override  List<ProgressStage> get progressStages => [    ProgressStage(      message: l10n.entryProgressInit,      progressValue: 0,    ),    ProgressStage(      message: l10n.entryPreprocessing,      progressValue: 0.1,    ),    ProgressStage(      message: l10n.entryProgressPrepare,      progressValue: 0.6,    ),    ProgressStage(      message: l10n.entryProgressDone,      progressValue: 1.0,    ),  ];}

Ещё один урок: навигация очень быстро перестаёт быть “списком экранов”

На старте мне казалось, что go_router нужен просто как аккуратный маршрутизатор. Потом в приложении появились:

  • tab navigation;

  • shell-экраны;

  • вложенные разделы;

  • отдельные ветки навигации;

  • глубоко вложенные контентные маршруты.

Быстро выяснилось, что навигация в реальном приложении описывает не страницы, а структуру продукта.

Поэтому у меня быстро появились:

  • StatefulShellRoute.indexedStack;

  • отдельные navigator key под ветки;

  • вложенные shell-маршруты;

  • рекурсивный билдер для древовидных маршрутов.

Это не та часть проекта, которую обычно выносят на слайды. Но она хорошо показывает, насколько архитектура приспособлена к росту продукта.

Пока роутинг плоский, всё выглядит красиво.

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

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

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

Я бы не стал автоматически тащить в каждый Flutter-проект:

  • полный reset дерева состояний, если приложение маленькое;

  • WebSocket-инфраструктуру, если backend отлично живёт на коротких запросах;

  • собственный кэш-слой на SharedPreferences, если продукт по-настоящему offline-first;

  • staged-progress, если backend уже умеет отдавать реальный поэтапный прогресс;

  • сложный hybrid auth, если у сервера нет отдельной прикладной модели доступа.

Это всё полезно не потому, что “звучит взросло”, а потому что в моём случае иначе становилось больно.

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

Что у меня осталось после всей этой работы

Если сформулировать вывод прямо, он будет таким: в зрелом Flutter-проекте самые важные инженерные решения обычно находятся не в том месте, где выбирают между Bloc, Riverpod и Provider.

Они находятся в более грязных зонах:

  • на стыке identity и backend access;

  • на границе между HTTP и realtime;

  • в переходах между пользователями;

  • в офлайн-поведении;

  • в длинных фоновых операциях;

  • в моменте, когда приложение должно не “как-то выжить”, а повести себя предсказуемо.

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

Мне и нравится Flutter за то, что он даёт достаточно свободы, чтобы такие решения собрать. Но одновременно он не скрывает инженерную правду: библиотека не решает продуктовую архитектуру за тебя, она только даёт инструменты.

А вот как приложение переживает реальную жизнь, уже зависит от того, насколько ты готов проектировать не “идеальный happy path”, а грязную, шумную, человеческую эксплуатацию.

Если будет видно, что статья зайдёт, хотел бы потом отдельно рассказать про:

  • как устроен HTTP + WebSocket для фоновых задач;

  • как безопасно переживать anonymous -> linked account;

ссылка на оригинал статьи https://habr.com/ru/articles/1024656/