
Управление состоянием — один из ключевых аспектов разработки приложений на Flutter. Часто для этой задачи выбирают тяжелые и многофункциональные решения вроде BLoC, Riverpod или GetX. Однако во многих проектах подобная инфраструктура избыточна: не каждое приложение требует сложной архитектуры и дополнительного уровня абстракции.
В данной статье мы расскажем про встроенные инструменты Flutter, которые позволяют реализовать надежный и предсказуемый state-менеджмент без сторонних фреймворков. Вы узнаете, как использовать ValueNotifier и Provider для удобной работы с состоянием и когда такой подход является оптимальным.
Ключевые инструменты
Для реализации простого управления состоянием во Flutter можно использовать инструменты, которые уже доступны в стандартной экосистеме. В данном подходе основную роль выполняют два механизма, обеспечивающие обновление данных и их связь с интерфейсом.
• ValueNotifier — инструмент для хранения одного значения (числа, строки или объекта) и уведомления подписчиков при его изменении. Работает по принципу обновления значения и автоматической передачи этого события слушателям.
• ValueListenableProvider — компонент из пакета provider, который подписывается на ValueNotifier и перестраивает зависимые виджеты при каждом изменении значения. Это позволяет поддерживать интерфейс в актуальном состоянии без дополнительного кода.
Другие простые встроенные варианты
После рассмотрения подхода на основе ValueNotifier и Provider полезно упомянуть и другие встроенные механизмы, которые Flutter предоставляет для управления состоянием. Они решают более узкие задачи и подходят в ситуациях, когда требуется минимальное вмешательство в архитектуру и простая реакция интерфейса на изменения данных.
Управление локальным состоянием с помощью setState()
Самый простой способ управления состоянием — встроенный метод setState() во Flutter. Он идеально подходит для управления локальным состоянием одного виджета.
dart
class SimpleCounter extends StatefulWidget {
@override
_SimpleCounterState createState() => _SimpleCounterState();
}
class _SimpleCounterState extends State<SimpleCounter> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text('$_counter'),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
child: Icon(Icons.add),
),
);
}
}
Преимущества:
• Предельно простой и интуитивно понятный механизм.
• Не требует подключения сторонних пакетов.
• Подходит для небольших виджетов с минимальной логикой.
Недостатки:
• Не предназначен для передачи состояния между разными виджетами.
• Частые обновления могут вызывать полное перестроение виджета, что снижает эффективность.
Передача данных по дереву через InheritedWidget
InheritedWidget — это базовый механизм Flutter для эффективной передачи данных вниз по дереву виджетов. Provider построен на его основе.
dart
class MyInheritedData extends InheritedWidget {
final int counter;
final Function() increment;
MyInheritedData({
required this.counter,
required this.increment,
required Widget child,
}) : super(child: child);
@override
bool updateShouldNotify(MyInheritedData oldWidget) {
return counter != oldWidget.counter;
}
static MyInheritedData of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<MyInheritedData>()!;
}
}
// Использование в виджете
Text('${MyInheritedData.of(context).counter}')
Когда использовать:
Для простой передачи данных вглубь дерева виджетов
Когда нужно избежать добавления сторонних зависимостей
Для изучения того, как работают более высокоуровневые решения
Управление сложным состоянием через ChangeNotifier
Если ValueNotifier работает с одним значением, то ChangeNotifier может управлять состоянием целого объекта с множеством полей.
dart
class UserSettings extends ChangeNotifier {
String _username = 'Гость';
ThemeMode _themeMode = ThemeMode.light;
bool _notificationsEnabled = true;
String get username => _username;
ThemeMode get themeMode => _themeMode;
bool get notificationsEnabled => _notificationsEnabled;
void updateUsername(String newName) {
_username = newName;
notifyListeners(); // Уведомляем всех слушателей
}
void toggleTheme() {
_themeMode = _themeMode == ThemeMode.light
? ThemeMode.dark
: ThemeMode.light;
notifyListeners();
}
void toggleNotifications() {
_notificationsEnabled = !_notificationsEnabled;
notifyListeners();
}
}
// В приложении
ChangeNotifierProvider(
create: (_) => UserSettings(),
child: MyApp(),
);
Пример базового применения: счетчик на основе ValueNotifier
Традиционно знакомство с управлением состоянием во Flutter начинают с простого счетчика. В данном разделе рассмотрим, как реализовать этот пример с использованием ValueNotifier, отделив логику от интерфейса и обеспечив корректное обновление данных.
Создание сервиса состояния
В файле counter_service.dart создаём класс, который будет содержать состояние и логику его изменения. Он выступает в роли хранилища, к которому будет обращаться интерфейс.
import 'package:flutter/foundation.dart';
// Используется ValueNotifier<int>, где хранимым значением является целое число.
// Это значение отслеживается и при его изменении автоматически уведомляются слушатели.
class CounterService extends ValueNotifier<int> {
// В конструкторе вызывается базовый конструктор ValueNotifier
// и задаётся начальное значение состояния (0).
CounterService() : super(0);
// Методы для изменения текущего состояния.
void increment() {
value++; // Изменение значения вызывает автоматическое уведомление слушателей.
}
void decrement() {
value--;
}
// Так как класс наследуется от ValueNotifier, вызов notifyListeners()
// осуществляется автоматически при обновлении value.
}
Обеспечиваем доступ к сервису через Provider
На верхнем уровне нашего приложения (обычно в main.dart или app.dart) размещаем созданный сервис в дереве виджетов с помощью Provider. Это позволит любому виджету ниже по дереву получать к нему доступ.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'counter_service.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
// Используется ChangeNotifierProvider, так как ValueNotifier
// наследуется от ChangeNotifier и поддерживает механизм уведомлений.
return ChangeNotifierProvider(
create: (_) => CounterService(), // Инициализация экземпляра сервиса состояния.
child: const MaterialApp(
home: MyHomePage(),
),
);
}
}
Используем и отслеживаем состояние в интерфейсе
Теперь любой виджет ниже по дереву получает доступ к CounterService и может реагировать на изменения состояния.
Создадим файл my_home_page.dart:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'counter_service.dart';
class MyHomePage extends StatelessWidget {
const MyHomePage({super.key});
@override
Widget build(BuildContext context) {
// Используем Consumer.
// Consumer<T> отслеживает изменения в провайдере типа T (в данном случае CounterService).
// При изменении значения в CounterService перестраивается только участок интерфейса внутри билдера Consumer.
return Scaffold(
appBar: AppBar(title: const Text('Простой счетчик')),
body: Center(
child: Consumer<CounterService>(
builder: (_, counterService, __) {
// Внутри билдера доступен актуальный экземпляр CounterService.
// Получаем текущее значение и отображаем его.
return Text(
'${counterService.value}', // Текущее значение из ValueNotifier.
style: Theme.of(context).textTheme.headlineMedium,
);
},
),
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(
onPressed: () {
// Получаем сервис и вызываем метод изменения состояния.
// Provider.of с listen: false не вызывает перестроение текущего виджета,
// поскольку здесь требуется только выполнение действия.
Provider.of<CounterService>(context, listen: false).increment();
},
child: const Icon(Icons.add),
),
const SizedBox(height: 10),
FloatingActionButton(
onPressed: () {
Provider.of<CounterService>(context, listen: false).decrement();
},
child: const Icon(Icons.remove),
),
],
),
);
}
}
Практический пример: переключение темы
Рассмотрим реализацию переключения между светлой и тёмной темой с использованием того же подхода к управлению состоянием.
theme_service.dart
Сервис, отвечающий за хранение и переключение темы приложения.
import 'package:flutter/material.dart';
class ThemeService extends ValueNotifier<ThemeMode> {
ThemeService() : super(ThemeMode.light);
void toggleTheme() {
value = value == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;
}
// Геттер для определения текущего режима темы.
bool get isDarkMode => value == ThemeMode.dark;
}
main.dart
Подключение ThemeService к дереву виджетов и использование его в MaterialApp.
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => ThemeService(), // Передаём ThemeService в дерево виджетов.
child: Consumer<ThemeService>(
// Consumer оборачивает MaterialApp, чтобы приложение обновлялось при смене темы.
builder: (context, themeService, child) {
return MaterialApp(
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
themeMode: themeService.value, // Текущее значение темы из ThemeService.
home: const MyHomePage(),
);
},
),
);
}
}
Кнопка переключения темы в MyHomePage
Кнопка, вызывающая смену темы через ThemeService.
// ... inside MyHomePage's build method ...
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Кнопка смены темы.
FloatingActionButton(
onPressed: () {
Provider.of<ThemeService>(context, listen: false).toggleTheme();
},
child: const Icon(Icons.brightness_6),
),
// ... остальные кнопки ...
],
),
Комбинированный пример: список задач (To-Do List)
Давайте создадим более практичный пример — простое приложение для управления списком задач, используя только ValueNotifier и Provider.
Модель данных и сервис состояния
dart
// todo_model.dart
class TodoItem {
final String id;
final String title;
bool completed;
TodoItem({
required this.id,
required this.title,
this.completed = false,
});
}
// todo_service.dart
import 'package:flutter/foundation.dart';
class TodoService extends ValueNotifier<List<TodoItem>> {
TodoService() : super([]);
void addTodo(String title) {
value = [
...value,
TodoItem(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: title,
),
];
}
void toggleTodo(String id) {
value = value.map((todo) {
if (todo.id == id) {
return TodoItem(
id: todo.id,
title: todo.title,
completed: !todo.completed,
);
}
return todo;
}).toList();
}
void removeTodo(String id) {
value = value.where((todo) => todo.id != id).toList();
}
int get pendingCount => value.where((todo) => !todo.completed).length;
int get completedCount => value.where((todo) => todo.completed).length;
}
Интерфейс приложения
dart
// todo_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'todo_service.dart';
class TodoScreen extends StatelessWidget {
final TextEditingController _controller = TextEditingController();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Список задач'),
actions: [
Consumer<TodoService>(
builder: (context, service, child) {
return Chip(
label: Text('${service.pendingCount}'),
backgroundColor: Colors.blue,
labelStyle: TextStyle(color: Colors.white),
);
},
),
SizedBox(width: 10),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: InputDecoration(
hintText: 'Добавить новую задачу...',
border: OutlineInputBorder(),
),
),
),
SizedBox(width: 10),
ElevatedButton(
onPressed: () {
if (_controller.text.isNotEmpty) {
Provider.of<TodoService>(context, listen: false)
.addTodo(_controller.text);
_controller.clear();
}
},
child: Text('Добавить'),
),
],
),
),
Expanded(
child: Consumer<TodoService>(
builder: (context, service, child) {
if (service.value.isEmpty) {
return Center(
child: Text(
'Нет задач',
style: TextStyle(fontSize: 18, color: Colors.grey),
),
);
}
return ListView.builder(
itemCount: service.value.length,
itemBuilder: (context, index) {
final todo = service.value[index];
return ListTile(
leading: Checkbox(
value: todo.completed,
onChanged: (_) {
Provider.of<TodoService>(context, listen: false)
.toggleTodo(todo.id);
},
),
title: Text(
todo.title,
style: TextStyle(
decoration: todo.completed
? TextDecoration.lineThrough
: null,
color: todo.completed ? Colors.grey : null,
),
),
trailing: IconButton(
icon: Icon(Icons.delete, color: Colors.red),
onPressed: () {
Provider.of<TodoService>(context, listen: false)
.removeTodo(todo.id);
},
),
);
},
);
},
),
),
],
),
);
}
}
Оптимизация производительности
Оптимизация обновлений интерфейса играет важную роль при работе с состоянием во Flutter. Даже при использовании простых механизмов важно контролировать, какие именно части дерева виджетов должны перестраиваться при изменении данных. Flutter и Provider предлагают инструменты, позволяющие минимизировать лишние перестроения и повысить производительность приложения.
Selector вместо Consumer для точных обновлений
Consumer перестраивает виджет при любом изменении состояния. Если в вашем объекте состояния много полей, но виджет зависит только от одного из них, используйте Selector:
dart
// Вместо Consumer<UserProfile>:
Selector<UserProfile, String>(
selector: (context, profile) => profile.username,
builder: (context, username, child) {
return Text('Привет, $username!');
},
)
// Виджет перестроится только когда изменится username,
// даже если другие поля UserProfile изменятся.
Кеширование виджетов с помощью child
Используйте параметр child в Consumer и Selector для кеширования статических частей виджета:
dart
Consumer<MyService>(
builder: (context, service, child) {
return Column(
children: [
// Эта часть не будет перестраиваться при каждом обновлении
child!,
// Эта часть перестраивается
Text('${service.value}'),
],
);
},
child: const HeaderWidget(), // Статический виджет
)
Когда какой подход использовать?
Выбор подхода к управлению состоянием зависит от конкретной задачи и масштаба вашего приложения. Вот подробное текстовое руководство по выбору.
1. Для локального состояния одного виджета
Используйте setState() — это самый простой и прямой способ. Он идеально подходит, когда состояние нужно только внутри одного виджета и нигде больше не используется. Например, для отслеживания, раскрыт ли аккордеон или активно ли переключатель. Не усложняйте архитектуру без необходимости.
2. Для связи нескольких виджетов на одном экране
Используйте ValueNotifier с ValueListenableBuilder — когда несколько виджетов на одном экране зависят от одного и того же значения. Например, если у вас есть счетчик, который должен отображаться в заголовке и в теле страницы одновременно. Этот подход создает легкую реактивную связь без лишних абстракций.
3. Для состояния, используемого в разных частях приложения
Используйте ValueNotifier или ChangeNotifier вместе с Provider — когда ваше состояние должно быть доступно из различных экранов или глубоко вложенных виджетов. Классические примеры: сервис аутентификации (информация о пользователе), корзина покупок в интернет-магазине или настройки темы приложения. Provider обеспечивает четкое и централизованное управление таким состоянием.
4. Для управления сложным объектом состояния
Используйте ChangeNotifier — когда ваше состояние представляет собой не просто число или строку, а целый объект с множеством полей и методов (например, профиль пользователя с именем, email, аватаркой и методами для обновления). ChangeNotifier позволяет инкапсулировать всю логику и уведомлять об изменениях единым вызовом notifyListeners().
5. Для учебных целей и минимальных зависимостей
Используйте базовый InheritedWidget — если вы хотите глубоко понять, как работают механизмы передачи данных во Flutter, или если в проекте критически важно избегать любых сторонних пакетов. Этот подход является фундаментом, на котором построены все высокоуровневые решения, включая Provider.
Практические рекомендации по выбору
1 Начинайте с самого простого решения, которое решает вашу текущую задачу. Не прогнозируйте избыточную сложность.
2 Эволюционируйте постепенно. Начните с setState() для виджета. Если состояние «переросло» его, поднимите его с помощью ValueNotifier и Provider на уровень выше.
3 Логически группируйте состояние. Не создавайте один огромный сервис на все приложение. Лучше иметь несколько небольших и отвественных сервисов: AuthService, CartService, SettingsService.
4 Избегайте глубокой вложенности Provider'ов в дереве виджетов. Если вам нужно предоставить несколько сервисов, используйте MultiProvider для чистоты кода.
5 Помните, что многие приложения успешно живут на ValueNotifier/ChangeNotifier и Provider. Переход на BLoC, Riverpod или GetX оправдан только тогда, когда вы реально упираетесь в ограничения более простых инструментов, например, при очень сложной асинхронной бизнес-логике или в крупной команде, где нужна строгая архитектура.
Краткий итог: Используйте setState для изолированного, ValueNotifier для общего на экране, а Provider — для общего в приложении. Переходите к более сложным фреймворкам только тогда, когда исчерпали возможности этой простой и эффективной связки.
Выводы: Дальнейшие шаги
Когда текущий стек перестаёт покрывать задачи, можно перейти к более продвинутым инструментам:
Riverpod — современная альтернатива Provider с лучшей безопасностью типов.
BLoC — строгая архитектура для больших команд и сложной бизнес-логики.
GetX — минималистичный и быстрый вариант, когда важна лаконичность.
Но важно помнить: большинство приложений не достигают уровня сложности, требующего этих инструментов. Стек ValueNotifier + Provider остаётся оптимальным для широкого спектра задач.