flutter-arquitectura-mvvm-provider

Patrón MVVM en Flutter con Provider

  • 4 min

MVVM es un patrón que separa vista, datos y lógica de presentación.

Cuando una aplicación crece, separar archivos no basta: también necesitas decidir qué responsabilidad pertenece a cada pieza.

En Flutter, el rey indiscutible (gracias a su similitud con la forma de trabajar de Provider) es el MVVM (Model - View - ViewModel).

No te asustes por las siglas. Es simplemente una regla para saber dónde escribir cada línea de código para no mezclar churras con merinas.

¿Qué es cada pieza?

Vamos a mapear los conceptos teóricos a lo que ya sabemos de Flutter:

  • ¿Qué es? Son los datos puros. Las clases que representan “cosas” del mundo real.
  • ¿Qué hace? Representa datos y, según el diseño, puede incluir reglas propias del dominio. No conoce la interfaz.
  • En tu código: Son tus clases con fromJson y toJson.
  • Ejemplo: class Producto { final String nombre; final double precio; ... }
  • ¿Qué es? Es la Interfaz de Usuario. Lo que el usuario ve y toca.
  • ¿Qué hace? Pinta la pantalla basándose en el estado actual y captura los clics del usuario.
  • En tu código: Son tus Widgets (Screens).
  • Ejemplo: class PantallaCarrito extends StatelessWidget { ... }
  • ¿Qué es? Es el intermediario. El cerebro.

  • ¿Qué hace?

  • Guarda el Estado (la lista de productos, si está cargando, etc.).

  • Contiene la Lógica de Negocio (calcular el total, validar un cupón).

  • Expone métodos para que la Vista los llame.

  • En tu código: puede ser una clase ChangeNotifier que exponemos mediante Provider.

Flujo de trabajo

En esta variante, la vista recibe del ViewModel el estado que necesita y le envía las acciones del usuario.

Usuario toca un botón en la Vista (onTap).

Vista llama a una función del ViewModel (provider.agregar()).

ViewModel procesa la lógica, actualiza sus Modelos internos y notifica (notifyListeners).

Vista escucha el cambio y se redibuja automáticamente.

Ejemplo práctico: un carrito de compra

Vamos a ver cómo se ve esto en código real.

Datos tontos. Solo estructura.

class Item {
  final String nombre;
  final double precio;

  Item({required this.nombre, required this.precio});
}
Copied!

Aquí vive la lógica. Si mañana decides que el carrito tiene un máximo de 10 items, cambias este archivo. La vista ni se entera.

import 'package:flutter/material.dart';
import '../models/item.dart';

class CartProvider extends ChangeNotifier { // Esto es el ViewModel
  // Estado privado
  final List<Item> _items = [];

  // Getters para la vista (Estado público)
  List<Item> get items => List.unmodifiable(_items);
  
  double get precioTotal {
    return _items.fold(0, (total, item) => total + item.precio);
  }

  // Lógica de Negocio (Acciones)
  void agregarItem(Item item) {
    _items.add(item);
    notifyListeners(); // ¡Avisa a la Vista!
  }

  void limpiarCarrito() {
    _items.clear();
    notifyListeners();
  }
}
Copied!

La vista es “tonta”. No calcula sumas. Solo muestra lo que le dice el ViewModel.

class CartScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Escuchamos al ViewModel (Watch)
    final cart = context.watch<CartProvider>();

    return Scaffold(
      appBar: AppBar(title: Text("Mi Carrito")),
      body: Column(
        children: [
          // Lista de items
          Expanded(
            child: ListView.builder(
              itemCount: cart.items.length,
              itemBuilder: (ctx, i) => ListTile(
                title: Text(cart.items[i].nombre),
                trailing: Text("\$${cart.items[i].precio}"),
              ),
            ),
          ),
          
          // Total y Botón
          Container(
            padding: EdgeInsets.all(20),
            color: Colors.green[100],
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text(
                  "Total: \$${cart.precioTotal}", // El cálculo lo hizo el ViewModel
                  style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
                ),
                ElevatedButton(
                  onPressed: () {
                    // Acción del usuario -> Llamada al ViewModel
                    context.read<CartProvider>().limpiarCarrito();
                  },
                  child: Text("Pagar"),
                )
              ],
            ),
          )
        ],
      ),
    );
  }
}
Copied!

Qué aporta esta separación

Podrías pensar: “¿Para qué crear tres archivos si puedo hacerlo todo en el Widget?”.

  1. Testabilidad: Puedes probar tu CartProvider (verificar que la suma funciona) sin tener que arrancar un emulador ni dibujar píxeles.
  2. Mantenibilidad: Si el diseñador cambia la pantalla de roja a azul, solo tocas la Vista. Si el negocio cambia la regla de impuestos, solo tocas el ViewModel.
  3. Reutilización: Puedes usar el mismo CartProvider para mostrar el número de items en un icono pequeño en el HomeScreen.