Saltar al contenido principal

State Management simple de la aplicación

Una forma sencilla de State Management.

Ahora que conoces la programación declarativa de UI y la diferencia entre el State efímero y el de la aplicación, estás listo para aprender sobre el State Management simple de la aplicación.

En esta página, vamos a utilizar el paquete Provider. Si eres nuevo en Flutter y no tienes una razón de peso para elegir otro enfoque (Redux, Rx, hooks, etc.), este es probablemente el enfoque con el que deberías empezar. El paquete Provider es fácil de entender y no utiliza mucho código. También utiliza conceptos que son aplicables en cualquier otro enfoque.

Dicho esto, si tienes una sólida formación en State Management de otros frameworks reactivos, puedes encontrar los paquetes y tutoriales listados en la página de opciones.

Nuestro ejemplo

#
Un gif animado que muestra una aplicación Flutter en uso. Comienza con el usuario en una pantalla de inicio de sesión. Inician sesión y son llevados a la pantalla del catálogo, con una lista de artículos. Hacen clic en varios artículos y, al hacerlo, los artículos se marcan como "añadidos". El usuario hace clic en un botón y es llevado a la vista del carrito. Ven los artículos allí. Vuelven al catálogo y los artículos que compraron todavía muestran "añadido". Fin de la animación.

Para ilustrarlo, considera la siguiente aplicación sencilla.

La aplicación tiene dos pantallas separadas: un catálogo y un carrito (representados por los widgets MyCatalog y MyCart, respectivamente). Podría ser una aplicación de compras, pero puedes imaginar la misma estructura en una aplicación simple de red social (reemplazando el catálogo por el "muro" y el carrito por los "favoritos").

La pantalla del catálogo incluye una barra de aplicación personalizada (MyAppBar) y una vista con desplazamiento de muchos elementos de lista (MyListItems).

Aquí está la aplicación visualizada como un árbol de widgets.

Un árbol de widgets con MyApp en la parte superior, y MyCatalog y MyCart debajo. MyCart es un nodo hoja, pero MyCatalog tiene dos hijos: MyAppBar y una lista de MyListItems.

Así que tenemos al menos 5 subclases de Widget. Muchas de ellas necesitan acceso al State que "pertenece" a otra parte. Por ejemplo, cada MyListItem necesita poder agregarse a sí mismo al carrito. También podría querer ver si el elemento mostrado actualmente ya está en el carrito.

Esto nos lleva a nuestra primera pregunta: ¿dónde deberíamos poner el State actual del carrito?

Elevar el State

#

En Flutter, tiene sentido mantener el State por encima de los widgets que lo utilizan.

¿Por qué? En frameworks declarativos como Flutter, si quieres cambiar la UI, tienes que reconstruirla. No hay una forma sencilla de tener MyCart.updateWith(somethingNew). En otras palabras, es difícil cambiar imperativamente un widget desde el exterior, llamando a un método en él. E incluso si pudieras hacer que esto funcione, estarías luchando contra el framework en lugar de dejar que te ayude.

dart
// BAD: DO NOT DO THIS
void myTapHandler() {
  var cartWidget = somehowGetMyCartWidget();
  cartWidget.updateWith(item);
}

Incluso si logras que el código anterior funcione, luego tendrías que lidiar con lo siguiente en el widget MyCart:

dart
// BAD: DO NOT DO THIS
Widget build(BuildContext context) {
  return SomeWidget(
    // The initial state of the cart.
  );
}

void updateWith(Item item) {
  // Somehow you need to change the UI from here.
}

Tendrías que tener en cuenta el State actual de la UI y aplicarle los nuevos datos. Es difícil evitar errores de esta manera.

En Flutter, construyes un nuevo widget cada vez que cambian sus contenidos. En lugar de MyCart.updateWith(somethingNew) (una llamada a un método) usas MyCart(contents) (un constructor). Como solo puedes construir nuevos widgets en los métodos build de sus padres, si quieres cambiar contents, este necesita vivir en el padre de MyCart o por encima.

dart
// GOOD
void myTapHandler(BuildContext context) {
  var cartModel = somehowGetMyCartModel(context);
  cartModel.add(item);
}

Ahora MyCart tiene solo una ruta de código para construir cualquier versión de la UI.

dart
// GOOD
Widget build(BuildContext context) {
  var cartModel = somehowGetMyCartModel(context);
  return SomeWidget(
    // Just construct the UI once, using the current state of the cart.
    // ···
  );
}

En nuestro ejemplo, contents necesita vivir en MyApp. Cada vez que cambia, reconstruye MyCart desde arriba (más sobre eso más adelante). Debido a esto, MyCart no necesita preocuparse por el ciclo de vida; simplemente declara qué mostrar para cualquier contents dado. Cuando eso cambia, el antiguo widget MyCart desaparece y es completamente reemplazado por el nuevo.

El mismo árbol de widgets de arriba, pero ahora mostramos una pequeña insignia de 'carrito' junto a MyApp, y hay dos flechas aquí. Una viene de uno de los MyListItems al 'carrito', y otra va del 'carrito' al widget MyCart.

A esto nos referimos cuando decimos que los widgets son inmutables. No cambian, se reemplazan.

Ahora que sabemos dónde colocar el State del carrito, veamos cómo acceder a él.

Acceder al State

#

Cuando un usuario hace clic en uno de los elementos del catálogo, este se agrega al carrito. Pero dado que el carrito vive por encima de MyListItem, ¿cómo hacemos eso?

Una opción sencilla es proporcionar un callback que MyListItem pueda llamar cuando se hace clic en él. Las funciones de Dart son objetos de primera clase, por lo que puedes pasarlas de la manera que quieras. Así, dentro de MyCatalog puedes definir lo siguiente:

dart
@override
Widget build(BuildContext context) {
  return SomeWidget(
    // Construct the widget, passing it a reference to the method above.
    MyListItem(myTapCallback),
  );
}

void myTapCallback(Item item) {
  print('user tapped on $item');
}

Esto funciona bien, pero para un State de la aplicación que necesitas modificar desde muchos lugares diferentes, tendrías que pasar una gran cantidad de callbacks, lo cual se vuelve tedioso bastante rápido.

Afortunadamente, Flutter tiene mecanismos para que los widgets proporcionen datos y servicios a sus descendientes (en otras palabras, no solo a sus hijos, sino a cualquier widget debajo de ellos). Como cabría esperar de Flutter, donde Todo es un Widget™, estos mecanismos son solo tipos especiales de widgets: InheritedWidget, InheritedNotifier, InheritedModel y más. No los cubriremos aquí, porque son un poco de bajo nivel para lo que intentamos hacer.

En su lugar, vamos a usar un paquete que funciona con los widgets de bajo nivel pero es sencillo de usar. Se llama Provider.

Antes de trabajar con Provider, no olvides agregar la dependencia en tu pubspec.yaml.

Para agregar el paquete Provider como dependencia, ejecuta flutter pub add:

flutter pub add provider

Ahora puedes import 'package:provider/provider.dart'; y empezar a construir.

Con Provider, no necesitas preocuparte por callbacks o InheritedWidgets. Pero sí necesitas entender 3 conceptos:

  • ChangeNotifier
  • ChangeNotifierProvider
  • Consumer

ChangeNotifier

#

ChangeNotifier es una clase simple incluida en el SDK de Flutter que proporciona notificación de cambios a sus oyentes. En otras palabras, si algo es un ChangeNotifier, puedes suscribirte a sus cambios. (Es una forma de Observable, para aquellos familiarizados con el término).

En Provider, ChangeNotifier es una forma de encapsular el State de tu aplicación. Para aplicaciones muy simples, te bastará con un solo ChangeNotifier. En las complejas, tendrás varios modelos y, por lo tanto, varios ChangeNotifiers. (No necesitas usar ChangeNotifier con Provider en absoluto, pero es una clase fácil de trabajar).

En nuestro ejemplo de aplicación de compras, queremos gestionar el State del carrito en un ChangeNotifier. Creamos una nueva clase que lo extiende, así:

dart
class CartModel extends ChangeNotifier {
  /// Internal, private state of the cart.
  final List<Item> _items = [];

  /// An unmodifiable view of the items in the cart.
  UnmodifiableListView<Item> get items => UnmodifiableListView(_items);

  /// The current total price of all items (assuming all items cost $42).
  int get totalPrice => _items.length * 42;

  /// Adds [item] to cart. This and [removeAll] are the only ways to modify the
  /// cart from the outside.
  void add(Item item) {
    _items.add(item);
    // This call tells the widgets that are listening to this model to rebuild.
    notifyListeners();
  }

  /// Removes all items from the cart.
  void removeAll() {
    _items.clear();
    // This call tells the widgets that are listening to this model to rebuild.
    notifyListeners();
  }
}

El único código que es específico de ChangeNotifier es la llamada a notifyListeners(). Llama a este método cada vez que el modelo cambie de una manera que pueda cambiar la UI de tu aplicación. Todo lo demás en CartModel es el modelo en sí y su lógica de negocio.

ChangeNotifier es parte de flutter:foundation y no depende de ninguna clase de nivel superior en Flutter. Es fácilmente testeable (ni siquiera necesitas usar pruebas de widgets para ello). Por ejemplo, aquí tienes una prueba unitaria simple de CartModel:

dart
test('adding item increases total cost', () {
  final cart = CartModel();
  final startingPrice = cart.totalPrice;
  var i = 0;
  cart.addListener(() {
    expect(cart.totalPrice, greaterThan(startingPrice));
    i++;
  });
  cart.add(Item('Dash'));
  expect(i, 1);
});

ChangeNotifierProvider

#

ChangeNotifierProvider es el widget que proporciona una instancia de un ChangeNotifier a sus descendientes. Viene del paquete Provider.

Ya sabemos dónde colocar ChangeNotifierProvider: por encima de los widgets que necesitan acceder a él. En el caso de CartModel, eso significa en algún lugar por encima tanto de MyCart como de MyCatalog.

No querrás colocar ChangeNotifierProvider más alto de lo necesario (porque no quieres contaminar el alcance). Pero en nuestro caso, el único widget que está por encima de MyCart y MyCatalog es MyApp.

dart
void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CartModel(),
      child: const MyApp(),
    ),
  );
}

Ten en cuenta que estamos definiendo un builder que crea una nueva instancia de CartModel. ChangeNotifierProvider es lo suficientemente inteligente como para no reconstruir CartModel a menos que sea absolutamente necesario. También llama automáticamente a dispose() en CartModel cuando la instancia ya no es necesaria.

Si deseas proporcionar más de una clase, puedes usar MultiProvider:

dart
void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (context) => CartModel()),
        Provider(create: (context) => SomeOtherClass()),
      ],
      child: const MyApp(),
    ),
  );
}

Consumer

#

Ahora que CartModel se proporciona a los widgets en nuestra aplicación a través de la declaración de ChangeNotifierProvider en la parte superior, podemos comenzar a usarlo.

Esto se hace a través del widget Consumer.

dart
return Consumer<CartModel>(
  builder: (context, cart, child) {
    return Text('Total price: ${cart.totalPrice}');
  },
);

Debemos especificar el tipo de modelo al que queremos acceder. En este caso, queremos CartModel, por lo que escribimos Consumer<CartModel>. Si no especificas el genérico (<CartModel>), el paquete Provider no podrá ayudarte. Provider se basa en tipos, y sin el tipo, no sabe lo que quieres.

El único argumento requerido del widget Consumer es el builder. El builder es una función que se llama cada vez que cambia el ChangeNotifier. (En otras palabras, cuando llamas a notifyListeners() en tu modelo, se llaman todos los métodos builder de todos los widgets Consumer correspondientes).

El builder se llama con tres argumentos. El primero es context, que también obtienes en cada método build.

El segundo argumento de la función builder es la instancia del ChangeNotifier. Es lo que estábamos pidiendo en primer lugar. Puedes usar los datos en el modelo para definir cómo debería verse la UI en cualquier momento dado.

El tercer argumento es child, que está ahí para optimización. Si tienes un gran subárbol de widgets debajo de tu Consumer que no cambia cuando cambia el modelo, puedes construirlo una vez y obtenerlo a través del builder.

dart
return Consumer<CartModel>(
  builder: (context, cart, child) => Stack(
    children: [
      // Use SomeExpensiveWidget here, without rebuilding every time.
      ?child,
      Text('Total price: ${cart.totalPrice}'),
    ],
  ),
  // Build the expensive widget here.
  child: const SomeExpensiveWidget(),
);

La mejor práctica es colocar tus widgets Consumer lo más profundo posible en el árbol. No querrás reconstruir grandes porciones de la UI solo porque cambió algún detalle en alguna parte.

dart
// DON'T DO THIS
return Consumer<CartModel>(
  builder: (context, cart, child) {
    return HumongousWidget(
      // ...
      child: AnotherMonstrousWidget(
        // ...
        child: Text('Total price: ${cart.totalPrice}'),
      ),
    );
  },
);

En su lugar:

dart
// DO THIS
return HumongousWidget(
  // ...
  child: AnotherMonstrousWidget(
    // ...
    child: Consumer<CartModel>(
      builder: (context, cart, child) {
        return Text('Total price: ${cart.totalPrice}');
      },
    ),
  ),
);

Provider.of

#

A veces, realmente no necesitas los datos en el modelo para cambiar la UI pero aún así necesitas acceder a ellos. Por ejemplo, un botón ClearCart quiere permitir al usuario eliminar todo del carrito. No necesita mostrar el contenido del carrito, solo necesita llamar al método clear().

Podríamos usar Consumer<CartModel> para esto, pero eso sería un desperdicio. Estaríamos pidiendo al framework que reconstruya un widget que no necesita ser reconstruido.

Para este caso de uso, podemos usar Provider.of, con el parámetro listen establecido en false.

dart
Provider.of<CartModel>(context, listen: false).removeAll();

El uso de la línea anterior en un método build no hará que este widget se reconstruya cuando se llame a notifyListeners.