Kürzlich musste ich Onboarding-Walkthroughs einführen für ClickUp neulinge! Dies war eine wirklich wichtige Aufgabe, denn viele neue Benutzer waren dabei, die Plattform mit dem unglaublich lustigen Werbespot, den wir beim Super Bowl uraufgeführt haben ! ✨
über ClickUp
Der Walkthrough ermöglicht es unseren zahlreichen neuen Benutzern, die ClickUp vielleicht noch nicht kennen, schnell zu verstehen, wie man verschiedene Funktionen der Anwendung nutzt. Es ist ein fortlaufender Aufwand, genau wie die neue ClickUp University ressource, die wir verfolgen! 🚀
Glücklicherweise erlaubte es mir die Software-Architektur hinter der ClickUp Flutter-Mobilanwendung, diese Funktion recht schnell zu implementieren, sogar durch die Wiederverwendung der echten Widgets aus der Anwendung! Das bedeutet, dass der Walkthrough dynamisch und reaktionsschnell ist und genau mit den realen Anwendungsbildschirmen der App übereinstimmt - und das wird auch so bleiben, selbst wenn sich die Widgets weiterentwickeln.
Außerdem konnte ich die Funktionen aufgrund der richtigen Trennung von Belangen implementieren.
Mal sehen, was ich hier meine. 🤔
Trennung der Belange
Der Entwurf einer Softwarearchitektur ist eines der komplexesten Themen für Ingenieurteams. Neben allen Verantwortlichkeiten ist es immer schwierig, zukünftige Softwareentwicklungen vorherzusehen. Aus diesem Grund kann die Erstellung einer gut geschichteten und entkoppelten Architektur Ihnen und Ihren Teamkollegen bei vielen Dingen helfen!
Der Hauptvorteil der Erstellung kleiner entkoppelter Systeme ist zweifellos die Testbarkeit! Und das ist es, was mir geholfen hat, eine Demo-Alternative zu den bestehenden Bildschirmen der App zu erstellen!
Eine Schritt-für-Schritt-Anleitung
Wie können wir nun diese Prinzipien auf eine Flutter-Anwendung anwenden?
Wir werden ein paar Techniken freigeben, mit denen wir ClickUp anhand eines einfachen Beispiels bauen.
Das Beispiel ist so einfach, dass es vielleicht nicht alle Vorteile dahinter erhellt, aber glauben Sie mir, es wird Ihnen helfen, viel besser wartbare Flutter-Anwendungen mit komplexen Codebasen zu erstellen. 💡
Die Anwendung
Als Beispiel werden wir eine Anwendung erstellen, die die Population der USA für jedes Jahr anzeigt.
über ClickUp
Wir haben hier zwei Bildschirme:
- homeScreen": listet einfach alle Jahre von 2000 bis heute auf. Wenn der Benutzer auf eine Jahreskachel tippt, navigiert er zum
DetailScreen
mit einem Navigationsargument, das auf das ausgewählte Jahr eingestellt ist. - detailScreen": holt sich das Jahr aus dem Argument der Navigation und ruft die Funktiondatausa.io API für dieses Jahr auf und analysiert die JSON-Daten, um den zugehörigen Wert für die Population zu extrahieren. Wenn Daten verfügbar sind, wird eine Beschreibung mit der Population angezeigt.
Wir werden uns auf die Implementierung von "DetailScreen" konzentrieren, da sie mit ihrem asynchronen Aufruf die interessanteste ist.
Schritt 1]. Naiver Ansatz
über ClickUp
Die naheliegendste Implementierung für unsere App ist die Verwendung eines einzigen StatefulWidget
für die gesamte Logik.
Dart Source Code & Demo
Zugriff auf das Argument "Jahr" in der Navigation
Um auf das gewünschte Jahr zuzugreifen, lesen wir die RouteSettings
aus dem geerbten Widget ModalRoute
.
void didChangeDependencies() {
super.didChangeDependencies();
final year = ModalRoute.of(context)!.settings.arguments as int;
// ...
}
HTTP-Aufruf
Dieses Beispiel ruft die Funktion get
aus dem Paket http
auf, um die Daten aus der Datei
datausa.io API
, parst das resultierende JSON mit der Methode jsonDecode
aus der Bibliothek dart:convert
und behält Future
als Teil des Status mit einer Eigenschaft namens _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<Map<dynamic, dynamic>?> _loadMeasure(int year) async {
_year = Jahr;
final uri = Uri.parse(
'https://datausa.io/api/data?drilldowns=Nation&measures=Population&year=$year');
final ergebnis = await get(uri);
final body = jsonDecode(result.body);
final data = body['data'] as Liste<dynamic>;
if (data.isNotEmpty) {
return data.first;
}
return null;
Rendering
Um den Widget-Baum zu erstellen, verwenden wir einen FutureBuilder
, der sich selbst unter Berücksichtigung des aktuellen Status unseres asynchronen _future
-Aufrufs neu aufbaut.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
titel: Text('Jahr $_year'),
),
body: FutureBuilder<Map<dynamic, dynamic>?>(
future: _future,
builder: (Kontext, Snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.erledigt:
final error = snapshot.error;
if (error != null) {
// return "error" tree.
}
final data = snapshot.data;
if (daten != null) {
// "Ergebnis"-Baum zurückgeben.
}
// return "empty" data tree.case ConnectionState.none:
case ConnectionState.waiting:
case ConnectionState.active:
// Rückgabe "Laden" Datenbaum.
}
},
),
);
}
Rückblick
Okay, die Implementierung ist kurz und verwendet nur eingebaute Widgets, aber denken Sie an unsere ursprüngliche Absicht: die Erstellung von Demo-Alternativen (oder Tests) für diesen Bildschirm. Es ist sehr schwierig, das Ergebnis des HTTP-Aufrufs zu kontrollieren, um die Anwendung zu zwingen, in einem bestimmten Zustand zu rendern.
Hier hilft uns das Konzept der Inversion der Kontrolle. 🧐
Schritt 2. Umkehrung der Kontrolle
über ClickUp
Dieses Prinzip kann für neue Entwickler schwer zu verstehen sein (und auch schwer zu erklären), aber die allgemeine Idee ist, die Belange außerhalb unserer Komponenten zu extrahieren - so dass sie nicht für die Wahl des Verhaltens verantwortlich sind - und es stattdessen zu delegieren.
In einer häufigeren Situation besteht es einfach darin, Abstraktionen zu erstellen und Implementierungen in unsere Komponenten zu injizieren, damit ihre Implementierung später bei Bedarf geändert werden kann.
Aber keine Sorge, nach unserem nächsten Beispiel wird es mehr Sinn machen! 👀 Dart Source Code & Demo
Erstellen eines API Client-Objekts
In der Reihenfolge des HTTP-Aufrufs zu unserer API haben wir unsere Implementierung in einer eigenen Klasse DataUsaApiClient
isoliert. Wir haben auch eine Klasse Measure
erstellt, um die Daten leichter manipulieren und pflegen zu können.
klasse DataUsaApiClient {
const DataUsaApiClient({
this.endpoint = 'https://datausa.io/api/data',
});
final Zeichenfolge endpoint;
Future<Maßnahme?> getMaßnahme(int Jahr) 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 Liste<dynamic>;
if (data.isNotEmpty) {
return Measure.fromJson(data.first as Map<Zeichenfolge, Objekt?>);
}
return null;
}
Einen API Client bereitstellen
Für unser Beispiel verwenden wir die bekannte
anbieter
paket, um eine Instanz von DataUsaApiClient
an der Wurzel unseres Baums zu injizieren.
Anbieter<DataUsaApiClient>(
create: (Kontext) => const DataUsaApiClient(),
kind: const MaterialApp(
startseite: HomePage(),
),
)
Verwendung des API-Client
Der Anbieter ermöglicht es jedem nachgeordneten Widget ( wie _unserem _DetailScreen_
), den nächsthöheren DataUsaApiClient
im Baum zu lesen. Wir können dann seine getMeasure
-Methode verwenden, um unser Future
zu starten, anstelle der eigentlichen HTTP-Implementierung.
@overridevoid didChangeDependencies() {
super.didChangeDependencies();
final year = ModalRoute.of(context)!.settings.arguments as int;
if (_year != year) {
_year = Jahr;
final api = context.read<DataUsaApiClient>();
_future = api.getMeasure(year);
}
}
Demo API Client
Jetzt können wir uns das zunutze machen!
Falls Sie es noch nicht wussten: Jede Klasse in dart definieren implizit auch eine zugehörige Schnittstelle . Dies ermöglicht es uns, eine alternative Implementierung von "DataUsaApiClient" bereitzustellen, die bei Aufrufen der Methode "getMeasure" immer dieselbe Instanz zurückgibt.
Diese Methode
class DemoDataUsaApiClient implements DataUsaApiClient {
const DemoDataUsaApiClient(this.measure);
final Measure messen;
@overrideString get endpoint => '';
@override
Future<Maßnahme?> getMaßnahme(int Jahr) {
return Future.Wert(measure);
}
Anzeige einer Demo-Seite
Wir haben nun alle Schlüssel, um eine Demo Instanz der DetailPage
anzuzeigen!
Wir überschreiben einfach die aktuell bereitgestellte DataUsaApiClient
Instanz, indem wir unseren DetailScreen
in einen Anbieter verpacken, der stattdessen eine DemoDataUsaApiClient
Instanz erzeugt!
Und das war's - unser DetailScreen
liest stattdessen diese Demo-Instanz und verwendet unsere demoMeasure
-Daten anstelle eines HTTP-Aufrufs.
ListTile(
title: const Text('Demo öffnen'),
onTap: () {
const demoMeasure = Measure(
jahr: 2022,
population: 425484,
nation: 'Vereinigte Staaten',
);
Navigator.push(
kontext,
MaterialSeitenRoute(
einstellungen: RouteSettings(arguments: demoMeasure.year),
builder: (Kontext) {
return Anbieter<DataUsaApiClient>(
create: (context) =>
const DemoDataUsaApiClient(demoMessung),
child: const DetailScreen(),
);
},
),
);
},
)
Rückblick
Dies ist ein großartiges Beispiel für Inversion of control. Unser Widget DetailScreen
ist nicht mehr für die Logik der Datenbeschaffung verantwortlich, sondern delegiert sie an ein dediziertes Client Objekt. Und wir sind jetzt in der Lage, Demo-Instanzen des Bildschirms zu erstellen oder Widget-Tests für unseren Bildschirm zu implementieren! Großartig! 👏
Aber wir können noch mehr erledigen!
Da wir zum Beispiel nicht in der Lage sind, einen Ladezustand zu simulieren, haben wir nicht die volle Kontrolle über jede Zustandsänderung auf der Ebene unseres Widgets.
Schritt 3. Zustandsverwaltung
über ClickUp
Dies ist ein heißes Thema in Flutter!
Ich bin mir sicher, dass Sie bereits lange Threads von Leuten gelesen haben, die versuchen, die beste State-Management-Lösung für Flutter zu wählen. Und um das klarzustellen, das ist nicht das, was wir in diesem Artikel erledigen werden. Unserer Meinung nach ist alles in Ordnung, solange Sie Ihre Business-Logik von Ihrer visuellen Logik trennen! Die Erstellung dieser Schichten ist wirklich wichtig für die Wartbarkeit. Unser Beispiel ist einfach, aber in realen Anwendungen kann die Logik schnell komplex werden und eine solche Trennung macht es viel einfacher, Ihre reinen Logik-Algorithmen zu finden. Dieses Thema wird oft unter dem Begriff State Management zusammengefasst.
In diesem Beispiel verwenden wir einen einfachen ValueNotifier
neben einem Provider
. Aber wir hätten auch Folgendes verwenden können
flutter_bloc
oder
riverpod
(oder eine andere Lösung), und es hätte auch gut funktioniert. Die Prinzipien bleiben dieselben, und solange Sie Ihre Zustände und Ihre Logik getrennt haben, ist es sogar möglich, Ihre Codebasis von einer der anderen Lösungen zu portieren.
Diese Trennung hilft uns auch, jeden Zustand unserer Widgets zu kontrollieren, so dass wir sie auf jede erdenkliche Weise nachahmen können! Dart Source Code & Demo
Erstellen eines eigenen Status
Anstatt sich auf den AsyncSnapshot
des Frameworks zu verlassen, repräsentieren wir nun unseren Bildschirmzustand als DetailState
Objekt.
Es ist auch wichtig, die Methoden hashCode
und operator ==
zu implementieren, um unser Objekt durch Werte vergleichbar zu machen. So können wir erkennen, ob zwei Instanzen als unterschiedlich angesehen werden sollten.
💡 Die gleichsetzbar oder eingefroren Pakete sind großartige Optionen, um die Methoden
hashCode
undoperator ==
zu implementieren!
abstract class DetailState {
const DetailState(this.year);
final int Jahr;
@overridebool operator ==(Objekt anderes) =>
identisch(dies, anderes) ||
(andere ist DetailState &&
runtimeType == other.runtimeType &&
year == other.year);
@overrideint get hashCode => runtimeType.hashCode ^ Jahr;
Unser Zustand ist immer mit einem "Jahr" verbunden, aber wir haben auch vier verschiedene mögliche Zustände in Bezug darauf, was wir dem Benutzer zeigen wollen:
NotLoadedDetailState
: die Datenaktualisierung hat noch nicht begonnen- loadingDetailState": Die Daten werden gerade geladen
- loadedDetailState": Die Daten wurden erfolgreich mit einer zugehörigen "Messung" geladen
- noDataDetailState": Die Daten wurden geladen, aber es sind keine Daten verfügbar
- unknownErrorDetailState": der Vorgang ist aufgrund eines unbekannten Fehlers fehlgeschlagen
class NotLoadedDetailState extends DetailState {
const NotLoadedDetailState(int year) : super(year);
}
class LoadedDetailState erweitert DetailState {
const LoadedDetailState({
erforderlich int Jahr,
required this.measure,
}) : super(year);
final Maßnahme measure;
@overridebool operator ==(Objekt anderes) =>
identical(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 Jahr) : super(Jahr);
}
class UnknownErrorDetailState extends DetailState {
const UnknownErrorDetailState({
erforderlich int Jahr,
required this.error,
}) : super(year);
endgültiger dynamischer Fehler;
@overridebool operator ==(Objekt anderes) =>
identical(this, other) ||
(andere ist UnknownErrorDetailState &&
jahr == anderes.Jahr &&
error == other.error);
@overrideint get hashCode => Objekt.hash(super.hashCode, error.has
Diese Zustände sind klarer als ein AsyncSnapshot
, da sie wirklich unsere Anwendungsfälle darstellen. Und auch das macht unseren Code besser wartbar!
💡 Wir empfehlen dringend die Union-Typen aus dem Freezed-Package zur Darstellung Ihrer logischen Zustände! Es fügt eine Reihe von Hilfsmitteln wie die Methoden
copyWith
odermap
hinzu.
Platzierung der Logik in einem Notifier
Nun, da wir eine Darstellung unseres Zustands haben, müssen wir irgendwo eine Instanz davon speichern - das ist der Zweck des DetailNotifier
. Er speichert die aktuelle Instanz von DetailState
in seiner Eigenschaft value
und bietet Methoden zur Aktualisierung des Zustands.
Wir bieten einen NotLoadedDetailState
-Ausgangszustand und eine refresh
-Methode zum Laden von Daten aus der api
und zur Aktualisierung des aktuellen value
.
class DetailNotifier extends ValueNotifier<DetailState> {
DetailNotifier({
required int Jahr,
required this.api,
}) : super(DetailState.notLoaded(year));
final DataUsaApiClient api;
int get Jahr => Wert.Jahr;
Future<void> refresh() async {
if (Wert ist! LoadingDetailState) {
wert = DetailState.loading(Jahr);
try {
final result = await api.getMeasure(year);
if (ergebnis != null) {
wert = DetailState.loaded(
jahr: Jahr,
measure: Ergebnis,
);
} else {
wert = DetailState.noData(Jahr);
}
} catch (Fehler) {
wert = DetailState.unknownError(
jahr: Jahr,
error: Fehler,
);
}
}
}
Einen Status für die Ansicht bereitstellen
Um den Zustand unseres Bildschirms zu instanziieren und zu beobachten, verlassen wir uns auch auf den Anbieter und seinen ChangeNotifierProvider
. Diese Art von Anbieter sucht automatisch nach jedem erstellten ChangeListener
und löst jedes Mal einen Neuaufbau vom Consumer
aus, wenn er über eine Änderung benachrichtigt wird (wenn sich unser Notifier-Wert von dem vorherigen unterscheidet).
class DetailScreen extends StatelessWidget {
const DetailScreen({
Schlüssel? Schlüssel,
}) : super(Schlüssel: Schlüssel);
@override
Widget build(BuildContext context) {
final year = ModalRoute.of(context)!.settings.arguments as int;
return ChangeNotifierProvider<DetailNotifier>(
create: (Kontext) {
final notifier = DetailNotifier(
jahr: Jahr,
api: context.read<DataUsaApiClient>(),
);
notifier.refresh();
return notifier;
},
kind: Consumer<DetailNotifier>(
builder: (context, notifier, child) {
final state = notifier.value;
// ...
},
),
);
}
}
Rückblick
Großartig! Unsere Anwendungsarchitektur sieht langsam ziemlich gut aus. Alles ist in klar definierte Schichten aufgeteilt, mit spezifischen Anliegen! 🤗
Eine Sache fehlt allerdings noch für die Testbarkeit, wir wollen den aktuellen DetailState
definieren, um den Zustand unseres zugehörigen DetailScreen
zu steuern.
Schritt 4. Visuelles dediziertes Layout Widget
über ClickUp
Im letzten Schritt haben wir unserem Widget DetailScreen
ein wenig zu viel Verantwortung übertragen: es war für die Instanziierung des DetailNotifier
verantwortlich. Und wie wir bereits gesehen haben, versuchen wir, jegliche logische Verantwortung in der Ansichtsschicht zu vermeiden!
Wir können dieses Problem leicht lösen, indem wir eine weitere Schicht für unser Bildschirm-Widget erstellen: Wir teilen unser DetailScreen
-Widget in zwei:
DetailScreen
ist verantwortlich für die Einstellung der verschiedenen Abhängigkeiten unseres Bildschirms vom aktuellen Anwendungsstatus (Navigation, Melder, Status, Dienste, ...),DetailLayout
wandelt einfach einenDetailState
in einen eigenen Baum von Widgets um.
Durch die Kombination der beiden können wir einfach DetailLayout
-Demo-/Testinstanzen erstellen, haben aber DetailScreen
für den tatsächlichen Anwendungsfall in unserer Anwendung.
Dart Source Code & Demo
Spezielles Layout
Um eine bessere Trennung der Bereiche zu erreichen, haben wir alles unter dem Consumer
Widget in ein dediziertes DetailLayout
Widget verschoben. Dieses neue Widget konsumiert nur Daten und ist nicht für die Instanziierung verantwortlich. Es wandelt lediglich den gelesenen Zustand in einen bestimmten Widget-Baum um.
Der Aufruf ModalRoute.of
und die Instanz ChangeNotifierProvider
verbleiben im DetailScreen
, und dieses Widget gibt einfach das DetailLayout
mit einem vorkonfigurierten Abhängigkeitsbaum zurück!
Diese kleine Verbesserung ist spezifisch für die Verwendung von Anbietern, aber Sie werden bemerken, dass wir auch einen ProxyProvider
hinzugefügt haben, so dass ein beliebiges nachgeordnetes Widget einen DetailState
direkt konsumieren kann. Dies wird es einfacher machen, Daten zu spiegeln.
class DetailScreen extends StatelessWidget {
const DetailScreen({
Schlüssel? key,
}) : super(Schlüssel: Schlüssel);
@override
Widget build(BuildContext context) {
final year = ModalRoute.of(context)!.settings.arguments as int;
return ChangeNotifierProvider<DetailNotifier>(
create: (Kontext) {
final notifier = DetailNotifier(
jahr: Jahr,
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({
Schlüssel? Schlüssel,
}) : super(Schlüssel: Schlüssel);
@override
Widget build(BuildContext context) {
return Consumer<DetailState>(
builder: (Kontext, Zustand, Kind) {
return Scaffold(
appBar: AppBar(
titel: Text('Jahr ${state.year}'),
),
body: () {
// ...
}(),
);
},
);
}
Extrahieren von Widgets als eigene Klassen
Zögern Sie nie, einen Widget-Baum in eine eigene Klasse zu extrahieren! Es wird die Leistung verbessern zu verbessern und den Code wartbarer zu machen.
In unserem Beispiel haben wir ein visuelles Layout-Widget für jeden der zugehörigen Statustypen erstellt:
if (state is NotLoadedDetailState || state is LoadingDetailState) {
return const LoadingDetailLayout();
}
if (state is LoadedDetailState) {
return LoadedDetailLayout(state: state);
}
if (state is UnknownErrorDetailState) {
return UnknownErrorDetailLayout(state: state);
}
return const NoDataDetailLayout();
Demo-Instanzen
Jetzt haben wir die volle Kontrolle darüber, was wir nachahmen und auf unserem Bildschirm anzeigen können!
Wir müssen nur ein DetailLayout
mit einem Provider<DetailState>
umhüllen, um den Zustand des Layouts zu simulieren.
ListTile(
titel: const Text('Öffne "geladene" Demo'),
onTap: () {
Navigator.push(
kontext,
MaterialSeitenRoute(
builder: (context) {
return Anbieter<DetailState>.wert(
wert: const DetailState.loaded(
jahr: 2022,
measure: Maßnahme(
year: 2022,
population: 425484,
nation: 'Vereinigte Staaten',
),
),
kind: const DetailLayout(),
);
},
),
);
},
),
ListTile(
title: const Text('Demo "Laden" öffnen'),
onTap: () {
Navigator.push(
kontext,
MaterialSeitenRoute(
builder: (context) {
return Anbieter<DetailState>.wert(
wert: const DetailState.loading(2022),
child: const DetailLayout(),
);
},
),
);
},
),
Schlussfolgerung
Es ist definitiv nicht einfach, eine wartbare Software-Architektur zu erstellen! Zukünftige Szenarien zu antizipieren kann viel Aufwand erfordern, aber ich hoffe, dass die wenigen freigegebenen Tipps Ihnen in Zukunft helfen werden!
Die Beispiele sehen vielleicht einfach aus - es könnte sogar so aussehen, als ob wir übertechnisiert wären - aber wenn die Komplexität Ihrer App wächst, werden Ihnen diese Standards sehr helfen! 💪
Viel Spaß mit Flutter, und folgen Sie dem Blog, um weitere technische Artikel wie diesen zu erhalten! Bleiben Sie auf dem Laufenden!