
Управление состоянием — одна из самых спорных и при этом критически важных тем для Flutter‑приложений. На первый взгляд кажется, что рынок давно полон готовых решений, но на практике у каждой команды есть свои требования, которые могут не укладываться в чужие подходы.
Сегодня мы релизим в опенсорс ещё один state management для Flutter — ровно так же, как чуть меньше года назад мы выпустили yet another DI. Неужели снова ничего не подошло из готового? Да, снова не подошло.
В этой статье я расскажу, почему у нас снова зачесались руки сделать собственную библиотеку: как мы пришли к созданию собственного state management и чем он отличается от Riverpod, BLoC или Redux, какие компромиссы пришлось искать, чтобы совместить несовместимое.
Предыстория: как появился yx_state
Первым фреймворком для управления состоянием в Яндекс Про был FishRedux — довольно громоздкий redux‑подобный комбайн, который пронизывал весь проект. Со временем он начал устаревать, перестал обновляться и стал активно генерировать техдолг.
Чтобы избавиться от этого балласта, мы решили писать все новые фичи на Riverpod. Причём сразу сформировали достаточно строгий набор правил: что можно делать, а что категорически нельзя. Подробно об этом мы рассказывали два года назад на MergeConf.
Благодаря этим правилам, всё управление состоянием выглядело единообразно — через StateNotifier, который по сути использовался как аналог редьюсера в Redux:
только синхронные функции;
без сайд‑эффектов;
с инкапсулированной мутацией состояния;
без каких‑либо зависимостей.
А вся бизнес‑логика над разными состояниями и походами во внешние API — это уже обязанность сущностей, которые мы назвали Manager.
Со временем выяснилось, что широта трактовки этих правил порождала всё больше вопросов и неудобств. Например, зачем отделять StateNotifier от Manager в кейсе, когда состояние и бизнес‑логика вокруг него связаны один к одному? И что ещё важнее — почему вообще существует прямой доступ на мутацию состояния через StateNotifier, если, по сути, оно должно меняться только строго в рамках бизнес‑логики?
Череда таких обсуждений подтолкнула нас переосмыслить и развить существующий подход. StateNotifier хорош своей простотой: это обычный класс с методами, которые явно выражают операции над состоянием. Но у него нет продвинутых возможностей BLoC — например, для работы с асинхронными событиями, без которых сложно объединить асинхронную логику и мутацию стейта.
В StateNotifier невозможно гарантировать транзакционность асинхронных операций или гибко управлять очерёдностью их выполнения — потому что самой очереди просто не существует. С другой стороны, Redux и BLoC берут своё: они позволяют гибко и наглядно отслеживать события и состояния. Но оба чрезвычайно многословны: никаких простейших методов у класса, каждое событие нужно выносить в отдельный класс и даже для простейших операций приходится писать лишний код.
Итак, мы хотели совместить несовместимое:
Простейший синтаксис, который будет интуитивно понятен после любого другого стейт‑менеджмента.
Полный контроль за очередью асинхронных событий/операций и возможность реализовывать кастомные обработчики этой очереди.
Возможность мониторить всё происходящее: как изменение состояния, так и выполняемые операции.
Возможность вызывающему коду решать, хочет ли он дожидаться вызванной операции, без дополнительных ухищрений.
Так и появился yx_state.
Что такое yx_state
yx_state — это набор библиотек для управления состоянием в Dart‑ и Flutter‑приложениях, созданный с фокусом на простоту использования и гибкость настройки.
Набор состоит из трёх основных библиотек:
yx_state — основная библиотека для управления состоянием
yx_state_flutter — набор виджетов для интеграции с Flutter
yx_state_transformers — набор трансформеров для управления стратегиями выполнения операций
Ключевые особенности yx_state:
Простота. Методы вместо событий — никаких дополнительных классов и маппингов.
Последовательность выполнения. Встроенная очередь операций предотвращает состояния гонки.
Удобство. Дождаться выполнения операции привычным способом через await.
Гибкие стратегии. Возможность настройки поведения выполнения операций (последовательно, параллельно и другие).
Глобальные переопределения. Возможность настройки поведения всех State Manager в приложении в одном месте.
Обработка ошибок. Встроенная обработка исключений.
Интеграция с Flutter. Набор виджетов для интеграции с Flutter.
yx_state объединяет лучшие практики из существующих решений, избегая их недостатков: сложности и бойлерплейта BLoC, ограниченности StateNotifier и избыточности других фреймворков для управления состоянием.
Установка и базовое использование
Для начала работы с yx_state необходимо добавить зависимости в файл pubspec.yaml вашего проекта:
dependencies:
yx_state: <version>
StateManager<T>
— это основной класс для управления состоянием в yx_state. Он предоставляет геттеры на состояние (state
) и стрим состояния (stream
).
handle
— это приватный метод для выполнения бизнес‑логики и изменения состояния. Он принимает функцию‑обработчик с параметром emit
, который позволяет обновлять состояние.
Создадим файл counter_state_manager.dart
и определим StateManager, который будет управлять состоянием счётчика:
import 'package:yx_state/yx_state.dart';
class CounterStateManager extends StateManager<int> {
// Конструктор принимает начальное состояние
CounterStateManager(super.state);
// Метод для увеличения счётчика
void increment() => handle((emit) async {
emit(state + 1);
});
// Метод для уменьшения счётчика
void decrement() => handle((emit) async {
emit(state - 1);
});
}
Создадим наш CounterStateManager и протестируем его работу:
void main() {
// Создаём новый StateManager
final counter = CounterStateManager(0);
// Подписываемся на изменения
counter.stream.listen(print);
// Изменяем состояние
counter.increment(); // Счётчик: 1
counter.increment(); // Счётчик: 2
counter.decrement(); // Счётчик: 1
// Освобождаем ресурсы
counter.close();
}
Состояние изменяется с помощью функции emit, которую можно вызывать несколько раз, что позволяет постепенно изменять наше состояние.
class CounterStateManager extends StateManager<int> {
CounterStateManager(super.state);
// Метод с множественными вызовами emit
Future<void> complexIncrement() => handle(
(emit) async {
// Первое изменение состояния
emit(state + 1);
// Асинхронная операция
await Future.delayed(const Duration(milliseconds: 100));
// Второе изменение состояния
emit(state + 1);
// Ещё одна асинхронная операция
await Future.delayed(const Duration(milliseconds: 100));
// Третье изменение состояния
emit(state + 1);
},
);
}
Если вызвать emit
после завершения функции handle
, то она не будет изменять состояние, а в дебаг‑режиме выбросит ошибку assert
— AssertionError
.
class CounterStateManager extends StateManager<int> {
CounterStateManager(super.state);
// ❌ Неправильно — emit после завершения функции handle
Future<void> badIncrement() => handle(
(emit) async {
// Асинхронная операция без await
Future.delayed(
const Duration(milliseconds: 100),
() => emit(state + 1), // Ошибка! emit после завершения handle
);
},
);
}
Это ограничение гарантирует, что изменения состояния происходят только в контролируемом контексте, и предотвращает неожиданные изменения состояния после завершения операций.
Функция handle. Важно помнить, что вся бизнес‑логика должна находиться внутри функции handle
, потому что только так yx_state может гарантировать выполнение операций в соответствии с выбранной стратегией обработки. Код, написанный вне handle
, не будет подчиняться выбранной стратегии, а это в свою очередь может привести к ошибкам, состоянию гонки и другому непредсказуемому поведению.
import 'package:yx_state/yx_state.dart';
class CounterStateManager extends StateManager<int> {
CounterStateManager(super.state);
// ✅ Правильно — вся логика внутри handle
Future<void> increment() => handle((emit) async {
// Вся бизнес-логика здесь
final newValue = state + 1;
await Future.delayed(const Duration(milliseconds: 100));
emit(newValue);
});
// ❌ Неправильно — логика вне handle
Future<void> badIncrement() async {
// Логика вне handle — это антипаттерн
final newValue = state + 1;
await Future.delayed(const Duration(milliseconds: 100));
return handle((emit) async => emit(newValue));
}
}
Особенности библиотеки
Библиотека yx_state обладает рядом особенностей, которые делают её удобной для использования.
Последовательное выполнение операций. Главная особенность метода handle
заключается в том, что он управляет выполнением кода особым образом. Все операции выполняются последовательно — если вы несколько раз подряд вызовете методы, использующие handle
, они не будут выполняться одновременно. Вместо этого каждая новая операция будет ждать завершения предыдущей, образуя очередь.
Добавим задержку в методы increment
и decrement
:
import 'package:yx_state/yx_state.dart';
class CounterStateManager extends StateManager<int> {
// Конструктор принимает начальное состояние
CounterStateManager(super.state);
// Метод для увеличения счётчика
void increment() => handle((emit) async {
await Future.delayed(const Duration(milliseconds: 500));
emit(state + 1);
});
// Метод для уменьшения счётчика
void decrement() => handle((emit) async {
await Future.delayed(const Duration(milliseconds: 250));
emit(state - 1);
});
}
Результат выполнения кода остался прежним, но теперь мы можем увидеть, что операции выполняются последовательно.
Ожидание выполнения операции. Метод handle
возвращает Future<void>
, что позволяет дождаться выполнения операции.
import 'package:yx_state/yx_state.dart';
class CounterStateManager extends StateManager<int> {
// Конструктор принимает начальное состояние
CounterStateManager(super.state);
// Метод для увеличения счётчика
Future<void> increment() => handle((emit) async {
await Future.delayed(const Duration(milliseconds: 500));
emit(state + 1);
});
// Метод для уменьшения счётчика
Future<void> decrement() => handle((emit) async {
await Future.delayed(const Duration(milliseconds: 250));
emit(state - 1);
});
}
Изменим наш main и посмотрим на результат:
void main() async {
// Создаём новый StateManager
final counter = CounterStateManager(0);
// Изменяем состояние
await counter.increment();
print('Счётчик: ${counter.state}');
await counter.increment();
print('Счётчик: ${counter.state}');
await counter.decrement();
print('Счётчик: ${counter.state}');
// Освобождаем ресурсы
counter.close();
}
Выполнение методов increment
и decrement
будет ожидать завершения операции.
Обработка ошибок. У StateManager есть встроенный механизм для обработки исключений. Если вы не перехватываете исключения, они будут автоматически перехвачены и обработаны.
Обработанные исключения будут передаваться в StateManagerObserver
, из которого вы можете отправлять их в аналитику или систему логирования.
class CounterStateManager extends StateManager<int> {
// Конструктор принимает начальное состояние
CounterStateManager(super.state);
// Метод для увеличения счетчика с ошибкой
Future<void> incrementWithError() => handle((emit) async {
try {
await Future.delayed(
const Duration(milliseconds: 500),
() => throw Exception('Error'),
);
emit(state + 1);
} on Object catch (error, stackTrace) {
addError(error, stackTrace);
}
});
}
Глобальный наблюдатель. StateManagerObserver
— это абстрактный класс, который позволяет отслеживать и реагировать на различные события в жизненном цикле управления состоянием по всему приложению.
Для его использования необходимо создать класс, который будет наследоваться от StateManagerObserver
и переопределять необходимые методы.
class LoggingObserver extends StateManagerObserver {
const LoggingObserver();
@override
void onChange(
StateManagerBase<Object?> stateManager,
Object? currentState,
Object? nextState,
Object? identifier,
) {
print('${stateManager.runtimeType}: $currentState -> $nextState');
super.onChange(stateManager, currentState, nextState, identifier);
}
@override
void onError(
StateManagerBase<Object?> stateManager,
Object error,
StackTrace stackTrace,
Object? identifier,
) {
print('Ошибка в ${stateManager.runtimeType}: $error');
super.onError(stateManager, error, stackTrace, identifier);
}
}
void main() {
// Устанавливаем наблюдателя глобально
StateManagerOverrides.observer = const LoggingObserver();
}
Одна из особенностей наблюдателя заключается в том, что мы можем идентифицировать методы, которые были вызваны. Для этого необходимо передать параметр identifier
в метод handle
.
class CounterStateManager extends StateManager<int> {
// Конструктор принимает начальное состояние
CounterStateManager(super.state);
// Метод для увеличения счётчика
Future<void> increment() => handle(
(emit) async {
await Future.delayed(const Duration(milliseconds: 500));
emit(state + 1);
},
identifier: 'increment', // Добавляем идентификатор
);
// Метод для уменьшения счётчика
Future<void> decrement() => handle(
(emit) async {
await Future.delayed(const Duration(milliseconds: 250));
emit(state - 1);
},
identifier: 'decrement', // Добавляем идентификатор
);
}
Теперь мы можем увидеть, какой метод был вызван, в логах наблюдателя:
class LoggingObserver extends StateManagerObserver {
const LoggingObserver();
...
@override
void onHandleStart(
StateManagerBase<Object?> stateManager,
Object? identifier,
) {
print('Handle started: $identifier');
super.onHandleStart(stateManager, identifier);
}
@override
void onHandleDone(
StateManagerBase<Object?> stateManager,
Object? identifier,
) {
print('Handle done: $identifier');
super.onHandleDone(stateManager, identifier);
}
}
Опциональный identifier
позволяет гибко балансировать между простотой, бойлерплейтом и глубоким мониторингом. В простейшем случае его можно не использовать совсем, при этом, если потребуется комплексный анализ выполненных вызовов, можно передать в качестве identifier
не только простые строки, но и целые объекты — всё это будет доступно для анализа через StateManagerObserver
.
Глобальное переопределение. Библиотека yx_state предоставляет механизм глобального переопределения поведения через класс StateManagerOverrides
. Это позволяет настроить поведение всех StateManager в приложении в одном месте, не изменяя код каждого отдельного StateManager.
Переопределение наблюдателя. Переопределяя глобальный наблюдатель, мы можем следить за всеми изменениями состояний и жизненным циклом всех StateManager в приложении.
class LoggingObserver extends StateManagerObserver {
const LoggingObserver();
}
void main() {
// Устанавливаем наблюдателя глобально
StateManagerOverrides.observer = const LoggingObserver();
}
Переопределение стратегии выполнения операций. По умолчанию все операции выполняются последовательно, но, переопределив данный параметр, можно изменить поведение на другое, например, параллельное выполнение. Это не коснётся тех StateManager, которые переопределили данный параметр через конструктор.
import 'package:yx_state_transformers/yx_state_transformers.dart';
void main() {
// Все StateManager'ы будут использовать параллельное выполнение
StateManagerOverrides.defaultHandlerFactory = concurrent;
}
Переопределение логики сравнения состояний. Логика сравнения определяет, когда следует установить новое состояние. По умолчанию она сравнивает текущее и новое состояние и, если они не равны, устанавливает новое состояние. Переопределив данный параметр, можно изменить поведение на другое. Это не коснётся тех StateManager, которые переопределили данный метод в классе.
void main() {
// Всегда уведомлять об изменениях, даже когда новое состояние идентично предыдущему
// По умолчанию используется `(current, next) => current != next`
StateManagerOverrides.defaultShouldEmit = (current, next) => true;
}
Стратегии выполнения операций
Библиотека yx_state_transformers предоставляет набор трансформеров для управления выполнением операций.
Подключаем библиотеку:
dependencies:
yx_state_transformers: <version>
Переопределяем стратегию выполнения операций у нашего CounterStateManager:
import 'package:yx_state_transformers/yx_state_transformers.dart';
class CounterStateManager extends StateManager<int> {
CounterStateManager(super.state) : super(handler: concurrent());
...
}
Теперь все операции будут выполняться параллельно. Если handler
не передан, то будет использоваться значение по умолчанию из глобального переопределения StateManagerOverrides.defaultHandlerFactory
.
Доступные стратегии:
sequential()
— последовательное выполнение (по умолчанию);concurrent()
— параллельное выполнение всех операций;droppable()
— игнорирует новые операции, пока выполняется текущая;restartable()
— отменяет текущую операцию при поступлении новой.
При использовании стратегий, которые подразумевают прерывание операций (droppable()
и restartable()
), необходимо помнить о нескольких важных моментах:
Прерванная функция всё ещё будет выполняться, но вызовы
emit
будут игнорироваться и состояние не изменится. Если вы хотите прервать выполнение кода, используйте геттерemit.isDone
и остановите выполнение вашего кода.Если вы ожидаете выполнения функции через
await handle(...)
, ожидание завершится, когда функция будет отменена или прервана.
Интеграция с Flutter
Библиотека yx_state_flutter предлагает четыре базовых виджета для взаимодействия с пользовательским интерфейсом. Эти виджеты явно требуют передачи стейт‑менеджера и не используют механизм InheritedWidget
. Существует множество библиотек и подходов к организации этого механизма, поэтому мы не навязываем вам конкретный способ.
Подключаем библиотеку:
dependencies:
yx_state_flutter: <version>
StateBuilder. Виджет для перестройки UI при изменении состояния. Поддерживает фильтрацию через параметр buildWhen
:
import 'package:yx_state_flutter/yx_state_flutter.dart';
StateBuilder<int>(
// StateReadable — это read-only-интерфейс стейт-менеджера
stateReadable: counterManager,
// buildWhen — возможность указать условие для перестройки
builder: (context, state, child) {
return Text('Счётчик: $state');
},
);
StateListener. Виджет для выполнения сайд‑эффектов без перестройки UI. Поддерживает фильтрацию через параметр listenWhen
:
import 'package:yx_state_flutter/yx_state_flutter.dart';
StateListener<int>(
stateReadable: counterManager,
// listenWhen — возможность указать условие для выполнения сайд-эффекта
listener: (context, state) {
if (state >= 10) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Счётчик достиг значения $state!')),
);
}
},
child: CounterWidget(),
);
StateConsumer. Виджет, объединяющий функциональность StateBuilder
и StateListener
. Поддерживает раздельную фильтрацию через buildWhen
и listenWhen
:
import 'package:yx_state_flutter/yx_state_flutter.dart';
StateConsumer<int>(
stateReadable: counterManager,
listener: (context, state) {
if (state == 100) {
showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text('Поздравляем!'),
content: Text('Вы достигли 100!'),
),
);
}
},
builder: (context, state, child) {
return Text('Счётчик: $state');
},
);
StateSelector. Виджет для перестройки только при изменении определённой части состояния:
import 'package:yx_state_flutter/yx_state_flutter.dart';
StateSelector<int, bool>(
stateReadable: counterManager,
selector: (state) => state > 0,
builder: (context, isPositive, child) {
return Icon(
isPositive ? Icons.thumb_up : Icons.thumb_down,
color: isPositive ? Colors.green : Colors.red,
);
},
);
Оптимизация с помощью child. Все виджеты принимают параметр child
, который не зависит от состояния и не будет перестраиваться:
StateBuilder<int>(
stateReadable: counterManager,
builder: (context, state, child) {
return Column(
children: [
Text('Счётчик: $state'),
child ?? const SizedBox.shrink(),
],
);
},
child: const SomeWidget(),
)
Заключение
Итак, я рассказал об основных принципах управления состоянием с помощью yx_state: чем он отличается от других стейт‑менеджеров, как можно кастомизировать очередь операций и как он связывается с Flutter‑виджетами. Для более детального изучения библиотеки зягляните в исходники — всё открыто в репозитории.
В Яндекс Про yx_state уже активно используется — около ста самых разных фичей работают на нём, и внедрение продолжается: все новые фичи мы сразу пишем, используя yx_state. Теперь, когда библиотека обкатана в бою, пришло время поделиться ею с сообществом. Ищите библиотеку на pub.dev, подключайте себе в проект и делитесь фидбеком в issues на Гитхабе.
Это не первый опенсорс‑проект от Flutter‑команды Городских сервисов: прошлой осенью мы выкатили DI‑библиотеку yx_scope, а теперь с релизом yx_state начинаем формировать собственную экосистему архитектурных инструментов.
Каждая часть этой экосистемы решает конкретную архитектурную задачу и органично дополняет другие, но при этом каждая библиотека — независимый инструмент. Вы можете использовать их вместе или по отдельности: мы не предлагаем универсальный фреймворк, который заставляет подгонять весь проект под себя. Напротив — наши библиотеки легко встраиваются в любой проект и точечно закрывают свои задачи простым и понятным способом.
Мы продолжим развивать обе библиотеки и экспериментировать с новыми инструментами. Stay tuned!