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

Separación de intereses en aplicaciones de aleteo

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 la DetailScreen 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

Enfoque ingenuo Stateful Widget

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

Inversión de control a proveedor y cliente api

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

Gestión de estados en aplicaciones flutter

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 y operator ==!

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 comenzado
  • LoadingDetailState: 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 o map.

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

Widgets visuales de diseño dedicado en aplicaciones flutter

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 un DetailState 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!