¿Cómo usar Model-View ViewModel en Flutter?

MVVM Arquitecture

Cómo MVVM nos permite organizar mejor nuestro código, y por qué es uno de los patrones más fácil de implementar en Flutter

¿Qué es Model-View ViewModel?

Es un patrón de desarrollo que te permite separar la aplicación (cliente) en 3 partes básicas, Model, View y ViewModel, la meta de esta separación es la de ofrecer un esquema donde las responsabilidades estén bien delimitadas y la comunicación entre las partes sea lo más limpia posible.


View: Representa todo lo que el usuario puede ver y con lo que puede interactuar, en esta parte solo esperamos poder recibir comandos del usuario y poder mostrar los datos procesados mediante componentes visuales.

Model: Es la sección que representa la información, el conjunto de datos, no posee ninguna representación visual.

ViewModel: Es el intermediario entre la vista y el modelo, su función principal es abstraer de la vista todos los datos que provee el modelo para una correcta visualización de los mismos, a su vez el ViewModel también se encarga de recibir los los comandos enviados por la vista y comunicar al modelo si debe o no actualizar algún dato.

Motivación

Veamos el siguiente ejemplo, se tiene un StatefullWidget en donde la subclase extiende de un State, en esta última se realiza un llamado a un servicio cuando se inicializa la pantalla, luego de recibir la información se actualizan los datos de contactos y la siguiente página.

// ----------------- NO MVVM PATTERN -----------------
class ContactsScreen extends StatefullWidget {

 const ContactScreen({Key? key});

}

class _ContactsScreenState extends State<MyScreen> {

 List<Contact> contacts = [];

 int page = 0;

 @override

 void initState() {

   fetchContacts(page)

     .then((result) {

       setState(() {

         contacts = result.contacts;

         page = result.nextPage;

       });

     });

   super.initState();

 }

 @override

 Widget build(BuildContext context) {

   return ListView.builder(

     itemCount: viewModel.contacts.length,

     itemBuilder: (context, idx) {

       final contact = viewModel.contacts[idx];

       return ContactListItem(contact: contact);

     }

   );

 }

}

¿Qué podemos observar de este ejemplo?

Básicamente mezclamos conceptos y si fuese un componente mucho más amplio se volvería un clásico código espagueti, los widgets deberían encargarse de solo mostrar información y componentes al usuario, pero si además debe preocuparse por como obtener la información y mantenerla actualizada, se le está atribuyendo muchas responsabilidades.

Con este ejemplo en mente, pasemos ahora a recrear todo pero aplicando MVVM, y veamos que ventajas podemos percibir.

Aplicación de MVVM con la librería Provider
Primero: La Vista

En el método build antes de agregar el ListView deberíamos agregar los widgets de ChangeNotifierProvider y Consumer, con el primero le damos contexto a la vista de quien puede realizar modificaciones sobre ella, con el segundo especificamos qué partes son las que se verán afectadas durante cada update.

En el initState inicializamos lo que sería nuestro viewModel, con él podremos manipular la data que queramos mostrar en la vista, justo después de su inicialización se puede ver como se hace el llamado a un método onScreenInitialized, es en ese método donde se buscan los primeros contactos y luego se manda a re-dibujar esa parte de la pantalla con los resultados (esto lo veremos en detalle cuando expliquemos el ViewModel).

Como se puede ver, nos queda un widget más simple, donde lo más “complejo” ha sido agregar el ChangeNotifierProvider y el Consumer, pero retiramos cualquier lógica referente al dominio.

// ----------------- VIEW -------------------
class ContactsScreen extends StatefullWidget {

 const ContactScreen({Key? key});

}

class _ContactsScreenState extends State<ContactsScreen> {

 late ContactsScreenViewModel _viewModel;

 @override

 void initState() {

   _viewModel = ContactsScreenViewModel(

     ActionsFactory.getContacts()

   );

   _viewModel.onScreenInitialized();

   super.initState();

 }

 @override

 Widget build(BuildContext context) {

   return ChangeNotifierProvider<ContactsScreenViewModel>.value(

     value: _viewModel,

     builder: (context, _) {

       return Consumer<ContactsScreenViewModel>(

         builder: (context, viewModel, _) =>

           ListView.builder(

             itemCount: viewModel.contacts.length,

             itemBuilder: (context, idx) {

               final contact = viewModel.contacts[idx];

               return ContactListItem(contact: contact);

             }

           )

       )

     }

   )

 }

}
Segundo: El ViewModel

Como se puede ver, en el viewModel ahora tenemos un lugar donde podemos guardar temporalmente la lista de contactos, esta lista es la que se expondrá a la vista para su posterior renderizado, al tener esta lógica en el viewModel tenemos la ventaja de poder trabajar con mayor comodidad el comportamiento de la vista.

Cuando se trabaja con el viewModel se puede dividir la interacción en dos partes, la primera vendría siendo la manipulación de los datos, esto cuando modificamos los modelos que maneja el viewModel, como viene a ser el caso de la lista de contactos, y la segunda parte cuando se le notifica a la vista que debe hacer un re-build debido a estos cambios. Esto último lo logramos haciendo que nuestro viewModel extienda sus funcionalidades a través de clase ChangeNotifier, la cual nos provee el método notifyListeners.

// ----------------- VIEW_MODEL ---------------------
class ContactsScreenViewModel with ChangeNotifier {

 final GetContacts _getContacts;

 ContactsScreenViewModel(this._getContacts);

 List<Contact> _contacts = [];

 List<Contact> get contacts => _contacts;

 int _page = 0;

 void onScreenInitialized() async {

   _fetchContacts();

 }

 void onEndPageReached() async {

   _fetchContacts();

 }

 void _fetchContacts() {

   var result = await _getContacts(_page);

   _contacts = result.contacts;

   _page = result.nextPage;

   notifyListeners();

 }

}
Tercero: El Modelo

Y por último tenemos el modelo, que no es más que las estructura de datos que creamos para representar la información que queremos, por ahora solo tenemos la clase contacto.

// --------------- MODEL ------------------
class Contact {

 final String id;

 final String name;

 final String phoneNumber;

 Contact(this.id, this.name, this.phoneNumber);

}

// -------------- Result DTO -------------

class ContactsPage {

 final List<Contact> contacts;

 final int nextPage;

 ContactsPage(this.contacts, this.nextPage);

}

// ----- BONUS: Action (for a DDD Architecture) -------

class GetContacts {

 final ContactsService _contactsService;

 final UserRepository _userRepository;

 GetContacts(this._contactsService, this._userRepository);

 Future<ContactsPage> call(int page) {

   var userId = _userRepository.get().userId;

   return _contactsService.getFor(userId, page);

 }

}
Bonus Part

Si bien no era el punto principal de este post, cabe destacar que un ventaja adicional que nos ofrece el patrón MVVM es la posibilidad de realizar test al comportamiento de nuestra app, como se puede ver abajo, los tests que se realizan no son a los widgets sino a los métodos que estos acceden.

// -------------------- TESTS ----------------------
main() {

 late GetContacts getContacts;

 late ContactsScreenViewModel viewModel;

 setUp(() {

   getContacts = MockGetContacts();

   viewModel = ContactsScreenViewModel(getContacts);

 });

 test('on screen initialized get contacts', () {

   viewModel.onScreenInitialized();

   verify(getContacts(0)).called(1);

 });

 test('on screen initialized get contacts', () {

   viewModel.onScreenInitialized();

   viewModel.onEndPageReached();

   verify(getContacts(1)).called(1);

 });

}

Conclusión

Existen distintos patrones de desarrollo al momento de trabajar en un proyecto front-end, pero considero que el Model-View-ViewModel ofrece un esquema muy completo y sencillo de adoptar cuando se trabaja con Flutter, este esquema desde el primer momento te permite organizar mejor tu proyecto, que se entienda como deben estar separadas las partes y como cada una puede interactuar o no con la otra.

En base a lo anterior se puede decir que permite que tu aplicación sea mucho más escalable, pudiendo modificar alguna de las 3 partes sin que el resto se vea afectada directamente.

Provider a su vez es una librería muy sencilla de utilizar, en comparación a muchas otras que por lo general se enfocan más en un esquema reactivo, pero en proyectos grandes, esta reactividad se puede tornar compleja y difícil de seguir.