Saltar al contenido principal

El patrón Command

Simplifica la lógica del view model implementando una clase Command.

Model-View-ViewModel (MVVM) es un patrón de diseño que separa una funcionalidad de una aplicación en tres partes: el modelo, el view model y la vista. Las vistas y los view models componen la capa de UI de una aplicación. Los repositorios y servicios representan la capa de datos de una aplicación, o la capa del modelo de MVVM.

Un comando es una clase que envuelve un método y ayuda a manejar los diferentes States de ese método, como running, complete y error.

View models pueden usar comandos para manejar la interacción y ejecutar acciones. También puedes usarlos para mostrar diferentes UI States, como indicadores de carga cuando una acción se está ejecutando, o mostrar un diálogo de error cuando una acción falla.

Los view models pueden volverse muy complejos a medida que una aplicación crece y las funcionalidades se vuelven más grandes. Los comandos pueden ayudar a simplificar los view models y a reutilizar código.

En esta guía, aprenderás cómo usar el patrón Command para mejorar tus view models.

Desafíos al implementar view models

#

Las clases de view model en Flutter se implementan típicamente extendiendo la clase ChangeNotifier. Esto permite que los view models llamen a notifyListeners() para refrescar las vistas cuando se actualizan los datos.

dart
class HomeViewModel extends ChangeNotifier {
  // ···
}

Los view models contienen una representación del UI State, incluyendo los datos que se muestran. Por ejemplo, este HomeViewModel expone la instancia de User a la vista.

dart
class HomeViewModel extends ChangeNotifier {

  User? get user => // ...
  // ···
}

Los view models también contienen acciones típicamente desencadenadas por la vista, como una acción load encargada de cargar al user.

dart
class HomeViewModel extends ChangeNotifier {

  User? get user => // ...
  // ···
  void load() {
    // load user
  }
  // ···
}

UI State en los view models

#

Un view model también contiene UI State además de los datos, como si la vista se está ejecutando o si ha experimentado un error. Esto permite que la aplicación le diga al usuario si la acción se ha completado correctamente.

dart
class HomeViewModel extends ChangeNotifier {

  User? get user => // ...

  bool get running => // ...

  Exception? get error => // ...

  void load() {
    // load user
  }
  // ···
}

Puedes usar el State de ejecución para mostrar un indicador de progreso en la vista:

dart
ListenableBuilder(
  listenable: widget.viewModel,
  builder: (context, _) {
    if (widget.viewModel.running) {
      return const Center(child: CircularProgressIndicator());
    }
    // ···
  },
)

O usar el State de ejecución para evitar ejecutar la acción varias veces:

dart
void load() {
  if (running) {
    return;
  }
  // load user
}

Manejar el State de una acción puede complicarse si el view model contiene múltiples acciones. Por ejemplo, agregar una acción edit() al HomeViewModel puede llevar al siguiente resultado:

dart
class HomeViewModel extends ChangeNotifier {
  User? get user => // ...

  bool get runningLoad => // ...

  Exception? get errorLoad => // ...

  bool get runningEdit => // ...

  Exception? get errorEdit => // ...

  void load() {
    // load user
  }

  void edit(String name) {
    // edit user
  }
}

Compartir el State running entre las acciones load() y edit() puede que no siempre funcione, porque es posible que quieras mostrar un componente de UI diferente cuando se ejecuta la acción load() que cuando se ejecuta la acción edit(); tendrás el mismo problema con el State error.

Desencadenar acciones de la UI desde los view models

#

Las clases de view model pueden tener problemas al ejecutar acciones de la UI y cuando el State del view model cambia.

Por ejemplo, es posible que quieras mostrar un SnackBar cuando ocurre un error, or navegar a una pantalla diferente cuando se completa una acción. Para implementar esto, escucha los cambios en el view model y realiza la acción según el State.

En la vista:

dart
@override
void initState() {
  super.initState();
  widget.viewModel.addListener(_onViewModelChanged);
}

@override
void dispose() {
  widget.viewModel.removeListener(_onViewModelChanged);
  super.dispose();
}
dart
void _onViewModelChanged() {
  if (widget.viewModel.error != null) {
    // Show Snackbar
  }
}

Necesitas limpiar el State de error cada vez que ejecutas esta acción, de lo contrario, esta acción ocurre cada vez que se llama a notifyListeners().

dart
void _onViewModelChanged() {
  if (widget.viewModel.error != null) {
    widget.viewModel.clearError();
    // Show Snackbar
  }
}

Patrón Command

#

Puede que te encuentres repitiendo el código anterior una y otra vez, implementando un State running diferente para cada acción en cada view model. En ese punto, tiene sentido extraer este código en un patrón reutilizable llamado command.

Un comando es una clase que encapsula una acción de un view model, y expone los diferentes States que puede tener una acción.

dart
class Command extends ChangeNotifier {
  Command(this._action);

  bool get running => // ...

  Exception? get error => // ...

  bool get completed => // ...

  void Function() _action;

  void execute() {
    // run _action
  }

  void clear() {
    // clear state
  }
}

En el view model, en lugar de definir una acción directamente con un método, creas un objeto command:

dart
class HomeViewModel extends ChangeNotifier {
  HomeViewModel() {
    load = Command(_load)..execute();
  }

  User? get user => // ...

  late final Command load;

  void _load() {
    // load user
  }
}

El método load() anterior se convierte en _load(), y en su lugar se expone el comando load a la View. Los States running y error anteriores se pueden eliminar, ya que ahora son parte del comando.

Ejecutar un comando

#

En lugar de llamar a viewModel.load() para ejecutar la acción de carga, ahora llamas a viewModel.load.execute().

El método execute() también se puede llamar desde el view model. La siguiente línea de código ejecuta el comando load cuando se crea el view model.

dart
HomeViewModel() {
  load = Command(_load)..execute();
}

El método execute() establece el State running en true y restablece los States error y completed. Cuando la acción finaliza, el State running cambia a false y el State completed cambia a true.

Si el State running es true, el comando no puede comenzar a ejecutarse nuevamente. Esto evita que los usuarios desencadenen un comando múltiples veces al presionar un botón rápidamente.

El método execute() del comando captura cualquier Exceptions lanzada automáticamente y la expone en el State error.

El siguiente código muestra una clase Command de ejemplo que ha sido simplificada con fines de demostración. Puedes ver una implementación completa al final de esta página.

dart
class Command extends ChangeNotifier {
  Command(this._action);

  bool _running = false;
  bool get running => _running;

  Exception? _error;
  Exception? get error => _error;

  bool _completed = false;
  bool get completed => _completed;

  final Future<void> Function() _action;

  Future<void> execute() async {
    if (_running) {
      return;
    }

    _running = true;
    _completed = false;
    _error = null;
    notifyListeners();

    try {
      await _action();
      _completed = true;
    } on Exception catch (error) {
      _error = error;
    } finally {
      _running = false;
      notifyListeners();
    }
  }

  void clear() {
    _running = false;
    _error = null;
    _completed = false;
  }
}

Escuchar el State del comando

#

La clase Command extiende de ChangeNotifier, lo que permite a las Views escuchar sus States.

En el ListenableBuilder, en lugar de pasar el view model a ListenableBuilder.listenable, pasa el comando:

dart
ListenableBuilder(
  listenable: widget.viewModel.load,
  builder: (context, child) {
    if (widget.viewModel.load.running) {
      return const Center(child: CircularProgressIndicator());
    }
  // ···
)

Y escuchar los cambios en el State del comando para ejecutar acciones de la UI:

dart
@override
void initState() {
  super.initState();
  widget.viewModel.addListener(_onViewModelChanged);
}

@override
void dispose() {
  widget.viewModel.removeListener(_onViewModelChanged);
  super.dispose();
}
dart
void _onViewModelChanged() {
  if (widget.viewModel.load.error != null) {
    widget.viewModel.load.clear();
    // Show Snackbar
  }
}

Combinar command y ViewModel

#

Puedes apilar múltiples Widgets ListenableBuilder para escuchar los States running y error antes de mostrar los datos del view model.

dart
body: ListenableBuilder(
  listenable: widget.viewModel.load,
  builder: (context, child) {
    if (widget.viewModel.load.running) {
      return const Center(child: CircularProgressIndicator());
    }

    if (widget.viewModel.load.error != null) {
      return Center(
        child: Text('Error: ${widget.viewModel.load.error}'),
      );
    }

    return child!;
  },
  child: ListenableBuilder(
    listenable: widget.viewModel,
    builder: (context, _) {
      // ···
    },
  ),
),

Puedes definir múltiples clases de comandos en un solo view model, simplificando su implementación y minimizando la cantidad de código repetido.

dart
class HomeViewModel2 extends ChangeNotifier {
  HomeViewModel2() {
    load = Command(_load)..execute();
    delete = Command(_delete);
  }

  User? get user => // ...

  late final Command load;

  late final Command delete;

  Future<void> _load() async {
    // load user
  }

  Future<void> _delete() async {
    // delete user
  }
}

Extender el patrón Command

#

El patrón Command se puede extender de múltiples formas. Por ejemplo, para admitir un número diferente de argumentos.

dart
class HomeViewModel extends ChangeNotifier {
  HomeViewModel() {
    load = Command0(_load)..execute();
    edit = Command1<String>(_edit);
  }

  User? get user => // ...

  // Command0 accepts 0 arguments
  late final Command0 load;

  // Command1 accepts 1 argument
  late final Command1<String> edit;

  Future<void> _load() async {
    // load user
  }

  Future<void> _edit(String name) async {
    // edit user
  }
}

Poniéndolo todo junto

#

En esta guía, aprendiste cómo usar el patrón de diseño Command para mejorar la implementación de los view models al usar el patrón de diseño MVVM.

A continuación, puedes encontrar la clase Command completa tal como se implementó en el ejemplo de Compass App para las pautas de arquitectura de Flutter. También utiliza la clase Result para determinar si la acción se completó con éxito o con un error.

Esta implementación también incluye dos tipos de comandos, un Command0, para acciones sin parámetros, y un Command1, para acciones que toman un parámetro.

dart
// Copyright 2024 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';

import 'package:flutter/foundation.dart';

import 'result.dart';

/// Defines a command action that returns a [Result] of type [T].
/// Used by [Command0] for actions without arguments.
typedef CommandAction0<T> = Future<Result<T>> Function();

/// Defines a command action that returns a [Result] of type [T].
/// Takes an argument of type [A].
/// Used by [Command1] for actions with one argument.
typedef CommandAction1<T, A> = Future<Result<T>> Function(A);

/// Facilitates interaction with a view model.
///
/// Encapsulates an action,
/// exposes its running and error states,
/// and ensures that it can't be launched again until it finishes.
///
/// Use [Command0] for actions without arguments.
/// Use [Command1] for actions with one argument.
///
/// Actions must return a [Result] of type [T].
///
/// Consume the action result by listening to changes,
/// then call to [clearResult] when the state is consumed.
abstract class Command<T> extends ChangeNotifier {
  bool _running = false;

  /// Whether the action is running.
  bool get running => _running;

  Result<T>? _result;

  /// Whether the action completed with an error.
  bool get error => _result is Error;

  /// Whether the action completed successfully.
  bool get completed => _result is Ok;

  /// The result of the most recent action.
  ///
  /// Returns `null` if the action is running or completed with an error.
  Result<T>? get result => _result;

  /// Clears the most recent action's result.
  void clearResult() {
    _result = null;
    notifyListeners();
  }

  /// Execute the provided [action], notifying listeners and
  /// setting the running and result states as necessary.
  Future<void> _execute(CommandAction0<T> action) async {
    // Ensure the action can't launch multiple times.
    // e.g. avoid multiple taps on button
    if (_running) return;

    // Notify listeners.
    // e.g. button shows loading state
    _running = true;
    _result = null;
    notifyListeners();

    try {
      _result = await action();
    } finally {
      _running = false;
      notifyListeners();
    }
  }
}

/// A [Command] that accepts no arguments.
final class Command0<T> extends Command<T> {
  /// Creates a [Command0] with the provided [CommandAction0].
  Command0(this._action);

  final CommandAction0<T> _action;

  /// Executes the action.
  Future<void> execute() async {
    await _execute(_action);
  }
}

/// A [Command] that accepts one argument.
final class Command1<T, A> extends Command<T> {
  /// Creates a [Command1] with the provided [CommandAction1].
  Command1(this._action);

  final CommandAction1<T, A> _action;

  /// Executes the action with the specified [argument].
  Future<void> execute(A argument) async {
    await _execute(() => _action(argument));
  }
}