Caso de estudio de la capa de UI
Un recorrido por la capa de UI de una aplicación que implementa la arquitectura MVVM.
La capa de UI de cada función o funcionalidad en tu aplicación Flutter debe estar
compuesta por dos componentes: una View
y
un ViewModel.
En el sentido más general, los view models gestionan el State de la UI,
y las vistas muestran el State de la UI.
Las vistas y los view models tienen una relación de uno a uno;
para cada vista, existe exactamente un view model correspondiente que
gestiona el State de esa vista.
Cada par de vista y view model conforma la UI para una sola función.
Por ejemplo, una app podría tener clases llamadas
LogOutView y un LogOutViewModel.
Definir un view model
#Un view model es una clase Dart responsable de manejar la lógica de la UI. Los view models toman modelos de datos del dominio como entrada y exponen esos datos como State de la UI a sus vistas correspondientes. Encapsulan la lógica que la vista puede adjuntar a los manejadores de eventos, como cuando se presiona un botón, y gestionan el envío de estos eventos a la capa de datos de la app, donde ocurren los cambios de datos.
El siguiente fragmento de código es una declaración de clase para
una clase view model llamada HomeViewModel.
Sus entradas son los repositorios
que proveen sus datos.
En este caso,
el view model depende del
BookingRepository y UserRepository como argumentos.
class HomeViewModel {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) :
// Repositories are manually assigned because they're private members.
_bookingRepository = bookingRepository,
_userRepository = userRepository;
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
// ...
}
Los view models siempre dependen de repositorios de datos, los cuales son proporcionados como argumentos al constructor del view model. Los view models y los repositorios tienen una relación de muchos a muchos, y la mayoría de los view models dependerán de múltiples repositorios.
Al igual que en la declaración de ejemplo anterior de HomeViewModel,
los repositorios deberían ser miembros privados en el view model;
de lo contrario, las views tendrían acceso directo a
la capa de datos de la aplicación.
State de la UI
#La salida de un view model son los datos que una view necesita para renderizarse, lo que generalmente se denomina State de la UI, o simplemente State. El State de la UI es una instantánea inmutable de los datos necesarios para renderizar completamente una view.
El view model expone el State como miembros públicos.
En el view model del siguiente ejemplo de código,
los datos expuestos son un objeto User,
así como los itinerarios guardados del usuario, los cuales
se exponen como un objeto de tipo List<BookingSummary>.
class HomeViewModel {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) : _bookingRepository = bookingRepository,
_userRepository = userRepository;
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
User? _user;
User? get user => _user;
List<BookingSummary> _bookings = [];
/// Items in an [UnmodifiableListView] can't be directly modified,
/// but changes in the source list can be modified. Since _bookings
/// is private and bookings is not, the view has no way to modify the
/// list directly.
UnmodifiableListView<BookingSummary> get bookings => UnmodifiableListView(_bookings);
// ...
}
Como se mencionó, el State de la UI debería ser inmutable. Esta es una parte crucial para lograr un software libre de errores.
La aplicación compass utiliza package:freezed para
imponer la inmutabilidad en las clases de datos. Por ejemplo,
el siguiente código muestra la definición de la clase User.
freezed proporciona inmutabilidad profunda
y genera la implementación de métodos útiles como
copyWith y toJson.
@freezed
class User with _$User {
const factory User({
/// The user's name.
required String name,
/// The user's picture URL.
required String picture,
}) = _User;
factory User.fromJson(Map<String, Object?> json) => _$UserFromJson(json);
}
Actualizar el State de la UI
#Además de almacenar el State,
los view models deben indicarle a Flutter que vuelva a renderizar las views cuando
la capa de datos proporciona un nuevo State.
En la aplicación Compass, los view models extienden ChangeNotifier
para lograr esto.
class HomeViewModel extends ChangeNotifier {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) : _bookingRepository = bookingRepository,
_userRepository = userRepository;
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
User? _user;
User? get user => _user;
List<BookingSummary> _bookings = [];
List<BookingSummary> get bookings => _bookings;
// ...
}
HomeViewModel.user es un miembro público del que depende la view.
Cuando fluyen nuevos datos desde la capa de datos y
es necesario emitir un nuevo State, se llama a notifyListeners.
- El nuevo State es proporcionado al view model desde un Repository.
- El view model actualiza su State de la UI para reflejar los nuevos datos.
- Se llama a
ViewModel.notifyListeners, alertando a la View sobre el nuevo State de la UI. - La vista (widget) se vuelve a renderizar.
Por ejemplo, cuando el usuario navega a la pantalla Home y se crea el view model,
se llama al método _load.
Hasta que este método se complete, el State de la UI estará vacío y
la view mostrará un indicador de carga.
Cuando el método _load se completa, si tiene éxito,
hay nuevos datos en el view model y este debe
notificar a la view que hay nuevos datos disponibles.
class HomeViewModel extends ChangeNotifier {
// ...
Future<Result> _load() async {
try {
final userResult = await _userRepository.getUser();
switch (userResult) {
case Ok<User>():
_user = userResult.value;
_log.fine('Loaded user');
case Error<User>():
_log.warning('Failed to load user', userResult.error);
}
// ...
return userResult;
} finally {
notifyListeners();
}
}
}
Definir una view
#Una view es un widget dentro de tu aplicación.
A menudo, una view representa una pantalla en tu aplicación que
tiene su propia ruta e incluye un Scaffold
en la parte superior del
subárbol de widgets, como la HomeScreen, pero este no es siempre el caso.
A veces, una view es un único elemento de la interfaz de usuario que
encapsula una funcionalidad que debe reutilizarse en toda la aplicación.
Por ejemplo, la aplicación Compass tiene una view llamada LogoutButton,
que se puede colocar en cualquier lugar del árbol de widgets donde un usuario
esperaría encontrar un botón de cierre de sesión.
La view LogoutButton tiene su propio view model llamado LogoutViewModel.
Y en pantallas más grandes, puede haber múltiples views en pantalla que
ocuparían la pantalla completa en un dispositivo móvil.
Los widgets dentro de una view tienen tres responsabilidades:
- Muestran las propiedades de datos del view model.
- Escuchan actualizaciones del view model y se vuelven a renderizar cuando hay nuevos datos disponibles.
- Adjuntan callbacks del view model a los manejadores de eventos, si corresponde.
Continuando con el ejemplo de la funcionalidad Home,
el siguiente código muestra la definición de la view HomeScreen.
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key, required this.viewModel});
final HomeViewModel viewModel;
@override
Widget build(BuildContext context) {
return Scaffold(
// ...
);
}
}
La mayoría de las veces, las únicas entradas de una view deberían ser una clave (key),
que todos los widgets de Flutter toman como argumento opcional,
y el correspondiente view model de la view.
Mostrar datos de la UI en una view
#Una view depende de un view model para obtener su State. En la aplicación Compass,
el view model se pasa como argumento en el constructor de la view.
El siguiente fragmento de código de ejemplo es del widget HomeScreen.
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key, required this.viewModel});
final HomeViewModel viewModel;
@override
Widget build(BuildContext context) {
// ...
}
}
Dentro del widget, puedes acceder a las reservas pasadas desde el viewModel.
En el siguiente código,
la propiedad booking se proporciona a un subwidget.
@override
Widget build(BuildContext context) {
return Scaffold(
// Some code was removed for brevity.
body: SafeArea(
child: ListenableBuilder(
listenable: viewModel,
builder: (context, _) {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(...),
SliverList.builder(
itemCount: viewModel.bookings.length,
itemBuilder: (_, index) => _Booking(
key: ValueKey(viewModel.bookings[index].id),
booking:viewModel.bookings[index],
onTap: () => context.push(Routes.bookingWithId(
viewModel.bookings[index].id)),
onDismissed: (_) => viewModel.deleteBooking.execute(
viewModel.bookings[index].id,
),
),
),
],
);
},
),
),
Actualizar la UI
#El widget HomeScreen escucha las actualizaciones del view model con
el widget ListenableBuilder.
Todo en el subárbol de widgets bajo el widget ListenableBuilder
se vuelve a renderizar cuando cambia el Listenable
proporcionado.
En este caso, el Listenable proporcionado es el view model.
Recuerda que el view model es de tipo ChangeNotifier
el cual es un subtipo del tipo Listenable.
@override
Widget build(BuildContext context) {
return Scaffold(
// Some code was removed for brevity.
body: SafeArea(
child: ListenableBuilder(
listenable: viewModel,
builder: (context, _) {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(),
SliverList.builder(
itemCount: viewModel.bookings.length,
itemBuilder: (_, index) =>
_Booking(
key: ValueKey(viewModel.bookings[index].id),
booking: viewModel.bookings[index],
onTap: () =>
context.push(Routes.bookingWithId(
viewModel.bookings[index].id)
),
onDismissed: (_) =>
viewModel.deleteBooking.execute(
viewModel.bookings[index].id,
),
),
),
],
);
}
)
)
);
}
Manejo de eventos de usuario
#Finalmente, una vista necesita escuchar eventos de los usuarios, para que el view model pueda manejar esos eventos. Esto se logra exponiendo un método de callback en la clase view model que encapsula toda la lógica.
En la HomeScreen, los usuarios pueden eliminar eventos reservados previamente deslizando
un widget Dismissible.
Recuerda este código del fragmento anterior:
SliverList.builder(
itemCount: widget.viewModel.bookings.length,
itemBuilder: (_, index) => _Booking(
key: ValueKey(viewModel.bookings[index].id),
booking: viewModel.bookings[index],
onTap: () => context.push(
Routes.bookingWithId(viewModel.bookings[index].id)
),
onDismissed: (_) =>
viewModel.deleteBooking.execute(widget.viewModel.bookings[index].id),
),
),
En la HomeScreen, el viaje guardado de un usuario está representado por
el widget _Booking. Cuando se descarta un _Booking,
se ejecuta el método viewModel.deleteBooking.
Una reserva guardada es un State de la aplicación que persiste más allá de
una sesión o de la vida útil de una view,
y solo los repositorios deberían modificar dicho State de la aplicación.
Por lo tanto, el método HomeViewModel.deleteBooking a su vez
llama a un método expuesto por un repositorio en la capa de datos,
como se muestra en el siguiente fragmento de código.
Future<Result<void>> _deleteBooking(int id) async {
try {
final resultDelete = await _bookingRepository.delete(id);
switch (resultDelete) {
case Ok<void>():
_log.fine('Deleted booking $id');
case Error<void>():
_log.warning('Failed to delete booking $id', resultDelete.error);
return resultDelete;
}
// Some code was omitted for brevity.
// final resultLoadBookings = ...;
return resultLoadBookings;
} finally {
notifyListeners();
}
}
En la aplicación Compass, estos métodos que manejan eventos de usuario se llaman comandos.
Objetos Command
#Los comandos son responsables de la interacción que comienza en la capa de UI y
fluye de regreso a la capa de datos. Específicamente en esta aplicación,
un Command es también un tipo que ayuda a actualizar la UI de manera segura,
independientemente del tiempo de respuesta o de los contenidos.
La clase Command envuelve un método y
ayuda a manejar los diferentes estados de ese método,
como running, complete y error.
Estos estados facilitan la visualización de diferentes interfaces de usuario,
como indicadores de carga cuando Command.running es true.
El siguiente es código de la clase Command.
Se ha omitido parte del código con fines demostrativos.
abstract class Command<T> extends ChangeNotifier {
Command();
bool running = false;
Result<T>? _result;
/// true if action completed with error
bool get error => _result is Error;
/// true if action completed successfully
bool get completed => _result is Ok;
/// Internal execute implementation
Future<void> _execute(action) async {
if (_running) return;
// Emit running state - e.g. button shows loading state
_running = true;
_result = null;
notifyListeners();
try {
_result = await action();
} finally {
_running = false;
notifyListeners();
}
}
}
La clase Command en sí extiende ChangeNotifier,
y dentro del método Command.execute,
notifyListeners se llama varias veces.
Esto permite que la view maneje diferentes estados con muy poca lógica,
de lo cual verás un ejemplo más adelante en esta página.
También habrás notado que Command es una clase abstracta.
Está implementada por clases concretas como Command0 y Command1.
El número entero en el nombre de la clase se refiere al
número de argumentos que espera el método subyacente.
Puedes ver ejemplos de estas clases de implementación en
el directorio utils de la aplicación Compass.
Asegurar que las views puedan renderizarse antes de que existan los datos
#En las clases de view model, los comandos se crean en el constructor.
class HomeViewModel extends ChangeNotifier {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) : _bookingRepository = bookingRepository,
_userRepository = userRepository {
// Load required data when this screen is built.
load = Command0(_load)..execute();
deleteBooking = Command1(_deleteBooking);
}
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
late Command0 load;
late Command1<void, int> deleteBooking;
User? _user;
User? get user => _user;
List<BookingSummary> _bookings = [];
List<BookingSummary> get bookings => _bookings;
Future<Result> _load() async {
// ...
}
Future<Result<void>> _deleteBooking(int id) async {
// ...
}
// ...
}
El método Command.execute es asíncrono,
por lo que no puede garantizar que los datos estén disponibles cuando
la view quiera renderizar. Esto explica el porqué la aplicación Compass utiliza Commands.
En el método Widget.build de la view,
el comando se utiliza para renderizar condicionalmente diferentes widgets.
// ...
child: ListenableBuilder(
listenable: viewModel.load,
builder: (context, child) {
if (viewModel.load.running) {
return const Center(child: CircularProgressIndicator());
}
if (viewModel.load.error) {
return ErrorIndicator(
title: AppLocalization.of(context).errorWhileLoadingHome,
label: AppLocalization.of(context).tryAgain,
onPressed: viewModel.load.execute,
);
}
// The command has completed without error.
// Return the main view widget.
return child!;
},
),
// ...
Dado que el comando load es una propiedad que existe en
el view model en lugar de ser algo efímero,
no importa cuándo se llame al método load o cuándo se resuelva.
Por ejemplo, si el comando de carga se resuelve antes de que
el widget HomeScreen se haya creado siquiera,
no es un problema porque el objeto Command sigue existiendo
y expone el State correcto.
Este patrón estandariza cómo se resuelven los problemas comunes de la UI en la aplicación,
lo que hace que tu base de código sea menos propensa a errores y más escalable,
pero no es un patrón que todas las aplicaciones querrán implementar.
El hecho de querer utilizarlo depende en gran medida de
otras decisiones arquitectónicas que tomes.
Muchas librerías que te ayudan a gestionar el State tienen
sus propias herramientas para resolver estos problemas.
For ejemplo, si fueras a usar
streams
y StreamBuilders
en tu aplicación,
las clases AsyncSnapshot
proporcionadas por Flutter ya tienen
esta funcionalidad incorporada.
Comentarios
#Dado que esta sección del sitio web está evolucionando, ¡agradecemos tus comentarios!
A menos que se indique lo contrario, la documentación en este sitio refleja Flutter 3.44.0. Página actualizada por última vez el 2026-06-10. Ver código fuente oreportar un problema.