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 allaDetailScreen
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 un
Provider'. 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
eoperator ==
!
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 iniziatoLoadingDetailState
: 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
omap
.
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 unDetailState
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!