Gestión de Estado Reactiva con BLoC en Flutter: Un Tutorial Completo
Este tutorial te guiará a través de la implementación del patrón BLoC (Business Logic Component) para una gestión de estado reactiva en Flutter. Aprenderás desde los fundamentos hasta la creación de una aplicación de ejemplo, manejando eventos, estados y datos de manera eficiente y escalable.
¡Bienvenido a este tutorial completo sobre la gestión de estado con BLoC en Flutter! 🎉 Si has trabajado con Flutter, sabes que la gestión de estado es un pilar fundamental para construir aplicaciones robustas y mantenibles. BLoC, o Business Logic Component, es uno de los patrones más populares y poderosos para lograrlo, ofreciendo una clara separación de preocupaciones y una arquitectura reactiva.
¿Qué es BLoC y por qué usarlo? 🧐
BLoC es un patrón de arquitectura que ayuda a separar la lógica de negocio de la interfaz de usuario. Su principio fundamental es que todo en la aplicación debe ser un Stream de eventos o un Stream de estados. Esto significa que la lógica de negocio procesa eventos de entrada y emite nuevos estados como salida.
Ventajas de BLoC:
- Separación de preocupaciones: La lógica de negocio está completamente desacoplada de la UI, haciendo el código más modular y fácil de entender.
- Reusabilidad: Los BLoCs pueden ser reutilizados en diferentes partes de la aplicación o incluso en diferentes proyectos.
- Testabilidad: Al tener una lógica de negocio aislada, es mucho más sencillo escribir pruebas unitarias para cada BLoC.
- Predecibilidad: Dada una serie de eventos, siempre obtendremos el mismo estado de salida, lo que facilita la depuración y el mantenimiento.
- Escalabilidad: Ideal para proyectos grandes donde el estado puede volverse complejo rápidamente.
Conceptos Clave de BLoC 🔑
Antes de sumergirnos en el código, es crucial entender los componentes básicos del ecosistema BLoC:
- Eventos (Events): Representan acciones o entradas del usuario (o de otras partes de la aplicación). Son la entrada al BLoC.
- Estados (States): Representan el estado actual de la aplicación o de una parte de ella. Son la salida del BLoC.
- BLoC: La clase central que toma Streams de eventos, los procesa utilizando lógica de negocio y emite Streams de estados.
bloc_builder: Un widget que reacciona a los cambios de estado emitidos por un BLoC y reconstruye una parte de la interfaz de usuario.bloc_listener: Un widget que escucha los cambios de estado de un BLoC y realiza acciones una sola vez (navegación, mostrar un snackbar, etc.) sin reconstruir la UI.bloc_provider: Un widget que hace un BLoC disponible para sus widgets descendientes en el árbol de widgets.
Preparación del Entorno 🛠️
Para seguir este tutorial, necesitarás tener Flutter instalado y configurado. Además, utilizaremos el paquete flutter_bloc, que es la implementación oficial y más popular del patrón BLoC en Flutter.
1. Crear un Nuevo Proyecto Flutter
Si aún no tienes un proyecto, crea uno con el siguiente comando:
flutter create bloc_counter_app
cd bloc_counter_app
2. Añadir Dependencias
Abre el archivo pubspec.yaml y añade las siguientes dependencias bajo dependencies:
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^8.1.3
equatable: ^2.0.5 # Utilizado para comparar objetos de forma sencilla (eventos y estados)
Guarda el archivo y ejecuta flutter pub get en tu terminal para obtener los paquetes.
Ejemplo Práctico: Un Contador Simple con BLoC 🔢
Comenzaremos con un ejemplo clásico: una aplicación de contador. Esta aplicación tendrá dos botones (+ y -) para incrementar y decrementar un número, y un texto que mostrará el valor actual.
1. Definir Eventos (Events) 📝
Los eventos representan las intenciones del usuario. En nuestro contador, tendremos dos eventos: incrementar y decrementar.
Crea una carpeta lib/counter y dentro un archivo counter_event.dart:
part of 'counter_bloc.dart';
@immutable
abstract class CounterEvent extends Equatable {
const CounterEvent();
@override
List<Object> get props => [];
}
class IncrementCounter extends CounterEvent {
const IncrementCounter();
@override
String toString() => 'IncrementCounter';
}
class DecrementCounter extends CounterEvent {
const DecrementCounter();
@override
String toString() => 'DecrementCounter';
}
2. Definir Estados (States) 📊
El estado de nuestra aplicación es simplemente el valor actual del contador.
Crea un archivo counter_state.dart dentro de lib/counter:
part of 'counter_bloc.dart';
@immutable
class CounterState extends Equatable {
final int counterValue;
const CounterState(this.counterValue);
@override
List<Object> get props => [counterValue];
CounterState copyWith({
int? counterValue,
}) {
return CounterState(
counterValue ?? this.counterValue,
);
}
@override
String toString() => 'CounterState(counterValue: $counterValue)';
}
El método copyWith es una práctica común para crear un nuevo estado basado en el anterior, modificando solo las propiedades necesarias.
3. Crear el BLoC (Business Logic Component) 🧠
Ahora, implementaremos la lógica que transforma los eventos en estados. Este será el corazón de nuestro contador BLoC.
Crea un archivo counter_bloc.dart dentro de lib/counter:
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart';
part 'counter_event.dart';
part 'counter_state.dart';
class CounterBloc extends Bloc<CounterEvent, CounterState> {
CounterBloc() : super(const CounterState(0)) {
on<IncrementCounter>((event, emit) {
emit(CounterState(state.counterValue + 1));
});
on<DecrementCounter>((event, emit) {
emit(CounterState(state.counterValue - 1));
});
}
}
4. Integrar con la Interfaz de Usuario (UI) 🖼️
Finalmente, conectaremos nuestro BLoC con la UI. Usaremos BlocProvider para proporcionar el BLoC y BlocBuilder para reconstruir la UI cuando el estado cambie.
Modifica el archivo lib/main.dart:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:bloc_counter_app/counter/counter_bloc.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => CounterBloc(),
child: MaterialApp(
title: 'BLoC Counter App',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const CounterPage(),
),
);
}
}
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Contador con BLoC')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'El valor actual del contador es:',
),
BlocBuilder<CounterBloc, CounterState>(
builder: (context, state) {
return Text(
'${state.counterValue}',
style: Theme.of(context).textTheme.headlineMedium,
);
},
),
],
),
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
FloatingActionButton(
heroTag: 'incrementBtn',
onPressed: () => context.read<CounterBloc>().add(const IncrementCounter()),
tooltip: 'Incrementar',
child: const Icon(Icons.add),
),
const SizedBox(height: 10),
FloatingActionButton(
heroTag: 'decrementBtn',
onPressed: () => context.read<CounterBloc>().add(const DecrementCounter()),
tooltip: 'Decrementar',
child: const Icon(Icons.remove),
),
],
),
);
}
}
BlocListener para Efectos Secundarios 👂
En ocasiones, no queremos reconstruir la UI, sino realizar una acción de una sola vez en respuesta a un cambio de estado. Aquí es donde BlocListener entra en juego. Por ejemplo, mostrar un SnackBar o navegar a otra pantalla.
Vamos a añadir un BlocListener para mostrar un mensaje si el contador llega a 5 o -5.
Modifica CounterPage en main.dart:
// ... (código anterior)
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Contador con BLoC')),
body:
BlocListener<CounterBloc, CounterState>(
listener: (context, state) {
if (state.counterValue == 5) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('¡Llegaste a 5! 🎉')),
);
} else if (state.counterValue == -5) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('¡Llegaste a -5! 🥶')),
);
}
},
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'El valor actual del contador es:',
),
BlocBuilder<CounterBloc, CounterState>(
builder: (context, state) {
return Text(
'${state.counterValue}',
style: Theme.of(context).textTheme.headlineMedium,
);
},
),
],
),
),
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
FloatingActionButton(
heroTag: 'incrementBtn',
onPressed: () => context.read<CounterBloc>().add(const IncrementCounter()),
tooltip: 'Incrementar',
child: const Icon(Icons.add),
),
const SizedBox(height: 10),
FloatingActionButton(
heroTag: 'decrementBtn',
onPressed: () => context.read<CounterBloc>().add(const DecrementCounter()),
tooltip: 'Decrementar',
child: const Icon(Icons.remove),
),
],
),
);
}
}
Ahora, cuando el contador alcance 5 o -5, verás un SnackBar en la parte inferior de la pantalla sin que se reconstruya todo el widget principal. BlocListener es excelente para efectos secundarios que no implican cambios directos en la UI mostrada.
BlocConsumer para Combinar Listener y Builder 🔄
Si necesitas tanto escuchar eventos para efectos secundarios como construir UI en respuesta a cambios de estado, puedes usar BlocConsumer. Este widget combina las funcionalidades de BlocListener y BlocBuilder.
Vamos a modificar el ejemplo para usar BlocConsumer:
// ... (imports y MyApp igual)
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Contador con BLoC')),
body: BlocConsumer<CounterBloc, CounterState>(
listener: (context, state) {
if (state.counterValue == 5) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('¡Llegaste a 5! 🎉')),
);
} else if (state.counterValue == -5) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('¡Llegaste a -5! 🥶')),
);
}
},
builder: (context, state) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'El valor actual del contador es:',
),
Text(
'${state.counterValue}',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
);
},
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
FloatingActionButton(
heroTag: 'incrementBtn',
onPressed: () => context.read<CounterBloc>().add(const IncrementCounter()),
tooltip: 'Incrementar',
child: const Icon(Icons.add),
),
const SizedBox(height: 10),
FloatingActionButton(
heroTag: 'decrementBtn',
onPressed: () => context.read<CounterBloc>().add(const DecrementCounter()),
tooltip: 'Decrementar',
child: const Icon(Icons.remove),
),
],
),
);
}
}
Estructura de Carpetas Sugerida 📂
Para aplicaciones más grandes, una buena estructura de carpetas es crucial. Una estructura común para BLoC es organizar por características o módulos.
lib/
├── main.dart
├── app_config/
│ └── app_config.dart
├── common/
│ ├── widgets/
│ └── utils/
├── features/
│ ├── counter/
│ │ ├── bloc/
│ │ │ ├── counter_bloc.dart
│ │ │ ├── counter_event.dart
│ │ │ └── counter_state.dart
│ │ ├── models/
│ │ ├── views/
│ │ │ └── counter_page.dart
│ │ └── widgets/
│ ├── auth/
│ │ ├── bloc/
│ │ │ ├── auth_bloc.dart
│ │ │ ├── auth_event.dart
│ │ │ └── auth_state.dart
│ │ ├── data/
│ │ ├── models/
│ │ └── views/
│ └── settings/
│ └── ...
└── services/
├── api_service.dart
└── auth_service.dart
Esta estructura ayuda a mantener la lógica de negocio, los eventos, los estados y las vistas agrupados por funcionalidad, facilitando la navegación y el mantenimiento.
Patrón Repository con BLoC (Más Avanzado) 🚀
Para aplicaciones reales, el BLoC interactuará con repositorios que se encargan de obtener datos (desde APIs, bases de datos locales, etc.). Esto añade otra capa de abstracción y mejora la separación de preocupaciones.
1. Crear un CounterRepository
Crea un archivo lib/counter/data/counter_repository.dart:
class CounterRepository {
int _initialCounter = 0;
Future<int> fetchInitialCounter() async {
// Simula una llamada a API o base de datos
await Future.delayed(const Duration(seconds: 1));
return _initialCounter;
}
// Podríamos tener métodos para guardar el contador si fuera persistente
Future<void> saveCounter(int value) async {
// Simula guardar el contador
await Future.delayed(const Duration(milliseconds: 500));
_initialCounter = value; // Actualizar el valor en el repositorio
print('Contador guardado: $value');
}
}
2. Actualizar CounterEvent y CounterState para incluir carga
Modifica counter_event.dart:
// ... (código existente)
class LoadCounter extends CounterEvent {
const LoadCounter();
@override
String toString() => 'LoadCounter';
}
Modifica counter_state.dart para manejar un estado de carga:
// ... (código existente)
enum CounterStatus { initial, loading, success, failure }
class CounterState extends Equatable {
final int counterValue;
final CounterStatus status;
const CounterState(this.counterValue, {this.status = CounterStatus.initial});
@override
List<Object> get props => [counterValue, status];
CounterState copyWith({
int? counterValue,
CounterStatus? status,
}) {
return CounterState(
counterValue ?? this.counterValue,
status: status ?? this.status,
);
}
@override
String toString() => 'CounterState(counterValue: $counterValue, status: $status)';
}
3. Modificar CounterBloc para usar el Repository
Modifica counter_bloc.dart para inyectar el repositorio y manejar el evento LoadCounter:
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart';
import 'package:bloc_counter_app/counter/data/counter_repository.dart'; // Importa el repositorio
part 'counter_event.dart';
part 'counter_state.dart';
class CounterBloc extends Bloc<CounterEvent, CounterState> {
final CounterRepository _counterRepository; // Inyectar el repositorio
CounterBloc(this._counterRepository) : super(const CounterState(0)) {
on<IncrementCounter>((event, emit) {
final newValue = state.counterValue + 1;
emit(state.copyWith(counterValue: newValue));
_counterRepository.saveCounter(newValue); // Guardar el nuevo valor
});
on<DecrementCounter>((event, emit) {
final newValue = state.counterValue - 1;
emit(state.copyWith(counterValue: newValue));
_counterRepository.saveCounter(newValue); // Guardar el nuevo valor
});
on<LoadCounter>((event, emit) async {
emit(state.copyWith(status: CounterStatus.loading));
try {
final initialValue = await _counterRepository.fetchInitialCounter();
emit(state.copyWith(counterValue: initialValue, status: CounterStatus.success));
} catch (_) {
emit(state.copyWith(status: CounterStatus.failure));
}
});
}
}
4. Actualizar main.dart para proporcionar el Repository y LoadCounter
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:bloc_counter_app/counter/counter_bloc.dart';
import 'package:bloc_counter_app/counter/data/counter_repository.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return RepositoryProvider(
create: (context) => CounterRepository(),
child: BlocProvider(
create: (context) => CounterBloc(context.read<CounterRepository>())..add(const LoadCounter()), // Se inyecta el repositorio y se dispara el evento de carga
child: MaterialApp(
title: 'BLoC Counter App',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const CounterPage(),
),
),
);
}
}
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Contador con BLoC')),
body: BlocConsumer<CounterBloc, CounterState>(
listener: (context, state) {
if (state.status == CounterStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Error al cargar el contador 😥')),
);
} else if (state.counterValue == 5) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('¡Llegaste a 5! 🎉')),
);
} else if (state.counterValue == -5) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('¡Llegaste a -5! 🥶')),
);
}
},
builder: (context, state) {
if (state.status == CounterStatus.loading) {
return const Center(child: CircularProgressIndicator());
} else if (state.status == CounterStatus.failure) {
return const Center(child: Text('Error: No se pudo cargar el contador.'));
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'El valor actual del contador es:',
),
Text(
'${state.counterValue}',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
);
},
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
FloatingActionButton(
heroTag: 'incrementBtn',
onPressed: () => context.read<CounterBloc>().add(const IncrementCounter()),
tooltip: 'Incrementar',
child: const Icon(Icons.add),
),
const SizedBox(height: 10),
FloatingActionButton(
heroTag: 'decrementBtn',
onPressed: () => context.read<CounterBloc>().add(const DecrementCounter()),
tooltip: 'Decrementar',
child: const Icon(Icons.remove),
),
],
),
);
}
}
Con esta implementación, la aplicación simulará una carga inicial de datos, mostrando un indicador de progreso, y manejará posibles errores de carga. Además, cada vez que el contador cambia, se "guardará" el nuevo valor en el repositorio simulado.
Pruebas de BLoC (Unit Testing) ✅
Una de las grandes ventajas de BLoC es su facilidad para ser probado. Como la lógica de negocio está aislada, podemos probar los BLoCs de forma independiente.
Para esto, necesitaremos la dependencia bloc_test.
dev_dependencies:
flutter_test:
sdk: flutter
bloc_test: ^9.1.5 # Añade esta dependencia
mocktail: ^1.0.0 # Para crear mocks de repositorios
flutter pub get
Crea un archivo test/counter/counter_bloc_test.dart:
import 'package:bloc_counter_app/counter/counter_bloc.dart';
import 'package:bloc_counter_app/counter/data/counter_repository.dart';
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
// Mock del repositorio para aislar el BLoC en las pruebas
class MockCounterRepository extends Mock implements CounterRepository {}
void main() {
group('CounterBloc', () {
late MockCounterRepository mockCounterRepository;
setUp(() {
mockCounterRepository = MockCounterRepository();
// Mockear la respuesta inicial del repositorio
when(() => mockCounterRepository.fetchInitialCounter())
.thenAnswer((_) async => 0);
// Mockear el método saveCounter
when(() => mockCounterRepository.saveCounter(any()))
.thenAnswer((_) async => Future.value());
});
blocTest<
CounterBloc,
CounterState>('emits [CounterState(0, status: success)] when LoadCounter is added',
build: () => CounterBloc(mockCounterRepository),
act: (bloc) => bloc.add(const LoadCounter()),
expect: () => <CounterState>[
const CounterState(0, status: CounterStatus.loading),
const CounterState(0, status: CounterStatus.success),
],
);
blocTest<
CounterBloc,
CounterState>('emits [CounterState(1)] when IncrementCounter is added',
build: () => CounterBloc(mockCounterRepository),
seed: () => const CounterState(0, status: CounterStatus.success),
act: (bloc) => bloc.add(const IncrementCounter()),
expect: () => <CounterState>[
const CounterState(1, status: CounterStatus.success),
],
);
blocTest<
CounterBloc,
CounterState>('emits [CounterState(-1)] when DecrementCounter is added',
build: () => CounterBloc(mockCounterRepository),
seed: () => const CounterState(0, status: CounterStatus.success),
act: (bloc) => bloc.add(const DecrementCounter()),
expect: () => <CounterState>[
const CounterState(-1, status: CounterStatus.success),
],
);
blocTest<
CounterBloc,
CounterState>('emits [CounterState(0, status: failure)] when LoadCounter fails',
build: () {
when(() => mockCounterRepository.fetchInitialCounter())
.thenThrow(Exception('Error de carga')); // Simular un error
return CounterBloc(mockCounterRepository);
},
act: (bloc) => bloc.add(const LoadCounter()),
expect: () => <CounterState>[
const CounterState(0, status: CounterStatus.loading),
const CounterState(0, status: CounterStatus.failure),
],
);
});
}
Ejecuta las pruebas con flutter test test/counter/counter_bloc_test.dart.
Consideraciones Adicionales y Buenas Prácticas ✨
- Uso de
CubitvsBloc:Cubites una versión simplificada de BLoC, ideal para estados más simples que no requieren manejar eventos de manera explícita (solo funciones que emiten estados). Para lógicas más complejas con muchos eventos y estados,Bloces más adecuado. Siempre puedes refactorizar deCubitaBlocsi la complejidad crece. BlocObserver: Para depuración, puedes usarBlocObserverpara observar todos los cambios de estado y eventos en tu aplicación. Esto es muy útil para entender el flujo de datos.
// En main.dart
import 'package:bloc/bloc.dart';
class SimpleBlocObserver extends BlocObserver {
@override
void onEvent(Bloc bloc, Object? event) {
super.onEvent(bloc, event);
print('onEvent ${bloc.runtimeType} $event');
}
@override
void onChange(BlocBase bloc, Change change) {
super.onChange(bloc, change);
print('onChange ${bloc.runtimeType} $change');
}
@override
void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
print('onError ${bloc.runtimeType} $error $stackTrace');
super.onError(bloc, error, stackTrace);
}
@override
void onTransition(Bloc bloc, Transition transition) {
super.onTransition(bloc, transition);
print('onTransition ${bloc.runtimeType} $transition');
}
}
void main() {
Bloc.observer = SimpleBlocObserver(); // Añadir al inicio de main
runApp(const MyApp());
}
- Manejo de estados complejos: Para estados con múltiples propiedades, usa el patrón
copyWithen tus clases de estado (Equatablete ayudará mucho aquí) para crear nuevos estados inmutables de manera eficiente. MultiBlocProvideryMultiRepositoryProvider: Si tu aplicación tiene múltiples BLoCs o repositorios, puedes usar estos widgets para proveerlos de manera limpia en el árbol de widgets.
¿Por qué la inmutabilidad es importante en BLoC?
La inmutabilidad de los estados asegura que cada vez que un estado cambia, se crea una nueva instancia de estado. Esto permite a `BlocBuilder` y `BlocListener` detectar cambios de forma fiable y optimizar las reconstrucciones de la UI. Si mutas el estado existente, estos widgets no sabrán que ha habido un cambio.¡Felicidades! Has completado un recorrido exhaustivo por la gestión de estado con BLoC en Flutter. Desde los fundamentos hasta la implementación con un repositorio y las pruebas unitarias, ahora tienes las herramientas para construir aplicaciones Flutter robustas y escalables con BLoC.
Comentarios (0)
Aún no hay comentarios. ¡Sé el primero!