Separazione delle preoccupazioni nelle applicazioni Flutter
Engineering at ClickUp

Separazione delle preoccupazioni nelle applicazioni Flutter

Di recente, ho dovuto implementare dei walkthrough per l'onboarding per ClickUp nuovi arrivati! Si trattava di un'attività davvero importante, perché molti nuovi utenti stavano per scoprire la piattaforma con la incredibilmente divertente che abbiamo presentato in anteprima al Super Bowl ! ✨

via ClickUp

Il walkthrough permette ai nostri numerosi nuovi utenti, che magari non conoscono ancora ClickUp, di capire rapidamente come utilizzare diverse funzioni dell'applicazione. Si tratta di un lavoro richiesto continuamente, proprio come il nuovo ClickUp University risorsa che stiamo perseguendo! 🚀

Fortunatamente, l'architettura software dell'applicazione mobile ClickUp Flutter mi ha permesso di implementare questa funzione abbastanza rapidamente, anche riutilizzando i widget reali dell'applicazione! Ciò significa che il walkthrough è dinamico, reattivo e corrisponde esattamente alle schermate reali dell'app - e continuerà a farlo anche quando i widget si evolveranno.

Ho potuto implementare la funzione anche grazie alla giusta separazione delle preoccupazioni.

Vediamo cosa intendo. 🤔

Separazione delle preoccupazioni

La progettazione di un'architettura software è uno degli argomenti più complessi per i team di ingegneri. Tra tutte le responsabilità, è sempre difficile anticipare le future evoluzioni del software. Ecco perché la creazione di un'architettura ben stratificata e disaccoppiata può aiutare voi e i vostri compagni di squadra in molte cose!

Il principale vantaggio della creazione di piccoli sistemi disaccoppiati è senza dubbio la testabilità! Ed è questo che mi ha aiutato a creare una demo alternativa delle schermate esistenti dell'app!

Una guida passo-passo

Ora, come possiamo applicare questi principi a un'applicazione Flutter?

Condivideremo alcune tecniche utilizzate per costruire ClickUp con un semplice esempio.

L'esempio è così semplice che potrebbe non far capire tutti i vantaggi che ci sono dietro, ma credetemi, vi aiuterà a creare applicazioni Flutter molto più manutenibili con codebase complesse. 💡

L'applicazione

A titolo di esempio, creeremo un'applicazione che mostra la popolazione degli Stati Uniti per ogni anno.

via ClickUp

Abbiamo due schermate:

  • HomeScreen: elenca semplicemente tutti gli anni dal 2000 a oggi. Quando l'utente tocca un anno, passa alla DetailScreen con un argomento di navigazione impostato sull'anno selezionato.
  • DetailScreen : ottiene l'anno dall'argomento di navigazione, chiama il metodoaPI di datausa.io per questo anno e analizza i dati JSON per estrarre il valore della popolazione associato. Se i dati sono disponibili, viene visualizzata un'etichetta con la popolazione.

Ci concentreremo sull'implementazione DetailScreen, poiché è la più interessante con la sua chiamata asincrona.

Passaggio 1. Approccio ingenuo

/$$$img/ https://clickup.com/blog/wp-content/uploads/2022/03/image-5-1400x607.png Approccio ingenuo Stateful Widget /$$$img/

tramite ClickUp

L'implementazione più ovvia per la nostra app è l'uso di un singolo StatefulWidget per l'intera logica. Codice sorgente e demo di Dart

Accesso all'argomento di navigazione `year

Per accedere all'anno richiesto, leggiamo le RouteSettings dal widget ereditato ModalRoute.

void didChangeDependencies() {
    super.didChangeDependencies();
    final year = ModalRoute.of(context)!.settings.arguments as int;
    // ...
}

Chiamata HTTP

Questo esempio invoca la funzione get del pacchetto http per ottenere i dati dall'oggetto aPI di datausa.io , analizza il JSON risultante con il metodo jsonDecode della libreria dart:convert e mantiene Future come parte dello stato con una proprietà chiamata _future.

late Future<Map<dynamic, dynamic>?> _future;


void didChangeDependencies() {
    super.didChangeDependencies();
    final year = ModalRoute.of(context)!.settings.arguments as int;
    if (_year != year) {
      _future = _loadMeasure(year);
    }
}


Future<Mappa<dinamica, dinamica>?> _loadMeasure(int year) async {
    _anno = anno;
    final uri = Uri.parse(
        'https://datausa.io/api/data?drilldowns=Nation&measures=Population&year=$anno');
    final result = await get(uri);
    final body = jsonDecode(result.body);
    final data = body['data'] as Elenco<dynamic>;
    if (data.isNotEmpty) {
      return data.first;
    }
    return null;

Rendering

Per creare l'albero dei widget, usiamo un FutureBuilder, che si ricostruisce in base allo stato corrente della nostra chiamata asincrona _future.

@override
Widget build(BuildContext context) {
return Scaffold(
    appBar: AppBar(
    titolo: Testo('Anno $_anno'),
    ),
    corpo: FutureBuilder< Mappa<dinamica, dinamica>?>(
    futuro: _futuro,
    builder: (contesto, snapshot) {
        switch (snapshot.connectionState) {
        case ConnectionState.terminato:
            final error = snapshot.error;
            if (error != null) {(Errore)
                // restituisce l'albero "errore".
            }
            final data = snapshot.data;
            if (data != null) {
                // restituisce l'albero "risultato".
            }
            // restituisce dati "vuoti" tree.case ConnectionState.none:
        case ConnectionState.waiting:
        case ConnectionState.active:
            // restituisce un albero di dati "in caricamento".
        }
    },
    ),
);
}

Recensione

Ok, l'implementazione è breve e utilizza solo widget integrati, ma ora pensiamo alla nostra intenzione iniziale: costruire alternative demo (o test) per questa schermata. È molto difficile controllare il risultato della chiamata HTTP per forzare l'applicazione a rendere in un certo stato.

È qui che il concetto di inversione del controllo ci aiuterà. 🧐

Passaggio 2. Inversione del controllo

/$$$img/ https://clickup.com/blog/wp-content/uploads/2022/03/image-6-1400x607.png Inversione del controllo al provider e al client API /$$$img/

tramite ClickUp

Questo principio può essere difficile da capire per i nuovi sviluppatori (e anche da spiegare), ma l'idea generale è quella di estrarre le preoccupazioni al di fuori dei nostri componenti, in modo che non siano responsabili della scelta del comportamento, e di delegarlo.

In una situazione più comune, consiste semplicemente nel creare astrazioni e iniettare implementazioni nei nostri componenti, in modo che la loro implementazione possa essere modificata in seguito, se necessario.

Ma non preoccupatevi, avrà più senso dopo il prossimo esempio! 👀 Codice sorgente e demo di Dart

Creazione di un oggetto client API

In ordine alla chiamata HTTP alla nostra API, abbiamo isolato la nostra implementazione in una classe dedicata DataUsaApiClient. Abbiamo anche creato una classe Measure per rendere i dati più facili da manipolare e mantenere.

classe DataUsaApiClient {
  const DataUsaApiClient({
    this.endpoint = 'https://datausa.io/api/data',
  });


  stringa finale endpoint;


  Future<Measure?> getMeasure(int year) async {
    final uri =
        Uri.parse('$endpoint?drilldowns=Nation&measures=Population&year=$year');
    final result = await get(uri);
    final body = jsonDecode(result.body);
    final data = body['data'] as Elenco<dynamic>;
    if (data.isNotEmpty) {
      return Measure.fromJson(data.first as Mappa<Stringa, Oggetto?>);
    }
    return null;
  }

Fornire un client API

Per il nostro esempio, utilizziamo il noto client provider per iniettare un'istanza di DataUsaApiClient alla radice del nostro albero.

Provider<DataUsaApiClient>(
    create: (context) => const DataUsaApiClient(),
        child: const MaterialApp(
        home: HomePage(),
    ),
)

Utilizzo del client API

Il provider consente a qualsiasi widget discendente ( come la nostra DetailScreen_) di leggere il più vicino DataUsaApiClient superiore nell'albero. Possiamo quindi usare il suo metodo getMeasure per avviare il nostro Future, al posto dell'attuale implementazione HTTP.

@overridevoid didChangeDependencies() {
    super.didChangeDependencies();
    final year = ModalRoute.of(context)!.settings.arguments as int;
    if (_year != year) {
      _anno = anno;
      final API = context.read<DataUsaApiClient>();
      _future = api.getMeasure(anno);
    }
}

Demo client API

Ora possiamo trarre vantaggio da questo!

Nel caso non lo si sapesse: qualsiasi classe in dart definiscono implicitamente anche un'interfaccia associata . Questo ci permette di fornire un'implementazione alternativa di DataUsaApiClient che restituisce sempre la stessa istanza dalle chiamate al metodo getMeasure.

Questo metodo

classe DemoDataUsaApiClient implementa DataUsaApiClient {
  const DemoDataUsaApiClient(this.measure);


  misura finale;


  @overrideString get endpoint => '';


  @override
  Futuro<Misura?> getMisura(int anno) {
    return Future.value(measure);
  }

Visualizzazione di una pagina dimostrativa

Ora abbiamo tutte le chiavi per visualizzare un'istanza demo della DetailPage!

È sufficiente sovrascrivere l'istanza di DataUsaApiClient attualmente fornita, avvolgendo il nostro DetailScreen in un provider che crei invece un'istanza di DemoDataUsaApiClient!

Ed ecco che il nostro DetailScreen legge questa istanza demo e usa i nostri dati demoMeasure invece di una chiamata HTTP.

ListTile(
    titolo: const Testo('Apri la demo'),
    onTap: () {
        const demoMeasure = Measure(
            anno: 2022,
            popolazione: 425484,
            nazione: 'Stati Uniti',
        );
        Navigator.push(
            contesto,
            MaterialePaginaRoute(
                impostazioni: RouteSettings(argomenti: demoMeasure.year),
                costruttore: (contesto) {
                    return Provider<DataUsaApiClient>(
                        create: (context) =>
                            const DemoDataUsaApiClient(demoMeasure),
                        child: const DetailScreen(),
                    );
                },
            ),
        );
    },
)

Recensione

Il nostro widget DetailScreen non è più responsabile della logica di ottenimento dei dati, ma la delega a un oggetto client dedicato. E ora siamo in grado di creare istanze demo dello schermo o di implementare test del widget per il nostro schermo! Fantastico! 👏

Ma possiamo fare ancora meglio!

Poiché non siamo in grado di simulare uno stato di caricamento, ad esempio, non abbiamo il pieno controllo di qualsiasi cambiamento di stato a livello di widget.

Passaggio 3. Gestione dello stato

/$$$img/ https://clickup.com/blog/wp-content/uploads/2022/03/image-7-1400x607.png Gestione degli stati nelle applicazioni flutter /$$$img/

via ClickUp

Questo è un argomento caldo in Flutter!

Sono sicuro che avrete già letto lunghi thread di persone che cercano di eleggere la migliore soluzione di gestione degli stati per Flutter. E per essere chiari, non è quello che faremo in questo articolo. A nostro avviso, finché si separa la logica aziendale da quella visiva, si è a posto! La creazione di questi livelli è davvero importante per la manutenibilità. Il nostro esempio è semplice, ma nelle applicazioni reali la logica può diventare rapidamente complessa e questa separazione rende molto più facile trovare gli algoritmi di logica pura. Questo argomento viene spesso riepilogato come gestione degli stati.

In questo esempio, stiamo usando un ValueNotifier' di base insieme a unProvider'. Ma avremmo potuto anche usare flutter_bloc oppure fiumepod (o un'altra soluzione)_, e avrebbe funzionato benissimo. I principi rimangono gli stessi e, a patto di separare gli stati e la logica, è persino possibile effettuare il porting della propria base di codice da una delle altre soluzioni.

Questa separazione ci aiuta anche a controllare qualsiasi stato dei nostri widget, in modo da poterli prendere in giro in ogni modo possibile! Codice sorgente e demo di Dart

Creazione di uno stato dedicato

Invece di affidarci ad AsyncSnapshot del framework, ora rappresentiamo il nostro stato dello schermo come un oggetto DetailState.

È anche importante implementare i metodi hashCode e operator == per rendere il nostro oggetto confrontabile per valore. Questo ci permette di rilevare se due istanze devono essere considerate diverse.

💡 Il equiparabile o congelato i pacchetti sono ottime opzioni per aiutarvi a implementare i metodi hashCode e operator ==!

classe astratta DetailState {
  const DetailState(this.year);
  final int anno;


  @overridebool operator ==(Object other) =>
      identico(this, other) ||
      (altro è DetailState &&
          runtimeType == other.runtimeType &&
          year == other.year);


  @overrideint get hashCode => runtimeType.hashCode ^ year;

Il nostro stato è sempre associato a un anno, ma abbiamo anche quattro stati possibili distinti per quanto riguarda ciò che vogliamo mostrare all'utente:

  • NotLoadedDetailState: l'aggiornamento dei dati non è ancora iniziato
  • LoadingDetailState: i dati sono attualmente in fase di caricamento
  • loadedDetailState": i dati sono stati caricati con esito positivo con una misura associata
  • NoDataDetailState: i dati sono stati caricati, ma non ci sono dati disponibili
  • unknownErrorDetailState": l'operazione è fallita a causa di un errore sconosciuto
class NotLoadedDetailState extends DetailState {
  const NotLoadedDetailState(int year) : super(year);
}


class LoadedDetailState extends DetailState {
  const LoadedDetailState({
    required int anno,
    required this.measure,
  }) : super(anno);


  misura finale misura;


  @overridebool operator ==(oggetto altro) =>
      identico(this, other) ||
      (other is LoadedDetailState && measure == other.measure);


  @overrideint get hashCode => runtimeType.hashCode ^ measure.hashCode;
}


class NoDataDetailState extends DetailState {
  const NoDataDetailState(int year) : super(year);
}


class LoadingDetailState extends DetailState {
  const LoadingDetailState(int anno) : super(anno);
}


class UnknownErrorDetailState extends DetailState {
  const UnknownErrorDetailState({
    required int anno,
    required this.error,
  }) : super(anno);


  errore dinamico finale;


  @overridebool operator ==(Oggetto altro) =>
      identico(questo, altro) ||
      (other is UnknownErrorDetailState &&
          year == other.year &&
          error == other.error);


  @overrideint get hashCode => Object.hash(super.hashCode, error.has

Questi stati sono più chiari di un AsyncSnapshot, perché rappresentano davvero i nostri casi d'uso. E ancora, questo rende il nostro codice più maintainer!

💡 Raccomandiamo vivamente di utilizzare il I tipi di unione del pacchetto congelato per rappresentare gli stati logici! Aggiunge molte utilità come i metodi copyWith o map.

Mettere la logica in un notificatore

Ora che abbiamo una rappresentazione del nostro stato, dobbiamo memorizzarne un'istanza da qualche parte: questo è lo scopo di DetailNotifier. Esso manterrà l'istanza di DetailState corrente nella sua proprietà value e fornirà i metodi per aggiornare lo stato.

Forniamo uno stato iniziale NotLoadedDetailState e un metodo refresh per caricare i dati dall'API e aggiornare il valore corrente.

class DetailNotifier extends ValueNotifier<DetailState> {
  DetailNotifier({
    required int anno,
    required this.API,
  }) : super(DetailState.notLoaded(year));


  final DataUsaApiClient api;


  int get year => valore.anno;


  Future<void> refresh() async {
    se (valore è! LoadingDetailState) {
      valore = DetailState.loading(anno);
      try {
        final result = await api.getMeasure(anno);
        se (risultato = null)
          valore = DetailState.loaded(
            anno: anno,
            misura: risultato,
          );
        } else {
          valore = DetailState.noData(anno);
        }
      } catch (errore) {
        valore = DetailState.unknownError(
          anno: anno,
          errore: errore,
        );
      }
    }
  }

Fornire uno stato alla visualizzazione

Per istanziare e osservare lo stato della nostra schermata, ci affidiamo anche al provider e al suo ChangeNotifierProvider. Questo tipo di provider cerca automaticamente qualsiasi ChangeListener creato e triggera una ricostruzione dal Consumer ogni volta che gli viene notificato un cambiamento (quando il valore del nostro notificatore è diverso da quello precedente)

class DetailScreen extends StatelessWidget {
  const DetailScreen({
    Chiave? chiave,
  }) : super(chiave: chiave);


  @override
  Widget build(BuildContext context) {
    final year = ModalRoute.of(context)!.settings.arguments as int;
    return ChangeNotifierProvider<DetailNotifier>(
      create: (context) {
        final notifier = DetailNotifier(
          anno: anno,
          aPI: context.read<DataUsaApiClient>(),
        );
        notifier.refresh();
        return notifier;
      },
      bambino: Consumatore<DetailNotifier>(
        builder: (context, notifier, child) {
             final state = notifier.value;
            // ...
        },
      ),
    );
  }
}

Recensione

Ottimo! L'architettura della nostra applicazione comincia ad avere un bell'aspetto. Tutto è separato in livelli ben definiti, con preoccupazioni specifiche! 🤗

Manca ancora una cosa per la testabilità: vogliamo definire il DetailState corrente per controllare lo stato del DetailScreen associato.

Passaggio 4. Widget di layout visuale dedicato

/$$$img/ https://clickup.com/blog/wp-content/uploads/2022/03/image-8-1400x607.png Widget di layout dedicati visivi nelle applicazioni flutter /$$$img/

via ClickUp

Nell'ultimo passaggio, abbiamo dato un po' troppa responsabilità al nostro widget DetailScreen: era responsabile dell'istanziazione del DetailNotifier. E come abbiamo visto in precedenza, cerchiamo di evitare qualsiasi responsabilità logica a livello di visualizzazione!

Possiamo risolvere facilmente questo problema creando un altro livello per il nostro widget schermo: divideremo il nostro widget DetailScreen in due:

  • DetailScreen è responsabile dell'impostazione delle varie dipendenze del nostro schermo dallo stato corrente dell'applicazione (navigazione, notificatori, stato, servizi, ...),
  • DetailLayout converte semplicemente un DetailState in un albero di widget dedicato.

Combinando le due cose, saremo in grado di creare semplicemente istanze demo/test di DetailLayout, ma di avere DetailScreen per i casi d'uso reali della nostra applicazione. Codice sorgente e demo di Dart

Layout dedicato

Per ottenere una migliore separazione delle preoccupazioni, abbiamo spostato tutto ciò che si trova sotto il widget Consumer in un widget dedicato DetailLayout. Questo nuovo widget consuma solo i dati e non è responsabile di alcuna istanziazione. Si limita a convertire lo stato di lettura in uno specifico albero di widget.

La chiamata a ModalRoute.of e l'istanza di ChangeNotifierProvider rimangono nel DetailScreen e questo widget restituisce semplicemente il DetailLayout con un albero di dipendenze preconfigurato!

Questo piccolo miglioramento è specifico per l'uso dei provider, ma si noterà che abbiamo anche aggiunto un ProxyProvider, in modo che qualsiasi widget discendente possa consumare direttamente un DetailState. In questo modo sarà più facile prendere in giro i dati.

class DetailScreen extends StatelessWidget {
  const DetailScreen({
    Chiave? chiave,
  }) : super(chiave: chiave);


  @override
  Widget build(BuildContext context) {
    final year = ModalRoute.of(context)!.settings.arguments as int;
    return ChangeNotifierProvider<DetailNotifier>(
      create: (context) {
        final notifier = DetailNotifier(
          anno: anno,
          aPI: context.read<DataUsaApiClient>(),
        );
        notifier.refresh();
        return notifier;
      },
      child: child: ProxyProvider<DetailNotifier, DetailState>(
        update: (context, value, previous) => value.value,
        child: const DetailLayout(),
      ),
    );
  }
}


class DetailLayout extends StatelessWidget {
  const DetailLayout({
    Chiave? chiave,
  }) : super(chiave: chiave);


  @override
  Widget build(BuildContext context) {
    return Consumatore<DetailState>(
      builder: (context, state, child) {
        return Scaffold(
          appBar: AppBar(
            titolo: Testo('Anno ${stato.anno}'),
          ),
          body: () {
              // ...
          }(),
        );
      },
    );
  }

Estrarre i widget come classi dedicate

Non esitate mai a estrarre un albero di widget in una classe dedicata! In questo modo migliorare le prestazioni e rendere il codice più maintainer.

Nel nostro esempio abbiamo creato un widget di layout visivo per ogni tipo di stato associato:

if (state is NotLoadedDetailState || state is LoadingDetailState) {
    return const LoadingDetailLayout();
}
se (state is LoadedDetailState) {
    return LoadedDetailLayout(state: state);
}
if (state is UnknownErrorDetailState) {
    return UnknownErrorDetailLayout(state: state);
}
return const NoDataDetailLayout();

Istanze dimostrative

Ora abbiamo il pieno controllo su ciò che possiamo simulare e visualizzare sullo schermo!

Dobbiamo solo avvolgere un DetailLayout con un Provider<DetailState> per simulare lo stato del layout.

ListTile(
    titolo: const Testo('Apri la demo "caricata"'),
    onTap: () {
        Navigator.push(
        contesto,
        MaterialPageRoute(
            costruttore: (contesto) {
            return Provider<DetailState>.value(
                    valore: const DetailState.loaded(
                    anno: 2022,
                    measure: Measure(
                        anno: 2022,
                        popolazione: 425484,
                        nazione: 'Stati Uniti',
                    ),
                    ),
                    figlio: const DetailLayout(),
                );
            },
        ),
        );
    },
),
ListTile(
    titolo: const Testo('Apri la demo "loading"'),
    onTap: () {
        Navigator.push(
        contesto,
        MaterialePaginaRoute(
            costruttore: (contesto) {
                    return Provider<DetailState>.value(
                        valore: const DetailState.loading(2022),
                        child: const DetailLayout(),
                    );
                },
            ),
        );
    },
),

Conclusione

Creare un'architettura software definitivamente maintainer non è facile! Anticipare gli scenari futuri può richiedere molto lavoro richiesto, ma spero che i pochi consigli che ho condiviso vi aiutino in futuro!

Gli esempi possono sembrare semplici - potrebbe anche sembrare che stiamo esagerando con l'ingegneria - ma quando la complessità della vostra app crescerà, avere questi standard vi aiuterà molto! 💪

Divertitevi con Flutter e seguite il blog per trovare altri articoli tecnici come questo! Restate sintonizzati!