Trennung von Belangen bei Flatteranwendungen
Engineering at ClickUp

Trennung von Belangen bei Flatteranwendungen

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

Naiver Ansatz Stateful Widget

ü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

Inversion der Kontrolle zum Anbieter und API Client

ü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

Statement-Management in Flutter-Anwendungen

ü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 und operator == 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 oder map 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

Visuelle dedizierte Layout Widgets in Flutter-Anwendungen

ü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 einen DetailState 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!