Clean Arquitecture: Mi visión en Flutter (2/2), implementando con Getx

En el primer artículo hablé de qué suponía Clean Arquitectura (CA) y mostré una primera configuración de directorios.

En éste vamos a ver como implementar usando Getx para todo, gestor de estados, navegación, inyección y consumir apis rest. Podeis sustituir cada uno de estos elementos por la preferencia que tengáis

Codificaremos  el siguiente esquema:


Por un lado tenemos el Servidor que vía Internet nos ofrece los datos que utilizaremos en la aplicación. Estos datos estarán en formato json. Para el ejemplo usaré jsonplaceholder.

Crearé una aplicación flutter con tres pantallas:
  • Home, pantalla inicial que únicamente tendrá un botón para navegar a la siguiente pantalla y sirve de presentación.    
  • Usuarios, pantalla que muestra una lista de usuarios. Al seleccionar un usuario se enseña, en la siguiente pantalla, la lista de álbumes que el usuarios tiene
  • Álbumes, pantalla que muestra la lista de álbumes de un usuario. Se hace back para volver a la pantalla de usuarios.
Crearemos los dos modelos, para convertir del json a dart, Usuario y Album.

En cuanto a los Controladores se crearán dos, uno por cada api. Cómo en este caso no tenemos datos globales, no necesitamos más controladores.

En cuanto al Repositorio crearemos uno y lo inyectaremos a la aplicación ya que el repositorio es global  e inyectándole el proveedor (todos los que haya). Este repositorio luego lo inyectaremos a los controladores.

Como proveedor sólo tendremos uno, el que accede a jsonplaceholder.

1. Iniciar la aplicación

Cómo ya vimos en el anterior artículo, creamos la estructura básica de nuestro proyecto y, en mi caso, agregaré Getx al pubspec.yaml.




2. Modelos

Lo primero será crear los modelos, utilizando la web quicktype:

Nota: hay que hacer una modificación ya que esta web convierte usando @required y hay que cambiarlos por required si estamos en null-safe.


Simplemente pegamos los resultados en ambos ficheros:


3. Crear el proveedor

Aquí tenemos el código del proveedor:

import 'package:clean_arquitecture_flutter/data/models/album_model.dart';
import 'package:clean_arquitecture_flutter/data/models/usuario_model.dart';
import 'package:get/get.dart';


class PlaceholderapiProvider extends GetConnect {
  static PlaceholderapiProvider get to => Get.find<placeholderapiprovider>();

  @override
  onInit() async {
    httpClient.baseUrl = "http://jsonplaceholder.typicode.com";
  }

  Future<dynamic> getListaUsuarios() async {
    final resultado = await get("/users");
    if (!resultado.isOk) {
      throw ("Error la obtener la lista de usuarios (${resultado.statusCode!}: ${resultado.statusText})");
    }
    return resultado.body.map((e) => Usuario.fromJson(e)).toList();
  }
  Future<dynamic> getAlbumesUsuario({idUsuario}) async {
    final resultado = await get("/albums?userId=$idUsuario");
    if (!resultado.isOk) {
      throw ("Error la obtener la lista de usuarios (${resultado.statusCode!}: ${resultado.statusText})");
    }
    return resultado.body.map((e) => Album.fromJson(e)).toList();
  }
}

Mi proveedor lo voy a construir con Getx usando GetConnect para las comunicaciones, ya que así me ahorro poner un paquete como Dio, Http o similar. Heredando de esa clase tengo a mi disposición métodos como get, put, delete, etcetera y algunos más así como métodos para graphql como query o mutate.

Veamos lo que hace:

  • El método onInit yo lo utilizo para no tener que estar escribiendo en cada llamada la url del servidor. Se configura usando el objeto httpClient que nos suministra GetConnect.
  • Luego están los dos métodos qué vamos a utilizar para obtener la lista de usuarios y la lista de albumes de un usuario. Estos métodos no son muy complicados y quiero haceros observar que en caso de que haya un error http yo lo voy a devolver como una excepción (más adelante veremos por qué). En caso de que todo haya ido bien, devuelvo los datos, ya convertida la cadena json recibida usando el mapa.

4. El repositorio

El código del repositorio es el siguiente

import 'package:clean_arquitecture_flutter/data/providers/placeholderapi_provider.dart';
import 'package:clean_arquitecture_flutter/data/models/album_model.dart';
import 'package:clean_arquitecture_flutter/data/models/usuario_model.dart';
import 'package:get/get.dart';

class Repositorio extends GetxController {
  static Repositorio get to => Get.find<repositorio>();
  final PlaceholderapiProvider proveedor;

  Repositorio({required this.proveedor});

  Future<dynamic> getListaUsuarios() async {
    return await proveedor.getListaUsuarios();
  }

  Future<dynamic> getAlbumesUsuario({idUsuario}) async {
    return await proveedor.getAlbumesUsuario(idUsuario: idUsuario);
  }
}  

La verdad es que visto así queda muy simple y flojo, pero es lo que tiene, por eso digo que si sólo tenemos un proveedor, no merece la pena. De todas formas como ejemplo practico y para que se vea el ciclo completo y la inyección entre controladores lo voy a utilizar.

Lo primero es que el repositorio es un controlador, por lo que le agrego un get para buscarlo, cómo hago en todos los controladores para simplificar luego la sintaxis.

Defino mi variable que contendrá el proveedor, si hay varios tendremos que definirlos todos, cada uno con su tipo y agregarlos en el constructor.

Definimos el constructor con los proveedores que tengamos que inyectarle.

Luego por cada método accesible de los proveedores tendremos aquí uno igual, que llama al del proveedor. 

Cómo se puede ver, esto sirve para independizar los proveedores de los controladores, y que con un único repositorio tengamos acceso a todos los proveedores sin tenernos que preocupar cual es el que físicamente resuelve alguna petición.

En sí el repositorio tiene poca explicación.

5. Crear los controladores

Esta es una parte que puede generar discusión. Lo más simple es crear un controlador por cada llamada. Pero se puede incluir varias llamadas en el mismo controlador, dependiendo de cómo se utilicen luego.

Hay que tener en cuenta que si dos llamadas a métodos distintos del controlador, están en la misma  página y se actualiza una, ambas generarán el mismo ciclo de onLoadin, onSuccess, onError, esto depende mucho del diseño y de cómo queremos que el usuario vea reflejada en la aplicación el ciclo de la invocación. Quizás requiera una explicación algo más profunda.

En nuestro caso como cada pagina tiene su propio acceso usaremos dos controladores uno por llamada.


import 'package:clean_arquitecture_flutter/data/repository.dart';
import 'package:get/get.dart';

class UsuariosController extends GetxController with StateMixin {
  static UsuariosController get to => Get.find<UsuariosController>();
  final Repositorio repositorio;

UsuariosController({required this.repositorio});

  @override
  void onInit() {
    change(null, status: RxStatus.empty());
    super.onInit();
  }

  consultarUsuarios() async {
    try {
      change(null, status: RxStatus.loading());
      final resultado = await repositorio.getListaUsuarios();
      change(resultado, status: RxStatus.success());
    } catch (e) {
      change(null, status: RxStatus.error(e.toString()));
    }
  }
} 

Este es el controlador que gestiona la llamada a la lista de usuarios, cómo se puede ver se utiliza el repositorio que debe de existir al instanciarlo.

El controlador usado extiende, además del GetxController del StateMixin. Esto es muy importante ya que el StateMixin es el que nos dota de la capacidad de emitir vía el método change las notificaciones a la parte visual sobre el estado de la operación. En el método onInit inicializamos el estado a empy. Esto es sobre todo necesario si no se quiere ejecutar al iniciar la página, ya que el estado por defecto es loading.

  • loading permite notificar que está trabajando
  • success informa de que la operación ha terminado correctamente y permite devolver los datos.
  • error cómo indica devuelve una cadena conteniendo el error al notificar.
  • empy el proceso no está haciendo nada y no hay datos.
Observad que el estado de error se produce como respuesta de un try-catch, motivo por lo que en el proveedor uso una excepción para notificar los errores, así no tengo que investigar la variable resultado, y, cualquier error, por ejemplo en el parser de json, queda perfectamente capturado y la aplicación no se rompe.

Creo que tiene poco comentario este tipo de controladores, ya que simplemente "envuelven" para gestionar los estados a la llamada del repositorio (o del proveedor si no hay).

El otro controlador es igual:

import 'package:clean_arquitecture_flutter/data/repository.dart';
import 'package:get/get.dart';

class AlbumesController extends GetxController with StateMixin {
  static AlbumesController get to => Get.find<AlbumesController>();
  final Repositorio repositorio;

  AlbumesController({required this.repositorio});

  @override
  void onInit() {
    change(null, status: RxStatus.empty());
    super.onInit();
  }

  consultarAlbumesUsuario({idUsuario}) async {
    try {
      change(null, status: RxStatus.loading());
      final resultado =
          await repositorio.getAlbumesUsuario(idUsuario: idUsuario);
      change(resultado, status: RxStatus.success());
    } catch (e) {
      change(null, status: RxStatus.error(e.toString()));
    }
  }
}

En ambos controladores no se devuelve nada directamente en la llamada sino que se utilizan los mecanismos de notificación del StateMixin.

Por este motivo estos controladores están fuertemente enlazados con la parte visual ya que sin ella no tiene sentido usarse, a diferencia de los proveedores y de los repositorios a los cuales podemos hacer test sin necesitar nada visual.

Así estos controladores sólo serán llamados desde componentes visuales.

Podemos decir que estos controladores son el puente entre la parte visual y la no visual.

Ahora nuestra aplicación muestra el siguiente árbol de ficheros:



7. UI, navegación e inyección

La parte del UI quedará la siguiente estructura y los ficheros:


Tenemos por un lado en el raiz el main.dart.

Luego una carpeta ui que contiene directamente las páginas, cómo ya comenté dado que las páginas sólo van ha tener un fichero cada una no las pongo en distintos directorios.

Seguidamente hay un directorio routes que es donde creo todos los ficheros referentes a la navegación y la inyección de contenido de las páginas.

8. Rutas y navegación (dir ui/routes)

Hay varias maneras de definir las rutas, yo utilizo un objeto con atributos estáticos. Creo el fichero rutas.dart  y defino las tres rutas que necesito. Utilizo la nomenclatura de URL ya que así es compatible con las aplicaciones web y también es la recomendación de Flutter.

class Rutas {
  static final home = "/";
  static final usuarios = "/usuarios";
  static final albumes = "/albumes";
}

Para poder navegar necesito el fichero donde defino las rutas y sus correspondientes widgets, además necesito definir los bindings o elementos que hay que inyectar a cada página.

Para los bindings creo el fichero bindings.dart


import 'package:clean_arquitecture_flutter/data/controllers/albumes_controller.dart';
import 'package:clean_arquitecture_flutter/data/controllers/usuarios_controller.dart';
import 'package:clean_arquitecture_flutter/data/repository.dart';
import 'package:get/get.dart';

class UsuariosBinding implements Bindings {
  @override
  void dependencies() {
    Get.lazyPut(() => UsuariosController(repositorio: Repositorio.to));
  }
}

class AlbumesBinding implements Bindings {
  @override
  void dependencies() {
    Get.lazyPut(() => AlbumesController(repositorio: Repositorio.to));
  }
}

Estos bindings lo que hacen es que al navegar a la página correspondiente, Getx carga las dependencias (las inyecta) y cuando sales de la página las elimina.

 Aquí podemos ver que a los controladores les inyectamos el repositorio, ya que lo necesitan. El repositorio ya tiene que estar cargado en la aplicación, por eso en vez de instanciarlo, lo pasamos haciendo una búsqueda de él.

Una vez definidas las rutas y los bindings defino las páginas que serán cargadas por cada ruta usando el fichero pages.dart

import 'package:clean_arquitecture_flutter/ui/albumes_page.dart';
import 'package:clean_arquitecture_flutter/ui/home_page.dart';
import 'package:clean_arquitecture_flutter/ui/routes/bindings.dart';
import 'package:clean_arquitecture_flutter/ui/routes/rutas.dart';
import 'package:clean_arquitecture_flutter/ui/usuarios_page.dart';
import 'package:get/get.dart';

class Paginas {
  static final lista = [
    GetPage(name: Rutas.home, page: () => HomePage()),
    GetPage(
      name: Rutas.usuarios,
      page: () => UsuariosPage(),
      binding: UsuariosBinding(),
    ),
    GetPage(
      name: Rutas.albumes,
      page: () => AlbumesPage(),
      binding: AlbumesBinding(),
    ),
  ];
}

Aquí defino la lista de paginas que voy a utilizar (en Getx), cada pagina se define por su nombre y el widget que la constituye.

En el caso de las paginas de usuarios y álbumes le añado la definición del binding que utilizan para que al navegar se carguen y asignen los controladores.

Main

import 'package:clean_arquitecture_flutter/data/providers/placeholderapi_provider.dart';
import 'package:clean_arquitecture_flutter/data/repository.dart';
import 'package:clean_arquitecture_flutter/ui/routes/pages.dart';
import 'package:clean_arquitecture_flutter/ui/routes/rutas.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';

void main() {
  Get.put(Repositorio(proveedor: Get.put(PlaceholderapiProvider())));

  runApp(GetMaterialApp(
    debugShowCheckedModeBanner: false,
    initialRoute: Rutas.home,
    getPages: Paginas.lista,
  ));
}

El main app sólo proporciona la configuración de la ruta incial y las configuración de páginas para el Getx.

La  primera línea es la inyección del repositorio, lo hago a nivel global ya que el repositorio es único y para toda la aplicación, por supuesto, el repositorio necesita el proveedor o los proveedores, por lo que le inyecto el proveedor del api que definimos antes. Así es cómo se inyecta uno en otro. 

Tengamos en cuenta que PlaceholderapiProvider() sólo se usa desde el Repositorio motivo por el que se instancia al mismo tiempo que se inyecta.Hay muchas formas de incialciar esto, se podria cargar antes el PlaceholderapiProvider y luego en Repositorio obtenerlo mediante el getter to. Pero me parece más limpia esta forma.

9. Páginas

Las páginas y su navegación serán


Página Home

Esta pagina no es necesaria pero era para que se viese la diferencia con las otras ya que esta no tiene inyección (binding) y simplemente sirve para ir a la de usuarios y que se vea la navegación.

import 'package:clean_arquitecture_flutter/ui/routes/rutas.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text('Home Page')),
        body: SafeArea(
            child: Center(
          child: ElevatedButton(
            onPressed: () => Get.offAndToNamed(Rutas.usuarios),
            child: Text('Lista de usuarios'),
          ),
        )));
  }
}

Cuando se presione el botón se ejecuta la navegación en este caso Get.offAndToNamed(Rutas.usuarios) hace que la pagina Home se elimine y se navegue a la pagina de los usuarios.

Página Usuarios


import 'package:clean_arquitecture_flutter/data/controllers/usuarios_controller.dart';
import 'package:clean_arquitecture_flutter/data/models/usuario_model.dart';
import 'package:clean_arquitecture_flutter/ui/routes/rutas.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';

class UsuariosPage extends GetView<UsuariosController> {
  @override
  Widget build(BuildContext context) {
    controller.consultarUsuarios();
    return Scaffold(
      appBar: AppBar(title: Text('Usuarios Page')),
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Center(
            child: controller.obx(
              (listaUsuarios) {
                return ListView.separated(
                  itemCount: listaUsuarios.length,
                  itemBuilder: (_, index) {
                    Usuario usuario = listaUsuarios[index];
                    return GestureDetector(
                      child: Text("${usuario.id.toString()} ${usuario.name}"),
                      onTap: () =>
                          Get.toNamed(Rutas.albumes, arguments: usuario),
                    );
                  },
                  separatorBuilder: (_, __) => Divider(),
                );
              },
              onLoading: CircularProgressIndicator(),
              onError: (error) => Text(
                error!,
                style: TextStyle(color: Colors.red),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Esta página contiene un Scaffold con una lista con los nombres de usuarios, al pulsar sobre algún nombre de la lista nos lleva a la pagina de sus álbumes.

Usando Getx esta página deriva de GetView y usa un controlador, este controlador es mapeado en la variable controller que invocaremos para que nada más iniciar la página cargue los datos de los usuarios.

En Getx el StateMixin tiene un método obx que es el que nos permite recibir las notificaciones de estado del controlador, y devolver el widget correspondiente. 

Cómo vemos cuando llegue respuesta se ejecuta una función al que se le pasa el valor obtenido en la llamada. En este caso se pinta la lista con el algunos datos de los usuarios. En el item de la lista el método onTap realiza la navegación haciendo un pop (poniendo encima) la página de álbumes y pasando como argumento del objeto usuario correspondiente al elemento.

El parametro onLoad permite devolver el widget que usaremos cuando estemos cargando, en este caso un CircularProgressIndicator.

Y si hay un error se mostrará un texto con el literal recibido del controlador.

Página Albumes

import 'package:clean_arquitecture_flutter/data/controllers/albumes_controller.dart';
import 'package:clean_arquitecture_flutter/data/models/album_model.dart';
import 'package:clean_arquitecture_flutter/data/models/usuario_model.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';

class AlbumesPage extends GetView<AlbumesController> {
  @override
  Widget build(BuildContext context) {
    final Usuario usuario = Get.arguments;
    controller.consultarAlbumesUsuario(idUsuario: usuario.id);
    return Scaffold(
      appBar: AppBar(title: Text('Albumes Page para ${usuario.name}')),
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Center(
            child: controller.obx(
              (listaAlbumes) {
                return ListView.separated(
                  itemCount: listaAlbumes.length,
                  itemBuilder: (_, index) {
                    Album album = listaAlbumes[index];
                    return Text("${album.title}");
                  },
                  separatorBuilder: (_, __) => Divider(),
                );
              },
              onLoading: CircularProgressIndicator(),
              onError: (error) => Text(
                error!,
                style: TextStyle(color: Colors.red),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Esta página también muestra un Scaffold en la barra de título he añadido el nombre del usuario recibido y como body se pinta una lista con los álbumes del usuario seleccionado en la otra página.

la línea final Usuario usuario = Get.arguments  nos permite recibir el usuario que teníamos en la otra pantalla que se envió en el Get.toNamed(...).

Aquí AlbumController al ser también un StateMixin utilizo su método obx para recibir los estados y datos, en éste caso los items no hacen nada.

 Para volver a la página anterior dado que hemos navegado superponiendo esta página a la anterior nos aparecerá de forma automática el indicador en el Scaffol para hacer el goBack.

0. Comentarios

Ahora, finalizado, el árbol de la aplicación sería


En este artículo hemos visto una visión de clean arquitecture usando Getx. El fin no es mostrar el uso del Getx sino mi idea de cómo enfocar la organización y estructura de los distintos componentes que forman la aplicación.

Aún así y todo, creo sinceramente que el planteamiento mostrado usando Getx es muy simple y muy económico, lo que se traduce en menos tiempo y un mejor mantenimiento futuro. Con muy, muy pocas líneas está todo montado y funcionando y creo que se entiende facilmente.

Aún así y todo creo que es relativamente fácil extrapolar usando Riverpod, Provider, o Bloc con Fluro u otra tecnología de navegación mas Dio o http y la inyección que os guste (o sin ella). 

Hay que tener en cuenta que aquí estamos usando 
  • State Management
  • Navigation & parameters
  • Content injection
  • Rest api
Queda claro que las páginas son muy simples (no es mi intención el diseño) y queda fuera de este artículo como codificar de forma adecuada su estructura. Lo normal es que se creen diferentes métodos dentro del objeto página o incluso otros objetos que simplifiquen la lectura. Pero eso es clean code y es otra historia ;).

Cómo curiosidad en Github dejo una versión de la pagina de usuarios que con un botón permite actualizar la pagina para que se vea cómo desde sí mima se actualizan los datos.

Un Saludo.

Repositorio Github


Comentarios

Publicar un comentario

Entradas populares de este blog

Clean Arquitecture: Mi visión en Flutter (1/2).

Getx 2 - variables reactivas - Lista, Mapas, Objetos