Как отслеживать изменения состояния компонента в BLoC?

«Как отслеживать изменения состояния компонента в BLoC?» — вопрос из категории State Management, который задают на 29% собеседований Flutter Разработчик. Ниже — развёрнутый ответ с разбором ключевых моментов.

Ответ

В архитектуре BLoC (Business Logic Component) отслеживание изменений состояния строится на Streams. Вот полная реализация с использованием flutter_bloc пакета:

1. Определение состояний и событий:

// events/counter_event.dart
abstract class CounterEvent {}

class IncrementCounter extends CounterEvent {}
class DecrementCounter extends CounterEvent {}
class ResetCounter extends CounterEvent {}

// states/counter_state.dart
abstract class CounterState {
  const CounterState();
}

class CounterInitial extends CounterState {
  const CounterInitial();
}

class CounterLoading extends CounterState {}

class CounterLoaded extends CounterState {
  final int count;
  final DateTime lastUpdated;

  const CounterLoaded(this.count, this.lastUpdated);

  @override
  bool operator ==(Object other) {
    return identical(this, other) ||
      other is CounterLoaded &&
      runtimeType == other.runtimeType &&
      count == other.count &&
      lastUpdated == other.lastUpdated;
  }

  @override
  int get hashCode => count.hashCode ^ lastUpdated.hashCode;
}

class CounterError extends CounterState {
  final String message;

  const CounterError(this.message);
}

2. Реализация BLoC:

// bloc/counter_bloc.dart
import 'package:bloc/bloc.dart';
import 'package:meta/meta.dart';

class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc() : super(const CounterInitial()) {
    on<IncrementCounter>(_onIncrement);
    on<DecrementCounter>(_onDecrement);
    on<ResetCounter>(_onReset);
  }

  Future<void> _onIncrement(
    IncrementCounter event,
    Emitter<CounterState> emit,
  ) async {
    try {
      emit(CounterLoading());

      // Имитация асинхронной операции
      await Future.delayed(Duration(milliseconds: 100));

      if (state is CounterLoaded) {
        final currentState = state as CounterLoaded;
        emit(CounterLoaded(
          currentState.count + 1,
          DateTime.now(),
        ));
      } else {
        emit(CounterLoaded(1, DateTime.now()));
      }
    } catch (e) {
      emit(CounterError('Ошибка при инкременте: $e'));
    }
  }

  Future<void> _onDecrement(
    DecrementCounter event,
    Emitter<CounterState> emit,
  ) async {
    if (state is CounterLoaded) {
      final currentState = state as CounterLoaded;
      if (currentState.count > 0) {
        emit(CounterLoaded(
          currentState.count - 1,
          DateTime.now(),
        ));
      }
    }
  }

  Future<void> _onReset(
    ResetCounter event,
    Emitter<CounterState> emit,
  ) async {
    emit(CounterLoaded(0, DateTime.now()));
  }
}

3. Отслеживание изменений в UI:

// main.dart или виджет
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

class CounterPage extends StatelessWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => CounterBloc(),
      child: Scaffold(
        appBar: AppBar(title: Text('BLoC Counter')),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              // BlocBuilder перестраивает виджет при изменении состояния
              BlocBuilder<CounterBloc, CounterState>(
                builder: (context, state) {
                  return Column(
                    children: [
                      if (state is CounterLoading)
                        CircularProgressIndicator(),

                      if (state is CounterLoaded)
                        Column(
                          children: [
                            Text(
                              'Счётчик: ${state.count}',
                              style: TextStyle(fontSize: 48),
                            ),
                            Text(
                              'Обновлено: ${state.lastUpdated.toString()}',
                              style: TextStyle(fontSize: 12, color: Colors.grey),
                            ),
                          ],
                        ),

                      if (state is CounterError)
                        Text(
                          state.message,
                          style: TextStyle(color: Colors.red),
                        ),
                    ],
                  );
                },
              ),

              SizedBox(height: 20),

              // BlocListener для side effects (навигация, показ диалогов)
              BlocListener<CounterBloc, CounterState>(
                listener: (context, state) {
                  if (state is CounterLoaded && state.count == 10) {
                    ScaffoldMessenger.of(context).showSnackBar(
                      SnackBar(content: Text('Достигнут счётчик 10!')),
                    );
                  }
                },
                child: SizedBox.shrink(),
              ),

              Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  ElevatedButton(
                    onPressed: () {
                      context.read<CounterBloc>().add(IncrementCounter());
                    },
                    child: Text('+'),
                  ),
                  SizedBox(width: 20),
                  ElevatedButton(
                    onPressed: () {
                      context.read<CounterBloc>().add(DecrementCounter());
                    },
                    child: Text('-'),
                  ),
                  SizedBox(width: 20),
                  ElevatedButton(
                    onPressed: () {
                      context.read<CounterBloc>().add(ResetCounter());
                    },
                    child: Text('Сброс'),
                  ),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }
}

4. Cubit как упрощённая альтернатива:

// cubit/counter_cubit.dart
import 'package:flutter_bloc/flutter_bloc.dart';

class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);

  void increment() => emit(state + 1);
  void decrement() => emit(state - 1);
  void reset() => emit(0);
}

// Использование в UI:
BlocBuilder<CounterCubit, int>(
  builder: (context, count) {
    return Text('Счётчик: $count');
  },
);

5. Мониторинг и отладка:

void main() {
  Bloc.observer = SimpleBlocObserver();
  runApp(MyApp());
}

class SimpleBlocObserver extends BlocObserver {
  @override
  void onCreate(BlocBase bloc) {
    super.onCreate(bloc);
    print('Создан: ${bloc.runtimeType}');
  }

  @override
  void onEvent(Bloc bloc, Object? event) {
    super.onEvent(bloc, event);
    print('Событие: ${event.runtimeType} в ${bloc.runtimeType}');
  }

  @override
  void onChange(BlocBase bloc, Change change) {
    super.onChange(bloc, change);
    print('Изменение в ${bloc.runtimeType}: $change');
  }

  @override
  void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
    print('Ошибка в ${bloc.runtimeType}: $error');
    super.onError(bloc, error, stackTrace);
  }
}

Ключевые принципы:

  1. State — иммутабельный — каждое изменение создаёт новый объект состояния
  2. Event — триггеры изменений — пользовательские действия или системные события
  3. Bloc/Cubit — бизнес-логика — преобразует события в новые состояния
  4. BlocBuilder/BlocListener — реактивность — автоматическое обновление UI
  5. BlocProvider — dependency injection — предоставляет экземпляр BLoC в дереве виджетов

Преимущества подхода:

  • Чёткое разделение бизнес-логики и представления
  • Предсказуемое управление состоянием
  • Легко тестируется (можно тестировать BLoC без UI)
  • Поддержка undo/redo через отслеживание состояний
  • Интеграция с hydrated_bloc для сохранения состояния