El patrón BLoC junto a Clean Architecture en Flutter

En el anterior artículo vimos como pueden encajar el patrón BLoC y Clean Architecture.

Como vimos el patrón bloc al ser bastante versátil, encaja bien con Clean Architecture si lo vemos como un patrón de presentación.

Vamos a ver en este artículo un ejemplo práctico en Flutter.

Refactorizando el ejemplo Shopping Cart

Vamos a trabajar sobre el mismo ejemplo del carrito de la compra que iniciamos en el artículo de gestion simple de estado en flutter.

En este ejemplo estabamos utilizando Clean Architecture, Model View Presenter y la estrategia de levantar estado que es la más básica en frameworks declarativos.

Vamos a refactorizar ese ejemplo pasando a utilizar el patrón bloc.

La idea es sustituir los presenters por blocs y sustituir la perforación de propiedades y eventos por interacciones con los blocs.

Decidiendo estrategia de bloc pattern a utilizar

Como vimos en la introdución al patrón bloc, la idea del patrón bloc es tener un intermediario entre la vista y el modelo, que en este caso al utilizar clean architecture sería el dominio, con un estado observable.

Este estado observable es lo que nos va a permitir compartir estado entre diferentes vistas del arbol.

Partiendo de esta idea base del patrón bloc, como ya vimos en el artículo de introducción existen diferentes implementaciones.

En este caso no voy a utilizar ninguna librería externa, porque creo que la mejor forma de aprender inicialmente cosas nuevas es hacértelo tu mismo y luego ya cuando lo tienes asimilado, es cuando creo que es el momento de utilizar librerías si se considera oportuno.

Para este ejemplo vamos a seguir la implementación de tener varias actiones de entrada en el bloc, a través de un método por cada acción y un único estado observable que representa la vista.

Infraestructura necesaria

Vamos a ver la infraestructura que necesitamos para poder implementar el patrón bloc.

Bloc base

Debido la implementación del patrón bloc que voy a utilizar, encaja bastante bien tener una clase base bloc de la que estiendan los demás blocs y que contenga el comportamiento en común.

abstract class Bloc <T> {
  final _stateController = StreamController<T>.broadcast();
  T _state;

  T get state => _state;
  Stream<T> get observableState => _stateController.stream;

  @protected
  void changeState(T state) {
    _state = state;
    _stateController.sink.add(state);
  }

  void dispose(){
    _stateController.close();
  }
}

El paquete async de dart, aparte de tener el tipo future para programación asíncrona, tiene el tipo Stream, que puede ser gestionado por un StreamController, y va a hacer que nuestro estado sea observable.

Cada vez que hagamos sink.add sobre el StreamController, las vistas suscritas serán notificadas del cambio.

Es importante el método dispose que es donde se cerrará el StreamController liberando todas las suscripciones al stream.

Bloc provider

Vamos a definir un Bloc por cada pantalla o concepto importante y si el estado de un bloc tiene que ser observado por hijos necesitamos tener un mecanismo de acceso al bloc desde los widgets hijos inferiores.

Para conseguir esto, en Flutter podemos utilizar el concepto de BlocProvider, creandonos un StatefulWidget widget.

class BlocProvider<T extends Bloc> extends StatefulWidget {
  const BlocProvider({
    Key key,
    @required this.child,
    @required this.bloc,
  }): super(key: key);

  final T bloc;
  final Widget child;

  @override
  _BlocProviderState<T> createState() => _BlocProviderState<T>();

  static T of<T extends Bloc>(BuildContext context){
    final BlocProvider<T> provider = context.findAncestorWidgetOfExactType();
    return provider.bloc;
  }
}

class _BlocProviderState<T> extends State<BlocProvider<Bloc>>{
  @override
  void dispose(){
    widget.bloc.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context){
    return widget.child;
  }
}

En este widget hay dos partes importantes, por un lado la invocación del método dispose del bloc para liberar recursos.

Por otro el método estático of, que será a través del cual vamos a poder acceder a un bloc definido en una parte superior del arbol de widgets.

Refactorizando el carrito de la compra

Este ejemplo consta de dos partes, por una lado los productos y por otro el carrito de la compra.

Al igual que en el artículo donde iniciamos el ejemplo, vamos a centrarnos en el carrito de la compra porque es donde vamos a ver la potencia del patrón bloc al compartir su estado por diferentes vistas.

Refactorizamos el estado

Primero vamos a refactorizar el estado definido anteriormente para contemplar todos los tipos de estado posibles como loading, error y updated que extienden de una misma clase base.

En dart no tenemos sum types o union types pero podemos crearnos algo similar definiendo clases independientes que heredan de una clase base y creamos métodos de factoria en la clase base para crear cada uno de los tipos.

No vamos a tener pattern maching pero al menos vamos a tener bien separados cada estado posible de forma independiente.

De esta forma estamos definiendo todos los estados posibles ya que el carrito no podrá encontrarse en dos estados de cada tipo a la vez, solo es posible tener un estado de los definidos.

abstract class CartState {
  CartState();

  factory CartState.loading() =>LoadingProductsState();

  factory CartState.loaded(  String totalPrice, int totalItems,
      List<CartItemState> items) =>
      LoadedCartState(totalPrice: totalPrice, totalItems:totalItems, items: items);

  factory CartState.error(String message) => ErrorCartState(message: message);
}

class LoadingProductsState extends CartState {
  LoadingProductsState();
}

class LoadedCartState extends CartState {
  final String totalPrice;
  final int totalItems;
  final List<CartItemState> items;

  LoadedCartState({this.totalPrice, this.totalItems,  @required this.items});
}

class ErrorCartState extends CartState {
  final String message;

  ErrorCartState({@required this.message});
}

class CartItemState {
  final String id;
  final String image;
  final String title;
  final String price;
  final int quantity;

  CartItemState(this.id, this.image, this.title, this.price, this.quantity);
}

Reemplazando CartPresenter por CartBloc

Ahora vamos a crearnos el bloc para el carrito.

class CartBloc extends Bloc<CartState> {
  final GetProductsUseCase _getProductsUseCase;
  final GetCartUseCase _getCartUseCase;
  final AddProductToCartUseCase _addProductToCartUseCase;
  final RemoveItemFromCartUseCase _removeItemFromCartUseCase;
  final EditQuantityOfCartItemUseCase _editQuantityOfCartItemUseCase;

  Cart _cart;
  List<Product> _products;

  CartBloc(
      this._getProductsUseCase,
      this._getCartUseCase,
      this._addProductToCartUseCase,
      this._removeItemFromCartUseCase,
      this._editQuantityOfCartItemUseCase);

  Future<void> init() async {
    _products = await _getProductsUseCase.execute();
    _loadCart();
  }

  void addProductToCartCart(ProductItemState productItemState) {
    final Product product =
        _products.firstWhere((product) => product.id == productItemState.id);

    _addProductToCartUseCase.execute(product).then((cart) {
      _cart = cart;
      changeState(_mapToState(cart));
    });
  }

  void removeCartItemOfCart(CartItemState cartItemState) {
    final CartItem cartItem =
        _cart.items.firstWhere((cartItem) => cartItem.id == cartItemState.id);

    _removeItemFromCartUseCase.execute(cartItem).then((cart) {
      _cart = cart;
      changeState(_mapToState(cart));
    });
  }

  void editQuantityOfCartItem(CartItemState cartItemState, int quantity) {
    final CartItem cartItem =
        _cart.items.firstWhere((cartItem) => cartItem.id == cartItemState.id);

    _editQuantityOfCartItemUseCase.execute(cartItem, quantity).then((cart) {
      _cart = cart;
      changeState(_mapToState(cart));
    });
  }

  void _loadCart() {
    _getCartUseCase.execute().then((cart) {
      _cart = cart;
      changeState(_mapToState(cart));
    }).catchError((error) {
      changeState(CartState.error('A network error has ocurrd'));
    });
  }

  CartState _mapToState(Cart cart) {
    final formatCurrency = NumberFormat.simpleCurrency(locale: 'es-ES');

    return CartState.loaded(
        formatCurrency.format(cart.totalPrice),
        cart.totalItems,
        cart.items
            .map((cartItem) => CartItemState(
                cartItem.id,
                cartItem.image,
                cartItem.title,
                formatCurrency.format(cartItem.price),
                cartItem.quantity))
            .toList());
  }
}

Aquí lo importante es que vamos a extender de Bloc y que cada vez que se produce un cambio de estado invocamos el método changeState de la clase base para que a través del estado observable utilizando stream, se notifique a los suscriptores del estado del cambio.

El resto es bastante similar al CarPresenter que ya teniamos, siendo ahora el BLoC el encargado de hablar con los casos de uso del dominio.

Refactorizando el widget App

Ahora necesitamos añadir el bloc provider dentro de la jerarquia de widgets.

Lo añadiremos sobre la vista que sea el padre más cercano a todos los widgets que comparten el estado del carrito.

En nuestro ejemplo al tener un jerarquia sencilla, lo vamos a ubicar encima de home page definiendolo en app, pero en casos más complejos con jerarquias grandes no tendría por que ser así.

class App extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Shopping Cart Flutter',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        canvasColor: Colors.grey[50],
      ),
      home: BlocProvider( bloc: getIt<CartBloc>(), child: HomePage(),)
        ,
    );
  }
}

Refactorizando el widget HomePage

Ya no necesitamos pasar el carrito o parte de sus propiedades ni las funciones manejadores de eventos que producen cambios en el carrito desde HomePage hacia los hijos.

Tampoco necesitamos que HomePage sea un StatefulWidget, podemos cambiarlo a StatelessWidget, ya que la gestión del estado se realiza en el bloc.

class HomePage extends StatelessWidget {
  final String searchTerm = 'Elements';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: MyAppBar(),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: <Widget>[
            Padding(
                padding: const EdgeInsets.all(16),
                child: Row(
                  children: <Widget>[
                    Text('Results for ',
                        style: Theme.of(context).textTheme.title),
                    Text(searchTerm,
                        style: Theme.of(context)
                            .textTheme
                            .title
                            .copyWith(color: Theme.of(context).primaryColor))
                  ],
                )),
            Expanded(
              child: ProductList(),
            )
          ],
        ),
      ),
      endDrawer:
          CartDrawer(),
    );
  }
}

Refactorizando widgets hijos

Ahora ya no es necesario pasar propiedades ni eventos a los hijos.

Simplemente en aquellos widgets hijos donde se necesite acceder bloc del carrito de la compra, utilizaremos el método estático of de BlocProvider para recuperarlo.

Aquellos widgets que necesiten renderizar en base al estado del carrito vamos a utilizar un widget especial que existe en Flutter que se llama StreamBuilder.

Este widget realiza el trabajo por nosotros de suscribirse al stream del bloc y renderizar cuando se produce un cambio en el stream.

Veamos por ejemplo CartContent:

class CartContent extends StatelessWidget {
  const CartContent();

  @override
  Widget build(BuildContext context) {
    final bloc = BlocProvider.of<CartBloc>(context);

    return StreamBuilder<CartState>(
        initialData: bloc.state,
        stream: bloc.observableState,
        builder: (context, snapshot) {
          final state = snapshot.data;

          if (state is LoadingCartState) {
            return const Center(child: CircularProgressIndicator());
          } else if (state is ErrorCartState) {
            return Center(child:Text(state.message));
          } else {
            return _renderCartContent(context, state);
          }
        });
  }
  
  ...

y aquellos widgets que necesiten invocar alguna acción del bloc, accederán al bloc de igual forma utilizando BlocProvider.

Veamos por ejemplo ProductItem:

class ProductItem extends StatelessWidget {
  final ProductItemState _productItem;

  const ProductItem(this._productItem);

  @override
  Widget build(BuildContext context) {
    final cartBloc = BlocProvider.of<CartBloc>(context);

    return Card(
        child: Column(
      children: <Widget>[
        Expanded(
            flex: 5,
            child: Image.network(
              _productItem.image,
              fit: BoxFit.fitWidth,
            )),
        Padding(
          padding: const EdgeInsets.all(8.0),
          child: Text(
            _productItem.title,
            maxLines: 2,
            overflow: TextOverflow.ellipsis,
            style: Theme.of(context).textTheme.body1,
          ),
        ),
        Text(_productItem.price, style: Theme.of(context).textTheme.headline),
        RawMaterialButton(
          child: Text(
            'Add to cart'.toUpperCase(),
            style: Theme.of(context)
                .textTheme
                .button
                .copyWith(color: Theme.of(context).primaryColor),
          ),
          onPressed: ()=> cartBloc.addProductToCartCart(_productItem),
        )
      ],
    ));
  }
}

La mayor ventaja de utilizar bloc Pattern frente a levantar el estado es que ahora cuando se produce un cambio en el carrito de la compra, no se renderizan de nuevo todos los widgets hijos de HomePage, solo aquellos suscritos a cambios de estado del carrito.

Código fuente

Os dejo el link al repositorio de GitHub donde podeís ver el código completo del ejemplo.

Recursos relacionados

Libro

Curso

Artículos

Conclusiones

En este artículo hemos visto un ejemplo práctico de como encajan el patrón BloC y Clean Architecture aplicado a Flutter.

Como hemos visto necesitamos crearnos una infraestructura básica si no utilizamos una libreria externa y ya no tenemos la necesidad de realizar perforación de propiedades.

Y vamos a tener un mejor rendimiento en nuestra app porque ahora cuando se produce un cambio en el carrito de la compra, no se renderizan de nuevo todos los widgets hijos de HomePage, solo aquellos suscritos a cambios de estado del carrito.