Recientemente, tuve que implementar recorridos de incorporación para ClickUp ¡recién llegados! Se trataba de una tarea realmente importante porque muchos usuarios nuevos estaban a punto de descubrir la plataforma con el anuncio increíblemente divertido que estrenamos en la Super Bowl ¡! ✨
a través de ClickUp
El tutorial permite a nuestros numerosos nuevos usuarios, que tal vez aún no conocen ClickUp, comprender rápidamente cómo utilizar varias funciones de la aplicación. Se trata de un esfuerzo continuo, al igual que el nuevo ClickUp University ¡recurso que estamos persiguiendo! 🚀
¡Afortunadamente, la arquitectura de software detrás de la aplicación móvil ClickUp Flutter me permitió implementar esta función con bastante rapidez, incluso mediante la reutilización de los widgets reales de la aplicación! Esto significa que el paseo es dinámico, sensible, y coincide exactamente con las pantallas de aplicación real de la app-y continuará, incluso cuando los widgets evolucionarán.
También pude implementar la función gracias a la correcta separación de intereses.
Vamos a ver lo que quiero decir aquí. 🤔
Separación de preocupaciones
Diseñar una arquitectura de software es uno de los temas más complejos para los equipos de ingeniería. Entre todas las responsabilidades, siempre es difícil anticipar futuras evoluciones del software. Por eso, crear una arquitectura bien estratificada y desacoplada puede ayudaros a ti y a tus compañeros de equipo en muchas cosas
¡El principal beneficio de crear pequeños sistemas desacoplados es sin duda la testabilidad! ¡Y esto es lo que me ayudó a crear una demo alternativa de las pantallas existentes de la app!
Una guía paso a paso
Ahora, ¿cómo podríamos aplicar estos principios a una aplicación Flutter?
Vamos a compartir algunas de las técnicas que utilizamos para construir ClickUp con un sencillo ejemplo.
El ejemplo es tan simple que puede que no ilumine todas las ventajas que hay detrás, pero créeme, te ayudará a crear aplicaciones Flutter mucho más mantenibles con bases de código complejas. 💡
La aplicación
A modo de ejemplo, vamos a crear una aplicación que muestre la población de EEUU para cada año.
vía ClickUp
Aquí tenemos dos pantallas :
HomeScreen
: simplemente lista todos los años desde 2000 hasta ahora. Cuando el usuario pulsa sobre un azulejo de año, navegará a laDetailScreen
con un argumento de navegación ajustado al año seleccionado.- detailScreen` : obtiene el año del argumento de navegación, llama a la funciónaPI datausa.io para este año, y analiza los datos JSON para extraer el valor de población asociado. Si hay datos disponibles, se muestra un rótulo con la población.
Nos centraremos en la implementación DetailScreen
ya que es la más interesante con su llamada asíncrona.
Paso 1. Enfoque ingenuo
vía ClickUp
La implementación más obvia para nuestra app, es utilizando un único StatefulWidget
para toda la lógica.
Código fuente y demo de Dart
Acceso al argumento de navegación `year
Para acceder al año solicitado, leemos el RouteSettings
del widget heredado ModalRoute
.
void didChangeDependencies() {
super.didChangeDependencies();
año final = ModalRoute.of(context)!.ajustes.argumentos as int;
// ...
}
Llamada HTTP
Este ejemplo invoca a la función get
del paquete http
para obtener los datos del archivo
aPI datausa.io
analiza el JSON resultante con el método jsonDecode
de la librería dart:convert
, y mantiene Future
como parte del estado con una propiedad llamada _future
.
late Futuro<Mapa<dinámico, dinámico>?> _futuro;
void didChangeDependencies() {
super.didChangeDependencies();
final year = ModalRoute.of(context)!.ajustes.argumentos as int;
if (_año != año) {
_futuro = _cargarMedida(año);
}
}
Futuro<Mapa<dinámico, dinámico>?> _loadMeasure(int año) async {
_year = año;
final uri = Uri.parse(
'https://datausa.io/api/data?drilldowns=Nation&measures=Population&year=$año');
final result = await get(uri);
final body = jsonDecode(result.body);
final data = body['data'] as Lista<dynamic>;
if (data.isNotEmpty) {
return datos.primero;
}
return null;
Renderización
Para crear el árbol de widgets, usamos un FutureBuilder
, que se reconstruye a sí mismo según el estado actual de nuestra llamada asíncrona _future
.
@override
Widget build(BuildContext context) {
return Andamio(
appBar: AppBar(
título: Texto('Año $_año'),
),
body: FutureBuilder<Mapa<dinámico, dinámico>?>(
futuro: _futuro,
constructor: (contexto, instantánea) {
switch (snapshot.connectionState) {
case ConnectionState.terminada:
error final = snapshot.error;
if (error != null) {
// return "error" tree.
}
final data = snapshot.data;
if (data != null) {
// devuelve el árbol "resultado".
}
// devuelve datos "vacíos" tree.case ConnectionState.none:
case ConnectionState.waiting:
case ConnectionState.active:
// devuelve árbol de datos "cargando".
}
},
),
);
}
Revisión
Vale, la implementación es corta y sólo utiliza widgets incorporados, pero ahora piensa en nuestra intención inicial: construir alternativas de demostración (o pruebas) para esta pantalla. Es muy difícil controlar el resultado de la llamada HTTP para forzar a la aplicación a renderizar en un determinado estado.
Ahí es donde nos ayudará el concepto de inversión de control. 🧐
Paso 2. Inversión del control
a través de ClickUp
Este principio puede ser difícil de entender para los nuevos desarrolladores (y también difícil de explicar), pero la idea general es extraer las preocupaciones fuera de nuestros componentes-para que no sean responsables de elegir el comportamiento-y delegarlo en su lugar.
En una situación más común, consiste simplemente en crear abstracciones e inyectar implementaciones en nuestros componentes para que su implementación pueda cambiarse posteriormente si es necesario.
Pero no te preocupes, ¡tendrá más sentido después de nuestro siguiente ejemplo! 👀 Dart Código Fuente y Demo
Creación de un objeto cliente API
Para controlar la llamada HTTP a nuestra API, hemos aislado nuestra implementación en una clase dedicada DataUsaApiClient
. También hemos creado una clase Measure
para facilitar la manipulación y el mantenimiento de los datos.
clase DataUsaApiClient {
const DataUsaApiClient({
this.endpoint = 'https://datausa.io/api/data',
});
final Cadena endpoint;
Future<Medida?> getMedida(int año) async {
final uri =
Uri.parse('$puntofinal?desgloses=Nación&medidas=Población&año=$año');
final result = await get(uri);
final body = jsonDecode(result.body);
final data = body['data'] as Lista<dynamic>;
if (data.isNotEmpty) {
return Measure.fromJson(data.first as Mapa<Cadena, Objeto?>);
}
return null;
}
Proporcionar un cliente API
Para nuestro ejemplo, vamos a utilizar el conocido
proveedor
para inyectar una instancia DataUsaApiClient
en la raíz de nuestro árbol.
Proveedor<DataUsaApiClient>(
create: (context) => const DataUsaApiClient(),
child: const MaterialApp(
inicio: HomePage(),
),
)
Uso del cliente API
El proveedor permite a cualquier widget descendiente ( como nuestro _DetailScreen_
) leer el DataUsaApiClient
superior más cercano en el árbol. Podemos entonces utilizar su método getMeasure
para iniciar nuestro Future
, en lugar de la implementación HTTP real.
@overridevoid didChangeDependencies() {
super.didChangeDependencies();
final year = ModalRoute.of(context)!.ajustes.argumentos as int;
if (_year != year) {
_year = año;
final api = context.read<DataUsaApiClient>();
_futuro = api.getMeasure(año);
}
}
Demo API cliente
¡Ahora ya podemos aprovecharlo!
Por si no lo sabías: cualquier clase
en dart también definen implícitamente una interfaz asociada
. Esto nos permite proporcionar una implementación alternativa de DataUsaApiClient
que siempre devuelve la misma instancia de sus llamadas al método getMeasure
.
Este método
class DemoDataUsaApiClient implements DataUsaApiClient {
const DemoDataUsaApiClient(this.measure);
final Measure measure;
@overrideString get endpoint => '';
@override
Future<Medida?> getMedida(int año) {
return Futuro.valor(medida);
}
Visualización de una página de demostración
¡Ya tenemos todas las claves para mostrar una instancia de demostración de la DetailPage
!
Simplemente reemplazamos la instancia DataUsaApiClient
proporcionada actualmente envolviendo nuestra DetailScreen
en un proveedor que crea una instancia DemoDataUsaApiClient
en su lugar
Y eso es todo - nuestro DetailScreen
lee esta instancia de demostración en su lugar, y utiliza nuestros datos demoMeasure
en lugar de una llamada HTTP.
ListTile(
título: const Texto('Abrir demo'),
onTap: () {
const demoMedida = Medida(
año 2022,
población: 425484,
nación: 'Estados Unidos',
);
Navigator.push(
contexto,
MaterialPageRoute(
ajustes: RouteSettings(argumentos: demoMeasure.year),
constructor: (contexto) {
return Proveedor<DataUsaApiClient>(
create: (context) =>
const DemoDataUsaApiClient(demoMedida),
child: const PantallaDetallada(),
);
},
),
);
},
)
Revisión
Este es un gran ejemplo de Inversión de control. Nuestro widget DetailScreen
ya no es responsable de la lógica de obtener los datos, sino que la delega en un objeto cliente dedicado. Y ahora podemos crear instancias de demostración de la pantalla, o implementar pruebas de widget para nuestra pantalla ¡Impresionante! 👏
¡Pero podemos hacerlo aún mejor!
Dado que no somos capaces de simular un estado de carga, por ejemplo, no tenemos el control total de cualquier cambio de estado en nuestro nivel de widget.
Paso 3. Gestión de estados
vía ClickUp
¡Este es un tema candente en Flutter!
Estoy seguro de que ya has leído largos hilos de gente que intenta elegir la mejor solución de gestión de estados para Flutter. Y para ser claros, eso no es lo que vamos a hacer en este artículo. En nuestra opinión, mientras separes tu lógica de negocio de tu lógica visual, ¡estás bien! Crear estas capas es realmente importante para la mantenibilidad. Nuestro ejemplo es simple, pero en aplicaciones reales, la lógica puede volverse rápidamente compleja y tal separación hace mucho más fácil encontrar tus algoritmos de lógica pura. Este tema se resume a menudo como gestión de estados.
En este ejemplo, estamos usando un ValueNotifier
básico junto a un Provider
. Pero también podríamos haber usado
flutter_bloc
o
riverpod
(u otra solución)_, y también habría funcionado muy bien. Los principios siguen siendo los mismos, y siempre y cuando usted separó sus estados y su lógica, es incluso posible portar su código base de una de las otras soluciones.
Esta separación también nos ayuda a controlar cualquier estado de nuestros widgets para que podamos burlarnos de ellos de todas las formas posibles Código fuente y demo de Dart
Creación de un estado dedicado
En lugar de confiar en el AsyncSnapshot
del framework, ahora representamos nuestro estado de pantalla como un objeto DetailState
.
También es importante implementar los métodos hashCode
y operator ==
para que nuestro objeto sea comparable por valor. Esto nos permite detectar si dos instancias deben ser consideradas diferentes.
💡 El equiparable o congelado ¡los paquetes son grandes opciones para ayudarte a implementar los métodos
hashCode
yoperator ==
!
clase abstracta DetailState {
const DetailState(this.year);
final int año;
@overridebool operador ==(Objeto otro) =>
idéntico(este, otro) || (otro es DetailState &&)
(otro es DetailState &&
runtimeType == otro.runtimeType &&
año == otro.año);
@overrideint get hashCode => runtimeType.hashCode ^ año;
Nuestro estado siempre está asociado a un year
, pero también tenemos cuatro posibles estados distintos respecto a lo que queremos mostrar al usuario:
NotLoadedDetailState
: la actualización de datos aún no ha comenzadoLoadingDetailState
: los datos se están cargando en ese momento- loadedDetailState": los datos se han cargado correctamente con una "medida" asociada
- noDataDetailState": los datos se han cargado, pero no hay datos disponibles
- unknownErrorDetailState": la operación ha fallado debido a un "error" desconocido
class NotLoadedDetailState extends DetailState {
const NotLoadedDetailState(int año) : super(año);
}
class EstadoDetalleCargado extends EstadoDetalle {
const LoadedDetailState({
requerido int año,
requerido this.measure,
}) : super(año);
final Measure measure;
@overridebool operador ==(Objeto otro) =>
identical(this, other) || (other is LoadedDetailState && measure == other.measure)
(otro es LoadedDetailState && medida == otro.medida);
@overrideint get hashCode => runtimeType.hashCode ^ measure.hashCode;
}
class NoDataDetailState extends DetailState {
const NoDataDetailState(int año) : super(año);
}
class LoadingDetailState extends DetailState {
const LoadingDetailState(int año) : super(año);
}
class UnknownErrorDetailState extends DetailState {
const UnknownErrorDetailState({
requerido int año,
requerido this.error,
}) : super(año);
error dinámico final;
@overridebool operador ==(Objeto otro) =>
idéntico(este, otro) || (otro es UnknownErrorDetailState &&)
(otro es UnknownErrorDetailState &&
año == otro.año &&
error == otro.error);
@overrideint get hashCode => Objeto.hash(super.hashCode, error.has
Estos estados son más claros que un AsyncSnapshot
, ya que realmente representa nuestros casos de uso. Y de nuevo, ¡esto hace que nuestro código sea más mantenible!
💡 Recomendamos encarecidamente el Tipos de unión del paquete congelado ¡para representar tus estados lógicos! Añade un montón de utilidades como los métodos
copyWith
omap
.
Poniendo la lógica en un Notificador
Ahora que tenemos una representación de nuestro estado, necesitamos almacenar una instancia del mismo en algún lugar - ese es el propósito del DetailNotifier
. Mantendrá la instancia actual de DetailState
en su propiedad value
y proporcionará métodos para actualizar el estado.
Proporcionamos un estado inicial NotLoadedDetailState
y un método refresh
para cargar datos de la api
y actualizar el valor actual.
class DetailNotifier extends ValueNotifier<DetailState> {
DetailNotifier({
requerido int año,
requerido this.api,
}) : super(DetailState.notLoaded(year));
final DataUsaApiClient api;
int get año => valor.año;
Future<void> refresh() async {
si (¡valor es! LoadingDetailState) {
valor = DetailState.loading(año);
try {
final result = await api.getMeasure(year);
if (result != null) {
valor = DetailState.loaded(
año: year,
medida: resultado,
);
} else {
valor = DetailState.noData(año);
}
} catch (error) {
valor = DetailState.unknownError(
año: año,
error: error,
);
}
}
}
Proporcionar un estado para la vista
Para instanciar y observar el estado de nuestra pantalla, también dependemos del proveedor y su ChangeNotifierProvider
. Este tipo de proveedor busca automáticamente cualquier ChangeListener
creado y desencadenará una reconstrucción desde el Consumer
cada vez que sea notificado de un cambio (cuando el valor de nuestro notificador sea diferente del anterior)
class DetailScreen extends StatelessWidget {
const DetailScreen({
¿clave? key,
}) : super(clave: clave);
@override
Widget build(BuildContext context) {
final año = ModalRoute.of(context)!.ajustes.argumentos as int;
return ChangeNotifierProvider<DetailNotifier>(
create: (contexto) {
final notifier = DetailNotifier(
año: year,
api: context.read<DataUsaApiClient>(),
);
notifier.refresh();
return notificador;
},
child: Consumidor<NotificadorDetalle>(
constructor: (contexto, notificador, hijo) {
estado final = notificador.valor;
// ...
},
),
);
}
}
Revisión
¡Genial! La arquitectura de nuestra aplicación empieza a tener muy buena pinta. ¡Todo está separado en capas bien definidas, con preocupaciones específicas! 🤗
Sin embargo, aún falta una cosa por probar, queremos definir el DetailState
actual para controlar el estado de nuestra DetailScreen
asociada.
Paso 4. Widget de diseño visual dedicado
vía ClickUp
En el último paso, le dimos demasiada responsabilidad a nuestro widget DetailScreen
: era el responsable de instanciar el DetailNotifier
. Y como hemos visto anteriormente, ¡intentamos evitar cualquier responsabilidad lógica en la capa de vista!
Podemos resolver esto fácilmente creando otra capa para nuestro widget de pantalla: dividiremos nuestro widget DetailScreen
en dos:
DetailScreen
se encarga de ajustar las distintas dependencias de nuestra pantalla a partir del estado actual de la aplicación (navegación, notificadores, estado, servicios, ...),DetailLayout
simplemente convierte unDetailState
en un árbol dedicado de widgets.
Combinando ambos, podremos simplemente crear instancias demo/test de DetailLayout
, pero teniendo DetailScreen
para el caso de uso real en nuestra aplicación.
Código fuente y demo de Dart
Diseño dedicado
Para conseguir una mejor separación de intereses, hemos movido todo lo que hay bajo el widget Consumer
a un widget dedicado DetailLayout
. Este nuevo widget sólo consume datos y no es responsable de ninguna instanciación. Sólo convierte el estado de lectura a un árbol de widgets específico.
La llamada a "ModalRoute.of" y la instancia "ChangeNotifierProvider" permanecen en la "DetailScreen", y este widget simplemente devuelve el "DetailLayout" con un árbol de dependencias preconfigurado
Esta pequeña mejora es específica para el uso del proveedor, pero también hemos añadido un "ProxyProvider" para que cualquier widget descendiente pueda consumir directamente un "DetailState". Esto facilitará el uso de datos simulados.
class DetailScreen extends StatelessWidget {
const DetailScreen({
¿clave? key,
}) : super(clave: clave);
@override
Widget build(BuildContext context) {
final año = ModalRoute.of(context)!.ajustes.argumentos as int;
return ChangeNotifierProvider<DetailNotifier>(
create: (contexto) {
final notifier = DetailNotifier(
año: year,
api: context.read<DataUsaApiClient>(),
);
notifier.refresh();
return notificador;
},
child: child: ProxyProvider<NotificadorDetalle, EstadoDetalle>(
update: (context, value, previous) => valor.valor,
child: const DetailLayout(),
),
);
}
}
clase DetailLayout extends StatelessWidget {
const DetailLayout({
¿clave?
}) : super(clave: clave);
@override
Widget build(BuildContext context) {
return Consumidor<EstadoDetalle>(
constructor: (contexto, estado, hijo) {
return Andamio(
appBar: AppBar(
título: Texto('Año ${state.year}'),
),
body: () {
// ...
}(),
);
},
);
}
Extracción de widgets como clases dedicadas
No dude nunca en extraer un árbol de widgets en una clase dedicada Esto mejorará el rendimiento y facilitar el mantenimiento del código.
En nuestro ejemplo hemos creado un widget de diseño visual para cada uno de los tipos de estado asociados:
if (state is NotLoadedDetailState || state is LoadingDetailState) {
return const LoadingDetailLayout();
}
if (state is LoadedDetailState) {
return LoadedDetailLayout(state: state);
}
if (state is UnknownErrorDetailState) {
return UnknownErrorDetailLayout(estado: estado);
}
return const NoDataDetailLayout();
Instancias de demostración
Ahora tenemos el control total sobre lo que podemos simular y mostrar en nuestra pantalla
Sólo tenemos que envolver un DetailLayout
con un Provider<DetailState>
para simular el estado del diseño.
ListTile(
título: const Text('Abrir demo "cargada"),
onTap: () {
Navegador.push(
contexto,
MaterialPageRoute(
constructor: (contexto) {
return Proveedor<EstadoDetalle>.valor(
valor: const DetailState.loaded(
año: 2022,
medida: Medida(
año: 2022,
población: 425484,
nación: 'Estados Unidos',
),
),
child: const DetailLayout(),
);
},
),
);
},
),
ListTile(
título: const Texto('Abrir demo "cargando"'),
onTap: () {
Navegador.push(
contexto,
MaterialPageRoute(
constructor: (contexto) {
return Proveedor<EstadoDetalle>.valor(
valor: const DetailState.loading(2022),
child: const DetailLayout(),
);
},
),
);
},
),
Conclusión
¡Crear una arquitectura de software mantenible definitivamente no es fácil! Anticipar escenarios futuros puede exigir mucho esfuerzo, ¡pero espero que los pocos consejos que he compartido te ayuden en el futuro!
Los ejemplos pueden parecer simples -incluso puede parecer que estamos sobre-ingeniería- pero a medida que la complejidad de tu app crece, ¡tener esos estándares te ayudará mucho! 💪
Diviértete con Flutter, ¡y sigue el blog para conseguir más artículos técnicos como este! ¡Permanece atento!