Управление состоянием — один из ключевых аспектов разработки приложений на 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 остаётся оптимальным для широкого спектра задач.

Комментарии (0)