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:
- 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.
1. Iniciar la aplicación
2. Modelos
3. Crear el 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
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
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.
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
8. Rutas y navegación (dir ui/routes)
class Rutas {
static final home = "/";
static final usuarios = "/usuarios";
static final albumes = "/albumes";
}
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
Página Home
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
- State Management
- Navigation & parameters
- Content injection
- Rest api
super bien explicado, felicitaciones
ResponderEliminar