Список статей

Управление состоянием с паттерном BLoC во Flutter

5 марта 2025 | Bekhzod

Flutter BLoC State Management

Управление состоянием — один из наиболее важных аспектов разработки на Flutter. Среди различных решений для управления состоянием паттерн BLoC (Business Logic Component) зарекомендовал себя как мощный и масштабируемый подход. Давайте разберёмся, как BLoC может преобразить ваши Flutter-приложения.

Что такое BLoC?

BLoC расшифровывается как Business Logic Component (компонент бизнес-логики). Это паттерн проектирования, который отделяет бизнес-логику от слоя пользовательского интерфейса, делая код более тестируемым, переиспользуемым и поддерживаемым. Паттерн использует Streams для обработки потока данных и изменений состояния.

Архитектура BLoC

Архитектура BLoC: разделение UI и бизнес-логики

Почему стоит выбрать BLoC?

BLoC обладает рядом преимуществ, которые выделяют его среди других решений для управления состоянием:

  • Разделение ответственности: бизнес-логика полностью отделена от кода UI
  • Тестируемость: легко писать юнит-тесты для бизнес-логики без зависимости от UI
  • Переиспользуемость: BLoC можно использовать в нескольких виджетах и экранах
  • Предсказуемость: однонаправленный поток данных делает изменения состояния предсказуемыми
  • Независимость от платформы: логику BLoC можно переиспользовать между Flutter Web, Mobile и Desktop

Начало работы с BLoC

Для начала добавьте пакет BLoC в pubspec.yaml:

dependencies:
  flutter_bloc: ^8.1.3
  equatable: ^2.0.5

Основные концепции

1. События (Events)

События — это входные данные для BLoC. Они представляют действия пользователя или системные события, которые вызывают изменения состояния. Вот как определяются события:

abstract class CounterEvent extends Equatable {
  const CounterEvent();

  @override
  List<Object> get props => [];
}

class IncrementEvent extends CounterEvent {}

class DecrementEvent extends CounterEvent {}

class ResetEvent extends CounterEvent {}

2. Состояния (States)

Состояния — это выходные данные BLoC. Они описывают текущее состояние вашего приложения:

class CounterState extends Equatable {
  final int count;
  final bool isLoading;

  const CounterState({
    this.count = 0,
    this.isLoading = false,
  });

  CounterState copyWith({
    int? count,
    bool? isLoading,
  }) {
    return CounterState(
      count: count ?? this.count,
      isLoading: isLoading ?? this.isLoading,
    );
  }

  @override
  List<Object> get props => [count, isLoading];
}

3. Создание BLoC

Теперь создадим BLoC, который связывает события с состояниями:

class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc() : super(const CounterState()) {
    on<IncrementEvent>(_onIncrement);
    on<DecrementEvent>(_onDecrement);
    on<ResetEvent>(_onReset);
  }

  void _onIncrement(IncrementEvent event, Emitter<CounterState> emit) {
    emit(state.copyWith(count: state.count + 1));
  }

  void _onDecrement(DecrementEvent event, Emitter<CounterState> emit) {
    emit(state.copyWith(count: state.count - 1));
  }

  void _onReset(ResetEvent event, Emitter<CounterState> emit) {
    emit(const CounterState());
  }
}

Поток архитектуры BLoC

Паттерн BLoC следует однонаправленному потоку данных:

  1. Слой UI: пользователь взаимодействует с интерфейсом и инициирует событие
  2. Событие: событие отправляется в BLoC
  3. BLoC: обрабатывает событие и выполняет бизнес-логику
  4. Состояние: BLoC генерирует новое состояние на основе результата
  5. Обновление UI: интерфейс перестраивается на основе нового состояния

Ключевые компоненты

BlocProvider

BlocProvider — это виджет, предоставляющий BLoC своим дочерним элементам. Он автоматически управляет созданием и уничтожением BLoC:

void main() {
  runApp(
    BlocProvider(
      create: (context) => CounterBloc(),
      child: MyApp(),
    ),
  );
}

BlocBuilder

BlocBuilder — виджет, который перестраивается при получении новых состояний:

BlocBuilder<CounterBloc, CounterState>(
  builder: (context, state) {
    return Text(
      '${state.count}',
      style: Theme.of(context).textTheme.headline4,
    );
  },
)

BlocListener

BlocListener используется для побочных эффектов, таких как навигация или отображение snackbar:

BlocListener<CounterBloc, CounterState>(
  listener: (context, state) {
    if (state.count == 10) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Вы достигли 10!')),
      );
    }
  },
  child: Container(),
)

BlocConsumer

BlocConsumer объединяет BlocBuilder и BlocListener:

BlocConsumer<CounterBloc, CounterState>(
  listener: (context, state) {
    if (state.count == 10) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Вы достигли 10!')),
      );
    }
  },
  builder: (context, state) {
    return Text('${state.count}');
  },
)

Полный пример: приложение-счётчик

Вот полный пример, объединяющий всё воедино:

class CounterPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('BLoC Счётчик')),
      body: Center(
        child: BlocBuilder<CounterBloc, CounterState>(
          builder: (context, state) {
            return Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text(
                  'Значение счётчика:',
                  style: TextStyle(fontSize: 20),
                ),
                Text(
                  '${state.count}',
                  style: TextStyle(
                    fontSize: 48,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                SizedBox(height: 20),
                Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    FloatingActionButton(
                      onPressed: () {
                        context.read<CounterBloc>()
                            .add(DecrementEvent());
                      },
                      child: Icon(Icons.remove),
                    ),
                    SizedBox(width: 20),
                    FloatingActionButton(
                      onPressed: () {
                        context.read<CounterBloc>()
                            .add(IncrementEvent());
                      },
                      child: Icon(Icons.add),
                    ),
                  ],
                ),
              ],
            );
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          context.read<CounterBloc>().add(ResetEvent());
        },
        child: Icon(Icons.refresh),
      ),
    );
  }
}

Реальный пример: API-запрос с BLoC

Рассмотрим более практичный пример с API-запросами:

// События
abstract class UserEvent extends Equatable {
  @override
  List<Object> get props => [];
}

class FetchUserEvent extends UserEvent {}

// Состояния
abstract class UserState extends Equatable {
  @override
  List<Object> get props => [];
}

class UserInitial extends UserState {}

class UserLoading extends UserState {}

class UserLoaded extends UserState {
  final User user;
  UserLoaded(this.user);
  
  @override
  List<Object> get props => [user];
}

class UserError extends UserState {
  final String message;
  UserError(this.message);
  
  @override
  List<Object> get props => [message];
}

// BLoC
class UserBloc extends Bloc<UserEvent, UserState> {
  final UserRepository repository;

  UserBloc(this.repository) : super(UserInitial()) {
    on<FetchUserEvent>(_onFetchUser);
  }

  Future<void> _onFetchUser(
    FetchUserEvent event,
    Emitter<UserState> emit,
  ) async {
    emit(UserLoading());
    try {
      final user = await repository.fetchUser();
      emit(UserLoaded(user));
    } catch (e) {
      emit(UserError(e.toString()));
    }
  }
}

Использование API BLoC в UI

class UserProfilePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Профиль пользователя')),
      body: BlocBuilder<UserBloc, UserState>(
        builder: (context, state) {
          if (state is UserLoading) {
            return Center(child: CircularProgressIndicator());
          } else if (state is UserLoaded) {
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text('Имя: ${state.user.name}'),
                  Text('Email: ${state.user.email}'),
                ],
              ),
            );
          } else if (state is UserError) {
            return Center(child: Text('Ошибка: ${state.message}'));
          }
          return Center(child: Text('Нажмите кнопку для загрузки пользователя'));
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          context.read<UserBloc>().add(FetchUserEvent());
        },
        child: Icon(Icons.refresh),
      ),
    );
  }
}

Лучшие практики

  • Единственная ответственность: каждый BLoC должен отвечать за одну конкретную функцию или область
  • Неизменяемые состояния: всегда используйте неизменяемые классы состояний с Equatable
  • Именование событий: используйте чёткие, основанные на действиях названия (например, LoginButtonPressed)
  • Именование состояний: используйте описательные названия, отражающие состояние (например, LoginLoading, LoginSuccess)
  • Обработка ошибок: всегда включайте состояния ошибок и обрабатывайте их корректно
  • Тестирование: пишите полноценные юнит-тесты для ваших BLoC

Тестирование BLoC

Одно из главных преимуществ BLoC — это тестируемость. Вот как можно его протестировать:

void main() {
  group('CounterBloc', () {
    late CounterBloc counterBloc;

    setUp(() {
      counterBloc = CounterBloc();
    });

    tearDown(() {
      counterBloc.close();
    });

    test('начальное состояние — CounterState с count равным 0', () {
      expect(counterBloc.state, CounterState(count: 0));
    });

    blocTest<CounterBloc, CounterState>(
      'генерирует [CounterState(count: 1)] при добавлении IncrementEvent',
      build: () => counterBloc,
      act: (bloc) => bloc.add(IncrementEvent()),
      expect: () => [CounterState(count: 1)],
    );

    blocTest<CounterBloc, CounterState>(
      'генерирует [CounterState(count: -1)] при добавлении DecrementEvent',
      build: () => counterBloc,
      act: (bloc) => bloc.add(DecrementEvent()),
      expect: () => [CounterState(count: -1)],
    );
  });
}

BLoC и другие решения для управления состоянием

BLoC vs Provider

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

BLoC vs Riverpod

Riverpod более гибок и обеспечивает безопасность на этапе компиляции, тогда как BLoC предлагает более структурированный подход с чёткими паттернами для событий и состояний.

BLoC vs GetX

GetX легковесен и прост в освоении, однако BLoC лучше тестируется и следует устоявшимся паттернам проектирования, что делает его предпочтительным для крупных приложений.

Когда использовать BLoC?

BLoC идеально подходит для:

  • Крупных приложений со сложной бизнес-логикой
  • Проектов с высокими требованиями к тестируемости
  • Команд, ценящих структурированную архитектуру
  • Приложений, требующих переиспользования логики на разных платформах
  • Проектов, над которыми работает несколько разработчиков одновременно

Заключение

Паттерн BLoC — это мощное решение для управления состоянием, привносящее структуру, тестируемость и масштабируемость во Flutter-приложения. Несмотря на более высокий порог вхождения по сравнению с более простыми решениями, преимущества становятся очевидными по мере роста приложения.

Отделяя бизнес-логику от UI, BLoC делает код более поддерживаемым и удобным для тестирования. Если вы создаёте продакшн-приложение на Flutter, особенно с комплексными требованиями, BLoC определённо заслуживает вашего внимания.

Список статей