Separación de intereses en aplicaciones de aleteo
Engineering at ClickUp

Separación de intereses en aplicaciones de aleteo

Recientemente, tuve que implementar tutoriales de incorporación para los nuevos usuarios de ClickUp. Esta fue una tarea muy importante, ya que muchos nuevos usuarios estaban a punto de descubrir la plataforma gracias al divertido anuncio que estrenamos en la Super Bowl. ✨

a través de ClickUp

La guía permite a nuestros numerosos usuarios nuevos, que quizá aún no conozcan ClickUp, comprender rápidamente cómo utilizar varias funciones de la aplicación. Se trata de un esfuerzo continuo, al igual que el nuevo recurso ClickUp University que estamos desarrollando.

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 reutilizando los widgets reales de la aplicación! Esto significa que el tutorial es dinámico, receptivo y coincide exactamente con las pantallas reales de la aplicación, y seguirá haciéndolo, incluso cuando los widgets evolucionen.

También pude implementar la función gracias a la correcta separación de responsabilidades.

Veamos a qué me refiero. 🤔

Separación de preocupaciones

El diseño de 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 las evoluciones futuras del software. Por eso, crear una arquitectura bien estratificada y desacoplada puede ayudarte a ti y a tus compañeros de equipo en muchas cosas.

La principal ventaja de crear pequeños sistemas desacoplados es, sin duda, la capacidad de prueba. ¡Y esto es lo que me ayudó a crear una alternativa de demostración de las pantallas existentes de la app!

Una guía paso a paso.

Ahora bien, ¿cómo podríamos aplicar esos principios a una aplicación Flutter?

Realizaremos un uso compartido de algunas técnicas que utilizamos para crear ClickUp con un ejemplo sencillo.

El ejemplo es tan sencillo que quizá no permita apreciar todas las ventajas que ofrece, pero créeme, te ayudará a crear aplicaciones Flutter mucho más fáciles de mantener con bases de código complejas. 💡

La aplicación

Como ejemplo, crearemos una aplicación que muestre la población de EE. UU. para cada año.

a través de ClickUp

Aquí tenemos dos pantallas:

  • HomeScreen: simplemente muestra una lista con todos los años desde 2000 hasta la actualidad. Cuando los usuarios pulsan en el mosaico de un año, se les redirige a DetailScreen con un argumento de navegación establecido en el año seleccionado.
  • DetailScreen : obtiene el año del argumento de navegación, llama a la API 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 de DetailScreen, ya que es la más interesante por su llamada asíncrona.

Paso 1. Enfoque ingenuo

Enfoque ingenuo Widget con estado
a través de ClickUp

La implementación más obvia para nuestra app, aplicación, es utilizar un único StatefulWidget para toda la lógica.

Acceso al argumento de navegación anual

Para acceder al año solicitado, leemos los RouteSettings del widget heredado ModalRoute.

Llamada HTTP

Este ejemplo invoca la función get del paquete http para obtener los datos de la API datausa.io, analiza el resultado del JSON con el método jsonDecode de la biblioteca dart:convert y mantiene Future como parte del estado con una propiedad denominada _future.

Renderización

Para crear el árbol de widgets, utilizamos un FutureBuilder, que se reconstruye a sí mismo en función del estado actual de nuestra llamada asíncrona _future.

Revisión

De acuerdo, la implementación es breve y solo utiliza widgets integrados, pero ahora piensa en nuestra intención inicial: crear 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 estado determinado.

Ahí es donde nos ayudará el concepto de inversión de control. 🧐

Paso 2. Inversión de control

Inversión de control a proveedor y cliente API.
a través de ClickUp

Este principio puede resultar difícil de entender para los desarrolladores noveles (y también difícil de explicar), pero la idea general es extraer las preocupaciones fuera de nuestros componentes, de modo que no sean responsables de elegir el comportamiento, y delegarlas en su lugar.

En una situación más habitual, consiste simplemente en crear abstracciones e inyectar implementaciones en nuestros componentes para que su implementación pueda modificarse más adelante si es necesario.

Pero no te preocupes, ¡lo entenderás mejor después de nuestro próximo ejemplo! 👀

Creación de un objeto cliente API

Para controlar la llamada HTTP a nuestra API, hemos aislado nuestra implementación en una clase DataUsaApiClient dedicada. También hemos creado una clase Measure para facilitar la manipulación y el mantenimiento de los datos.

Proporcionar un cliente API

Para nuestro ejemplo, utilizamos el conocido paquete proveedor para inyectar una instancia DataUsaApiClient en la raíz de nuestro árbol.

Uso del cliente API

El proveedor permite que cualquier widget descendiente (como nuestro DetailScreen) lea el DataUsaApiClient superior más cercano en el árbol. A continuación, podemos utilizar su método getMeasure para iniciar nuestro Future, en lugar de la implementación HTTP real.

Demostración Cliente API

¡Ahora podemos aprovechar esto!

Por si no lo sabías: todas las clases 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 desde sus llamadas al método getMeasure.

Este método

Este método

Mostrar una página de demostración

¡Ahora tenemos todas las claves para mostrar una instancia de demostración de DetailPage!

Simplemente sobrescribimos la instancia DataUsaApiClient proporcionada actualmente envolviendo nuestra DetailScreen en un proveedor que crea una instancia DemoDataUsaApiClient en su lugar.

Y eso es todo: nuestra DetailScreen lee esta instancia de demostración y utiliza nuestros datos demoMeasure en lugar de una llamada HTTP.

Revisión

Este es un gran ejemplo de inversión de control. Nuestro widget DetailScreen ya no se encarga de la lógica de obtención de 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 widgets para nuestra pantalla! ¡Genial! 👏

¡Pero podemos hacerlo aún mejor!

Dado que no podemos simular un estado de carga, por ejemplo, no tenemos control total sobre ningún cambio de estado a nivel de nuestro widget.

Paso 3. Gestión del estado

Gestión de instrucciones en aplicaciones Flutter.
a través de ClickUp

¡Este es un tema candente en Flutter!

Seguro que ya has leído largos hilos de personas que intentan elegir la mejor solución de gestión de estado para Flutter. Y, para que quede claro, eso no es lo que haremos en este artículo. En nuestra opinión, siempre que separes tu lógica de negocio de tu lógica visual, ¡no hay problema! Crear estas capas es muy importante para la mantenibilidad. Nuestro ejemplo es sencillo, pero en aplicaciones reales, la lógica puede volverse rápidamente compleja y esa separación hace que sea mucho más fácil encontrar tus algoritmos de lógica pura. Este tema se resume a menudo como gestión de estado.

En este ejemplo, utilizamos un ValueNotifier básico junto con un proveedor. Pero también podríamos haber utilizado flutter_bloc o riverpod (u otra solución), y también habría funcionado muy bien. Los principios siguen siendo los mismos y, siempre que hayas separado tus estados y tu lógica, es incluso posible portar tu código base desde una de las otras soluciones.

Esta separación también nos ayuda a controlar cualquier estado de nuestros widgets para poder simularlo de todas las formas posibles.

Creación de un estado dedicado

En lugar de depender del AsyncSnapshot del marco, ahora representamos el estado de nuestra pantalla como un objeto DetailState.

También es importante implementar los métodos hashCode y el operador == para que nuestro objeto sea comparable por valor. Esto nos permite detectar si dos instancias deben considerarse diferentes.

💡 ¡Los paquetes equatable o freezed son excelentes opciones para ayudarte a implementar los métodos hashCode y el operador ==!

💡 ¡Los paquetes equatable o freezed son excelentes opciones para ayudarte a implementar los métodos hashCode y el operador ==!

Nuestro estado siempre está asociado a un año, pero también tenemos cuatro estados distintos posibles en función de lo que queremos mostrar al usuario:

  • NotLoadedDetailState: la actualización de datos aún no ha comenzado.
  • LoadingDetailState: los datos se están cargando actualmente.
  • 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 falló debido a un error desconocido.

Esos estados son más claros que un AsyncSnapshot, ya que realmente representan nuestros casos de uso. Y, de nuevo, ¡esto hace que nuestro código sea más fácil de mantener!

💡 ¡Recomendamos encarecidamente los tipos Union del paquete freezed para representar tus estados lógicos! Añade muchas utilidades, como los métodos copyWith o map.

💡 ¡Recomendamos encarecidamente los tipos Union del paquete freezed para representar tus estados lógicos! Añade muchas utilidades, como los métodos copyWith o correlacionar.

Incorporar 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 de DetailNotifier. Mantendrá la instancia actual de DetailState en su propiedad de valor y proporcionará métodos para actualizar el estado.

Proporcionamos un estado inicial NotLoadedDetailState y un método de actualización para cargar datos desde la API y actualizar el valor actual.

Proporciona un estado para la vista.

Para instanciar y observar el estado de nuestra pantalla, también confiamos en el proveedor y su ChangeNotifierProvider. Este tipo de proveedor busca automáticamente cualquier ChangeListener creado y será el desencadenante de una reconstrucción desde el consumidor cada vez que se le notifique un cambio (cuando nuestro valor de notificación sea diferente al anterior).

Revisión

¡Genial! La arquitectura de nuestra aplicación empieza a tener muy buen aspecto. Todo está separado en capas bien definidas, con funciones específicas. 🤗

Sin embargo, aún falta algo para que sea testable: queremos definir el DetailState actual para controlar el estado de nuestra DetailScreen asociada.

Paso 4. Widget de diseño visual dedicado

Widgets de diseño visuales dedicados en aplicaciones Flutter.
a través de ClickUp

En el último paso, le dimos demasiada responsabilidad a nuestro widget DetailScreen: era responsable de instanciar 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 realizar los ajustes necesarios para configurar las diversas dependencias de nuestra pantalla a partir del estado actual de la aplicación (navegación, notificaciones, estado, servicios, etc.).
  • DetailLayout simplemente convierte un DetailState en un árbol dedicado de widgets.

Al combinar ambos, podremos crear fácilmente instancias de demostración/prueba de DetailLayout, pero tendremos DetailScreen para el caso de uso real en nuestra aplicación.

Diseño dedicado

Para lograr una mejor separación de conceptos, trasladamos todo lo que había bajo el widget Consumer a un widget DetailLayout específico. Este nuevo widget solo consume datos y no se encarga de ninguna instanciación. Simplemente convierte el estado de lectura en un árbol de widgets específico.

La instancia ModalRoute. of call y ChangeNotifierProvider permanece en DetailScreen, y este widget simplemente devuelve DetailLayout con un árbol de dependencias preconfigurado.

Esta pequeña mejora es específica para el uso de proveedores, pero notarás que también hemos añadido un ProxyProvider para que cualquier widget descendiente pueda consumir directamente un DetailState. Esto facilitará la simulación de datos.

Extracción de widgets como clases dedicadas

¡No dude en extraer un árbol de widgets a una clase dedicada! Mejorará el rendimiento y hará que el código sea más fácil de mantener.

En nuestro ejemplo, creamos un widget de diseño visual para cada uno de los tipos de estado asociados:

Instancias de demostración

¡Ahora tenemos control total sobre lo que podemos simular y mostrar en nuestra pantalla!

Solo tenemos que envolver un DetailLayout con un proveedor para simular el estado del diseño.

Conclusión

Crear una arquitectura de software fácil de mantener no es nada fácil. Anticiparse a situaciones futuras puede requerir mucho esfuerzo, pero espero que los consejos que he compartido te sean de ayuda en el futuro.

Los ejemplos pueden parecer sencillos, incluso puede que parezca que estamos sobreingenierizando, pero a medida que aumenta la complejidad de tu app, contar con esos estándares te será de gran ayuda. 💪

¡Diviértete con Flutter y sigue el blog para obtener más artículos técnicos como este! ¡Estate atento!