230 67 18MB
German Pages 432 [481] Year 2023
App-Entwicklung mit Flutter für Dummies
Schummelseite TASTENKOMBINATIONEN FÜR VSCODE +
/
+
: kopiert den markierten Bereich
+
/
+
: fügt den markierten Bereich ein
+
/
+
: markiert alles innerhalb einer Datei
+
/
+
: bietet verschiedene Quick-Fix-Optionen an, um zum Beispiel Widgets zu
wrappen oder zu extrahieren und Dateien zu importieren + +
/ +
+ +
/ /
: verschiebt die Zeile um eins nach oben/unten /
+ + +
+
: öffnet die VSCode-Befehlszeile
: sucht und öffnet Dateien : markiert identische Vorkommnisse des markierten Bereichs
: startet den Debugger und springt zum nächsten Schritt
VARIABLENTYPEN UND ITERABLES IN DART int: eine ganze Zahl, zum Beispiel var ganzeZahl = 1; double: eine Gleitkommazahl, zum Beispiel var gleitkommazahl = 1.2345; String: ein Text, zum Beispiel var text = "Ich bin ein Text"; boolean: kann den Wert true oder false annehmen, zum Beispiel var istBlau = false; dynamic: ein dynamischer Typ, der potenziell jeden anderen Typ annehmen kann, zum Beispiel List liste =[]; List: eine Liste mit int Werten, zum Beispiel var liste = [1, 2, 3, 4, 5]; Map: eine Map mit Strings als Key und dynamic-Werten, zum Beispiel var map = {"name": "Pummel"}; Set: Ein Set ist eine Map ohne doppelte Einträge, zum Beispiel var set = {1, 1.1, 2, 2.2, 3, 3.3};.
OBJEKTE IN DART ERSTELLEN var: Der Typ ergibt sich durch den Inhalt der Variablen und deren Wert ist veränderbar.
final: Zuweisung mit einem Wert, der nicht verändert werden kann const: Zuweisung mit einem Wert, der nicht veränderbar ist, aber zur Compile-Time bereits zur
Verfügung stehen muss late: Definition der Variablen, bevor ihr ein Wert zugewiesen wird. Der Wert ist nach Zuweisung
veränderbar. late final: Definition der Variablen, bevor ihr ein Wert zugewiesen wird. Der Wert ist nach
Zuweisung nicht mehr veränderbar. static: globale Variable, die klassenunabhängig angesprochen werden kann
PARAMETER-TYPEN IN KONSTRUKTOREN positional Parameter: TestKlasse(this.name); named Parameter: TestKlasse({required this.name}); optional named Parameter: TestKlasse({this.name});
HILFREICHE FLUTTER-BEFEHLE flutter pub get: installiert alle Packages, die in der pubspec.yaml Datei angegeben sind flutter clean: löscht den Build-Cache des Projektes flutter doctor: Finden Sie heraus, ob Ihre Flutter-Installation sich in einer guten Verfassung
befindet. flutter run: Starten Sie Ihre Flutter-App. flutter build release: Starten Sie einen Release-Build für Ihre Flutter-App. flutter upgrade: aktualisiert Ihre installierte Flutter-Version, falls es eine aktuellere gibt
HILFREICHE WIDGETS Scaffold: definiert die Basisstruktur eines Screens SafeArea: um sicherzugehen, dass die Widgets darin auf allen Arten von Smartphones voll sichtbar
sind Container: ein Objekt, dessen Größe, Farbe, Abstand definiert werden können und vieles mehr Column: um mehrere Widgets vertikal anzuordnen Row: um mehrere Widgets horizontal anzuordnen Padding: um einem Widget Abstand zu seiner Umwelt zu geben Text: um einen Text anzuzeigen und Styles zuzuweisen Image: um ein Bild darzustellen
Icon: um ein Icon aus der Flutter-Kollektion darzustellen GestureDetector: um User-Input abzufangen und eine Funktion zu triggern
App-Entwicklung mit Flutter für Dummies Bibliografische Information der Deutschen Nationalbibliothek Die Deutsche Nationalbibliothek verzeichnet diese Publikation in der Deutschen Nationalbibliografie; detaillierte bibliografische Daten sind im Internet über http://dnb.d-nb.de abrufbar. 1. Auflage 2023 © 2023 Wiley-VCH GmbH, Boschstraße 12, 69469 Weinheim, Germany Wiley, the Wiley logo, Für Dummies, the Dummies Man logo, and related trademarks and trade dress are trademarks or registered trademarks of John Wiley & Sons, Inc. and/or its affiliates, in the United States and other countries. Used by permission. Wiley, die Bezeichnung »Für Dummies«, das Dummies-Mann-Logo und darauf bezogene Gestaltungen sind Marken oder eingetragene Marken von John Wiley & Sons, Inc., USA, Deutschland und in anderen Ländern. Das vorliegende Werk wurde sorgfältig erarbeitet. Dennoch übernehmen Autoren und Verlag für die Richtigkeit von Angaben, Hinweisen und Ratschlägen sowie eventuelle Druckfehler keine Haftung. Coverfoto: elenabsl – stock.adobe.com Korrektur: Isolde Kommer Print ISBN: 978-3-527-72029-3 ePub ISBN: 978-3-527-84060-1
Über die Autorinnen
Die Autorin Mira Jago
Mira Jago ist Quereinsteigerin in die Programmierung, Tech-Mentorin für Startups und Unternehmerin mit eigener Flutter-Agentur in Hannover. Außerdem gibt sie Kurse zu Flutter. Die Nachfrage ist so hoch, dass sie sich am liebsten klonen würde.
Die Autorin Verena Zaiser
Verena Zaiser baute mit elf Jahren ihre erste eigene Website. Schon in der Schulzeit programmierte sie für Kunden. Sie studierte Informatik und ist heute freiberufliche Mobile-App-Entwicklerin in Stuttgart – natürlich immer mit Flutter.
Danksagung Mira möchte sich vor allem bei ihrer Tochter Ada Lou bedanken, dass sie so ein cooles Baby ist und ihrer Mutter Zeit zum Schreiben lässt. Und bei ihrem Ehemann Birger, der sich seit der Geburt zwei Jahre Elternzeit nimmt – er flucht zwar immer, wann dieses Buch endlich fertig ist, aber ohne ihn würde gar nichts gehen und wenig wäre schön. Eine dicke Umarmung an Verena – fürs Einspringen, wann immer die neue Mutterrolle zu viel abverlangt hat und für die superproduktive, spannende und spaßige Zusammenarbeit an diesem Buch. Und ein großes Dankeschön an Ansgar Oberholz – die absurde Idee, dass man parallel ein Kind kriegen, ein Unternehmen leiten, auf Bühnen sprechen und ein Buch schreiben könnte, nahm hier ihren Ursprung. Verena möchte sich in erster Linie bei ihrem Freund Matthias bedanken, der ihr mit seelischer und motivierender Unterstützung beistand und ihr half sich auf das Endergebnis zu konzentrieren. Ein großes Dankeschön auch an Mira, für die Möglichkeit gemeinsam ein Buch über Verenas Lieblingsthema Flutter zu schreiben. Mira war stets die Ruhe selbst, egal wie stressig es wurde mit Baby, Buch, Firma, Reisen und Verenas Perfektionismus. Ein weiteres großes Dankeschön geht an Christopher Marx, der spontan für die Fachkorrektur eingesprungen ist und die gesamte Flutter-Community, die immer hilfsbereit ist und einen riesigen Beitrag dazu leistet, dass Flutter so viel Spaß macht und so erfolgreich ist.
Inhaltsverzeichnis Cover Titelblatt Impressum Über die Autorinnen Danksagung
Einleitung Über dieses Buch Konventionen in diesem Buch Wie dieses Buch aufgebaut ist Symbole, die in diesem Buch verwendet werden Wie es weitergeht
Teil I: Einführung in Flutter Kapitel 1: Flutter und das große Feld der App-Entwicklung Flutter in a Nutshell Alternativen zur App-Entwicklung mit Flutter Vorteile von Flutter Wie funktionieren Flutter und Dart?
Kapitel 2: Startklar machen und rein ins Vergnügen Flutter installieren Entwicklungsumgebung einrichten
Kapitel 3: Ihre allererste App Eine neue Flutter-App Pummel The Fish Recap: Einführung in Flutter
Teil II: Programmieren mit Dart Kapitel 4: Pfeilschnell programmieren mit Dart Die erste Klasse Das ist Typsache Objekte bauen mit var, final, late oder const Wie Funktionen funktionieren Parameter für jede Lebenslage
Kapitel 5: Bedingte Anweisungen und Schleifen im Griff
Wenn A, dann B – bedingte Anweisungen in Dart Round and round it goes … Schleifen in Dart
Kapitel 6: Sammeln und Sortieren – Collections in Dart Drei Arten von Collections Methoden für Iterables
Kapitel 7: Asynchrone Programmierung – wenn es mal wieder länger dauert Futures, async und await Ein Datenfluss – auch Stream genannt
Kapitel 8: Vererbung und weitere praktische Dart-Features Vererbung in Dart Interfaces Mixins
Kapitel 9: Debugging in Dart – Probleme finden und lösen De-BUG-ging – die Jagd auf die Bugs Die DevTools von Flutter Recap: Programmieren mit Dart
Teil III: Wir bauen eine App Kapitel 10: Alles ist ein Widget Hier fängt alles an: die main.dart-Datei Widgets, Widgets überall … StatefulWidget und StatelessWidget Es wächst ein Widget-Baum Exkurs: Das Flutter-Framework – vor lauter Bäumen die App nicht mehr sehen
Kapitel 11: Widgets über Widgets – wie werden daraus tolle AppScreens? Screens anlegen Pummel The Fish – der SplashScreen Pummel The Fish – der HomeScreen Pummel The Fish – der DetailPetScreen Pummel The Fish – der CreatePetScreen
Kapitel 12: Ein bisschen DIY zwischendurch – Custom Widgets Custom Widget – ja, nein, vielleicht? _CustomWidget – das sollte lieber privat bleiben Ein Custom Widget für alle und überall!
Kapitel 13: Wenn das, dann das – oder das?
Wenn die Daten die UI bedingen sollen Natives Design Responsiveness umsetzen
Kapitel 14: Wo gehts hier lang? Routing in Flutter-Apps Wie gehts zum nächsten Screen? Named Routing – beim Navigieren den Überblick behalten Ich verfolge Sie auf Schritt und Klick – der Backstack Routing im Web und für Fortgeschrittene
Kapitel 15: Mach alles blau – Theming für Ihre App Wo das Theming in Ihrer App haust Farbe bekennen! Fun mit Fonts Recap: Wir bauen eine App
Teil IV: REST und Firebase – externe Daten beziehen und managen Kapitel 16: Schnittstellen anbinden Wer oder was ist eigentlich dieser REST? Und was hat er mit API vor? Alternativen zu REST REST-Requests REST-Response Los gehts – Daten per REST abrufen Asynchrone REST-API-Daten im Flutter-UI anzeigen Daten vom Backend holen – aber wann und wo? Daten aus dem Flutter-UI sammeln und an die REST-API senden
Kapitel 17: Firebase und der Cloud Firestore Die eierlegende Wollmilchsau Firebase-Installation und Einrichtung Cloud-Firestore-Anbindung Cloud-Firestore-Alternativen – ja, aber wann und warum? Recap: REST und Firebase – externe Daten beziehen und managen
Teil V: State-Management Kapitel 18: Stein auf Stein – App-Architektur in Flutter Was ist eine Architektur überhaupt? Das Chaos im Griff mit Ordnerstrukturen Pragmatisch, praktisch, gut – unser Architektur-Vorschlag
Kapitel 19: State-Management Was ist State-Management? Wozu braucht man das?
Kurzer Exkurs – das InheritedWidget
Kapitel 20: State-Management mit Bloc und Cubit Meine Straße, mein Zuhause, mein Bloc? Ihren ersten Cubit anlegen Kommunikation zwischen UI und Cubit Repositories zentral zur Verfügung stellen mit RepositoryProvider Cubits weiter vereinfachen – mit einem Enum-State ans Ziel Bloc und Cubit – so unterschiedlich und doch so gleich Good to Know und weiterführendes Wissen Recap: State-Management
Teil VI: Testen, builden und veröffentlichen Kapitel 21: Testing – wer, wie, was und wieso, weshalb, warum? Warum testen? Manuell oder automatisiert? Logik testen User Interface testen Einen Flow mithilfe von Integration-Tests testen Messbarkeit von Tests: die Test-Coverage
Kapitel 22: Der Android-Build Vorbereitung eines Builds (Android und iOS) Apps an Testpersonen verteilen Eine .apk- oder lieber eine .aab-Datei? Verteilung über den Google Play Store
Kapitel 23: Der iOS-Build Voraussetzungen Vorbereitung eines iOS-Builds Registrieren Sie Ihre App Erstellen Sie einen Build mit Xcode Apps an Testpersonen verteilen Fertig! Release Build erstellen und veröffentlichen Recap: Testen, builden und veröffentlichen Bye bye
Teil VII: Top-Ten-Teil Kapitel 24: Unsere 10 Lieblings-Widgets Widget 1: Chip Widget 2: Wrap Widget 3: CupertinoDatePicker und showDatePicker
Widget 4: PageView Widget 5: Table Widget 6: Hero Widget 7: AnimatedContainer Widget 8: Semantics Widget 9: SliverAppBar Widget 10: CustomPaint
Kapitel 25: Unsere 10 Flutter-Tipps und -Tricks Tipp 1: Wenn Sie einen komischen Fehler haben, den Sie nicht lösen können Tipp 2: Wenn ein iOS-Build nicht hinhaut Tipp 3: Konsistente Benennung von Dateien und Klassen Tipp 4: Arbeiten Sie mit einem Linter Tipp 5: Formatieren Sie Ihren Code mit einem Formatter Tipp 6: Verwenden Sie den automatischen Fix-Command von Dart Tipp 7: Updaten Sie Flutter und Ihre Dependencies regelmäßig Tipp 8: Wann für oder gegen ein Package oder Plug-in entscheiden Tipp 9: Separieren Sie einzelne Widgets in eigene Klassen und separate Dateien Tipp 10: Schauen Sie bei einem Flutter-Meetup vorbei
Abbildungsverzeichnis Stichwortverzeichnis End User License Agreement
Illustrationsverzeichnis Kapitel 3 Abbildung 3.1: Das frisch generierte Flutter-Projekt Abbildung 3.2: Geräteauswahl Abbildung 3.3: Die App beim ersten Starten Abbildung 3.4: Pummel The Fish Abbildung 3.5: SplashScreen Abbildung 3.6: HomeScreen Abbildung 3.7: DetailPetScreen Abbildung 3.8: CreatePetScreen
Kapitel 4 Abbildung 4.1: Null Safety hilft bei der Fehlererkennung.
Kapitel 9
Abbildung 9.1: Run and Debug Abbildung 9.2: Die App im Debug-Modus Abbildung 9.3: Breakpoints Abbildung 9.4: Die Debug-Leiste Abbildung 9.5: Objekte inspizieren Abbildung 9.6: Variablenwerte beobachten Abbildung 9.7: Den Widget Inspector öffnen Abbildung 9.8: Der Widget Inspector Abbildung 9.9: Element auswählen im Widget Inspector
Kapitel 10 Abbildung 10.1: Die Flutter-Beispiel-App Abbildung 10.2: Das Padding-Widget ausprobieren Abbildung 10.3: Ein Widget-Baum Abbildung 10.4: Die Ebenen des Flutter-Frameworks Abbildung 10.5: Flutters drei Bäume Abbildung 10.6: Die Element-Klasse im Flutter-Framework
Kapitel 11 Abbildung 11.1: Rename Symbol Abbildung 11.2: Quick-Fix-Import Abbildung 11.3: Der noch leere SplashScreen Abbildung 11.4: So soll der SplashScreen aussehen. Abbildung 11.5: Bildschirmaussparung Abbildung 11.6: Ein roter Container Abbildung 11.7: Der rote Container nimmt die Größe seines child-Widgets an. Abbildung 11.8: Der rote Container mit Text zentriert Abbildung 11.9: Pummel im Container Abbildung 11.10: Pummel im Container mit fit-Parameter Abbildung 11.11: Ordnerstruktur mit Logo im images-Ordner Abbildung 11.12: Der SplashScreen Abbildung 11.13: Der SplashScreen mit Padding Abbildung 11.14: Das Column-Widget Abbildung 11.15: Das Row-Widget Abbildung 11.16: Das Stack-Widget Abbildung 11.17: Der HomeScreen mit ListView Abbildung 11.18: HomeScreen mit Column und Rows Abbildung 11.19: HomeScreen mit FloatingActionButton Abbildung 11.20: Der HomeScreen mit ListView und ListTiles Abbildung 11.21: Der DetailPetScreen
Abbildung 11.22: Der DetailPetScreen ist halb fertig. Abbildung 11.23: Der DetailPetScreen ist fast fertig. Abbildung 11.24: Der CreatePetScreen Abbildung 11.25: CreatePetScreen mit TextFormFields Abbildung 11.26: CreatePetScreen mit Dropdown Abbildung 11.27: CreatePetScreen mit CheckboxListTile Abbildung 11.28: CreatePetScreen mit »Speichern«-Button Abbildung 11.29: Validierung der Eingabe beim Speichern
Kapitel 12 Abbildung 12.1: Ein Widget extrahieren Abbildung 12.2: Der CustomButton im CreatePetScreen
Kapitel 13 Abbildung 13.1: Der neue DetailPetScreen Abbildung 13.2: Der CreatePetScreen auf dem Android-Emulator Abbildung 13.3: Der CreatePetScreen auf einem iOS-Simulator Abbildung 13.4: Der Speichern-Button auf dem Smartphone Abbildung 13.5: Der Speichern-Button auf der Desktop-App Abbildung 13.6: Der CreatePetScreen mit Padding-Anpassung
Kapitel 14 Abbildung 14.1: Der Weg einer Nutzerin Abbildung 14.2: Der Rückweg der Nutzerin Abbildung 14.3: Der Rückweg der Nutzerin ohne SplashScreen Abbildung 14.4: Der Rückweg der Nutzerin wie im Web üblich
Kapitel 15 Abbildung 15.1: Widget-Baum mit ThemeData-Widget Abbildung 15.2: ColorScheme mit einzeln definierten Farben Abbildung 15.3: Die App mit Custom-Farben – aber die Schrift hat sich versteckt! Abbildung 15.4: Die Parameter des TextTheme-Widgets Abbildung 15.5: Default TextTheme aus der offiziellen Flutter Dokumentation: http... Abbildung 15.6: ColorScheme mit einzeln definierten Farben
Kapitel 16 Abbildung 16.1: Automatisch Overrides erstellen Abbildung 16.2: await ist rot unterkringelt Abbildung 16.3: Exception, weil ein Future zurückgegeben wird Abbildung 16.4: Einen Code-Abschnitt in eine separate Methode extrahieren Abbildung 16.5: Dokumentation von pushNamed anzeigen
Kapitel 17
Abbildung 17.1: Eine relationale Datenbank Abbildung 17.2: Eine nicht relationale Datenbank Abbildung 17.3: Cloud Firestore im Testmodus Abbildung 17.4: Die Cloud Firestore Console mit der pets-Collection und dem erste...
Kapitel 18 Abbildung 18.1: Der Datenfluss in einer App Abbildung 18.2: Die Ordnerstruktur der »Pummel The Fish«-App Abbildung 18.3: Der Screen-orientierte Ansatz Abbildung 18.4: Der Layer-First-Ansatz Abbildung 18.5: Der Feature-First-Ansatz
Kapitel 19 Abbildung 19.1: Die AppBar im HomeScreen mit AdoptionBag-Widget
Kapitel 20 Abbildung 20.1: Blocs erhalten Events und geben States zurück. Abbildung 20.2: Cubits empfangen Funktionen und geben States zurück. Abbildung 20.3: Einen neuen Cubit erstellen Abbildung 20.4: Context-Menü für praktische Bloc-Shortcuts Abbildung 20.5: Snackbar im BlocBuilder? Das funktioniert nicht. Abbildung 20.6: Cases automatisch auflisten
Kapitel 21 Abbildung 21.1: Neue Testdatei automatisch generieren lassen Abbildung 21.2: Methoden gruppieren Abbildung 21.3: Null is not a subtype of type Future Abbildung 21.4: Expected und Actual unterscheiden sich – aber warum? Abbildung 21.5: blocTest-Testfälle erstellen Abbildung 21.6: Der generierte Golden-Test-Screenshot Abbildung 21.7: Die Test-Coverage in der ManagePetsCubit-Klasse Abbildung 21.8: Die Flutter-Coverage aufgeschlüsselt
Kapitel 22 Abbildung 22.1: Crashlytics aktivieren Abbildung 22.2: Analytics aktivieren Abbildung 22.3: Das Analytics-Dashboard Abbildung 22.4: App Distribution aktivieren Abbildung 22.5: .apk-Datei hochladen Abbildung 22.6: .aab- und .apk-Dateien Abbildung 22.7: Die Google Play Console Abbildung 22.8: Eine neue App in der Google Play Console
Abbildung 22.9: Einen internen Test-Release erstellen Abbildung 22.10: Testversion zur Production-Version hochstufen
Kapitel 23 Abbildung 23.1: Der Apple-Developer-Account in der Übersicht Abbildung 23.2: Xcode-Einstellungen Abbildung 23.3: Xcode Build Abbildung 23.4: Xcode Build Abbildung 23.5: Testflight-Upload Abbildung 23.6: App-Store-Veröffentlichung
Einleitung Über dieses Buch Seit Ende 2018 ist die Welt der App-Entwicklung in Bewegung. Mit der Veröffentlichung von Flutter durch Google kam eine Technologie auf den Markt, die das Potenzial hat, die Apple- und Android-Welt zu einen und eine echte Alternative zur nativen Entwicklung zu bieten. Sowohl Auftraggebende als auch Entwickelnde waren schnell begeistert – Erstere, weil sie ihre Entwicklungskosten bei gleichbleibender Qualität halbieren können, und Letztere, weil Flutter sehr viel Wert auf Developer Experience legt. Seit 2021 ist Flutter Web in der Stable Version und seit 2022 kann man auch DesktopApps für Mac, Linux und Windows mit Flutter entwickeln. One ring to rule them all … Auch uns hat Flutter in seinen Bann gezogen, weswegen wir schon 2019 aus der AndroidWelt in die Flutter-Welt aufgebrochen sind und es bis heute nicht bereut haben. Mit diesem Buch wollen wir Sie dabei unterstützen, denselben Weg zu gehen. Wir beginnen mit den Grundlagen von Dart und Flutter und leiten Sie dann durch die komplexeren Themen des State-Managements und der Backend-Anbindung bis hin zum Testen, Builden und Veröffentlichen Ihrer App. Bei der Backend-Anbindung gehen wir im Detail auf Firebase ein, da dieses Backend-as-a-Service von Flutter-Entwickelnden gern genutzt wird. Wir wünschen Ihnen viel Erfolg und eine gesunde Frustrationstoleranz!
Konventionen in diesem Buch In diesem Buch werden Sie eine Flutter-App mit uns entwickeln. Den Code dazu finden Sie im Web unter https://losfluttern.de/pummelthefish oder alternativ auf der Verlags-Website unter https://wiley-vch.de/ISBN9783527720293.
GitHub-Repository Wir haben hier ein GitHub-Repository verlinkt. Wir empfehlen Ihnen, den Code mithilfe des Buches mitzuschreiben und auch anzupassen und für ein optimales Lernergebnis auch selbst damit herumzuspielen. Unsere zur Verfügung gestellte Code-Base sollte nur dazu da sein, Ihnen weiterzuhelfen, falls Sie den Code nicht selbst zum Laufen bekommen. Wenn Sie ein neues Kapitel beginnen und Ihr Code aus den vorherigen Kapiteln nicht funktioniert, empfehlen wir Ihnen, den entsprechenden Git-Branch zu dem Kapitel in unserem Repository zu suchen und ihn herunterzuladen. Von dort aus können Sie dann
entsprechend weiterarbeiten.
Flutter-Version Bitte beachten Sie, dass sich das Flutter-Framework in den letzten Jahren schnell entwickelt hat und das auch nach Veröffentlichung dieses Buches noch weiter tun wird. Es kann also sein, dass Sie den Code aus diesem Buch leicht anpassen müssen. Ihre Entwicklungsumgebung wird Sie darauf hinweisen. Sie können sich entscheiden: Entweder Sie arbeiten mit der neuesten Flutter-Version oder Sie arbeiten mit derselben Flutter-Version, mit der wir für dieses Buch gearbeitet haben. Je nachdem müssen die Packages in der pubspec.yaml-Datei entsprechend angepasst werden. Mit folgendem Befehl können Sie die Flutter-Version in der Konsole anpassen. >> flutter downgrade
Beispiel: >> flutter downgrade v3.3.0
Wir arbeiten mit dem stable-Channel von Flutter. Es gibt neben dem stable-Channel noch den beta- und den master-Channel. Falls Sie sich aus Versehen auf einem der beiden anderen wiederfinden, können Sie so zu dem stable-Channel wechseln: >> flutter channel stable
Jedes Kapitel allein gegen die Welt Vielleicht wollen Sie dieses Buch auch lieber am Strand lesen, ganz ohne Computer – oder parallel an einem eigenen Projekt arbeiten statt an unserer Beispiel-App. Gar kein Problem. Vielleicht haben Sie auch schon etwas Vorerfahrung mit Flutter und sind nicht an den Basics interessiert. Jedes Kapitel dieses Buches steht für sich, fühlen Sie sich frei, kreuz und quer zu lesen, wie Sie lustig sind!
Gender-Love Zum Glück ändert sich das langsam: Bisher sind wir es allerdings als Fachbücher-lesende Frauen nur allzu gut gewohnt, mit einem entschuldigendem Satz in der Einleitung darauf hingewiesen zu werden, dass wir zwar nie angesprochen werden, bei der männlichen Form aber immer mitgemeint sind. Wir wollten unseren männlichen Lesern nicht dasselbe antun, aber die Sternchen-Schreibweise das ganze Buch durchzuziehen, ist uns auch etwas umständlich. (Die vielen Anglizismen machen manche Sätze in diesem Buch eh schon etwas schwer verdaulich.) Also versuchen wir neutral zu schreiben, oder wir wechseln zwischen der männlichen und der weiblichen Form. Es mag sich am Anfang etwas gewöhnungsbedürftig für Sie
anfühlen – aber es ist uns halt wichtig, dass sich alle Lesenden von unserem Buch abgeholt fühlen. Und wenn es sich für Sie komisch anfühlt, dann verstehen Sie vielleicht auch besser, warum sich der bisherige Standard für uns komisch anfühlt und wir da nicht mitmachen möchten.
Törichte Annahmen über unsere Leserschaft Wir gehen davon aus, dass unsere Lesenden einen Computer bedienen können und schon einmal ein paar Zeilen programmiert haben – egal in welcher Sprache, aber am besten objektorientiert. Wir fangen ganz vorne an mit Flutter, Dart und App-Entwicklung, aber es würde den Rahmen sprengen, die Grundbausteine der Programmierung tiefergehend zu erklären. Erste Gehversuche mit der Versionskontrolle Git werden Ihnen auch als Vorkenntnis nützlich sein. Trotzdem – auch wenn Sie ganz neu in der Programmierung sind, möchten wir Sie ermutigen, es mit Flutter und mit diesem Buch zu versuchen. Sie werden vielleicht ein paar Dinge recherchieren müssen und das eine oder andere Kapitel zweimal lesen, aber wir haben immer versucht, alles so verständlich wie möglich zu erklären. Wenn Sie mal den Faden verlieren, finden Sie ihn bestimmt im nächsten Kapitel wieder. Bitte nehmen Sie zur Kenntnis, dass iOS- und macOS-Desktop-Apps nur mit einem Mac entwickelt werden können und Sie mit Windows daher nur Android-, Windows-Desktopund Web-Apps entwickeln können.
Wie dieses Buch aufgebaut ist Dieses Buch teilt sich in sieben Teile, die wiederum in Kapitel und Unterkapitel aufgeteilt sind.
Teil I: Einführung in Flutter In der Einführung wird das Flutter-Framework in seinen Kontext gesetzt – was ist neu daran? Was unterscheidet es von anderen App-Entwicklungstechnologien? Dieser Teil begleitet Sie bei der Flutter-Installation und der Installation der Entwicklungsumgebung.
Teil II: Programmieren mit Dart Die Programmiersprache Dart, auf der das Flutter-Framework basiert, war vor Flutter nicht weit verbreitet. Die Sprache ist sehr elegant, simpel und schnell zu lernen. Falls Sie schon mit Dart gearbeitet haben, können Sie dieses Kapitel selbstverständlich überspringen. Aber beachten Sie, dass hier schon einige Vorarbeiten an der Beispiel-App vorgenommen werden, die Sie in diesem Buch programmieren. Wenn Sie das Kapitel überspringen, laden Sie am besten anschließenden den entsprechenden Code herunter.
Teil III: Wir bauen eine App In diesem Teil lernen Sie, wie man das App-User-Interface mit Flutter gestaltet. Sie lernen, wie Theming und Routing funktionieren, wie ein Screen aufgebaut ist, wie Sie Custom Widgets anlegen und If-else-Verzweigungen im UI anbinden.
Teil IV: REST und Firebase – externe Daten beziehen und managen Eine App soll in der Regel Daten anzeigen und den Nutzenden die Möglichkeit bieten, Daten anzupassen oder einzupflegen. Diese liegen oft in einer externen Datenbank. In diesem Teil lernen Sie, Daten von einer REST-Schnittstelle zu managen. Weil die meisten Flutter-Entwickelnden Firebase als Backend-as-a-Service nutzen, zeigen wir Ihnen außerdem, wie Sie das Firebase-SDK einbinden können.
Teil V: State-Management Wie kommen die Daten von der Schnittstelle in die einzelnen Screens der App und wie wird sichergestellt, dass der State der App stimmt und überall zugänglich ist? Dafür ist das State-Management zuständig. Es gibt verschiedene Philosophien und Packages, um das State-Management zu bewerkstelligen. Wir bringen Ihnen ein von Google empfohlenes State-Management namens »bloc« bei.
Teil VI: Testing, builden und veröffentlichen Weil es von Entwickelnden so gern vernachlässigt wird und doch so wichtig ist, bekommt es bei uns viel Aufmerksamkeit: das Testen. Sie lernen nicht nur, Logik mit Unit-Tests zu überprüfen, sondern auch, die richtige Darstellung Ihrer Widgets durch Widget-Tests und Golden Tests zu sichern. Zum Schluss erhalten Sie noch eine kleine Einführung in Integration-Tests, um ganze User Flows mit Tests abdecken zu können. Danach wird es Zeit, über das Ausrollen der App an Beta-Testende zu sprechen, und welche Vorbereitungen Sie anschließend für die Veröffentlichung in Google Play und Apple App Store treffen sollten.
Teil VII: Top-Ten-Teil Im Top-Ten-Teil erfahren Sie hilfreiche Flutter-Tipps und -Tricks und lernen unsere Lieblingswidgets kennen.
Symbole, die in diesem Buch verwendet werden Diese Symbole verwenden wir im Buch:
Ein Tipp gibt Ihnen Zusatzinformationen zu dem diskutierten Thema. Eine Warnung warnt Sie vor häufigen Fehlern oder möglichen Problemen. Dieses Symbol zeigt an, dass der dazugehörige Kasten Nerd-Infos enthält, die Sie auch gern geflissentlich ignorieren können. Dieses Symbol stellt Ihnen eine konkrete Definition zur Verfügung. Hier können Sie selbst tätig werden und das Gelernte in die Praxis umsetzen.
Wie es weitergeht Flutter ist sehr einfach zu erlernen, aber andererseits auch sehr umfangreich. Es versucht, mehrere komplizierte Welten einfacher zu machen. Wenn Sie aus der iOS-Welt kommen, bringen Sie Problemlösungs-Skills aus dieser Welt mit, aber Ihnen fehlt AndroidErfahrung. Wenn Sie aus der Web-Welt kommen, werden Ihnen einige Gepflogenheiten aus der App-Welt merkwürdig vorkommen – und andersherum. Generell, wenn Sie etwas Neues lernen – aber deshalb insbesondere bei Flutter – ist es sehr empfehlenswert, dies in einer Gruppe zu tun. Im besten Fall kennen Sie ein paar Flutter-Entwickelnde, die weiter sind als Sie und Ihnen helfen können, wenn Sie allein und mit Online-Foren nicht weiterkommen. Auch Flutter-Entwickelnde, die so weit sind wie Sie und mit denen Sie sich austauschen und anspornen können, bereichern Ihren Programmieralltag. Wir empfehlen Ihnen darum, nach einer Meetup-Gruppe in Ihrer Nähe zu suchen. Wenn es in Ihrer Nähe keine gibt, suchen Sie sich eine Online-Gruppe. Es eignen sich vor allem die Google Developer Groups, die es in jeder größeren Stadt gibt, und die FlutterGruppen, die sich im Flutter-Meetup-Netzwerk befinden. Eine Übersicht finden Sie hier. https://gdg.community.dev https://www.meetup.com/pro/flutter
Manche Flutter-Gruppen sind städte- und länderübergreifend organisiert und daher im Flutter-Meetup-Netzwerk nicht gelistet. Es empfiehlt sich daher, auch einen Blick auf die offizielle Meetup-Seite zu werfen. https://www.meetup.com
Verena organisiert seit 2022 die deutschsprachige Flutter-DACH-Gruppe, die in vielen verschiedenen Städten in Deutschland, Österreich und der Schweiz tätig ist, und Mira seit 2017 die Google-Developer-Gruppe und die Women Techmakers in Hannover. Schauen Sie gerne vorbei, wenn Sie mal in der Nähe sind! Neben Stammtischgruppen sind natürlich Udemy-Kurse immer ein guter Weg, sich neue Skills anzueignen, insbesondere die von Maximilian Schwarzmüller können wir sehr empfehlen (gibt es auf Deutsch und auf Englisch). Verena produziert außerdem mit zwei befreundeten Flutter-Entwicklern den deutschsprachigen Flutter-DACH-Podcast, den es auf allen gängigen Plattformen zu hören gibt: https://anchor.fm/flutter-dach
Bei spezifischen Problemen mit Ihrem Code ist Stack Overflow immer für Sie da: https://stackoverflow.com
Flutter bietet eine der umfangreichsten und verständlichsten Dokumentationen an, die wir je in der Tech-Welt gesehen haben. Ein Blick lohnt sich immer: https://docs.flutter.dev
Analog zur Flutter-Dokumentation darf die Dart-Dokumentation natürlich nicht fehlen: https://dart.dev/guides
Wir wünschen Ihnen viel Spaß und Erfolg mit diesem Buch und freuen uns riesig, Sie in der Flutter-Welt begrüßen zu dürfen!
Teil I
Einführung in Flutter
IN DIESEM TEIL … Diskutieren wir, warum Flutter die Zukunft der App-Entwicklung ist Installieren Sie Flutter Entscheiden Sie: Entwicklungsumgebung einrichten – oder im Online-Editor arbeiten Erstellen Sie eine erste App und lernen das buchbegleitende App-Projekt kennen
Sie wollen sich also in die Flutter-Welt wagen. Herzlich willkommen! In diesem ersten Teil unseres Buches wollen wir Ihnen Flutter vorstellen und mit alternativen Sprachen und Frameworks vergleichen. Wir begleiten Sie beim Installieren von Flutter und beim Einrichten Ihrer Entwicklungsumgebung. Außerdem stellen wir Ihnen das buchbegleitende App-Projekt vor, an dem wir Ihnen die einzelnen Themen rund um Flutter praxisbezogen nahebringen wollen.
Kapitel 1
Flutter und das große Feld der AppEntwicklung IN DIESEM KAPITEL Lernen Sie die Geschichte der App-Entwicklung kennen Bekommen Sie einen Überblick über alternative Technologien zu Flutter Lesen Sie ein paar objektive und ein paar subjektive Gründe, warum und wann Flutter die beste Wahl ist Erhalten Sie einen kurzen Einblick, wie Flutter funktioniert und was es mit Dart auf sich hat
Willkommen im allerersten Kapitel Ihrer Reise in die Flutter-Welt! Wir starten mit einem kleinen Warm-up, indem wir Sie zunächst auf einen kleinen Exkurs entführen und Ihnen ein bisschen Grundwissen zu Flutter, seinen Vorteilen und Grenzen an die Hand geben. Anhand von alternativen Formen der App-Entwicklung lässt sich das wunderbar darstellen. Am Ende dieses Kapitels wissen Sie auf jeden Fall, was uns an Flutter so begeistert und warum wir Sie möglicherweise auch bald im Kreise der Begeisterten begrüßen dürfen.
Flutter in a Nutshell Flutter ist ein Cross-Platform-Framework von Google, das User-Interface-Elemente zur Entwicklung einer App zur Verfügung stellt. Seit 2021 ist es nicht einfach nur ein xbeliebiges, sondern das weltweit am meisten genutzte Cross-Platform-Framework auf dem Markt (https://www.statista.com/statistics/869224/worldwide-softwaredeveloper-working-hours/, Stand: 20.03.2023). Flutter-Apps laufen sowohl auf Android- und iOS-Smartphones als auch im Web und auf Windows-, Linux- und macOS-Desktops. Programmiert wird mit der Sprache Dart. In diesem Buch wollen wir Ihnen vor allem die Entwicklung von mobilen Apps für Android und iOS mit Flutter vorstellen – das meiste Gelernte lässt sich aber auch auf die anderen Plattformen anwenden.
Ein Framework ist eine Ansammlung von fertigen Komponenten und Tools, die Entwickelnde benutzen und anpassen können, mit dem Ziel, die Entwicklung zu beschleunigen. Sie können sich damit auf wichtige Aufgaben konzentrieren, anstatt mühselig jedes Mal von Neuem die Basisfunktionalitäten und -komponenten zu programmieren. Seit Flutter 2017 veröffentlicht wurde, wird es unter Entwickelnden lebhaft diskutiert. Es ist eine neue Herangehensweise an die App-Entwicklung. Von manchen wird es als kurzlebiger Trend eingestuft, andere denken, dass Flutter die anderen Programmiersprachen und Frameworks, die sich zur App-Entwicklung eignen, in Zukunft in den Schatten stellen wird.
Anfänge der App-Entwicklung App-Entwicklung war nicht immer ein eigenes Feld in der Software-Entwicklung – und es war auch nicht immer so prominent gespalten in Android- und iOS-Entwicklung, wie es das heute ist. 1994 kommt das erste Smartphone auf den Markt, aber es soll noch ein paar Jahre dauern, bis das Gerät massentauglich wird. Apps werden noch aus dem Internet heruntergeladen und wie Webseiten programmiert. Es entsteht eine kleine Hobby-Programmierszene für App-Entwicklung. 2007 kommt das erste iPhone auf den Markt und ein Jahr später öffnet der Apple App Store mit 500 Apps im Angebot seine virtuellen Pforten. Diese Apps laufen nur auf Apple-Geräten und können nicht aus dem Internet heruntergeladen, sondern nur über den Apple Store bezogen werden. Das ist bis heute so. Diese iPhone-Apps werden nicht mehr mit Web-Programmiersprachen geschrieben, sondern mit Objective-C, das schließlich 2014 von Swift abgelöst wird. 2008, ein Jahr nach dem iPhone, kommt das erste Android-Smartphone auf den Markt und der Google Play Store liefert die passenden Apps dazu. Diese Apps können auf allen Geräten mit Android-Betriebssystem laufen. Das Konzept ist etwas freier als in der Apple-Welt: Apps müssen nicht über den Google Play Store installiert werden, sie können auch aus dem Internet heruntergeladen und installiert oder per E-Mail verschickt werden. Doch die Programmiersprache ist auch keine Web-Sprache – Android-Apps werden erst mit Java, heute meist mit Kotlin programmiert. 2010 bringt Microsoft das Windows Phone auf den Markt. Apps für das Windows Phone können in C# geschrieben werden. Das Windows Phone setzt sich aber nicht gegen das iPhone und die AndroidSmartphones durch und verliert rasch seinen Marktanteil. 2017 wird die Produktion schließlich eingestellt. Der Markt der App-Entwicklung wächst. 2022 werden rund 1,6 Millionen iOS-Apps im Apple Store angeboten und über 3,5 Millionen Android-Apps im Google Play Store (https://www.statista.com/statistics/276623/number-of-apps-available-in-leading-app-stores, 20.03.2023). Pro Tag werden mittlerweile über 700 iOS-Apps veröffentlicht und fast 2000 Android-Apps.
Alternativen zur App-Entwicklung mit Flutter Die Tatsache, dass Sie dieses Buch in den Händen halten, lässt bereits vermuten, dass Sie
zumindest daran interessiert sind, in die Flutter-Welt einzutauchen. Trotzdem möchten wir Ihnen davor die Alternativen zur Flutter-Entwicklung aufzeigen, um zu erfahren wie (teilweise beschwerlich und zeitaufwendig) der Weg zur eigenen App bisher aussah oder teilweise immer noch aussieht. Die App-Entwickelnden-Szene teilt sich heute in verschiedene Plattformen: Entweder Sie programmieren nativ für Apple mit Swift und Xcode als Entwicklungsumgebung oder für Android mit Java oder Kotlin und Android Studio als Entwicklungsumgebung. Alternativ dazu wählen Sie eine Cross-Platform-Technologie wie Flutter, mit der Sie mehrere Betriebssysteme bedienen können. Auch eine Progressive Web-App (PWA) könnte als Alternative zu einer nativen oder einer Cross-Platform-App infrage kommen. In den folgenden Abschnitten gehen wir kurz auf alle drei Möglichkeiten ein.
Native Entwicklung Native Entwicklung mit Swift für Apple oder Kotlin und Java für Android ist immer noch ein wichtiges Feld. Speziell wenn eine App hauptsächlich auf Hardware-nahe Funktionen wie den Einsatz von Kamera, Bluetooth, verschiedenen Sensoren oder Audio angewiesen ist, sollten Sie über eine native Entwicklung nachdenken. Diese Implementierungen sind meist mit viel nativem plattformspezifischen Code verbunden und können daher nicht für mehrere Plattformen verwendet werden. Sie können diese Funktionalitäten in Flutter zwar mit Plug-ins meistens abdecken, bei der Performance müssen Sie aber teilweise mit Einbußen rechnen. Als mit Flutter entwickelnde Person werden Sie auch um kleine Einblicke in die Androidund iOS-Welt manchmal nicht herumkommen. Wenn Sie aus der nativen Entwicklung kommen, ist dies auf jeden Fall ein Vorteil. Ihr Wissen wird Ihnen in der FlutterEntwicklung sicher zugutekommen. Mit Flutter ist es mittlerweile auch möglich, plattformspezifisch jeweils andere Designelemente anzuzeigen. Die Libraries für Android- und iOS-spezifisches Design sind schon automatisch integriert.
Cross-Platform-Entwicklung In den letzten Jahren kristallisiert sich immer mehr heraus, dass der App-Markt zwischen Android und iOS gespalten bleiben wird. Gleichzeitig gibt es kaum noch Firmen, die es sich leisten wollen, eine der Plattformen zu ignorieren. Firmen, die eine App auf den Markt bringen, wollen in der Regel beide Smartphone-Betriebssysteme bedienen – und oft auch eine Webvariante ihres Produktes. Um nicht für ein Produkt zwei oder drei verschiedene Apps parallel entwickeln und pflegen zu müssen, entscheiden sich heute viele Firmen für eine Cross-Platform-Lösung. Eine Cross-Platform-App ist eine Software, die nicht nur auf einem, sondern auf mehreren
Betriebssystemen laufen kann. Diese Cross-Platform-Apps haben häufig Nachteile gegenüber den nativ programmierten Apps. Sie sind oft weniger performant und anfangs konnten die Cross-PlatformFrameworks nicht auf alle Hardware-Funktionen zugreifen, wie zum Beispiel Bluetooth oder GPS.
Cordova und Ionic Cross-Platform-Entwicklung begann ursprünglich mit hybriden Apps. Ein Beispiel dafür ist das Apache-Cordova-Framework – auch bekannt unter dem Namen »PhoneGap«. Entwickelnde programmieren eine Webseite und das Framework packt diese Webseite in einen nativen Android- oder iOS-Container. Diese Apps haben leider oft eine mäßige Performance, lange Ladezeiten, vorgegebene und nur bedingt anpassbare Designelemente und gelten darum als »billige« Variante der nativen Entwicklung für iOS und Android. Das Ionic-Framework funktioniert nach demselben Prinzip und wird heute noch genutzt – ist aber nicht weit verbreitet.
Xamarin Xamarin ist ein kostenpflichtiges Framework für .NET-Entwickelnde. Mit diesem Framework können in C# Apps für Android, iOS und Windows geschrieben werden. Es wird in Deutschland noch teilweise in mittelständischen Firmen eingesetzt, hat sich aber nie richtig durchsetzen können.
Unity und die Unreal Engine Cross-Platform-Spiele können mit Unity in C# oder der Unreal Engine in C++ programmiert werden. Diese Cross-Platform-Spiele-Apps sind übrigens für den Großteil des Umsatzes des Apple App und Google Play Stores verantwortlich (https://www.statista.com/statistics/266489/earnings-forecast-for-mobileapps-providers/, 20.03.2023). Für Flutter gibt es mittlerweile die Flame-Engine; mit der sich 2D-Spiele gut umsetzen lassen. Für komplexe Spieleentwicklung und vor allem 3DSpiele sollten Sie allerdings weiterhin Unity oder die Unreal Engine in Betracht ziehen.
React Native Mit React Native hat Meta vor ein paar Jahren eine ziemlich gute Cross-Platform-Lösung entwickelt. Der programmierte JavaScript-Code wird direkt in einen iOS- und Androidkompatiblen Code übersetzt, was der Performance zugutekommt und im Vergleich zum hybriden Ansatz deutlich näher am Look-and-feel von nativen Apps liegt. React Native ist weit verbreitet und war vor Flutter die beliebteste Cross-Platform-Lösung unter App Entwickelnden. 2021 hat sich Flutter offiziell gegenüber React Native als das meistgenutzte CrossPlatform-Framework weltweit durchgesetzt. Hauptgrund dafür ist, dass es gegenüber nativen Apps kaum Qualitätseinbußen gibt.
PWA-Entwicklung Progressive Web-Apps sind keine mobilen Apps im engeren Sinne, sondern eigentlich Webseiten, die mithilfe einer extra Service-Worker-Datei zu einer offline-fähigen, herunterladbaren Web-App erweitert werden können. Sie können prinzipiell mit jedem Web-Framework geschrieben werden (auch mit Flutter übrigens). Sogar WordPress Webseiten können mithilfe eines Plugins zu einer PWA erweitert werden.
Die Eigenarten einer PWA Die Apps können sowohl im Google Play Store, als auch auf normalen Webseiten im Internet gefunden und heruntergeladen werden. Eine Bereitstellung über den Apple App Store ist allerdings aktuell nicht möglich. Sie sehen aus wie native Apps und nutzen den Cache des Smartphone Browsers, um Daten zu speichern und offline zur Verfügung zu stellen. Genau wie Flutter gibt diese Technologie das Versprechen mit nur einer Codebase drei große Plattformen zu bedienen. Während man bei Flutter nach einer Anpassung des Codes drei verschiedene Builds anfertigen und dann auf drei verschiedenen Plattformen veröffentlichen muss, wird eine PWA meist nur im Web veröffentlicht und ist dann sofort von allen Plattformen aus zugänglich. Auf den ersten Blick ist eine PWA also noch schneller zu updaten als in Flutter programmierte Apps.
Die Probleme einer PWA Das Problem mit PWAs ist leider, dass es viele verschiedene Browser gibt und die Firmen dahinter Probleme haben, sich auf gemeinsame Standards zu einigen. Es geht nur langsam in die richtige Richtung. Safari zum Beispiel stellt sich gern quer, wenn es um die Frage geht, auf welche Smartphone-Funktionen der Browser und damit die PWA zugreifen darf. Das ständige Anpassen der App nach Browser Updates und das kompatibel Halten mit veralteten Browser Versionen ist umständlich und zeitaufwendig. Ein weiterer wichtiger Punkt gegen eine Entscheidung für eine PWA-Entwicklung ist, dass diese Apps für viele Smartphone-Nutzende noch unbekannt sind. Sie suchen eher im App Store nach Apps als im Internet, und wenn sie eine PWA im Internet finden, wissen sie meist nicht um die Möglichkeit, diese herunterzuladen und wie eine native App zu benutzen.
Vorteile von Flutter Sie sehen, es gibt also einige Möglichkeiten, eine App zu entwickeln. Wir halten Flutter jedoch in den meisten Fällen für die sinnvollste. Hier noch einmal die wichtigsten Gründe, sich für Flutter zu entscheiden, zusammengefasst und ein paar weiterführende im Überblick: So gut wie nativ: Mit Flutter können Apps entwickelt werden, die im Google Play
Store und im Apple Store genau wie native Apps gefunden werden können und ein ebenbürtiges »Look-and-feel« wie native Apps bieten. Viele Plattformen mit einer Codebase: Flutter Skills haben eine große Hebelwirkung – mit einem Framework können Sie sehr viele verschiedene Plattformen bedienen. Da kommt sonst kaum ein Cross-Platform-Framework ran. Das impliziert auch Plattform-Skalierbarkeit: Sie können mit einer beliebigen Plattform starten, schnell ein MVP an Benutzende ausrollen und jederzeit neue Plattformen nachziehen, wenn der Markt reif dafür ist – ohne großen Mehraufwand. Flutter-Entwicklung ist schnell: Mit Flutter können Sie ganz fix ein professionell aussehendes UI schaffen. Es eignet sich hervorragend, um einen App-Prototypen oder MVP (ein Minimum Viable Product ist die erste funktionsfähige Iteration eines Produktes) zu erstellen, weil der Weg von der Idee zum ersten MVP-Release so schnell gehen kann. Dart macht Spaß: Dart, die Sprache, mit der in Flutter entwickelt wird, ist einfach zu lernen, vor allem wenn man schon eine Programmiersprache beherrscht. Oft wird es als eine Mischung zwischen Kotlin, Swift und TypeScript beschrieben. Flutter ist Open Source: Ein Team von Google arbeitet ununterbrochen an der Weiterentwicklung von Flutter, aber Sie können auch selbst aktiv werden und an der Weiterentwicklung mitwirken. Außerdem haben Sie Zugriff auf den gesamten SourceCode und können die Funktionsweise von Flutter jederzeit selbst nachschlagen. Spaß beim Entwickeln: Flutter soll nicht nur für die App-Nutzenden eine Bereicherung sein, sondern auch für die Entwickelnden. Von Anfang an ist an das Entwicklungserlebnis für Entwickelnde gedacht worden und das sorgt dafür, dass Flutter einfach Spaß macht. Mit Hot-Reload, einer Funktion, die Sie in diesem Teil noch praktisch kennenlernen werden, kann die App zum Beispiel in Sekunden mit neuen Änderungen geupdatet werden, ohne einen langen Build-Prozess und einhergehende Wartezeit auszulösen. Eine hilfsbereite Community: Es hat sich mittlerweile auch eine große, aktive und sehr hilfsbereite Flutter Community entwickelt. Es gibt unzählige Tutorials, Hilfeforen und Blogs, die bei Problemen Hilfestellung leisten können. Auf Meet-ups und Konferenzen können Sie viele leidenschaftliche Flutter-Entwickelnde kennenlernen und sich austauschen. Premium-Dokumentation: Flutter ist sehr gut dokumentiert. Wenn Sie bereits mit anderen Frameworks, Programmiersprachen und Software-Entwicklungstools gearbeitet haben, wissen Sie wahrscheinlich, dass das nicht selbstverständlich ist. Package- und Plug-in-Vielfalt: Für nahezu jeden Use-Case gibt es mittlerweile ein passendes Package oder Plug-in, mit dem Sie Ihre Flutter-App um Funktionalität und Designelemente erweitern können.
Zukunftsfähige Technologie: Flutter ist »in« und das Interesse steigt, nicht nur bei Entwickelnden, auch bei potenzieller Kundschaft. Ein perfekter Zeitpunkt einzusteigen!
Wie funktionieren Flutter und Dart? Flutter ist ein Framework (von Google auch als UI-Toolkit bezeichnet) und Dart die Programmiersprache, mit der innerhalb dieses Frameworks programmiert wird – so viel wissen Sie bereits. Im Folgenden möchten wir aber noch einen Schritt weiter gehen und Ihnen einen kurzen Einblick geben, wie Flutter und Dart unter der Haube funktionieren und warum gerade Dart als Programmiersprache ausgewählt wurde. Wenn Sie gleich Ihre erste Flutter-App entwickeln, werden Sie sich zwei APIs (Schnittstellen) bedienen, der Flutter-API und der Dart-API.
Flutter-API Die Flutter-API gibt Ihnen eine Kollektion von UI-Elementen an die Hand, aus der Sie sich bedienen können. Bei Flutter heißen diese Elemente »Widgets«. Es gibt zum Beispiel Container, Buttons, Textfelder, Menüs, Bilder, Icons, Pop-ups und viele mehr. Sie können aus der Material Library Elemente im Android-Stil auswählen oder aus der Cupertino Library Elemente im iOS-Stil. Die meisten Widgets haben anpassbare Parameter, wie Farben, Größen, Abstände und Inhalte.
Dart-API Die Dart-API gibt Ihnen Zugriff auf die üblichen Funktionen, die in der Regel jede Programmiersprache anbietet: Sie können zum Beispiel eine Funktion aufrufen, die eine Kommazahl rundet, eine Collection sortiert oder Ihnen das derzeitige Datum zurückgibt. Mit Dart-Code können Sie die Logik der App schreiben und bestimmen, wann welche Daten geladen und wann welche UI-Elemente angezeigt werden sollen.
Warum Dart? Aber warum muss es eigentlich die unbekannte Sprache Dart sein? Hätten JavaScript, Java, Kotlin, Python oder irgendeine andere Sprache, die auch in anderen Bereichen der Software-Entwicklung eingesetzt wird, es nicht auch getan? Viele Entwickelnde, die wir treffen, haben ähnliche Bedenken.
Was ist so besonders an Dart? Dart ist eine leicht zu erlernende, moderne und elegante, für die UI-Entwicklung optimierte Programmiersprache. Sie ist objektorientiert und die Syntax ähnelt der von Kotlin, Swift und TypeScript. Seit Version 2.12 ist Dart Null-Safe und sorgt dafür, dass Flutter-Entwickelnde immer im
Blick haben müssen, wann eine Variable null werden kann. Das Flutter-Team hat sich vor allem für Dart entschieden, weil Dart schnell ist und gleichzeitig eine gute User Experience für App-Nutzende sowie App-Entwickelnde bietet. Aber warum ist das so?
JIT und AOT – bitte, was? Das Geheimnis dahinter? Dart unterstützt sowohl JIT- als auch AOT-Kompilierung. Diese Kombination lässt sich in anderen Sprachen nur selten finden, denn meist wird nur eine Form der Kompilierung unterstützt. JIT steht für Just-in-Time-Kompilierung. Wenn eine Sprache JIT kompiliert, heißt das, dass sie kompiliert, während die App läuft. Das führt für die App-Nutzenden zwar zu einer geringeren Performance, bietet aber vor allem Geschwindigkeitsvorteile beim Entwickeln – Stichwort Hot-Reload. Dart benutzt den JIT-Compiler, wenn Sie Ihre App während des Entwickelns auf einem Emulator oder auf einem angeschlossenen Smartphone debuggen. Führen Sie eine Änderung in Ihrem Code durch und speichern Sie diese, sehen Sie sie dank Hot-Reload innerhalb von Millisekunden auf Ihrem Gerät. Erst wenn Sie Ihre App veröffentlichen wollen und dafür einen sogenannten »Release Build« generieren, benutzt Dart den AOT-Compiler (Ahead-of-Time-Compiler). Dart kompiliert also, bevor die App von der benutzenden Person aus dem Store heruntergeladen oder im Web aufgerufen wird, zu nativem ARM- oder Intel-x64Maschinencode oder im Web zu JavaScript-Bytecode und sorgt so dafür, dass AppNutzende eine qualitativ ebenbürtige User Experience bekommen – analog einer nativ programmierten App. Es gibt nahezu keine Einbußen an Auflösung oder Geschwindigkeit. Es gibt also ein paar gute Gründe, warum sich das Flutter-Team für Dart entschieden hat. Auch wir sind uns sicher: Sie werden die Sprache bestimmt genauso schnell zu schätzen lernen wie wir. Sie sind nun bestens mit Hintergrundwissen ausgestattet – wagen wir uns an den praktischen Teil.
Kapitel 2
Startklar machen und rein ins Vergnügen IN DIESEM KAPITEL Installieren Sie Flutter unter Mac oder Windows Bringen Sie den Android-Emulator oder iOS-Simulator zum Laufen Richten Sie die Entwicklungsumgebung Visual Studio Code ein
Wir hoffen, dass wir Ihnen bisher einen guten Vorgeschmack auf die Entwicklung mit Flutter geben konnten und Sie nun Lust haben, sich gemeinsam mit uns ins Abenteuer zu stürzen.
Flutter installieren Im Normalfall geht die Flutter-Installation unkompliziert und zügig vonstatten, wenn Ihr Computer die Systemmindestanforderungen erfüllt (https://docs.flutter.dev/getstarted/install) und Sie die nachfolgenden Schritte befolgen. Als Alternative zur Flutter-Installation auf Ihrem PC oder Mac können Sie aber auch zunächst mit praktischen Tools wie dem DartPad (http://dartpad.dartlang.org), FlutLab (https://flutlab.io), CodePen (https://codepen.io) oder Zapp (https://zapp.run) ins Rennen gehen. All diese Tools sind kostenfrei, laufen im Browser ohne Installation und eignen sich gut für ein erstes Kennenlernen. Bedenken Sie allerdings, dass Sie – vor allem beim DartPad – etwas eingeschränkt sind, was den Komfort beim Programmieren angeht. Generell empfehlen wir einen Blick auf die offizielle FlutterInstallationsdokumentation: https://docs.flutter.dev/get-started/install.
Das Flutter-SDK Zum Entwickeln von Flutter-Apps benötigen Sie in erster Linie das Flutter-SDK, welches Sie unter https://docs.flutter.dev/development/tools/sdk/releases für Ihr Betriebssystem herunterladen können. Flutter hat drei verschiedene Versionszweige, auch Channels genannt: den stable-Channel, den beta-Channel und den master-Channel. Wir
empfehlen die Verwendung der aktuellen Version des stable-Channels, welche die stabilste Version beherbergt. Für das Testen von neuen Flutter-Features können Sie durch die Eingabe von flutter channel beta innerhalb einer Konsole auf den Beta-Channel wechseln. Aber Achtung – in der Betaversion können sich Bugs verstecken, die Ihre App instabil machen können. Über flutter channel stable gelangen Sie zurück auf den stable-Channel. Ein Hinweis: Das Flutter-SDK ist eigentlich nichts weiter als ein Git-Repository mit unterschiedlichen Branches für die verschiedenen Channel. Bei einem Wechsel der Channel wechseln Sie somit im Hintergrund nur den Branch und ein Upgrade von Flutter holt sich die neueste Version dieses Channels durch ein Pullen des Git-Repositories.
Windows Entpacken Sie die heruntergeladene Zip-Datei, beispielsweise unter C:\Benutzer\ \Documents. Wenn Sie Flutter in einer regulären Windows-Eingabeaufforderung ausführen möchten, müssen Sie Flutter als Umgebungsvariable zu Ihrem PATH hinzufügen: 1. Öffnen Sie die Windows-Suche und geben Sie Umgebungsvariable ein. Klicken Sie auf UMGEBUNGSVARIABLEN FÜR DIESES KONTO BEARBEITEN. 2. Suchen Sie unter BENUTZERVARIABLEN nach dem Eintrag PATH. 3. Fügen Sie Ihren oben definierten Flutter-Speicherort als Eintrag hinzu, also zum Beispiel C:\BENUTZER\\DOCUMENTS\FLUTTER\BIN. 4. Öffnen Sie eine neue Eingabeaufforderung, indem Sie die Tastenkombination + verwenden und cmd in das sich öffnende Fenster eingeben. Testen Sie durch das Ausführen des Kommandos flutter, ob die Einrichtung erfolgreich war. Sie sehen jetzt alle möglichen Flutter-Befehle. 5. Geben Sie flutter doctor in die Eingabeaufforderung ein. Dieser Befehl zeigt Ihnen an, ob mit Ihrer Flutter-Installation alles im Reinen ist.
Entpacken Sie die Zip-Datei nicht in C:\PROGRAM FILES oder ein ähnliches Verzeichnis, da hierfür erweiterte Berechtigungen benötigt werden.
Mac 1. Entpacken Sie die heruntergeladene Zip-Datei, indem Sie die folgenden Befehle ins Terminal eingeben. Das Terminal können Sie unter Mac öffnen, indem Sie die Tastenkombination + betätigen und Terminal in das Suchfeld eingeben:
$ cd ~/development $ unzip ~/Downloads/flutter_macos_3.7.11-stable.zip
2. Wenn Sie Flutter im aktuellen Terminal-Fenster ausführen möchten, müssen Sie den Pfad zu Ihrer Flutter-Installation zunächst exportieren, damit die Flutter-Befehle funktionieren, egal in welchem Ordner Sie das Terminal geöffnet haben. Hierfür genügt die Ausführung des Befehls im Terminal: $ export PATH="$PATH:pwd/flutter/bin
3. Um zu prüfen, ob die Flutter-Installation erfolgreich war, geben Sie flutter in den Terminal ein. Jetzt sollten Ihnen alle verfügbaren Flutter-Befehle angezeigt werden. 4. Geben Sie flutter doctor im Terminal ein. Dieser Befehl zeigt Ihnen an, ob mit Ihrer Flutter-Installation alles im Reinen ist.
Wenn Sie nicht jedes Mal den Pfad exportieren wollen, um mit den Flutter-Befehlen zu arbeiten, ist das am Mac leider nicht so einfach wie unter Windows. Es kommt darauf an, welche Version Ihr Betriebssystem hat. Am besten googeln Sie das selbstständig. Das Herzstück von Flutter sollte damit erfolgreich installiert sein. Zusätzlich zu Flutter benötigen Sie weitere Tools, um Android- und iOS-Apps zu entwickeln.
Android-Setup Um eine Android-App mit Flutter zu entwickeln, werden das Android-SDK, die zugehörigen SDK-Command-Line- und Build-Tools sowie ein Android-Emulator benötigt. Hierfür installieren Sie bitte Android Studio, das alle diese Tools gebündelt zur Verfügung stellt und zusätzlich als Entwicklungsumgebung verwendet werden kann.
Android Studio installieren 1. Laden Sie die aktuelle Version von Android Studio herunter: https://developer.android.com/studio. 2. Starten Sie Android Studio und folgen Sie den Schritten im ANDROID STUDIO SETUP WIZARD. 3. Führen Sie erneut flutter doctor aus, um sicherzugehen, dass die Installation erfolgreich war und Flutter und Android Studio sich gegenseitig gefunden haben.
Eine Android-App starten Um Ihre Flutter-Apps ausführen, testen und debuggen zu können, benötigen Sie entweder ein Android-Smartphone oder -Tablet (mindestens Android 4.1) oder einen Android Emulator. Physisches Android-Gerät
Um das Android-Gerät für diese Zwecke zu nutzen, folgen Sie diesen Schritten: 1. Aktivieren Sie die Entwickleroptionen sowie das USB-Debugging auf Ihrem Gerät. Wie das geht, erfahren Sie hier: https://developer.android.com/studio/debug/dev-options. 2. Installieren Sie den Google-USB-Treiber auf Ihrem Computer, falls Sie einen Windows-Rechner haben: https://developer.android.com/studio/run/win-usb. 3. Schließen Sie Ihr Android-Gerät über ein USB-Kabel an Ihren Computer an. Wenn Sie beim Verbinden nach einer Debugging-Berechtigung gefragt werden, akzeptieren Sie diese. 4. Finden Sie durch die Eingabe von flutter devices im Terminal in Android Studio heraus, ob Ihr Android-Gerät erfolgreich mit Ihrem Computer verbunden ist und entsprechend aufgelistet wird. Android-Emulator Um einen Android-Emulator zu verwenden, folgen Sie diesen Schritten: 1. Aktivieren Sie die VM ACCELERATION auf Ihrem Computer: https://developer.android.com/studio/run/emulator-acceleration.
2. Starten Sie Android Studio, klicken Sie auf KONFIGURIEREN und wählen Sie AVD MANAGER. Wählen Sie NEUES VIRTUELLES GERÄT ERSTELLEN. 3. Wählen Sie Ihren Wunsch-Android-Emulator (zum Beispiel Pixel 5) und klicken Sie auf WEITER. 4. Wählen Sie ein oder mehrere System-Images für die Android-Versionen, die Sie emulieren möchten (empfohlen wird x86 oder x86_64) und klicken Sie auf WEITER. 5. Wählen Sie HARDWARE – GLES 2.0 unter EMULATED PERFORMANCE, um die Hardwarebeschleunigung zu aktivieren. Mehr Informationen finden Sie hier: https://developer.android.com/studio/run/emulator-acceleration. 6. Wählen Sie FERTIG. 7. Klicken Sie den Startbutton, um Ihren Emulator zu starten.
Weitere Informationen zum Thema Emulator finden Sie unter https://developer.android.com/studio/run/managing-avds.
Android-Lizenzbestimmungen akzeptieren Bevor Sie nun anfangen können, Flutter-Apps zu programmieren, müssen Sie den Android-Lizenzbedingungen zustimmen. Sie können die Lizenzbedingungen einsehen und akzeptieren, indem Sie den Befehl flutter doctor --android-licenses in Ihre
Eingabeaufforderung oder Ihren Terminal eingeben.
iOS-Setup (nur Mac) Ein Mac mit installiertem Xcode wird vorausgesetzt, um iOS-Apps mit Flutter zu erstellen. Das Entwickeln von iOS-Apps unter Windows wird derzeit von Apple nicht unterstützt. 1. Laden und installieren Sie Xcode entweder über https://developer.apple.com/xcode oder direkt aus dem Mac App Store. 2. Konfigurieren Sie die Xcode Command-Line-Tools, um die neu installierte XcodeVersion zu verwenden: $ sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer $ sudo xcodebuild -runFirstLaunch
3. Stellen Sie sicher, dass Sie die Xcode-Lizenzbestimmungen beim ersten Starten von Xcode akzeptiert haben oder führen Sie sudo xcodebuild -license aus.
iOS-Simulator einrichten Sie können den iOS-Simulator wie folgt starten, sobald Xcode erfolgreich installiert wurde: 1. Öffnen Sie den Simulator entweder über die Spotlight-Suche oder führen Sie den Befehl open -a Simulator aus. 2. Der Simulator sollte ein 64-Bit-Gerät (iPhone 5s oder neuer) verwenden.
Ein physisches iOS-Gerät verwenden Um eine Flutter-App auf ein physisches iOS-Gerät zu deployen, ist ein Apple-DeveloperAccount notwendig. Mehr Infos dazu finden Sie unter https://developer.apple.com/support/compare-memberships. Sobald Ihre App Flutter-Plug-ins verwendet, ist außerdem der Third-Party-DependencyManager CocoaPods notwendig. Installieren Sie CocoaPods durch das Ausführen des Befehls sudo gem install cocoapods. Führen Sie die folgenden Xcode-Signing-Schritte aus, um das Provisioning für Ihr Projekt zu aktivieren: 1. Öffnen Sie den Default-Xcode-Workspace in Ihrem Projekt durch den Befehl open ios/Runner.xcworkspace. 2. Wählen Sie das Gerät aus, für das Sie die App deployen möchten, im GeräteDropdown neben dem RUN-BUTTON. 3. Wählen Sie das RUNNER PROJEKT in der linken Navigationsleiste aus.
4. In den RUNNER TARGET-Einstellungen wählen Sie ihr Development-Team unter SIGNING & CAPABILITIESTEAM aus. 5. Wenn dies Ihr erstes iOS-Projekt ist, müssen Sie sich zunächst in Xcode mit Ihrer Apple ID anmelden. 6. Wählen Sie VERTRAUEN, wenn ein Dialog Sie auffordert, die Verbindung zwischen iOS-Gerät und Mac zu akzeptieren.
Google Analytics sammelt und sendet anonymisierte Nutzungsstatistiken und Crashreports zur Verbesserung der Flutter-Tools automatisch an Google. Diesem Reporting kann über flutter config --no-analytics widersprochen werden.
Troubleshooting Für den Fall, dass die Flutter-Installation nach den oben genannten Schritten nicht wie geplant funktioniert, führen Sie erneut den Befehl flutter doctor aus und befolgen Sie die Anweisungen. Die häufigsten Fehlerquellen sind: Java ist nicht installiert Android Studio ist nicht installiert veraltete Android-Tools CocoaPods ist nicht installiert
Für den Fall, dass Sie mit einem Apple-Silicon(M1)-Prozessor arbeiten und es bei der Installation zu Problemen kommt, empfehlen wir Ihnen, die regelmäßig aktualisierte offizielle Dokumentation https://github.com/flutter/flutter/wiki/Developing-with-Flutter-onApple-Silicon zu durchstöbern. Aktuell arbeitet das Flutter-Team daran, die
Komplikationen, die durch den M1-Prozessor entstanden sind, zu lösen. Sollte sich Ihre Fehlermeldung nicht auf die oben genannten Optionen beziehen, kann die Suchmaschine Ihres Vertrauens oder ein Besuch auf stackoverflow.com helfen. Das Großartige daran: Alle uns bisher bekannten Probleme haben bereits eine Lösung.
Entwicklungsumgebung einrichten Wenn Sie sich entschieden haben, Flutter zu installieren und nicht mit den OnlineEntwicklungstools zu arbeiten, sollten Sie sich nun die Frage stellen, mit welcher Entwicklungsumgebung (IDE) Sie arbeiten möchten. Aktuell gibt es drei offizielle IDEs,
die Flutter und Dart vollumfänglich unterstützen: Android Studio von Google (basierend auf IntelliJ), Visual Studio Code (auch VSCode genannt) von Microsoft und Emacs. Alle Optionen sind für Windows, Mac und Linux verfügbar.
Für welche Entwicklungsumgebung soll ich mich entscheiden? Letzten Endes ist die Wahl der Entwicklungsumgebung Geschmackssache und Sie sollten sich für diejenige entscheiden, mit der Sie sich am wohlsten fühlen. Sollten Sie zuvor weder mit einer IntelliJ-basierten IDE noch mit VSCode (Achtung, »VSCode« ist kurz für »Visual Studio Code«, nicht zu verwechseln mit Visual Studio) oder mit Emacs gearbeitet haben, lohnt es sich in jedem Fall, mindestens zwei Optionen auszuprobieren und dann ein Urteil zu fällen. In diesem Buch werden wir VSCode verwenden. Langfristig überzeugt hat uns VSCode hauptsächlich aufgrund seiner Leichtgewichtigkeit und der Menge an hilfreichen und qualitativen Erweiterungen, die uns das Leben einfacher machen. Wenn Sie sich für Android Studio oder Emacs entschieden haben, können Sie den nächsten Abschnitt Einrichtung VSCode überspringen. Bedenken Sie, dass für Ihre gewählte Entwicklungsumgebung gegebenenfalls andere Schritte zur Einrichtung von Flutter und Dart notwendig sind.
Einrichtung VSCode 1. Laden Sie die aktuelle Version von VSCode für Windows, Mac oder Linux von https://code.visualstudio.com herunter. 2. Öffnen Sie die Befehlszeile über + + ( geben Sie INSTALL EXTENSIONS ein.
+ + auf macOS) und
3. Suchen Sie in den EXTENSIONS nach »Flutter« und installieren Sie das Plug-in sowie das Dart-Plug-in per Klick. 4. Starten Sie VSCode gegebenenfalls neu. 5. Öffnen Sie die Befehlszeile und suchen Sie nach »doctor«, wählen Sie RUN: FLUTTER DOCTOR, um sicherzustellen, dass die Einrichtung erfolgreich war.
Die Befehlszeile werden wir noch häufig benutzen. Es lohnt sich also, sich den Shortcut + + ( + + auf macOS) zu merken. Sie kann allerdings auch über das Menü VIEW | BEFEHLSZEILE geöffnet werden.
Hilfestellung durch VSCode-Erweiterungen
Die folgenden VSCode-Erweiterungen sind aus unserem Alltag als Flutter-AppEntwickelnde nicht wegzudenken. Sie können über die EXTENSION-Suche gefunden und installiert werden. Dart Data Class Generator: hilft beim Generieren von Konstruktoren, copyWith-, map-, json-, operator- und hashcode-Methoden. Awesome Flutter Snippets: hilft beim Generieren von Widgets und Methoden. Pubspec Assist: hilft beim Hinzufügen von neuen Packages und prüft auf Updates bestehender Packages. Bloc: bietet verschiedene Code-Snippets und Widget-Wrapper für das Erstellen von Blocs und Cubits an.
Es entstehen öfters neue, hilfreiche Erweiterungen von VSCode, die Sie beim Programmieren von Flutter-Apps unterstützen können. Wenn Sie einfach durch die existierenden Erweiterungen stöbern möchten, können Sie das in der linken Leiste in VSCode durch Klick auf das Würfel-Icon mit dem Namen »Extensions« tun.
Automatische Code-Fixes und Formatierung VSCode bietet durch kleine, unscheinbare Einstellungen tolle Helferlein, um Dateien automatisch beim Speichern zu formatieren und verwendete Imports zu organisieren. Außerdem werden offensichtliche Fehler sowie Verbesserungen automatisch beim Speichern angepasst. Das spart eine Menge Zeit! Diese Helferlein können Sie wie folgt aktivieren: 1. Öffnen Sie die VSCode-Settings über die Befehlszeile, indem Sie nach »Settings« suchen und PREFERENCE: OPEN USER SETTINGS (JSON) auswählen. 2. Suchen Sie nun nach dem Abschnitt, der für die Dart-Einstellungen wichtig ist und mit »[dart]« anfängt. 3. Fügen Sie die fehlenden Elemente hinzu oder – falls der Abschnitt noch nicht existiert – fügen Sie den ganzen Abschnitt hinzu. "[dart]": { "editor.formatOnSave": true, "editor.formatOnType": true, "editor.codeActionsOnSave": { "source.organizeImports": true, "source.fixAll": true } … },
Hoffentlich hat die Einrichtung bei Ihnen problemlos geklappt. Dann kann es ja jetzt endlich losgehen!
Kapitel 3
Ihre allererste App IN DIESEM KAPITEL Öffnen Sie Ihr erstes Flutter-App-Projekt und lernen die Ordnerstruktur und wichtige Dateien kennen Starten Sie Ihre App auf einem Emulator oder einem Smartphone Lernen Sie Hot-Reload lieben Bekommen Sie einen Ausblick auf das buchbegleitende App-Projekt »Pummel The Fish«
Die Installation ist geschafft, endlich kann es losgehen. Wenn Sie ein neues Flutter-Projekt erstellen, ist das Projekt nicht leer. Die Flutter-Beispiel-App besteht aus einem Screen, auf dem sich ein Button befindet, und einer Zahl, die mit jedem Klick hochgezählt wird. Sie werden sich anhand dieser Beispiel-App die Struktur einer Flutter-App ansehen. Anschließend stellen wir Ihnen das Konzept unserer »Pummel The Fish«-App vor. Dieses Buch leitet Sie an, die Flutter-Beispiel-App in die »Pummel The Fish«-App umzuschreiben. Natürlich können Sie das Buch auch einfach lesen – aber wir empfehlen unbedingt ein aktives Mitprogrammieren. In diesem Kapitel wird die Aufgabe definiert und in den restlichen Teilen des Buches erstellen wir Schritt für Schritt Screens, Navigation, Models, Logik und das Backend.
Eine neue Flutter-App Um eine neue App zu generieren, können Sie ganz bequem den VSCode-Generator verwenden und den Schritten im Wizard folgen.
Generieren mit dem Wizard 1. Öffnen Sie die BEFEHLSZEILE in VSCode. 2. Filtern Sie nach FLUTTER: NEW PROJECT und bestätigen Sie Ihre Eingabe. 3. Wählen Sie APPLICATION und den gewünschten Speicherort. 4. Geben Sie den Namen unseres zukünftigen Projekts »pummel_the_fish« ohne Leerzeichen ein.
5. Gedulden Sie sich einen Moment, bis alles für Sie automatisch eingerichtet wird. Abbildung 3.1 sollte Ihnen nun bekannt vorkommen.
Abbildung 3.1: Das frisch generierte Flutter-Projekt
Sie können ein neues Flutter-Projekt auch ohne Wizard über einen Terminal-Befehl erstellen: flutter create pummel_the_fish. Auch ist es möglich, weitere Projekteinstellungen bei der Erstellung per Parameter zu definieren, zum Beispiel den Organisationsnamen: flutter create --org de.losfluttern pummel_the_fish.
Die Ordnerstruktur Der generierte Code ist bereits ausführlich dokumentiert und es lohnt sich, einen Blick darauf zu werfen. Wir werden nur auf das Notwendigste eingehen. Schauen wir uns zunächst die generierten Ordner an: .dart_tool: Dieser Ordner enthält die Dart-Konfiguration. Im Normalfall werden Sie
hier nie etwas anpassen müssen. .idea/.vscode: Je nach verwendeter Entwicklungsumgebung enthalten diese Ordner
die Konfiguration für Android-Studio (.idea) oder VSCode (.vscode). android/ios: Hier befinden sich Android- beziehungsweise iOS-Container mitsamt
allen benötigten Konfigurationen, um eine Flutter-App für Android beziehungsweise iOS zu bauen. Spätestens wenn Sie Ihre App in den Google Play oder Apple App Store hochladen, werden hier Konfigurationsanpassungen für die jeweiligen Plattformen notwendig sein. lib: Ihr neues Zuhause für den Code, den Sie schreiben werden. Noch befindet sich lediglich die main.dart-Datei darin, die als Einstieg für die Flutter-App dient – das
wird sich aber bald ändern. test: Neben dem puren Coden spielt das Testen in der Software-Entwicklung eine
große und wichtige Rolle. Um es Entwickelnden so einfach wie möglich zu machen, liefert Flutter den Testordner, die Konfiguration, um Tests laufen zu lassen, sowie den ersten Test für die main.dart direkt mit. web/windows/mac/linux: Wie bereits zu Beginn dieses Buches erwähnt, können mit
Flutter nicht nur Android- und iOS- sondern auch Web-, Windows- und Mac-Apps erstellt werden. Diese Ordner liefern daher analog zu dem android- und ios-Ordner Konfigurationsdateien für die jeweilige Plattform.
Die generierte Basis-Ordnerstruktur darf sich nicht ändern. Die Ordnerstruktur innerhalb des lib-Ordners wird allerdings wachsen und gedeihen, sobald Sie anfangen, Ihre eigene App zu programmieren. Wichtige Dateien und ihre Funktionen: lib/main.dart: Hier startet das Programm und der erste Screen der App wird
aufgerufen. analysis_options.yaml: Hier werden sogenannte Linter-Regeln definiert, die bei
Missachtung dafür sorgen, dass eine Warnung im PROBLEMS-Tab angezeigt wird. Diese Regeln basieren auf dem mitgelieferten flutter_lints-Package, das eine Auswahl an Regeln vordefiniert. Das Package kann beliebig durch andere LinterPackages mit strengeren oder weniger strengen vordefinierten Regelsets, wie zum Beispiel lint (https://pub.dev/packages/lint) oder very_good_analysis (https://pub.dev/packages/very_good_analysis) ausgetauscht werden. Einzelne Regeln können in dieser Datei zusätzlich aktiviert und deaktiviert werden. pubspec.yaml: In dieser Datei managen Sie neben App-Name, -Beschreibung und -
Versionsnummer hauptsächlich Packages und Plug-ins. Eine riesige Auswahl an Packages finden Sie unter https://pub.dev.
In Flutter können Sie Packages und Plug-ins zu Ihrer App hinzufügen. Als Packages bezeichnet man modulare Erweiterungen, die in Dart geschrieben sind, als Plug-in bezeichnet man modulare Erweiterungen, die native Sprachen wie Objective-C, Swift, Java oder Kotlin verwenden und mithilfe von Dart eine Schnittstelle zu nativen plattformspezifischen Funktionalitäten herstellen.
Android-App starten Um die App auf einem Emulator zu starten, öffnen Sie die BEFEHLSZEILE in VSCode, filtern nach FLUTTER: LAUNCH EMULATOR und bestätigen Ihre Eingabe. Es öffnet sich ein kleines Fenster, in dem Sie sich zwischen einem von Ihnen im Kapitel »Android Setup« angelegten Emulator oder einem angeschlossenen Android-Smartphone wie in Abbildung 3.2 entscheiden können.
Abbildung 3.2: Geräteauswahl
Sie sollten jetzt auf dem Emulator die Flutter-Beispiel-App sehen können. Wenn Sie den Button klicken, sollte der Counter hochzählen.
iOS-App starten Den iOS-Simulator können Sie nur verwenden, wenn Sie Xcode bereits installiert haben. Dank Kapitel 2, »Startklar machen und rein ins Vergnügen«, sind Sie aber bereits bestens darauf vorbereitet. Um den Simulator nun zu starten, öffnen Sie die BEFEHLSZEILE, suchen nach FLUTTER: LAUNCH EMULATOR und wählen dann IOS SIMULATOR. Der Simulator öffnet sich. Im Simulator-Menü können Sie über FILE | NEW SIMULATOR… verschiedene Simulatoren aus den iPhone- und BetriebssystemVersionen auswählen und diese anschließend über die Auswahl unter OPEN SIMULATOR starten.
Hot-Reload und Hot-Restart Wenn Sie die App erfolgreich gestartet haben, sollten Sie die in Abbildung 3.3 gezeigte kleine App auf Ihrem Emulator oder Gerät sehen. Die Zahl in der Mitte des Bildschirms zählt jeweils eins nach oben, wenn Sie auf den runden Plus-Button klicken. Um Ihnen nun die häufig angepriesene Hot-Reload-Funktionalität zu zeigen, behalten Sie den Emulator oder Ihr Gerät im Blick und ändern bitte die Zeile 27 in der main.dart Datei wie folgt: const MyHomePage(title: "Pummel The Fish App"),
Abbildung 3.3: Die App beim ersten Starten
Diese Code-Zeile ruft den ersten Screen der App auf und übergibt einen Titel. Speichern Sie die Datei nun per + / + und beobachten Sie, wie die Änderung in Sekundenschnelle auf Ihrem Gerät sichtbar ist. Erweitern Sie das Experiment, indem Sie zusätzlich die Color im ThemeData-Widget unter primarySwatch in eine beliebige andere Farbe ändern, und beobachten Sie, wie schnell die Hot-Reload-Funktion Ihre App umfärbt. const MyHomePage( … theme: ThemeData( primarySwatch: Colors.red, ), ),
Listing 3.1 primarySwatch in Rot Wenn Sie nun vergleichen, wie lange es gedauert hat, um die App das erste Mal zu starten, im Vergleich dazu, wie lange es gedauert hat, diese kleine Textänderung vorzunehmen und sichtbar zu machen, ist Ihnen vermutlich klar, warum die Hot-Reload-Funktion die Entwicklung mit Flutter so schnell und komfortabel macht. Während Hot-Reload den Widget-Baum neu lädt, aber den App-State erhält, lädt HotRestart auch den App-State erneut. Das kann nötig sein, wenn Sie tiefergreifende Änderungen vorgenommen haben – ist aber immer noch schneller als ein Neustarten des Emulators.
Pummel The Fish »Pummel The Fish« ist das buchbegleitende App-Projekt, anhand dessen Sie die wichtigsten Flutter-Tipps und -Tricks lernen werden. Der Code steht Ihnen online in Form eines Git-Repositories zur Verfügung: https://losfluttern.de/pummelthefish. Pro Kapitel gibt es einen Branch, auf dem Sie jeweils die besprochenen Anpassungen finden können. In Abbildung 3.4 können Sie Pummel bewundern.
Abbildung 3.4: Pummel The Fish
Mini-Lastenheft Die App »Pummel The Fish« gibt den Nutzenden einen Überblick über Kuscheltiere, die zur Adoption stehen. Die App-Anwendenden können genauere Information über bestimmte Tiere erhalten, indem sie in der Liste auf dem Home-Screen auf das Tier klicken. Sie können auch selbst Tiere hinzufügen, wenn sie die Nase voll von ihrem Kuscheltier haben. Diese werden dann von der App in einem Backend gespeichert und in der Liste auf dem Home-Screen wiederum aus dem Backend geholt und angezeigt.
Screens Aus den folgenden vier Screens soll Ihre App am Ende des Buches bestehen.
SplashScreen Der Einstiegs-Screen, zu sehen in Abbildung 3.5, erscheint kurz, während die Daten laden, und zeigt das Logo der App. Anschließend wechselt der Screen automatisch (soll heißen, ohne auf User-Input zu reagieren) zum HomeScreen.
HomeScreen Zeigt eine Liste von Kuscheltieren, die zur Adoption stehen (vergleichen Sie Abbildung
3.6). In der AppBar ist links das Logo zu sehen, mittig der Name der App und rechts die Adoptionstasche, die die Nummer der derzeit ausgewählten Tiere anzeigt. Mit Klick auf ein Element in der Liste wird zum DetailPetScreen gewechselt. Mit Klick auf den FloatingActionButton öffnet sich der CreatePetScreen.
Abbildung 3.5: SplashScreen
Abbildung 3.6: HomeScreen
DetailScreen Hier werden genauere Information über ein einzelnes Tier angezeigt. Unter dem Foto zum Tier ist eine klickbare Leiste, die mit »Adoptier mich!« dazu auffordert, das hier gezeigte Kuscheltier zum Adoption Bag auf dem HomeScreen hinzuzufügen. Über einen PfeilButton in der AppBar kann zurücknavigiert werden. Das Mülleimer-Icon oben rechts löscht das ausgewählte Kuscheltier aus der Datenbank. Die Abbildung 3.7 zeigt den DetailPetScreen.
CreatePetScreen Über den CreatePetScreen können App-Nutzende selbst Tiere der Liste hinzufügen. Das Formular schließt sich beim Klick auf den Speichern-Button oder es kann über einen Pfeil in der AppBar zurücknavigiert und die Änderungen können verworfen werden (siehe Abbildung 3.8).
Daten Die Daten werden Sie erst von einer sogenannten »REST-Schnittstelle« holen, die wir für Sie unter https://losfluttern.de/pummelthefish/api zur Verfügung stellen. Was genau eine REST-Schnittstelle ist und wie Sie damit arbeiten können, erfahren Sie in Kapitel 16, »Schnittstellen anbinden«. Unsere zur Verfügung gestellte REST-API hat allerdings ein Manko: Sie können keine Tiere hinzufügen, weil wir unsere Datenbank natürlich nicht einfach für alle öffnen wollen. Sonst könnten andere lesende Personen Ihre kreativen Tiernamen stehlen und die Liste der Kuscheltiere würde zu lang werden.
Abbildung 3.7: DetailPetScreen
Abbildung 3.8: CreatePetScreen
Im Kapitel über Firebase werden Sie daher ein eigenes Projekt mit eigener Datenbank anlegen und diese mit Ihrer App verbinden. Dann werden Sie Tiere aus Ihrem eigenen Backend abrufen, hinzufügen und entfernen können.
Recap: Einführung in Flutter Sie haben in diesem Teil erfahren, was die Vorteile von Flutter gegenüber anderen Frameworks und Sprachen für die App-Entwicklung sind. Wir hoffen, wir konnten Sie motivieren, sich auf diese Lernreise einzulassen. Wenn alles funktioniert hat, haben Sie jetzt alles installiert und sind ready to go. (Wir drücken Ihnen die Daumen. Alternativ haben Sie mittlerweile aufgegeben und nutzen jetzt eine Online-Entwicklungsumgebung. Auch okay!) Sie haben Ihr erstes App-Projekt erstellt und einen Ausblick über das buchbegleitende »Pummel The Fish«-Projekt bekommen. Jetzt kanns losgehen! Flutter basiert auf der Sprache Dart, und bevor Sie tiefer in Flutter und App-ScreenDesign und Routing eintauchen, lernen Sie im nächsten Teil die Dart-Basics kennen, auf denen die Logik Ihrer App aufbauen wird.
Teil II
Programmieren mit Dart
IN DIESEM TEIL … Lernen Sie die wichtigsten Konzepte und Basics von Dart kennen Erfahren Sie, was Programmieren mit Sound Null Safety bedeutet und warum es Ihr Leben erleichtert Lernen Sie, was es mit asynchroner Programmierung auf sich hat Jagen Sie Bugs beim Debugging
Dart ist das, was man in einer Kneipe macht, wenn der Kicker besetzt ist. Zudem ist Dart aber auch eine ziemlich feine Programmiersprache. Falls Sie noch nie davon gehört haben, lassen Sie sich nicht davon abschrecken. Sie ist noch nicht sehr alt, aber sie ist es definitiv wert, gelernt zu werden. Und es geht so schön einfach …
Kapitel 4
Pfeilschnell programmieren mit Dart IN DIESEM KAPITEL Schreiben Sie Ihre erste Klasse in Dart und verwenden alle gängigen Dart-Typen Lernen Sie, was der Unterschied zwischen final, const, var und late ist Schreiben Sie Funktionen und Methoden und rufen diese auf Erfahren Sie, wie Null Safety in Dart funktioniert und warum Sie dieses Konzept nicht missen möchten
Flutter basiert auf der Sprache Dart. Obwohl diese schon 2013 erschienen ist, ist sie außerhalb des Flutter-Universums recht unbekannt. Dart wurde hauptsächlich von Google entwickelt und war eigentlich als Alternative zu JavaScript in Webbrowsern gedacht. Die neue Sprache sollte einige Probleme von JavaScript lösen. Das Ziel, JavaScript im Webbrowser zu ersetzen, wurde 2015 allerdings verworfen. Im Rahmen von Flutter ist Dart jetzt dennoch eine wichtige Sprache geworden und das Flutter-Team hat und wird auch zukünftig durch seine Nähe zum Dart-Team einen großen Einfluss auf die Weiterentwicklung der Sprache haben. Ein wichtiges Ziel bei der Entwicklung von Dart war und ist, dass die Sprache einfach erlernbar ist, indem sie auf einer vertrauten Syntax und bekannten Techniken beruht. Das Besondere an Dart – und ein wichtiger Punkt, warum sich das Flutter-Team für diese Sprache entschieden hat – ist der Fokus der Sprache auf einer optimalen User Experience sowohl für App-Nutzende als auch für App-Entwickelnde. Da Dart sowohl JIT- als auch AOT-Kompilierung unterstützt, wird Entwickelnden das Testen und Debuggen mit HotReload und Hot-Restart schnell und simpel möglich gemacht, während die fertig veröffentlichte App trotzdem keine Performance-Einbußen hat. Was diese Sprache noch so kann und wie sie aufgebaut ist, werden Sie in diesem Kapitel lernen. Wir setzen in diesem Kapitel die Grundkenntnisse von Objektorientierung voraus. Wenn Sie daher bereits Erfahrungen mit einer objektorientierten Sprache (wie zum Beispiel Java) gesammelt haben, wird Ihnen der Einstieg vermutlich leichtfallen. Wenn das nicht so ist – bleiben Sie trotzdem dran, lesen Sie bei Bedarf Abschnitte noch einmal und schlagen Sie parallel unbekannte Begriffe nach. Das Wichtigste
zum Schluss: Code along!
Die erste Klasse Die Einführung in Dart soll in diesem Buch direkt mit der Programmierung der im vorigen Kapitel vorgestellten »Pummel The Fish«-App verknüpft werden. Daher bitten wir Sie, den Code direkt in Ihre frisch erstellte App einzubauen. Lassen Sie den automatisch erzeugten Beispiel-Code erstmal noch drin, wir werden davon einige Teile später anschauen oder wiederverwenden. Schritt für Schritt werden sich die im Buch besprochenen Codezeilen, die Sie zu Ihrer App hinzufügen, in eine funktionierende App verwandeln. Wir legen direkt los: Bitte erstellen Sie innerhalb des lib-Ordners einen neuen Ordner namens data und in diesem einen weiteren Ordner namens models. In dem modelsOrdner erstellen Sie eine neue Datei namens pet.dart. In der »Pummel The Fish«-App geht es um Tiere: Sie sollen in der App angezeigt und hinzugefügt werden. Ziel dieser Pet-Klasse wird es sein, eine wiederverwendbare Schablone zu definieren, um damit PetObjekte erzeugen zu können. Eine Datei sollte in der Regel eine Klasse enthalten, und wir empfehlen, diese Klasse passend zum Dateinamen zu benennen. In der Pet-Klasse werden die Eigenschaften, die ein Pet-Objekt tragen kann, definiert. Außerdem wird anhand eines oder mehrerer Konstruktoren definiert, wie ein Pet-Objekt erstellt werden kann. Die wichtigsten Datentypen String (= Text), int (= ganze Zahl), double (= Zahl mit Nachkommastellen) und bool (= true oder false) sind in dieser Klasse vertreten und stellen vermutlich keine größeren Überraschungen dar. Ein enum bietet Ihnen ein Set an definierten Werten an und kann analog dem Species enum im Code-Beispiel definiert werden. Die Pet-Klasse definiert sich wie folgt: enum Species { dog, cat, bird, fish } class Pet { final String id; final String name; final Species species; final int age; final double weight; final double height; final bool isFemale; const Pet( this.id, this.name, this.species, this.age, this.weight, this.height,
this.isFemale, ); }
Listing 4.1: Die Pet-Klasse Der Konstruktor stellt eine Bauanleitung für unser Pet-Model dar. Um ein Objekt der PetKlasse nach dieser Bauanleitung zu erzeugen und es in der Konsole auszugeben, müssen Sie diesen Konstruktor aufrufen. Dafür wechseln Sie bitte in die main.dart-Datei. Die main-Methode ist der Einstiegspunkt in unser Programm. Hier können wir das gewünschte Objekt erzeugen und den Namen anschließend über die print-Methode in der Konsole ausgeben. Zuerst müssen Sie jedoch die Pet-Klasse und das enum Species importieren, denn Ihre main.dart-Datei kennt diese Entitäten noch nicht. Am einfachsten geht das, indem Sie auf die kleine Glühbirne in der Nähe der rot unterkringelten Pet-Klasse in der main.dart klicken und die erste Option auswählen. Es wird nun automatisch ein Pfad zu der pet.dart-Datei oben in der main.dart eingefügt. import "data/models/pet.dart"; void main() { const pummelTheFish = Pet( "1", "Pummel", Species.fish, 3, 200.0, 20.0, true, ); print(pummelTheFish.name); runApp(const MyApp()); }
Listing 4.2: Pummel in der Konsole
Strings können mit Single- oder Double-Quotes angegeben werden. Entscheiden Sie sich selbst für eine Option – wichtig ist nur, dass Sie dabeibleiben. Falls Sie Ihre App noch nicht gestartet haben, können Sie das nun tun, indem Sie in der linken Menüleiste in VSCode auf RUN & DEBUG klicken. Die Ausgabe in VSCode können Sie in der Menüleiste unter TERMINAL einblenden. Sie erscheint dann am unteren Bildschirmrand. Springen Sie dort in den Tab DEBUG-CONSOLE. Falls Ihre App bereits läuft, können Sie diese durch einen Hot-Restart ( + + / + + ) neu laden. Der folgende Text sollte im Terminal erscheinen, sobald Sie das
Programm gestartet oder einen Hot-Restart durchgeführt haben. >> I/flutter (25424): Pummel
Das ist Typsache Der Typ einer Variablen ist Dart zur Laufzeit immer bekannt. Er kann bereits während des Programmierens angegeben werden oder wird zur Compile-Time automatisch bestimmt. Geben Sie den Typ direkt beim Programmieren an, können Sie vom sogenannten »Static Type Checking« profitieren, das Ihnen Typ-Denkfehler direkt beim Programmieren anzeigt. Wissen Sie den Typ beim Programmieren noch nicht, so können Sie es mit dem Basis-Typ »Object« kennzeichnen.
Statisches Type Checking – der Analyzer denkt mit Das Type-System von Dart wird als »Sound Type System« bezeichnet. Das bedeutet, dass, wenn ein Objekt vom Typ String erzeugt wird, dieser Typ zur Laufzeit garantiert wird und sich nicht ändern kann. Wird beispielsweise einem String-Objekt ein int zugewiesen, endet das in einem »Compile-Time Error«. Um das an einem einfachen Beispiel zu verdeutlichen, versuchen Sie, unserer Variablen pummelTheFish einen neuen Namen zuzuweisen, zum Beispiel »Hansi«. Das sollte problemlos funktionieren. Versuchen Sie nun, ihr eine Zahl zuzuweisen, zum Beispiel 42. const pummelTheFish = Pet( "1", "Pummel", Species.fish, 3, 200.0, 20.0, true, ); // Ein String wird einem String zugewiesen // Kein Problem! pummelTheFish.name = "Hansi"; // Ein int wird einem String zugewiesen // Das geht nicht! pummelTheFish.name = 42;
Listing 4.3: Zuweisung von Werten unterschiedlichen Typs – mal möglich, mal nicht Noch bevor Sie die Anwendung starten können, wird die 42 rot markiert, und die Fehlermeldung A value of type int cannot be assigned to a variable of type String erscheint. Dem String-Attribut name kann der Wert 42 vom Typ int nicht zugewiesen werden.
Wird versucht, ein Objekt als String zu casten (as String), also ihm einem expliziten Typ zuzuweisen, wird dies hingegen mit einem Runtime-Error bestraft, wenn der Typ nicht übereinstimmt. // Ein int in einen String casten // Das geht nicht! pummelTheFish.name = 42 as String;
Listing 4.4: Falsche Zuweisung int as String
Type Inference – die Schreibarbeit können Sie sich sparen! Optionale Type-Annotationen funktionieren aufgrund des Type-Inference-Konzepts – auch Typableitung genannt. Dieses ordnet einem Ausdruck automatisch den Typ zu. Das erspart Ihnen unnötige Schreibarbeit. Zur Verdeutlichung sehen Sie hier jeweils ein Beispiel der Type Inference mit verschiedenen Typen: var name = "Pummel"; // String var isFemale = true; // bool var age = 3; // int var weight = 200.0; // double var species = Species.fish; // Species enum // explizite Angabe, dass der Typ noch unbekannt ist: Object food;
Listing 4.5: Das Type-Inference-Konzept
Objekte bauen mit var, final, late oder const Sie haben zu Beginn des Kapitels erfolgreich ein Objekt aus einer Pet-Klasse instanziiert und einer Variablen namens pummelTheFish zugewiesen und wissen über Type Inference Bescheid. Vielleicht haben Sie sich bereits gefragt, was es mit den ominösen final-, varund const-Keywords auf sich hat. Daher geben wir Ihnen im Folgenden einen kurzen Überblick über diese Keywörter – denn Sie werden sie in Zukunft brauchen.
var Durch die Definition der Variablen mit var kann ihr ein anderer Wert zugewiesen werden – ohne dass angegeben werden muss, was für einen Datentyp die Variable halten soll. var pummelTheFish = Pet("1", "Pummel", Species.fish, 3, 200.0, 20.0, true); pummelTheFish = Pet("2", "Bruno", Species.dog, 4, 320.0, 60.0, false);
Listing 4.6: Beispiel einer veränderbaren Variable durch var
final Der Wert der Variablen soll nach der Zuweisung nicht erneut zugewiesen werden können.
final pummelTheFish = Pet("1", "Pummel", Species.fish, 3, 200.0, 20.0, true) // Das geht nicht pummelTheFish = Pet("2", "Bruno", Species.dog, 4, 320.0, 60.0, false)
Listing 4.7: Beispiel einer nicht erneut zuweisbaren Variable durch final
late Der Wert der Variable wird spätestens vor der ersten Verwendung zugewiesen. Initial wird ihr kein Wert zugewiesen. Der Typ der Variable muss allerdings definiert werden. Passiert die Zuweisung nicht rechtzeitig, gibt es einen »LateInitializationError«. late Pet pummelTheFish; pummelTheFish = Pet("1", "Pummel", Species.fish, 3, 200.0, 60.0, true); // Eine erneute Zuweisung ist auch möglich pummelTheFish = Pet("2", "Bruno", Species.dog, 4, 320.0, 60.0, false);
Listing 4.8: Beispiel einer später zuweisbaren Variable durch late
late final Es gelten dieselben Eigenschaften wie bei late, nur kann der Variable nach der ersten Zuweisung kein neuer Wert zugewiesen werden. late final Pet pummelTheFish; pummelTheFish = Pet("1", "Pummel", Species.fish, 3, 200.0, 20.0, true); // Das ist nicht möglich pummelTheFish = Pet("2", "Bruno", Species.dog, 4, 320.0, 60.0, false);
Listing 4.9: Beispiel einer später zuweisbaren, aber danach unveränderbaren Variable durch late final
const const beschreibt einen Wert, der sich nicht ändert. Dieser Wert muss zur Compile-Time
bereits klar sein – das heißt, er kann sich nicht erst in dem Moment berechnen, wenn die App gestartet wird, sondern muss schon vorher feststehen. const maxNumberOfPets = 10; // Das funktioniert nicht maxNumberOfPets = 12; const pummelTheFish = Pet("1", "Pummel", Species.fish, 3, 200.0, 20.0, true); // Das funktioniert nicht pummelTheFish = Pet("2", "Bruno", Species.bird, 4, 320.0, 60.0, false);
Listing 4.10: Beispiel einer konstanten Variable durch const
Während Sie programmieren, befinden Sie sich in der Compile-Time. Ihre Befehle werden von der Entwicklungsumgebung in Maschinencode übersetzt, und der Compiler checkt, ob Sie keinen Quatsch geschrieben haben. Einige Fehler werden Sie so schon bemerken und fixen können. Wenn Sie die Fehler nicht fixen, wird der Compiler Sie keinen Build machen lassen. Wenn Sie zum Beispiel versuchen, einer als const deklarierten Variable einen nicht konstanten Wert zuzuweisen, wird er Ihnen einen Fehler anzeigen und keinen Build zulassen. Einige Fehler lassen sich jedoch erst in der Run-Time feststellen, also wenn Sie die App zum Beispiel auf einem Emulator starten. Bei Fehlern wird die App abstürzen und den entsprechenden Fehler anzeigen.
const und final – der Unterschied für Leute, die es ganz genau wissen wollen Der Unterschied zwischen const und final ist nicht ganz trivial und führt ab und zu zur Verwirrung. Daher wollen wir auf diese Erklärung noch einmal konkreter eingehen. Im Prinzip ist der Unterschied zwischen beiden Wörtern der folgende: ein const-Wert wird schon zur Compile-Time zugewiesen und ein final-Wert wird erst zur Run-Time zugewiesen. Anhand eines Beispiels wird vermutlich klarer, was damit konkret gemeint ist. const pummelsBirthday = "2022-02-02"; final now = DateTime.now();
Listing 4.11: const und final Während der const Wert pummelsBirthday einen nicht veränderbaren String beinhaltet, der schon zur Compile-Time definiert wird und feststeht, wird sich der final-Wert der now-Variable erst während der Run-Time definieren. DateTime.now() holt die aktuelle Zeit und das aktuelle Datum in dem Moment, in dem die Funktion aufgerufen wird. Zur Compile-Time ist der Wert noch unbestimmt, könnte also nicht einer Variablen zugewiesen werden, die als const deklariert ist. Folgendes wäre also falsch und würde vom Compiler als Fehler erkannt: // Das geht nicht const now = DateTime.now();
Listing 4.12: Beispiel falsche Zuweisung mit const
Wie Funktionen funktionieren Nachdem Sie nun Klassen, Variablen und Objekte erzeugen können, sind Sie gewappnet für Funktionen und Methoden. Auch hier gibt es in Dart keine großen Überraschungen im Vergleich zu anderen gängigen objektorientierten Programmiersprachen.
Aufbau einer Funktion Eine Funktion oder Methode besteht aus einem Header und einem Body. Der Header wiederum setzt sich aus drei Teilen zusammen: Rückgabewert, Titel und 0-n Parametern. Der Body beschreibt, was in dieser Funktion oder Methode passiert. In diesem Beispiel ist der Rückgabewert vom Typ String, der Titel ist getPetName und ein Parameter vom Typ Pet wird mitgegeben. Im Body wird der Name des Pet-Objekts zurückgegeben. String getPetName(Pet pet) { // Body return pet.name; }
Listing 4.13: Funktionsaufbau
Einzeilige Funktionen können durch die sogenannte »Fat-Arrow«-Schreibweise abgekürzt werden. String toString() { return "Hello World"; } String toString() => "Hello World";
Funktion versus Methode Funktionen und Methoden entscheiden sich nicht in ihrem Aufbau, dafür aber in ihrem Scope (= ihrer Reichweite). Methoden werden innerhalb einer Klasse definiert und sind an die Instanzen dieser Klasse gebunden. Sie haben also eine Referenz zu this. final DateTime birthday; const Pet( … this.birthday, ); int getAgeInDays() { return DateTime.now().difference(birthday).inDays; }
Listing 4.14: Definition einer Methode innerhalb der Pet-Klasse Um diese Methode aufzurufen, würden Sie zunächst ein Pet-Objekt erstellen und auf diesem Objekt die Methode wie folgt aufrufen: const pummelTheFish = const Pet(DateTime(2022, 2, 2)); final age = pummelTheFish.getAgeInDays();
Listing 4.15: Aufruf einer Methode
Der Scope von Methoden ist also auf eine Klasse beschränkt. Das gilt nicht für Funktionen: Funktionen werden außerhalb einer Klasse definiert und stehen unabhängig von einer Instanz dieser Klasse für die ganze Anwendung zur Verfügung. Da der Unterschied manchmal etwas schwammig ist, kann es ab und zu – auch in diesem Buch – zu Verwechslungen kommen.
Private Methoden Während in verschiedenen anderen Programmiersprachen wie zum Beispiel Java die Verwendbarkeit von Methoden und Variablen durch ein private-Keyword auf eine Klasse beschränkt werden kann, gibt es bei Dart den Unterstrich. Wenn eine Methode einen Namen mit vorangesetztem Unterstrich trägt, beschränkt dieses den Scope, in dem sie verwendet werden kann, auf die Datei, in der sie definiert ist. Dasselbe Prinzip gilt für Variablen. Die Methode _getAgeInDays im folgenden Beispiel lässt sich nur innerhalb der pet.dart-Datei aufrufen und ist nach außen, in anderen Dateien, vollständig unsichtbar. class Pet { final DateTime _pummelsBirthday = DateTime(2022, 2, 2); int _getAgeInDays() { return DateTime.now().difference(_pummelsBirthday).inDays; } }
Listing 4.16: Eine Pet-Klasse mit privater Methode
Lambda – die anonyme Funktion Möglicherweise kennen Sie die sogenannten Lambda-Funktionen schon aus anderen Programmiersprachen. Es handelt sich dabei um eine anonyme Funktion, die im Gegensatz zu den normalen Funktionen keinen Namen besitzt. Abseits davon sehen sie ähnlich aus, sie können ebenfalls mit 0-n Parametern arbeiten und haben einen Body. Ein Beispiel: (Typ parameter1, Typ parameter2, …) { // Body }
Listing 4.17: Beispielhaftes Grundgerüst eines Lambdas In Dart werden Ihnen Lambdas häufig in Kombination mit Iterables begegnen. Stellen Sie sich eine Liste pets mit Pet-Objekten vor. Sie möchten nun die ganze Liste durchlaufen und alle Pet-Objekte – die den Namen »Pummel« tragen – in der Liste finden und in eine neue pummels-Liste ablegen. Per Lambda ist das mit wenigen Zeichen Code einfach umgesetzt. Mit Hilfe eines where, das auf die pets-Liste angewendet wird, werden alle Pet-Objekte in der Liste auf den Namen »Pummel« überprüft. Die Pet-
Objekte, die die Anforderung im Lambda-Body erfüllen, werden zur pummels-Liste hinzugefügt. final pummels = pets.where( (Pet pet) { return pet.name == "Pummel"; }, ); // Lambda mit Fat-Arrow-Schreibweise final pummels = pets.where( (Pet pet) => pet.name == "Pummel", );
Listing 4.18: Lambda in der Praxis
Methoden und Funktionen in einer Klasse Damit Sie Ihr Wissen direkt in die Praxis umsetzen können, schreiben Sie jetzt ein FakePetRepository mit mehreren Methoden und einer Funktion. Ein Repository ist eine Klasse, in der für gewöhnlich externe Daten verwaltet werden. Sie werden im Laufe des Buches weitere Repositories, die externe Daten verwalten, anlegen. Sie beginnen aber mit einem Repository für selbstfabrizierte Testdaten. Die exemplarischen Methoden, die Sie hier finden, werden Sie auch in »richtigen« Repositories finden. Erstellen Sie bitte einen neuen Ordner repositories unterhalb des lib/data-Ordners. Im repositories-Ordner erstellen Sie eine neue Datei fake_pet_repository.dart. Beachten Sie, dass Dart-Dateien immer in der Lowercase-Schreibweise mit Unterstrichen benannt werden sollten. In dieser Datei definieren Sie die Klasse FakePetRepository, die eine private Pet-Liste _pets verwaltet. class FakePetRepository { final List _pets = [ const Pet( "1", "Pummel", Species.fish, 3, 200.0, 20.0, true, ), const Pet( "2", "Bruno", Species.dog, 4, 320.0, 60.0, false, ),
const Pet( "3", "Leonie", Species.cat, 6, 400.0, 45.0, true, ), const Pet( "4", "Harribart", Species.bird, 1, 220.0, 10.0, false, ), ]; }
Listing 4.19: Das FakePetRepository Diese Klasse soll eine Liste von Pet-Objekten mithilfe von fünf Methoden verwalten: das Hinzufügen eines neuen Pets (addPet), das Aktualisieren eines bestehenden Pet-Objekts in der Liste (updatePet), das Zurückgeben der gesamten Liste an angelegten PetObjekten (getAllPets), das Zurückgeben eines einzelnen Pet-Objekts aus der Liste (getPetById) und das Löschen eines Pet-Objekts aus der Liste (deletePetById). Die getAllPets-Methode greift außerdem auf eine private Methode _sortPetsByName zu. Ganz unten in der Klasse ist eine Funktion makeACoolPetName definiert, die ohne eine Instanz der FakePetRepository-Klasse aufgerufen werden kann und daher als statische Funktion bezeichnet wird. import "package:adobt_a_pet/data/models/pet.dart"; import "package:collection/collection.dart"; class FakePetRepository { final List _pets = […]; FakePetRepository(); // Fügt ein Pet-Objekt zur Liste hinzu void addPet(Pet pet) { _pets.add(pet); } // Aktualisiert ein Objekt in der Liste, falls vorhanden void updatePet(Pet pet) { final index = _pets.indexWhere( (element) => element.id == pet.id, ); if (index != -1) {
_pets[index] = pet; } } // Gibt das Pet-Objekt mit der gewünschten id zurück // Wenn es das Pet-Objekt nicht gibt, wird null zurückgegeben Pet? getPetById(String id) { return _pets.firstWhereOrNull( (petElement) => petElement.id == id, ); } // Gibt eine sortierte Liste an Pet-Objekten zurück List getAllPets() { _sortPetsByName(); return _pets; } // Löscht ein Pet-Objekt mit der gewünschten id aus der Liste void deletePetById(String id) { _pets.removeWhere((pet) => pet.id == id); } // Sortiert die Pet-Liste nach Namen void _sortPetsByName() { _pets.sort( (pet1, pet2) => pet1.name.compareTo(pet2.name), ); } // Generiert einen coolen Namen static String makeACoolPetName( String nameILike, Species species, ) { String coolName = "$nameILike the ${species.name}"; return coolName; } }
Listing 4.20: Das FakePetRepository-Grundgerüst
Sie können innerhalb von Strings auf Variablen zugreifen, indem Sie ein $-Zeichen verwenden: $nameILike. Das wird »Interpolation« genannt. Wenn Sie auf eine Eigenschaft eines Objekts zugreifen wollen, müssen Sie den Ausdruck zusätzlich in Klammern setzen: ${species.name}.
Externe Funktionen über Packages einbinden In der Methode getPetById wird eine externe Methode firstWhereOrNull aufgerufen. Diese ist im collection-Package enthalten, das Methoden für Listen gebündelt zur Verfügung stellt. Damit Sie diese externe Methode benutzen können, müssen Sie zunächst das collection-Package in der pubspec.yaml-Datei als Dependency hinzufügen.
dependencies: collection: ^1.17.0
Bitte achten Sie darauf, dass die Zeile collection: exakt zwei Leerzeichen eingerückt ist – die pubspec.yaml ist die eine Datei in Ihrer App, in der die Einrückung funktionelle und nicht nur ästhetische Bedeutung hat. Im Terminal geben Sie nun den Befehl ein, die Pakete aus der pubspec.yaml erneut zu laden – das tun Sie auch in Zukunft immer, wenn Sie hier etwas verändern. >> flutter pub get
Sie können alternativ auch in VSCode in die pubspec.yaml-Datei navigieren und hier oben rechts auf den nach unten zeigenden Pfeil klicken. Danach können Sie das importStatement an den Anfang der FakePetRepository-Klasse stellen, wie weiter vorne im Code-Beispiel gezeigt. Jetzt sollten weder das import-Statement noch die firstWhereOrNull-Methode rot unterstrichen sein. Woher können Sie wissen, welche externen Packages es gibt und welche Version Sie importieren müssen? Ganz einfach, sie sind alle gesammelt unter https://pub.dev. Dort können Sie auch sehen, wie beliebt das Package Ihrer Wahl ist, wann es veröffentlicht wurde und wie regelmäßig es gepflegt wird, welche Lizenz es hat (also unter welchen Gegebenheiten Sie es legal benutzen können) und von welchen anderen Dependencies das Package abhängt.
Funktionen aufrufen Zurück in der main.dart-Datei können Sie die neu erstellten Methoden innerhalb der main-Methode verwenden, indem Sie zunächst ein FakePetRepository-Objekt namens petRepository instanziieren, die fake_pet_repository.dart-Datei importieren und dann die Methoden entsprechend aufrufen. void main() { final petRepository = FakePetRepository(); final pets = petRepository.getAllPets(); print(pets.length); runApp(const MyApp()); }
Listing 4.21: Ausführen von Methoden der FakePetRepository-Klasse Ausgabe im Terminal: >> 4
Sie sollten jetzt eine grobe Idee haben, wie Funktionen in Dart definiert und aufgerufen
werden können. Wenn Sie nicht alles verinnerlicht haben, machen Sie sich keine Sorgen: Ihre IDE hilft Ihnen in der Regel recht gut zu bestimmen, wo Funktionen aufgerufen werden können und wo nicht.
Parameter für jede Lebenslage Funktionen, Methoden und Konstruktoren können Parameter mitgegeben werden. Diese können auf unterschiedliche Weise definiert werden, je nachdem, ob sie obligatorisch sind, ob ihnen ein Default-Wert gegeben werden soll oder ob sie so zahlreich sind, dass sie benannt werden sollten.
Positional Parameters Im vorherigen Beispiel unserer Pet-Klasse haben wir den folgenden Konstruktor definiert und ein neues Objekt instanziiert: const Pet( this.id, this.name, this.species, this.age, this.weight, this.height, this.isFemale, ); const pummelTheFish = Pet( "1", "Pummel", Species.fish, 3, 200.0, 20.0, true, );
Listing 4.22: Konstruktor mit Positional Parameters Die Parameter sind alle »Positional Parameters«. Es müssen alle Parameter übergeben werden, ansonsten erhalten Sie einen Fehler. Wenn das Objekt instanziiert wird, kann man mit dem Cursor über dem Klassenaufruf schweben und so die Reihenfolge der Parameter herausfinden. So wissen Sie zum Beispiel, dass 20.0 dem Gewicht zugeordnet wird und nicht der Höhe des Pet-Objekts.
Optional Named Parameter Vielleicht haben Sie sich schon dabei ertappt, dass Sie beim Erstellen und Lesen solch eines Pet-Objekts ins Rätseln kommen, welche Zahl nun zu welcher Eigenschaft gehört. Halten wir also fest: Angenehm und lesbar ist eine Instanziierung mit so vielen
Parametern nicht. Es ist auch nicht möglich, Default-Werte für die Parameter zu definieren. Besser geht das mit sogenannten »Named Parameters«. Im folgenden Beispiel aus Listing 4.23 wird der isFemale-Parameter zu einem Named Parameter umgebaut und erhält außerdem einen Default-Wert von true. const Pet( this.id, this.name, this.species, this.age, this.weight, this.height, { this.isFemale = true, } );
Listing 4.23: Konstruktor mit Positional und Named Parameters Schauen Sie sich hierzu nun die zwei folgenden Pet-Objekte pummelTheFish und brunoTheDog an. Während pummelTheFish nach Default-Wert weiblich ist, wird beim Instanziieren von brunoTheDog der Default-Wert überschrieben. Durch die Umwandlung in Named Parameter müssen Sie beim Setzen der Werte nun den Namen des Parameters angeben – allerdings nur dann, wenn Sie den Default-Wert für diesen Parameter überschreiben wollen. Die Named Parameter folgen immer auf die normalen Parameter. const pummelTheFish = const Pet( "1", "Pummel", Species.fish, 3, 200.0, 20.0, ); const brunoTheDog = Pet( "2", "Bruno", Species.dog, 4, 320.0, 60.0, isFemale: false, );
Listing 4.24: Anpassung der Initialisierung der Pet-Objekte
Required Named Parameter Named Parameter haben entweder einen Default-Wert oder eine required-Annotation. Letzteres bedeutet, dass ein Wert bei der Erstellung des Objekts übergeben werden muss,
sonst erhalten Sie direkt einen Fehler. const Pet( this.id, this.name, this.species, this.age, this.weight, this.height, { required this.isFemale, } );
Listing 4.25: Named Parameter mit required Annotation Im obigen Beispiel würde es Sinn ergeben, der Lesbarkeit zuliebe alle Parameter in Named Parameters umzuwandeln. Passen Sie den Konstruktor in der Pet-Klasse daher wie folgt an: const Pet({ required this.id, required this.name, required this.species, required this.age, required this.weight, required this.height, this.isFemale = true, });
Listing 4.26: Der Pet-Konstruktor mit Named Parametern Ihr Code im FakePetRepository sollte nun rot leuchten, passen Sie daher hier die Instanziierung der _pets-Liste für die weitere Verwendung an: final List _pets = [ const Pet( id: "1", name: "Pummel", species: Species.fish, weight: 200.0, height: 20.0, age: 3, ), const Pet( id: "2", name: "Bruno", species: Species.dog, weight: 320.0, height: 60.0, age: 4, isFemale: false, ),
const Pet( id: "3", name: "Leonie", species: Species.cat, weight: 400.0, height: 45.0, age: 6, ), const Pet( id: "4", name: "Harribart", species: Species.bird, weight: 220.0, height: 10.0, age: 1, isFemale: false, ) ];
Listing 4.27: Komplette Umwandlung in Named Parameters
Wir empfehlen bei mehr als einem Parameter generell die Verwendung von Named Parameters – ganz egal, ob diese optional sind oder nicht. Dies verbessert die Lesbarkeit Ihres Codes.
Alle Parametertypen in Kombination Die Parameter-Verwendung bezieht sich nicht nur auf die Verwendung in Konstruktoren, sondern wird auch analog in Funktionen und Methoden so genutzt. Passen Sie daher nun die bestehende Methode makeACoolPetName so an, dass diese jeden Parameter-Typ im Konstruktor repräsentiert. Der erste Parameter ist ein Positional Parameter: nameILike. Dieser wird beim Aufrufen der Methode als Erster übergeben und somit durch seine Position erkannt und nicht durch seinen Namen. Der Named Parameter titleOfNobility soll einen String-Wert annehmen oder leer sein – also null. Er ist beim Aufrufen der Methode somit optional. Auf null-Werte und all den Spaß, den sie mit sich bringen, werden wir im nächsten Kapitel eingehen. Ein weiterer Named Parameter species nimmt einen Species Enum-Wert entgegen. Er ist mit required gekennzeichnet und somit der einzige Named Parameter, der angegeben werden muss. Der dritte Named Parameter coolAdjective wird mit einem Default-Wert definiert und ist darum auch optional. Im Gegensatz zum titleOfNobility-Parameter kann er jedoch keinen null-Wert annehmen.
Im Body der Funktion wird überprüft, ob titleOfNobility einen String-Wert vorweisen kann, und falls ja, wird dieser verwendet. Falls ein null-Wert übergeben wurde, wird die Variable mit einem leeren String gefüllt. Ansonsten würde die nächste Code-Zeile nicht funktionieren, in der Sie den String aus den verschiedenen Variablen per Interpolation zusammenbauen. static String makeACoolPetName( String nameILike, { String? titleOfNobility, required Species species, String coolAdjective = "gangsta", }) { titleOfNobility = titleOfNobility ?? "", String coolName = "$titleOfNobility $nameILike the $coolAdjective ${species.name}"; return coolName; }
Listing 4.28: Parameter innerhalb einer Methode anpassen
Achtung, statisch! Beim Aufrufen dieser Funktion müssen Sie beachten, dass es sich um eine statische Funktion handelt. Das bedeutet, auf this kann nicht zugegriffen werden – sie ist somit unabhängig von einer FakePetRepository-Instanz. Ein Aufruf funktioniert daher wie folgt: final coolPetName = FakePetRepository.makeACoolPetName ( "Dieter", titleOfNobility: "Sir", species: Species.cat, coolAdjective: "deadly", ); print(coolPetName); final anotherCoolPetName = FakePetRepository .makeACoolPetName ( "Petra", species: Species.dog, ); print(anotherCoolPetName);
Listing 4.29: Parameter-Übung: Aufruf der Methode Ausgabe im Terminal: >> Sir Dieter the deadly cat >> Petra the gangsta dog
Null oder nicht null – das ist hier die Frage
Im Jahr 2021 entschied sich das Dart-Team für eine große Änderung eines Grundkonzepts der Programmiersprache und führte die sogenannte »Null Safety« ein. Was in anderen Programmiersprachen wie zum Beispiel Java, Kotlin, Rust, TypeScript und Apples Swift bereits Standard ist, erforderte in Dart – und somit auch bei der Verwendung von Flutter – eine größere Umstellung. Ein Glück, dass Sie erst jetzt in die Flutter-Welt eintauchen und direkt von Anfang an mit Null Safety durchstarten, denn damals mussten alle bestehenden Apps aufwendig migriert werden. Aber was ist Null Safety denn jetzt? Und was ist der Unterschied zwischen Sound Null Safety und Unsound Null Safety?
Von null auf hundert Am besten lässt sich das Konzept wie immer an einem Beispiel erklären. In der App sollen in Zukunft die besitzenden Personen des Kuscheltiers angezeigt werden. Wenn eine nutzende Person ein Kuscheltier hinzufügt, soll der Name dieser Person für das Kuscheltier gespeichert und später angezeigt werden. Allerdings möchten Sie natürlich auch ermöglichen, Kuscheltiere anonym zur Adoption freizugeben, weshalb Sie diese Option bei der Programmierung bedenken müssen. Zuerst erstellen Sie ein Model für einen »Owner«. Hierfür erstellen Sie bitte eine neue Datei owner.dart im models-Ordner, die die zwei String-Attribute id und name besitzt. class Owner { final String id; final String name; const Owner({ required this.id, required this.name, }); }
Listing 4.30: Owner Model Danach fügen Sie in der bereits existierenden Pet-Klasse ein neues Attribut namens owner hinzu. Dieses Attribut soll im Konstruktor required sein, weil es, nehmen wir mal an, später im Code abgefragt werden soll. Wenn Sie nun ein neues Pet-Objekt erstellen wollen, müssen Sie einen Owner mit angeben. Was aber, wenn dieser gerne anonym bleiben würde? class Pet { final String id; final String name; final Species species; final int age; final double weight; final double height; final bool isFemale;
final Owner owner; const Pet({ required this.id, required this.name, required this.species, required this.age, required this.weight, required this.height, this.isFemale = true, required this.owner, }); }
Listing 4.31: Pet-Model mit Owner-Attribut
In einer Zeit vor Null Safety Stellen Sie sich folgendes Szenario anhand von verschiedenen Pet-Objekten vor: const svenjaOwner = Owner(id: "1", name: "Svenja"); const pummelTheFish = Pet( id: "1", name: "Pummel", species: Species.fish, age: 3, weight: 200.0, height: 20.0, owner: svenjaOwner, ); const brunoTheDog = Pet( id: "2", name: "Bruno", species: Species.dog, age: 4, weight: 320.0, height: 60.0, owner: null, );
Listing 4.32: Pet-Objekte mit und ohne Owner Pummel hat eine Besitzerin, Brunos Herrchen oder Frauchen will lieber anonym bleiben und hat das Feld im Erstellformular nicht ausgefüllt. Daher wird hier null als Owner vergeben. In der Zeit vor Null Safety war das problemlos möglich und in vielen Sprachen wie Java und JavaScript ist es auch heute noch möglich. Somit gab es in obigem und folgendem Code auch zunächst keine sichtbare Fehlermeldung und Sie konnten die App problemlos starten. final List pets = [pummelTheFish, brunoTheDog]; void printOwners() {
for(final pet in pets) { print(pet.owner.name); } }
Listing 4.33: Pet-Objekte mit null Fehlern Wenn Sie die App allerdings gestartet hätten und die Methode printOwners aufgerufen worden wäre, wäre Ihnen die gesamte App um die Ohren geflogen. Da haben Sie als entwickelnde Person natürlich eine schwere Ausgangslage, was die Fehlersuche angeht, denn um dieses Szenario zu vermeiden, müsste Ihnen bewusst sein, dass das Owner-Objekt an dieser Stelle null sein kann. Sie müssten diesen Fall daher im Code behandeln: final List pets = [pummelTheFish, brunoTheDog]; void printOwners() { for(final pet in pets) { if(pet.owner != null) { print(pet.owner.name); } else { print("Besitzer anonym"); } } }
Listing 4.34: Pet-Objekte mit null-Check Im Endeffekt bedeutet das: Wenn Sie sich nicht immer darüber bewusst sind, ob etwas null sein kann, und das nicht bewusst abfangen, knallt es früher oder später. Und sind wir mal ehrlich – wer hat das wirklich immer im Blick? Es wäre doch viel praktischer, wenn einem zur Compile-Time schon mögliche Fehlerquellen angezeigt würden. Genau das ist das Ziel der Einführung von Null Safety bei Dart.
Null unter Kontrolle Das Dart-Team hat sich mit Version 2 daher dafür entschieden, Ihnen als entwickelnde Person mehr Kontrolle und Bewusstsein über mögliche Null-Vorkommnisse zu geben. Wie das konkret funktioniert? Ganz einfach! Sie machen den Owner nullable und vermitteln Dart damit, dass das Objekt entweder den Wert eines Owner-Objekts oder den Wert null annehmen kann. Das geht ganz einfach, indem Sie hinter die Owner-Typdefinition ein Fragezeichen hängen. Zusätzlich können Sie die required-Annotation im Konstruktor entfernen. Dadurch erhält das Owner-Attribut den Default-Wert null und muss beim Erstellen eines Pet-Objekts nicht zwingend mitgegeben werden. class Pet { …
final Owner? owner; const Pet({ .. this.owner, }); }
Listing 4.35: Owner-Attribut zum Pet-Model hinzufügen Die Auswirkungen dieser Änderung werden Sie – wie in Abbildung 4.1 – im Code direkt erkennen können, denn nun wird Ihnen das name-Attribut rot unterstrichen und Sie können den Code nicht mehr ausführen.
Abbildung 4.1: Null Safety hilft bei der Fehlererkennung.
Der Dart Compiler macht Ihnen damit bewusst, dass Sie hier handeln müssen. Daher teilen Sie dem Dart Compiler mit, dass der Owner potenziell null sein kann. Hierfür gibt es zwei Möglichkeiten. Eine if-Abfrage kann checken, ob der Wert null ist. if(pet.owner != null) { print("${pet.name} gehört zu ${pet.owner!.name}"); }
Listing 4.36: if-Abfrage zum Nullcheck Das Ausrufezeichen in pet.owner!.name sagt dem Compiler, dass Sie sich zwar bewusst sind, dass das null sein könnte, sich aber in diesem Fall sicher sind, dass es das nicht ist. Dank der if-Abfrage können Sie sich auch sicher sein. Sie sollten das Ausrufezeichen wirklich nur verwenden, wenn Sie sich ganz sicher sind.
Alternativ können Sie die Kurzschreibweise verwenden. print("${pet.name} gehört zu ${pet.owner?.name}");
Das Fragezeichen in pet.owner?.name sorgt dafür, dass der ganze Ausdruck null zurückgibt, anstatt abzustürzen, wenn auf einen null-Wert zugegriffen werden soll. Ausgabe in beiden Fällen: >> Pummel gehört zu Svenja. >> Bruno gehört zu null.
Wenn Sie die Ausgabe im Terminal etwas aufhübschen wollen, indem Sie nicht null direkt ausgeben, sondern einen formulierten Satz, können Sie das mit dem DoppelFragezeichen-Konstrukt umsetzen. (ein Wert der nullable ist) ?? (Rückgabe, falls null)
Wenn Sie es in Ihren Code einarbeiten, kann das wie folgt aussehen: // Abfrage durch if-else-Statement if(pet.owner != null) { print("${pet.name} gehört zu ${pet.owner!.name}"); } else { print("${pet.name}"s Besitzer*in möchte anonym bleiben."); } // Kurzschreibweise print("${pet.name} gehört zu ${pet.owner?.name ?? "niemandem"}");
Listing 4.37: Null-Check mit alternativer Terminal-Ausgabe bei null Die Ausgabe im Terminal in beiden Fällen: >> Pummel gehört zu Svenja >> Bruno gehört zu niemandem
Sound Null Safety und Unsound Null Safety – wo ist da der Unterschied? In der Übergangsphase war es möglich, Dart im sogenannten »Unsound Null Safety«Modus zu starten. Dies hatte den Grund, dass noch nicht alle Packages und Plug-ins auf Null Safety migriert waren und es somit zu einer vermischten Code-Base kam, bestehend aus teilweise Null Safety unterstützenden Packages und teilweise Null Safety nicht unterstützenden Packages. Dart 3 wird nur noch »Sound Null Safety« unterstützen und dadurch mit entsprechenden Compiler-Optimierungen glänzen können. Einen großen Teil der Dart-Basics kennen Sie jetzt schon! Im folgenden Kapitel lernen Sie, wie if-else-Verzweigungen und Schleifen in Dart geschrieben werden.
Kapitel 5
Bedingte Anweisungen und Schleifen im Griff IN DIESEM KAPITEL Lernen Sie alle in Dart verfügbaren bedingten Anweisungsoptionen kennen Bekommen Sie einen Überblick über die Schleifen in Dart
Es gibt wohl kaum eine moderne Programmiersprache, die ohne bedingte Anweisungen wie if-else auskommt und vermutlich auch kaum eine, in der man nicht einen CodePfad mehrmals durchlaufen kann, indem man Schleifen verwendet. Selbstverständlich bietet Dart Ihnen daher diese Werkzeuge an. Welche konkret das sind und wie Sie diese verwenden können, erfahren Sie in diesem Kapitel.
Wenn A, dann B – bedingte Anweisungen in Dart Manchmal ist es notwendig, innerhalb Ihres Source-Codes unterschiedliche Wege anzubieten und diese Wege basierend auf unterschiedlichen Daten einzuschlagen. Hierfür werden bedingte Anweisungen wie if-else und switch verwendet.
if und else – wenn ich könnte, würde ich ja … Dart bietet Ihnen die Standard-if-else-Verzweigung an. if (pummelTheFish.species == hansiTheBird.species) { print("Gleiche Spezies – Züchten möglich"); } else { print("Ungleiche Spezies – Züchten nicht möglich"); }
Listing 5.1: Eine if-else-Anweisung in der Praxis Dart bietet aber auch mit dem sogenannten »Ternary Operator« eine sexy Kurzform an. (statement) ? (statement trifft zu) : (statement trifft nicht zu)
Ein Beispiel: pummelTheFish.species == hansiTheBird.species
? print("Gleiche Spezies – Züchten möglich") : print("Ungleiche Spezies – Züchten nicht möglich");
Listing 5.2: Kurzform einer if-else-Anweisung Selbstverständlich können Sie auch mehrere if-else-if aneinanderreihen. Ein abschließender else-Zweig ist optional. if (pummelTheFish.species == Species.dog) { print("Pummel ist ein Hund."); } else if (pummelTheFish.species == Species.cat) { print("Pummel ist eine Katze."); } else if (pummelTheFish.species == Species.fish) { print("Pummel ist ein Fisch.") }
Listing 5.3: Eine if-else-if-Anweisung Auch in Kurzform ist dies möglich. Ein abschließender else-Zweig ist hier allerdings obligatorisch. (pummelTheFish.species == Species.dog) ? print("Pummel ist ein Hund.") : (pummelTheFish.species == Species.cat) ? print("Pummel ist eine Katze.") : (pummelTheFish.species == Species.fish) ? print("Pummel ist ein Fisch.") : print("Pummel gehört keiner Spezies an.");
Listing 5.4: Kurzform einer if-else-if-Anweisung Die Kurzform werden Sie auch im Teil über UI-Entwicklung mit Flutter öfters zu sehen bekommen, da sie benutzt wird, um Widgets im UI dynamisch anzuzeigen.
Switch – wer die Wahl hat, hat die Qual Neben der if-else-Anweisung darf natürlich die switch-Anweisung nicht zu kurz kommen. const brunoTheDog = Pet( id: "2", name: "Bruno", species: Species.dog, age: 4, weight: 320.0, height: 60.0, ); switch(brunoTheDog.species) { case Species.dog: print("Bruno ist ein Hund."); break; case Species.cat:
print("Bruno ist eine Katze."); break; case Species.bird: print("Bruno ist ein Vogel."); break; case Species.fish: print("Bruno ist ein Fisch."); break; }
Listing 5.4: Die switch-Anweisung mit einem enum Das besondere an einer switch-Anweisung basierend auf einem Enum ist, dass Sie keinen default-Zweig benötigen. Möchte man beispielsweise anhand eines int-Wertes cases definieren, so sollten Sie einen default-Zweig hinzufügen, um nicht bedachte Fälle abzufangen und einer Linter-Warnung zu entgehen. int numberOfPets = 3; switch(numberOfPets) { case 1: print("Das ist eine 1."); break; case 2: print("Das ist eine 2."); break; case 3: print("Das ist ein 3."); break; default: print("Das ist nicht 1, 2 oder 3, sondern eine andere Nummer."); break; }
Listing 5.5: Die switch-Anweisung mit default-Zweig
Sie können auch für mehrere cases dieselbe Ausgabe haben. Hierfür unterbrechen Sie den jeweiligen case nicht mit einem break, sondern schreiben direkt den nächsten case darunter. switch(number) { case 1: case 3: print("Das ist eine ungerade Zahl"); break; case 2: case 4: print("Das ist eine gerade Zahl"); break; }
In VSCode lassen sich Enum-Switch-Statements mit allen benötigten Zuständen einfach generieren. Sie müssen dafür nur switch(Ihre enum Variable) eingeben. Das switch-Statement sollte dann bereits gelb markiert sein. Über die QUICK-FIXFunktion – gekennzeichnet durch ein gelbes Lampen-Emoji – wählen Sie ADD MISSING CASE CLAUSES.
Round and round it goes … Schleifen in Dart Neben den bedingten Anweisungen bietet Dart auch die typischen Schleifen wie for, foreach, while und do-while an. Zur Vereinfachung wird hier die Pet-Klasse nur mit dem name-Attribut dargestellt. Alle anderen Attribute sind in diesen Beispielen nicht notwendig.
for-Schleife Mit einer for-Schleife können Sie den Code innerhalb der Schleife so oft ausführen, bis die Kondition erreicht wurde. final pets = [ const Pet(name: "Pummel"), const Pet(name: "Bruno"), const Pet(name: "Hansi"), ]; for (var i = 0; i < pets.length; i++) { print(pets[i].name); }
Listing 5.6: Die for-Schleife in der Praxis Ausgabe im Terminal: >> Pummel, Bruno, Hansi
for-in-Schleife – oder auch for-each-Schleife Eine for-in-Schleife ist ähnlich zu einer for-Schleife, nur dass sie sich nicht auf den Index des aktuellen Elements verweist, sondern das Element direkt anspricht. In anderen Programmiersprachen wird die for-in-Schleife oft auch als for-each-Schleife bezeichnet. for(final pet in pets) { print(pet.name); }
Listing 5.7: Die for-in-Schleife in der Praxis
Ausgabe im Terminal: >> Pummel, Bruno, Hansi
while-Schleife Die while-Schleife führt eine Aktion so lange aus, bis die Bedingung nicht mehr erfüllt ist. Hier fügt sie sieben Pet-Objekte mit dem Namen »Musterpet« hinzu: while (pets.length < 10) { pets.add(Pet(name: "Musterpet")); } print(pets.length);
Listing 5.8: Die while-Schleife in der Praxis Ausgabe im Terminal: >> 10
do-while-Schleife Die do-while-Schleife führt erst eine Bedingung aus und prüft dann, ob die Bedingung weiterhin erfüllt wird und noch eine Runde gedreht werden soll. Sie läuft also einmal öfter durch als die while-Schleife. Hier fügt sie acht Pet-Objekte mit dem Namen »Musterpet« hinzu: do { pets.add(Pet(name: "Musterpet")); } while (pets.length < 10); print(pets.length);
Listing 5.9: Die do-while-Schleife in der Praxis Ausgabe im Terminal: >> 11
Wir hoffen, Ihnen ist jetzt nicht schwindelig, nach dem ganzen im Kreis Laufen! Damit Schleifen erst richtig spannend werden, dürfen Iterables nicht fehlen. Diese lernen Sie im nächsten Kapitel kennen.
Kapitel 6
Sammeln und Sortieren – Collections in Dart IN DIESEM KAPITEL Erfahren Sie, was Listen, Maps und Sets sind und wie Sie sich unterscheiden Bekommen Sie einen Überblick über verfügbare Methoden der Iterables Lernen Sie, wie Sie null statt eines StateError zurückgeben können
Ohne Collections kommt man in der objektorientierten Programmierung nicht aus. Letztendlich ist eine Collection ein Objekt, das aus mehreren Elementen besteht. Dazu zählen in Dart List, Map und Set. In einer List können Elemente per Index ausgelesen werden. Eine Map hingegen besteht aus einer Key-Value-Kombination. Elemente können über den Key ausgelesen werden. Ein Set ist eine Gruppe von einzigartigen Elementen. Duplikate kann es in einem Set nicht geben. List und Set zählen zu den sogenannten Iterables. Im vereinfachten Sinne heißt das, dass man durch sie durch iterieren kann – zum Beispiel mit einer for-in-Schleife.
Drei Arten von Collections Auch wenn Ihnen das vielleicht nicht bewusst war: Wir haben von Anfang an schon einige Collections erzeugt und verwendet.
List Die List sollte Ihnen schon bekannt vorkommen. // Initialisierung einer List mit Pet-Elementen final List myPetList = []; // Initialisierung einer List mit dynamic-Elementen final myDynamicList = [];
Listing 6.1: Eine Pet-Liste bauen Eine List kann per Default eine unbestimmte Anzahl an Elementen aufnehmen. Mithilfe verschiedener Methoden können Sie Ihre Liste managen. Mit der add-Methode können
Sie zum Beispiel Elemente ans Ende der Liste anhängen, per remove-Methode ein bestimmtes Element aus der Liste entfernen.
Map Maps werden Sie vor allem in Teil 4, »REST und Firebase – externe Daten beziehen und managen«, verwenden, wenn Sie mit JSON-Daten arbeiten. Hier wird eine Map mit String keys und dynamic values erzeugt: Map myMap = {};
Set Der Unterschied zwischen einer List und einem Set ist, dass in einem Set jedes Element nur einmal existiert und außerdem die Elemente innerhalb des Sets keine konkrete Reihenfolge haben und somit nicht per Index angesprochen werden können: Set mySet = {};
Sie können auch eine Liste erstellen, die eine fest definierte Anzahl von Elementen hält: final filledListWithPummels = List.filled(5, "Pummel");
Die Zahl 5 gibt an, dass die Liste fünf Elemente beinhalten soll. Alle Elemente in diesem Fall halten den Wert »Pummel«. Die Liste würde also so aussehen: ["Pummel", "Pummel", "Pummel", "Pummel", "Pummel"];
Sie können in solch einer Liste keine neue Länge setzen und auch keine Elemente per add-Methode hinzufügen. // Das funktioniert nicht filledListWithPummels.length = 3; filledListWithPummels.add("Pummel");
Was Sie stattdessen tun müssen, um Werte zu ändern, ist, den Index des existierenden Elements anzusprechen und dann zuzuweisen. Wenn Sie beispielsweise an der ersten Stelle einen neuen String, zum Beispiel »Bruno«, zuweisen möchten, sieht das wie folgt aus: filledListWithPummels[0] = "Bruno";
Methoden für Iterables
Neben den weiter vorne angesprochenen for-in-Schleifen bieten Iterables verschiedene praktische Methoden an. Im Folgenden ein paar Beispiele anhand der petNames-Liste: final petNames = ["Pummel", "Bruno", "Hansi"];
Das erste Element der petNames-Liste zurückgeben: final firstPetName = petNames.first;
Prüfen, ob die petNames-Liste Elemente beinhaltet: final isEmpty = petNames.isEmpty;
Die petNames-Liste nach Namen sortieren: petNames.sort((petName1, petName2) => petName1.compareTo(petName2));
Elemente der petNames-Liste durchmischen: petNames.shuffle();
Mit indexOf können Sie den Index eines bestimmten Elements ermitteln. Falls das Element nicht in der Liste existiert, wird -1 zurückgegeben. petNames.indexOf("Pummel"); // Gibt 0 zurück petNames.indexOf("Harry"); // Gibt -1 zurück
Mit firstWhere wird das erste Element zurückgegeben, das die Kondition erfüllt. Falls kein Element gefunden wird, wird – falls definiert – der orElse Zweig ausgeführt, ansonsten wird ein StateError geworfen. Für dieses Beispiel benutzen wir noch einmal die vereinfachte Pet-Liste, die wir schon in vorangegangenen Kapiteln benutzt haben. final pets = [ const Pet(name: "Pummel"), const Pet(name: "Bruno"), const Pet(name: "Hansi"), ];
Listing 6.2: Vereinfachte Pet-Liste Pummel gibt es in unserer pets-Liste, also können Sie problemlos auf das Element zugreifen: final pummelTheFish = pets.firstWhere((pet) => pet.name == "Pummel");
Harry gibt es in unserer pets-Liste nicht, darum wirft folgende Code-Zeile einen StateError: final harryTheFish = pets.firstWhere((pet) => pet.name == "Harry");
Um einem StateError vorzubeugen, falls es Harry nicht gibt, können Sie den orElseParameter benutzen und damit einen Harry anlegen: final harryTheFish = pets.firstWhere((pet) => pet.name == "Harry", orElse: () => Pet(name: "Harry"));
Manchmal möchte man, dass statt eines StateError der Wert null zurückgegeben wird. Für diesen Anwendungsfall können Sie das collection-Package importieren, um Zugriff auf die firstWhereOrNull-Methode zu erhalten. Diese gibt null zurück, falls das gewünschte Element nicht in der Liste existiert. import "package:collection/collection.dart"; final petOrNull = pets.firstWhereOrNull((pet) => pet.name == "Pummel"));
War's das schon mit Dart? Fast! Es fehlt nur noch ein wichtiges Thema: Die asynchrone Programmierung. Die werden Sie sich im nächsten Kapitel anschauen.
Kapitel 7
Asynchrone Programmierung – wenn es mal wieder länger dauert IN DIESEM KAPITEL Ein Blick in die Zukunft mit Futures Lernen Sie, was ein Stream und ein Fluss gemeinsam haben
Dart unterstützt asynchrone Programmierung mit Hilfe von sogenannten Futures und Streams. Diese Konzepte werden Sie in diesem Kapitel näher kennenlernen. Asynchrone Programmierung ist selten trivial, also nehmen Sie sich Zeit, um alles zu verstehen! Ohne das Verständnis dieser Konzepte werden Sie wahrscheinlich keine gängige App zusammengeschustert bekommen – denn eine App schiebt in der Regel vor allem Daten hin und her und synchronisiert diese zwischen nutzenden Personen. Dafür brauchen Sie Darts asynchrone Konzepte. Sie werden diese später auch im Teil über State Management und im Teil über REST und Firebase wiederfinden und ausführlich üben.
Futures, async und await Stellen Sie sich vor, Sie gehen ins Bürgerbüro und wie immer ist viel los. Daher ziehen Sie eine Nummer. Sobald Ihre Nummer an der Reihe ist, werden Sie aufgerufen und können Ihr Anliegen vorbringen. Ein Future ist genau das. Sie stellen eine Anfrage, wissen aber, dass das Ergebnis eine unbestimmte Zeit dauert. Wahrscheinlich nicht so lange, wie Sie in der Regel in deutschen Behörden auf Ihren Aufruf warten – aber der Vergleich eignet sich insofern, als Sie den genauen Zeitpunkt nicht vorhersagen können. Sobald das Ergebnis da ist, werden Sie benachrichtigt und können Ihren eigentlichen Plan ausführen. Ein klassisches Beispiel für Futures in der Software-Entwicklung ist eine Datenabfrage einer Schnittstelle. Auf Schnittstellen gehen wir in Teil 4, »REST und Firebase – externe Daten beziehen und managen«, noch ausführlich ein. Aktuell genügt es für Sie, zu wissen, dass es eine unbestimmte Zeit dauern kann, bis ein Ergebnis von einer Anfrage an eine Schnittstelle zurückkommt. Sollten Sie Erfahrungen aus der Web-Programmierung mitbringen, hilft es Ihnen sicher, zu wissen, dass Futures analog zu Promises funktionieren.
Sie können sich vermutlich noch an die fake_pet_repository.dart-Datei erinnern. Rufen Sie sich noch einmal die Methode getAllPets ins Gedächtnis. List getAllPets() { _sortPetsByName(); return _pets; }
Listing 7.1: getAllPets-Methode Sie ruft eine separate private Methode _sortPetsByName auf und gibt die frisch sortierte Pet-Liste ohne Verzögerung zurück. Wir möchten diese Methode nun so verändern, dass sie nicht nur unsere lokal angelegte Pet-Liste zurückgeben kann, sondern stattdessen eine Pet-Liste von unserem Server. Der Server wird Ihnen nach einer unbestimmten Wartedauer eine Liste an Pet-Objekten zurückliefern, die Sie dann im Terminal ausgeben können. Da es aktuell noch keinen Server gibt, imitieren Sie ihn, indem Sie mit Future.delayed die Ausführung Ihres AppCodes einen Moment pausieren. Um Dart zu signalisieren, dass Sie warten möchten, müssen Sie ein await-Keyword vor die Abfrage hängen. Zusätzlich ist es notwendig, den Rückgabewert der Methode mit einem Future zu umschließen. Auch das async-Keyword zwischen Methoden-Header und -Body darf nicht fehlen. So sieht die Methode mit den Änderungen aus: Future getAllPetsFromMockServer() async { await Future.delayed(const Duration(seconds: 3)); return _pets; }
Listing 7.2: getAllPetsFromMockServer-Methode in asynchroner Form mit einem Future.delayed Als alternative Schreibweise zur Verwendung von await können Sie auch die thenSchreibweise verwenden. Während das await vor dem Aufruf der Future-Methode steht, wird das then hinten drangehängt. Sobald das Future fertig ausgeführt ist, erhalten Sie das result und können mit diesem im then-Body weiterarbeiten oder es zurückgeben. Da das Future.delayed hier kein result zurückliefert, können Sie das am besten anhand des folgenden Beispiels verstehen. Future getPet() async { … } final pet = await getPet(); print(pet); // oder getPet.then((pet) => print(pet));
Listing 7.3: Zwei Möglichkeiten, eine asynchrone Methode aufzurufen
Nutzen Sie Future, wenn Sie auf das Beenden der Ausführung einer Methode warten wollen, die aber keinen Rückgabewert liefern wird.
Ein Datenfluss – auch Stream genannt Nachdem Sie nun wissen, wie asynchrone Programmierung in Dart mit Futures, async und await funktioniert, lassen Sie uns einen Schritt weitergehen und über Streams sprechen. Stellen Sie sich vor, Sie haben einen Vertrag mit einem Tierfutter-Lieferanten für Ihr Tierheim. Sobald Sie den Vertrag unterschrieben haben, kommt der Tierfutter-Lieferant bei Ihnen vorbei und versorgt Sie mit neuen Vorräten. Wann das allerdings immer ist, ist unklar, da es immer wieder zu Lieferengpässen, Staus und anderen Verzögerungen kommen kann. Das einzige, was Sie wissen, ist, dass Sie Futter bekommen werden, solange der Vertrag läuft. Wenn Sie den Vertrag kündigen, wird er nicht mehr vorbeikommen. Somit ist ein Stream im Prinzip nichts anderes als eine for-Schleife um eine Future-Abfrage.
Ein Stream.periodic in der Praxis Es gibt also einen Vertrag mit Tierfutterlieferungen: Stream. Der Lieferant befüllt diesen Stream regelmäßig. Dieses Verhalten lässt sich am besten durch die Verwendung eines Stream.periodic simulieren, der basierend auf der gegebenen Dauer (hier alle drei Sekunden) immer wieder eine Aktion ausführt. final foodStream = Stream.periodic( const Duration(seconds: 3), (count) => "Hundefutter ${count}", );
Listing 7.4: Ein Stream.periodic Wenn dieser Vertrag unterschrieben ist, wird Ihnen Tierfutter geliefert, Sie haben also eine sogenannte StreamSubscription. Jedes Mal, wenn der Lieferant vorbeikommt, erhalten Sie eine Futterpackung. Solange der Vertrag aktiv ist und der Lieferant Futter in den Stream einspeist, werden Sie Tierfutterlieferungen erhalten. Somit sollten Sie mit den folgenden Zeilen alle drei Sekunden die Ausgabe »Hundefutter [Zahl]« erhalten. Die Zahl gibt dabei an, wie oft der Stream die Aktion bereits ausgeführt hat. final foodStreamSubscription = foodStream.listen((food) => print(food));
Wenn Sie den Vertrag kündigen wollen, können Sie die StreamSubscription canceln. foodDeliverySubscription.cancel();
Ein Stream als Rückgabewert Eine Methode, die einen Stream zurückgibt, sieht ähnlich aus wie eine Methode, die ein Future zurückgibt. Auch hier spielt das Stichwort »unbestimmte Zeit« wieder eine Rolle. Diesmal verwenden Sie allerdings nicht async vor dem Methoden-Body, sondern async*. Das bedeutet, Sie haben nicht nur einen Rückgabewert – wie das bei einem Future der Fall ist –, sondern die Methode gibt kontinuierlich Ergebnisse zurück. Erstellen Sie eine foodDeliveries-Liste, die verschiedene Futterarten in Form von Strings enthält. Schreiben Sie nun die Methode getFoodFromDelivery und geben Sie ihr einen Stream als Rückgabewert. Simulieren Sie die Wartezeit wieder mit einem await Future.delayed. Um den erhaltenen Wert dann zurückzugeben, nutzen Sie das Keyword yield. final foodDeliveries = ["Dog Food", "Cat Food", "Bird Food"]; Stream getFoodFromDelivery(List foodDeliveries) async* { for (final food in foodDeliveries) { await Future.delayed(Duration(seconds: 1)); yield food; } }
Listing 7.5: Die getFoodFromDelivery-Methode gibt einen Stream zurück. Die Methode steht, der Lieferant kann kommen! Rufen Sie die Methode auf und geben Sie ihr die String-Liste mit. Die oben erstellte Stream-Methode können Sie dann durch einen listen-Aufruf anzapfen. Sobald ein neues Element aus der erstellten foodDeliveries-Liste vom Stream ausgegeben wird, geben Sie es per print im Terminal aus. getFoodFromDelivery(foodDeliveries).listen((food) { print(food); });
Listing 7.6: Der Stream wird im Terminal ausgegeben. Die zugehörige Terminalausgabe lautet wie folgt: // Eine Sekunde Wartezeit >> Dog Food // Eine Sekunde Wartezeit >> Cat Food // Eine Sekunde Wartezeit >> Bird Food
Streams sind eher ein fortgeschrittenes Konzept. Uns ist aber wichtig, dass Sie diese Form eines Streams zumindest einmal gesehen haben. In Teil 5, »State Management«, werden Streams nochmal relevant und dann auch ausführlicher erklärt. Die wichtigsten Dart Basics kennen Sie nun. Um Dart noch etwas eleganter zu nutzen und
Code-Duplizierung zu vermeiden, lernen Sie im nächsten Kapitel Vererbung, Interfaces und Mixins kennen.
Kapitel 8
Vererbung und weitere praktische DartFeatures IN DIESEM KAPITEL Lernen Sie, wie Vererbung in Dart funktioniert Schreiben Sie Ihr erstes eigenes Interface Erweitern Sie Ihren Code mit Mixins
Das, was Sie bisher über Dart gelernt haben, wird Ihnen reichen, um die meisten Aufgaben gut zu bewältigen. Aber wenn Sie einen Schritt weiter gehen möchten, geben wir Ihnen hier noch ein paar nützliche Dart-Features an die Hand, um Ihren Dart-Code noch eleganter zu machen und Duplizierungen zu vermeiden.
Vererbung in Dart Dart zählt zu den objektorientierten Programmiersprachen. Daher darf eines der grundlegendsten Konzepte – die Vererbung – natürlich nicht fehlen. Mit Vererbung haben Sie die Möglichkeit, Klassen aufeinander aufzubauen und dadurch Eigenschaften und Methoden weiterzugeben. Stellen Sie sich eine Klasse Animal mit zwei Eigenschaften name und age sowie einer Methode greetReader vor: class Animal { final String name; final int age; const Animal({required this.name, required this.age}); void greetReader() { print( "Hallo, ich bin $name und ich bin $age Jahre alt." ); } }
Listing 8.1: Die Animal-Klasse mit zwei Eigenschaften und einer Methode
Sie möchten nun Hunde-, Katzen-, Fisch- und Vogel-Objekte mit dieser einen Klasse bauen können. Allerdings möchten Sie, dass Hunde eine bark-Methode haben, Katzen eine miau-Methode, Fische eine blub-Methode und Vögel eine piep-Methode. Sie könnten all diese Methoden in die Animal-Klasse packen: class Animal { … void bark() { print("Wuff wuff"); } void miau() { print("Miauuuu"); } void blub() { print("Blubb blubb blubb"); } void piep() { print("Piep piep"); } }
Listing 8.2: Eine Animal-Klasse mit verschiedenen Methoden Wenn Sie nun ein neues Animal-Objekt erstellen, können Sie allerdings immer auf alle Methoden zugreifen. final dog = Animal(name: "Hund", age: 2); dog.bark(); dog.miau(); dog.blub(); dog.piep();
Listing 8.3: Auf alle Methoden kann zugegriffen werden. Vermutlich gibt es noch weitere Eigenschaften und Methoden, die sich je nach AnimalTyp unterscheiden. Was Sie stattdessen tun können, ist, für jeden Animal-Typ eine eigene Klasse anzulegen, nur die Methoden dort reinzuschreiben, die von diesem Animal-Typ verwendet werden können, und von der Animal-Klasse erben zu lassen. Entfernen Sie also die bark-, miau-, blub- und piep-Methoden aus der Animal-Klasse und erstellen Sie eine neue Dog-Klasse, die von der Animal-Klasse erbt. Hierfür verwenden Sie das extends-Keyword: class Dog extends Animal { Dog({required super.name, required super.age}): void bark() { print("Wuff wuff"); } }
Listing 8.4: Die Animal-Klasse extenden Wenn Sie nun ein neues Dog-Objekt erstellen, haben Sie Zugriff auf die bark-Methode, aber auch weiterhin Zugriff auf name, age und die greatReader-Methode. Die Eigenschaften name und age müssen Sie im Konstruktor angeben, da diese bei der Erstellung des Objekts benötigt werden. Durch das vorangestellte super verweisen Sie auf die Klasse, von der geerbt wird, in diesem Fall also Animal. final dog = Dog(name: "Hund", age: 2);
dog.bark(); dog.greetReader();
Listing 8.5: Lassen Sie den Hund bellen. Ausgabe im Terminal: >> Wuff wuff >> Hallo, ich bin ein Hund und ich bin 2 Jahre alt.
Interfaces Während durch die Verwendung von extends Klassen erweitert werden können, können mit Hilfe eines Interfaces Verträge zu Klassen abgeschlossen werden. Das bedeutet, dass alle Methoden (die, die nicht privat sind – also nicht durch einen vorangehenden Unterstrich gekennzeichnet sind) in der Klasse, in der das Interface verwendet wird, per @override überschrieben werden sollten. Ein Interface stellt lediglich den Vertrag auf, welche Methoden es geben soll und welche Rückgabewerte und Parameter diese benötigen, jedoch nicht, wie diese konkret implementiert werden. Überschreiben Sie diese Methoden nicht, wird Ihnen der Compiler einen Fehler melden und Sie darum bitten, die entsprechenden Methoden einzubauen. In Dart werden Interfaces nicht speziell gekennzeichnet. Es gilt die Regel des impliziten Interfaces. Das bedeutet, dass jede Klasse als Interface verwendet werden und per implements-Keyword an die gewünschten Klassendefinitionen gehängt werden kann: class A { void methodeA(String text) { // TODO: implement } } class B implements A { @override void methodeA(String text) { // TODO: implement } } class C implements A { @override void methodeA(String text) { // TODO: implement } }
Listing 8.6: Eine normale Klasse A wird als Interface für die Klassen B und C verwendet.
Während nur von einer Klasse geerbt werden kann, können mehrere Klassen als Interface einer Klasse angegeben werden:
class A extends B {} class A implements B, C, D {}
Abstrakte Klassen und Methoden Im vorangegangenen Beispiel könnten Sie problemlos ein Objekt der Klasse A instanziieren. Oft möchten Sie allerdings nicht, dass Objekte des Interfaces selbst instanziiert werden können, weswegen Interfaces oft als abstrakte Klassen definiert werden. Hierzu fügen Sie einfach das Keyword abstract vor dem class-Keyword ein und machen aus den Methoden-Implementierungen nur Methoden-Verträge, bestehend aus Rückgabewert, Methodenname und Parametern: abstract class A { void methodeA(String text); } class B implements A { @override void methodeA(String text) { // TODO: implement } } class C implements A { @override void methodeA(String text) { // TODO: implement } }
Listing 8.7: Ein Interface als abstrakte Klasse Eine Instanziierung eines Objekts der Klasse A ist nun nicht mehr möglich: final a = A(); // das geht nicht
Interfaces in der Praxis Ein klassischer Interface-Anwendungsfall in unserer App wäre zum Beispiel ein PetRepository. Sie haben bereits ein FakePetRepository erstellt, das für die aktuellen Zwecke zunächst ausreicht, indem es Mock-Daten zurückgibt. Im späteren Verlauf des Buches möchten Sie aber noch ein RestPetRepository und FirebasePetRepository erstellen, die weitestgehend dieselben Methoden-Grundgerüste beinhalten, sich aber in der Implementierung der Methoden unterscheiden. Während Sie beim FakePetRepository Fake-Daten aus einer lokal angelegten Liste verwenden, werden beim RestPetRepository Daten von einer REST-API bezogen und beim FirebasePetRepository Daten von einer Firebase-Instanz. Die Methoden-Verträge ändern sich aber nicht, sie nehmen dieselben Parameter entgegen und geben denselben Objekt-Typ zurück. Die Definition eines Interfaces für das Grundkonstrukt jeder Variante des PetRepository ist also sinnvoll. Erstellen Sie eine neue Datei pet_repository.dart im lib/data/repositories-Ordner und definieren Sie eine abstrakte Klasse PetRepository sowie vier abstrakte Methoden
getPetById, getAllPets, addPet und deletePetById: abstract class PetRepository { Pet? getPetById(String id); List getAllPets(); void addPet(); void deletePetById(String id); }
Listing 8.8: Ein PetRepository-Interface muss her. Das FakePetRepository kann nun das frisch angelegten PetRepository-Interface implementieren und die Methoden überschreiben. Wechseln Sie daher in Ihre FakePetRepository-Klasse und wenden Sie das neu genannte Konzept an, indem Sie ein implements PetRepository hinter das FakePetRepository hängen. Ihre Methoden getPetById, getAllPets, addPet und deletePetById sollten Sie nun mit einem @override versehen. An der Implementierung der Methoden selbst müssen Sie nichts ändern. class FakePetRepository implements PetRepository { @override Pet? getPetById(String id) { … } @override List getAllPets() { … } @override void addPet() { … } @override void deletePetById(String id) { … } }
Listing 8.9: Das FakePetRepository mit Interface In späteren Kapiteln werden Sie das PetRepository wieder verwenden und spätestens dann wird Ihnen auffallen, wie praktisch dieses Konzept ist.
Mixins Mit Mixins können Sie Teile Ihres Codes an andere Klassen »anheften«. Hierfür stellen Sie sich noch einmal unterschiedliche Klassen Dog, Cat, Fish und Bird vor: class Dog {} class Cat {} class Fish {} class Bird {}
Listing 8.10: Definition von verschiedenen Tier-Klassen
Nun haben Sie eventuell zusätzlich Methoden, die nicht zu allen Klassen passen, aber doch zu mehr als einer – zum Beispiel die Fähigkeit zu laufen, die Fähigkeit zu fliegen oder zu schwimmen. Anders als bei der Vererbung haben Sie hier Methoden, die auf mehr als eine Klasse, aber nicht auf alle Klassen zutreffen. Hierfür eignet sich die Verwendung eines Mixins perfekt, daher lassen sie uns die genannten Fortbewegungsmöglichkeiten in eine solche Form gießen: mixin WalkMixin { void walk() { print("Laufen"); } } mixin SwimMixin { void swim() { print("Schwimmen"); } } mixin FlyMixin { void fly() { print("Fliegen"); } }
Listing 8.11: Verschiedene Mixin-Definitionen mit Funktionen Sobald diese definiert sind, können Sie flexibel die Klassen, die das jeweilige Mixin benötigen, verbinden. Dazu schreiben Sie einfach nach dem Klassennamen das withKeyword und den Namen des Mixins: class Dog with WalkMixin {} class Cat with WalkMixin {} class Fish with SwimMixin {} class Bird with FlyMixin {}
Listing 8.12: Anreicherung der Tier-Klassen mit Mixins
Es ist auch möglich, eine Klasse mit mehreren Mixins anzureichern: class Dog with WalkMixin, SwimMixin {}
Wenn Sie nun die Klassen verwenden, haben Sie Zugriff auf die Funktionen, die die angehängten Mixins zur Verfügung stellen, ohne dass Sie diese extra in der jeweiligen Klasse definieren müssen. final dog = Dog(); dog.walk(); dog.swim(); final cat = Cat(); cat.walk();
final fish = Fish(); fish.swim(); final bird = Bird(); bird.fly();
Listing 8.13: Verwendung von Klassen mit Mixins Das kann Ihnen sehr viel Schreibarbeit bei wiederverwendbarem Code sparen und zusätzlich müssen Sie mögliche Änderungen nur einmal in den Mixins vornehmen und nicht in jeder Klasse separat. Jetzt können Sie loslegen mit Ihren Dart-Basics! Aber was, wenn's dann mal nicht funktioniert? Im nächsten Kapitel bauen Sie Ihre Debugging-Skills aus!
Kapitel 9
Debugging in Dart – Probleme finden und lösen IN DIESEM KAPITEL Lernen Sie, wie Sie mit dem Debugger in VSCode umgehen können Halten Sie den Code an bestimmten Stellen mit Breakpoints an Experimentieren Sie mit den Flutter-DevTools
Nun kennen Sie die wichtigsten Eigenschaften und Funktionalitäten der DartProgrammiersprache. Obwohl Dart noch viel mächtiger und umfangreicher ist als das, was wir Ihnen bis jetzt gezeigt haben, wird Ihnen dieses Wissen reichen, um eine erste App zu bauen. Was hingegen noch fehlt, ist die Bekanntschaft mit einem sehr wichtigen Werkzeug namens »Debugger«. Dieses Werkzeug wird immer dann besonders wertvoll, wenn mal etwas nicht so funktioniert wie geplant. Dieses Werkzeug geschickt zu handhaben, wird Ihr Leben um einiges vereinfachen. In unserer Programmier-Laufbahn sind uns leider schon viele Menschen begegnet, die sich das Leben unnötig schwer gemacht haben und viel Zeit bei der Fehlersuche verschwendet haben, weil sie es einfach nicht besser wussten. Wir möchten dem direkt entgegenwirken, Ihnen die wichtigsten Funktionen nahelegen und Ihnen auch zeigen, wie Sie mit den DevTools UI-Fehler finden können.
De-BUG-ging – die Jagd auf die Bugs Aus dem Wort Debugging lässt sich schnell der Zweck ableiten, denn es bedeutet nichts anderes, als Bugs innerhalb eines Programms aufzuspüren und zu entfernen. Geprägt wurde der Begriff von Admiral Grace Hopper in den 40ern. Sie arbeitete gerade mit einem Mark-II-Computer an der Harvard-Universität, der Probleme bereitete. Ihre Kollegen fanden eine Motte in einem Relay des Computers, die der Auslöser der Probleme war. Grace merkte daraufhin an, ihre Kollegen wären dabei, das System zu »debuggen«. Bei den Problemen, die Ihnen mit Ihrer Flutter-App begegnen werden, wird es sich aber wahrscheinlich eher selten um lebendiges Kriechzeug in Ihrem Laptop handeln – und öfters um Ihre Denkfehler im Code. Um diese entdecken zu können, gibt Ihnen VSCode
den Debugger an die Hand.
Debuggen in VSCode Der integrierte Debugger lässt sich in VSCode einfach starten, indem Sie in der linken Seitenleiste auf das Play-Icon mit dem Käfer klicken. Dort sehen Sie dann einen RUN AND DEBUG-Button (Abbildung 9.1), der die geöffnete App im Debug-Modus startet. Alternativ können Sie unter Windows und Mac auch die Taste verwenden, um den Debug-Modus zu starten.
Abbildung 9.1: Run and Debug
Sobald der Debugger erfolgreich gestartet ist, ändert sich diese Ansicht, und Sie sollten die verschiedenen Abschnitte VARIABLES, WATCH, CALL STACK und BREAKPOINTS wie in Abbildung 9.2 sehen. Jeden dieser Abschnitte können Sie bei Bedarf ein- oder ausklappen. Auf die Funktion der einzelnen Abschnitte gehen wir im Folgenden genauer ein.
Abbildung 9.2: Die App im Debug-Modus
Mit Breakpoints die Zeit anhalten Wir hoffen, dass Sie mit Breakpoints in Ihrer Programmier-Laufbahn bereits Bekanntschaft machen durften. Falls nicht: Mit Breakpoints können Sie Ihr Programm an einem oder mehreren von Ihnen definierten Code-Zeilen anhalten und inspizieren. Sie können während der Pausierung beispielsweise Werte von Variablen auslesen und vergleichen, den Laufweg Ihres Programms verfolgen und überprüfen, ob sich alles so verhält, wie Sie sich das gedacht haben.
Breakpoints setzen, deaktivieren oder löschen Um einen Breakpoint zu setzen, klicken Sie einfach in die gewünschte Zeile ganz links neben der Zeilennummer. Sie sehen dann einen kleinen roten Punkt: Das ist Ihr Breakpoint. Sie können beliebig viele Breakpoints setzen. Alle gesetzten Breakpoints werden im RUN AND DEBUG-Tab links unter BREAKPOINTS aufgelistet. Sie können diese dort auch einzeln deaktivieren und wieder aktivieren oder löschen. Per Klick auf den jeweiligen Breakpoint springen Sie in die Datei an die gewünschte Stelle. Im BreakpointAbschnitt können Sie auch alle Breakpoints gleichzeitig deaktivieren oder löschen, wie Sie in Abbildung 9.3 sehen können.
Abbildung 9.3: Breakpoints
Die Breakpoint-Navigation können Sie über die kleine Debug-Leiste steuern, die Sie in Abbildung 9.4 sehen.
Abbildung 9.4: Die Debug-Leiste
Die Funktionen dieser Leiste kurz von links nach rechts erklärt: Mit dem Pause-Button können Sie Ihre App anhalten. Sollte die App aufgrund eines Breakpoints automatisch angehalten worden sein, wird sich der Pause-Button in einen Play-Button verwandeln. Per Klick auf den Play-Button können Sie Ihre App fortsetzen. Den Step-Over-Button können Sie verwenden, wenn Sie bei einem Breakpoint angehalten haben und zur nächsten Zeile springen möchten, ohne auf dem Weg dorthin Funktionen aufzurufen. Mithilfe des Step-Into-Buttons können Sie in die Funktionen hineinnavigieren, die Sie mit dem Step-Over-Button überspringen würden. Mit dem Step-Out-Button können Sie eine Funktion, in die Sie sich hineinbegeben haben, wieder verlassen. Der gelbe Blitz-Button führt einen Hot-Reload aus. Der grüne Pfeil-Button führt einen Hot-Restart aus. Der Stop-Button stoppt den Debug-Modus und beendet Ihre App.
Mit der blauen Lupe können Sie den Widget-Inspektor öffnen, den Sie im weiteren Verlauf dieses Kapitels noch kennenlernen werden. Am besten setzen Sie zur Übung ein paar Breakpoints und klicken sich durch Ihren Code. Verwenden Sie dabei am besten die Debug-Leiste. Sobald Sie mit der Debug-Navigation warm geworden sind, beobachten Sie, wie sich der VARIABLES-Abschnitt verändert, wenn Sie an einem Breakpoint angehalten haben. Dort können Sie Objekte genauer inspizieren, auf die Sie zu der Zeit des aktuellen Breakpoints Zugriff haben (siehe Abbildung 9.5).
Abbildung 9.5: Objekte inspizieren
Variablenwerte beobachten Meistens geht es bei der Bug-Suche darum, Werte von bestimmten Variablen zu einem bestimmten Zeitpunkt im Programmverlauf zu überprüfen. Der WATCH-Abschnitt gibt Ihnen die Möglichkeit, dies zu tun. Per Klick auf das kleine Plus können Sie einen neuen Ausdruck hinzufügen, zum Beispiel _counter (siehe Abbildung 9.6).
Abbildung 9.6: Variablenwerte beobachten
Nun können Sie beobachten, wie sich die Werte des Objekts verändern, wenn Sie sich durch Ihr Programm klicken. Wichtig zu wissen ist, dass dieses Objekt sich im Scope befinden muss. Das bedeutet, wenn es zum Beispiel ein lokales Objekt innerhalb einer Klasse ist, wird es bei Breakpoints in einer anderen Klasse nicht abrufbar sein. Sie können im WATCH-Abschnitt auch direkt eigene neue Ausdrücke definieren, die nicht direkt im Code zu finden sind, zum Beispiel _counter == 0, um herauszufinden, ob der _counter den gewünschten Wert an der Breakpoint-Stelle erreicht hat. Ihrer Kreativität und Ihren Bedürfnissen sind keine Grenzen gesetzt, solange zum Zeitpunkt des Breakpoints alle angesprochenen Objekte vorhanden sind.
Die Debug Console Sobald Sie Ihre App debuggen, beginnt die Debug Console, Sie darüber zu informieren, was gerade mit Ihrer Software passiert. Wenn es einen Fehler gibt, werden Sie ihn in den meisten Fällen hier auch sehen können. Fehler sind in Rot geschrieben. Wenn Sie diesen Fehler nicht selbstständig identifizieren und fixen können, googeln Sie am besten die Fehlermeldung. Mit einer hohen Wahrscheinlichkeit haben sich andere schon mit demselben Problem herumgeschlagen. Sie werden über die Debug Console allerdings nicht über ästhetische Fehler informiert. Wenn ein Button Ihrer App verrutscht oder ein Text zum Beispiel nicht sichtbar oder zu groß für den Screen ist, können Sie stattdessen versuchen, mit den DevTools den Fehler zu identifizieren.
Die DevTools von Flutter Neben dem integrierten Debugger von VSCode können Sie auch ein zusätzliches Flutterspezifisches Tool namens DevTools einsetzen. Dieses können Sie über die Befehlszeile öffnen, indem Sie nach »DevTools« suchen. Daraufhin sollte sich ein Auswahldialog öffnen, der Ihnen mehrere Optionen bietet (siehe Abbildung 9.7). Wählen Sie OPEN WIDGET INSPECTOR PAGE.
Abbildung 9.7: Den Widget Inspector öffnen
Wir werden uns in diesem Buch auf die Verwendung des Widget Inspectors beschränken. Es lohnt sich aber, sich auch mit den anderen Funktionalitäten auseinanderzusetzen.
Widget Inspector – der App-Aufbau Der Widget Inspector ist ein super Tool, um Fehler in Ihrem UI zu verstehen. Er gibt Ihnen einen aktuellen »Snapshot« Ihres App-Aufbaus. Sie können in jedes Widget in der Baumstruktur klicken, um im Layout Explorer – direkt rechts daneben – nähere Informationen darüber zu bekommen (siehe Abbildung 9.8).
Abbildung 9.8: Der Widget Inspector
Elemente auswählen Über den Button ganz links oben, der beim Drüber-Hovern TOOGLE SELECT WIDGET MODE anzeigt, können Sie auf Ihrem Gerät oder Emulator, auf dem die App aktuell läuft, ein beliebiges Element auswählen, und Sie springen automatisch an die entsprechende Stelle im Code. Zusätzlich dazu können Sie im WIDGET INSPECTOR die zugehörigen Detailinformationen sehen (siehe Abbildung 9.9). Um das nächste Element auszuwählen, verwenden Sie den blauen Lupen-Button am unteren linken Rand auf Ihrem Gerät oder Emulator. Erst dann befinden Sie sich wieder im Auswahlmodus.
Abbildung 9.9: Element auswählen im Widget Inspector
Wir wünschen Ihnen in der Zukunft viel Erfolg bei der Käferjagd! Wir haben Ihnen hoffentlich die wichtigsten Tools dazu gezeigt. Jetzt brauchen Sie nur noch ein strukturiertes Vorgehen, eine Prise Fingerspitzengefühl und ganz viel Frustrationstoleranz. Für unsere »Pummel The Fish«-App bleibt Ihnen die Option, wenn Sie einen Fehler nicht identifizieren können, einfach den Code mit der Lösung von unserer Webseite https://losfluttern.de/pummelthefish oder von der Webseite des Verlags WileyVCH herunterzuladen: https://wiley-vch.de/ISBN9783527720293.
Recap: Programmieren mit Dart Sie haben in diesem Teil die Sprache Dart kennen – und vielleicht auch schon lieben –
gelernt. Vielleicht ist es dafür auch noch zu früh, aber hoffentlich war es spannend genug für weitere Dates in der Zukunft! Sie können nun bereits Klassen anlegen und Objekte instanziieren, Schleifen durchlaufen, asynchrone Daten holen, Klassen beerben, Variablen handhaben und Bugs jagen. Im nächsten Teil werden Sie sich tiefer mit dem FlutterFramework beschäftigen, mit dem Sie die Screens Ihrer App bauen werden.
Teil III
Wir bauen eine App
IN DIESEM TEIL … Lernen Sie den Aufbau einer Flutter-App kennen Machen Sie Bekanntschaft mit den wichtigsten Widgets Navigieren Sie von Screen zu Screen und übergeben Daten zwischen Screens Lernen Sie, wie Sie Bilder, Fonts und Icons importieren und integrieren Definieren Sie ein Theme für Ihre App
Wie in Flutter das UI gebaut ist, ist recht speziell: Alles besteht aus ineinander verschachtelten Widgets. Wie genau das funktioniert und was das mit Bäumen zu tun hat, erfahren Sie in diesem Teil. Sie werden außerdem die wichtigsten Widgets kennenlernen, wie Theming und Navigation funktionieren und wie Sie ein eigenes Custom Widget bauen. Damit Sie gleich ins Tun kommen, setzen Sie Ihr Wissen immer direkt in der »Pummel The Fish«-App um.
Kapitel 10
Alles ist ein Widget IN DIESEM KAPITEL Schauen Sie sich die main.dart-Datei einmal genauer an Lernen Sie, wie eine Flutter-App aus Widgets aufgebaut ist Erfahren Sie mehr über den Unterschied zwischen Stateful- und StatelessWidgets
Was sind Widgets? Ist wirklich alles in Flutter ein Widget? Und was ist der Unterschied zwischen einem Stateless- und einem StatefulWidget? Fragen über Fragen, zu denen Sie in diesem Kapitel die Antworten finden werden. Dazu schauen Sie sich die FlutterBeispiel-App einmal genauer an.
Hier fängt alles an: die main.dart-Datei Die Flutter-Beispiel-App, die Sie in den bisherigen Kapiteln schon kennengelernt haben, bietet initial einen fertig programmierten Screen an. Der relevante Code befindet sich in der main.dart-Datei. Wie Sie schon gelernt haben, startet hier Ihre App. MyApp ist ein sogenanntes StatelessWidget (erklären wir noch), welches bei Aufruf das MaterialApp-Widget zurückgibt. Dieses Widget – könnte man sagen – ist das »Mutter-
Widget« aller Widgets dieser App. Hier wird das Theme für die App definiert sowie das Routing initialisiert, das für die Navigation innerhalb der App zuständig ist. In der Flutter-Beispiel-App gibt es nur einen Screen, der im home-Parameter aufgerufen wird. Dieser Screen ist somit der erste Screen, der direkt nach dem Starten der App angezeigt wird. Der Screen heißt MyHomePage und seinem Konstruktor wird der Parameter title mitgegeben. Diesen haben Sie schon einmal geändert, als Sie den Emulator ausprobiert haben. home: const MyHomePage(title: "Pummel The Fish App"),
Weiter unten in der main.dart-Datei wird der MyHomePage-Screen definiert. Schauen Sie sich vor allem den Code in der build-Methode an, denn hier wird der Screen gebaut und das gebaute Widget, das angezeigt werden soll, zurückgegeben. void main() { runApp(const MyApp()); }
class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: "Flutter Demo", theme: ThemeData( primarySwatch: Colors.blue, ), home: const MyHomePage( title: "Pummel The Fish App", ), ); } }
Listing 10.1: Die main-Methode – der Startpunkt einer jeden Flutter-App class MyHomePage extends StatefulWidget { … } class _MyHomePageState extends State { … @override Widget build(BuildContext context) { return Scaffold( // Hier wird der Screen gebaut ); } }
Listing 10.2: Hier wird der Screen gebaut. Wenn Sie jetzt den Emulator starten (klicken Sie in der Menüleiste links auf das PLAY ICON mit dem kleinen Käfer und dann den Button RUN & DEBUG) können Sie auf dem Emulator-Screen, wie in Abbildung 10.1, die Elemente sehen, die innerhalb des Scaffold-Widgets definiert sind.
Abbildung 10.1: Die Flutter-Beispiel-App
Ein Scaffold ist ein Widget, das das Grundgerüst eines Screens bildet. Im bodyParameter des Scaffolds liegen weitere Widgets. Das Center-Widget zentriert das Widget, das es beinhaltet, das Column-Widget kann mehrere Widgets halten und ordnet sie untereinander in einer Spalte an, und das Text-Widget hält einen Text. Der FloatingActionButton führt bei Klick eine Funktion aus. Alles im Screen scheint aus Widgets zu bestehen und auch unser Screen MyHomePage selbst scheint ein Widget zu sein. Was sind diese Widgets und wofür braucht man sie genau?
Widgets, Widgets überall … Jedes Flutter-Tutorial beginnt mit dem Satz: »In Flutter, everything is a widget.« (auf Deutsch: »In Flutter ist alles ein Widget.«). Also wollen wir hier auch keine Ausnahme machen. In Flutter ist alles ein Widget. Wenn Sie schon einmal ein User Interface mit einem beliebigen anderen Framework gebaut haben, kennen Sie vielleicht schon die Idee, dass sich ein User Interface aus Elementen wie Buttons, Containern oder Textfeldern zusammensetzt. Bei Flutter heißen diese Elemente »Widgets« – aber sie umfassen weit mehr als nur Buttons, Fotos, Überschriften und andere UI-Elemente, die klar als solche erkennbar sind. Zum Beispiel sind Spalten und Reihen, die andere Widgets im Seitenlayout anordnen, ebenfalls Widgets in Flutter: das Column-Widget und das Row-Widget. Auch Abstandshalter sind ein eigenes Widget, Padding-Widget genannt. Soll ein Text also zum Beispiel Abstand zu den anderen UI-Elementen haben, können Sie das Text-Widget in ein Padding-Widget wrappen. Das tun Sie, indem Sie das TextWidget in den child-Parameter des Padding-Widgets platzieren. Suchen Sie in der Flutter-Beispiel-App den Satz »You have pushed the button this many times:« und wrappen Sie das Text-Widget in ein Padding-Widget. Beachten Sie, dass Sie jetzt direkt das Padding const setzen können, anstatt das Text-Widget darin, denn sowohl Padding- als auch Text-Widget sind in diesem Fall const. Wenn das Parent-Widget const ist, müssen auch alle darunterliegenden Child-Widgets const sein. const Padding( padding: EdgeInsets.all(80), child: Text( "You have pushed the button this many times:", ), ),
Listing 10.3: Das Padding-Widget Wenn Sie HOT RELOAD betätigen, werden Sie sehen, wie sich der Text mit dem neuen Abstand von 80 Pixeln in alle Richtungen neu positioniert, wie in Abbildung 10.2. Einige Widgets können auf User-Input reagieren. Der FloatingActionButton, ElevatedButton, TextButton sowie andere Button-Widgets in Flutter haben zum Beispiel einen onPressed-Parameter. Hier kann eine Funktion hinterlegt werden, die aufgerufen wird, sobald der oder die App-Nutzende den Button klickt. Das GestureDetector-Widget ist ebenfalls ein sehr wichtiges Tool für Sie, um User-Input zu handhaben. Es kann auf unterschiedlichen User-Input wie Doppelklick (onDoubleTap), lange Klicks (onLongPress) und weitere Gesten reagieren. Sie können es um jedes beliebige Widget wrappen und die entsprechenden User-Input-Events abfangen und mit dem Triggern von Funktionen auf sie reagieren. Manche Widgets sind ziemlich intelligent. Das ListView-Widget kann große Mengen an Daten sparsam anzeigen – gerendert werden nur die Elemente der Liste, die die AppNutzenden auf dem Screen aufrufen. Der FutureBuilder kann auf eine asynchrone Funktion warten und wird zum Beispiel eingesetzt, um Daten von einem externen Server zu laden und je nach Ladezustand unterschiedliche Widgets im UI anzuzeigen. Sie sehen also, die Widget-Welt ist groß und divers. Im nächsten Kapitel werden Sie die wichtigsten Widgets kennenlernen und einige Screens aus ihnen bauen.
Abbildung 10.2: Das Padding-Widget ausprobieren
StatefulWidget und StatelessWidget Ein Widget ist entweder ein StatelessWidget oder ein StatefulWidget. StatelessWidgets haben keinen veränderbaren State und ihre Struktur ist simpel. Ein StatelessWidgets eignet sich gut, wenn das Interface nicht dynamisch beeinflussbar sein
soll. import "package:flutter/material.dart"; class ExampleWidget extends StatelessWidget { const ExampleWidget({super.key}); @override Widget build(BuildContext context) { // Hier wird das Widget gebaut } }
Listing 10.4: Grundgerüst eines StatelessWidgets Ein Text-Widget ist zum Beispiel ein StatelessWidget, ein TextFormField hingegen, das je nach Tastatureingabe der anwendenden Person einen Text anzeigt, ist ein StatefulWidget. Das ist sinnvoll, weil es flexibel auf Eingaben reagieren muss und Änderungen entsprechend im UI reflektiert werden sollen. Es updatet seinen Zustand nach jeder Eingabe und verändert so sein Aussehen. Ein StatefulWidget ist etwas komplizierter im Aufbau. Es besteht nicht nur aus einem Konstruktor und der build-Methode, sondern zusätzlich auch einer State-Klasse, die das Widget jedes Mal dann neu baut, wenn eine Änderung durch einen setState-Aufruf ausgelöst wird. import "package:flutter/material.dart"; class ExampleWidget extends StatefulWidget { const ExampleWidget({super.key}); @override State createState() => _ExampleWidgetState(); } class _ExampleWidgetState extends State { @override Widget build(BuildContext context) { // Hier wird das Widget gebaut } }
Listing 10.5: Grundgerüst eines StatefulWidgets
Auch App-Screens sind Widgets Ein App-Screen selbst ist auch ein Widget. Er kann daher ebenfalls ein StatelessWidget oder ein StatefulWidget sein. Ein Screen, der einfach Text und Bilder zeigt und mit Klick auf einen Button zum nächsten Screen führt – hier reicht ein StatelessWidget. Soll der Screen aber auf User-Interaktion reagieren und ein Klick auf einen Button zum Beispiel die Hintergrundfarbe des Screens ändern, dann wird ein StatefulWidget benötigt (zumindest solange Sie noch kein State-Management in Ihrer App haben – aber dazu später mehr). Der MyHomePage-Screen im Beispiel-Code ist ein StatefulWidget – wenn Sie auf den Button klicken, soll sich der Text, also die Counter-Zahl, auf dem Screen dynamisch verändern. Anhand des MyHomePage-Widgets können Sie also angewandt sehen, wie Sie ein StatefulWidget schreiben können. Um die Eigenschaften und Funktionsweise eines StatefulWidgets zur Verfügung zu haben, muss die MyHomePage-Klasse von der StatefulWidget-Klasse erben. Außerdem wird eine zusätzliche State-Klasse benötigt. Der Teil dieses Widgets, der veränderbar sein soll, findet sich in der State-Klasse innerhalb der build-Methode. Wenn innerhalb der State-Klasse setState ausgeführt wird, wird die build-Methode des _MyHomePageState-Widgets neu aufgerufen. Im Beispiel-Code passiert dies, sobald der FloatingActionButton geklickt wird. class MyHomePage extends StatefulWidget { final String title; const MyHomePage({super.key, required this.title}); @override State createState() => _MyHomePageState(); } class _MyHomePageState extends State { int _counter = 0; void _incrementCounter() { setState(() { _counter++; }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ),
body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text( "You have pushed the button this many times:", ), Text( "$_counter", style: Theme.of(context).textTheme.headline4, ), ], ), ), floatingActionButton: FloatingActionButton( onPressed: _incrementCounter, tooltip: "Increment", child: const Icon(Icons.add), ), ); } }
Listing 10.6: Das komplette MyHomePage-Widget aus der Flutter-Beispiel-App
Sie sind nicht darauf beschränkt, nur die Widgets zu benutzen, die schon im FlutterFramework vorhanden sind. Egal, ob Sie einen Screen wie MyHomePage oder einen eigenen Custom-Button oder ein beliebiges Design-Element definieren wollen – Ihnen sind keine Grenzen gesetzt. Aus den Flutter-eigenen Widgets können Sie beliebig andere Widgets bauen und diese dann auch einfach wiederverwenden. So können Sie zum Beispiel Code-Duplizierung vermeiden. Was genau bei der Definition eines eigenen Widgets zu beachten ist und wann es sich lohnt, eigene Widgets zu bauen, lernen Sie in Kapitel 12, »Ein bisschen DIY zwischendurch – Custom Widgets«.
StatefulWidgets mit setState updaten Wenn Sie sich in der aktuellen Beispiel-App den FloatingActionButton in der MyHomePage-Klasse etwas genauer ansehen, erkennen Sie dort, dass durch die Verwendung des onPressed-Parameters die Methode _incrementCounter bei jedem Klick des Buttons aufgerufen wird. floatingActionButton: FloatingActionButton( onPressed: _incrementCounter, tooltip: "Increment", child: const Icon(Icons.add), ),
Listing 10.7: Die onPressed-Methode beim FloatingActionButton
Wenn Sie nun in die _incrementCounter-Methode springen, werden Sie vermutlich schnell erkennen, dass sie nichts anderes tut, als die Variable counter um eins hochzuzählen. Zusätzlich dazu wird allerdings noch ein mysteriöser setState-Aufruf ausgelöst. Was es damit wohl auf sich hat? void _incrementCounter() { setState(() { _counter++; }); }
Listing 10.8: Der Counter zählt hoch.
counter++ ist in Dart die Kurzschreibweise für counter = counter + 1.
Die counter-Variable finden Sie in einem Text-Widget wieder, weswegen sie auf dem Screen als Text angezeigt wird. Mit jedem Klick auf den Button können Sie beobachten, dass die dargestellte Zahl sich um eins vergrößert. Probieren Sie nun einmal aus, was passiert, wenn Sie setState nicht aufrufen. Nehmen Sie setState aus der Funktion heraus und zählen Sie nur die Variable hoch. void _incrementCounter() { _counter++; }
Listing 10.9: Der Counter zählt hoch, ohne setState(). Wenn Sie jetzt auf HOT RELOAD klicken, werden Sie sehen, dass sich nichts mehr tut, wenn Sie den FloatingActionButton klicken. Die Zahl auf dem Screen ändert sich nicht mehr. Wenn Sie aber einen Breakpoint in der Funktion setzen, bei der Zeile, in der Ihre counter-Variable hochgezählt wird, wird der Debugger hier bei jedem Klick auf den Button stehenbleiben, und Sie werden sehen, dass sich der Wert der Variablen weiterhin wie gewünscht ändert. (Falls Sie mit dem Debugger noch nicht warm geworden sind, können Sie sich den Wert der counter-Variablen auch per print("$counter") ausgeben lassen.) Aber woran liegt es, dass der Text sich nicht mehr ändert? Ein essenzielles Element bei der Verwendung von StatefulWidgets ist der Aufruf von setState. Dieser Aufruf sorgt dafür, dass ein Rebuild des Widgets ausgelöst wird, indem die buildMethode erneut aufgerufen wird, und die Änderungen, die Sie innerhalb von setState ausgeführt haben, im UI sichtbar werden. Die Änderung ist ohne den setState-Aufruf somit nicht sichtbar, weil das Widget MyHomePage nicht erneut gezeichnet wird. Wenn Sie den setState-Aufruf wieder hinzufügen und einen Hot-Reload ausführen, werden Sie sehen, dass die Zahl sich wieder sichtbar verändert, sobald Sie den FloatingActionButton klicken.
Sie müssen setState nicht um die Änderung wrappen, wie im Beispiel. Sie können es auch anschließend aufrufen. Allerdings möchten wir Ihnen sehr empfehlen, setState immer um den Teil des Codes zu wrappen, der die Änderungen der UI bedingt. Ansonsten könnte es passieren, dass Sie setState unnötig mehrmals aufrufen oder beim Umschreiben des Codes ein nötiges setState aus Versehen löschen. // Best practice setState(() { counter++; }); // Funktioniert auch counter++; setState(() {});
Listing 10.10: Verschiedene Möglichkeiten, setState aufzurufen
Wann sollten Sie ein StatelessWidget und wann ein StatefulWidget nehmen? Wenn Sie Ihr UI in Flutter programmieren und selbst Widgets schreiben – wie Screens oder kleinere UI-Elemente – werden Sie sich immer die Frage stellen, ob Sie dafür ein StatelessWidget oder ein StatefulWidget verwenden sollen. Letztendlich ist es nicht wichtig, wie Sie sich entscheiden. Wenn Sie kein State Management eingebaut haben, ist ein StatefulWidget nötig, sobald sich das Aussehen des Widgets bei Interaktion dynamisch ändern soll. Ein StatefulWidget gibt Ihnen außerdem die Möglichkeit, auf die Lifecycle-Methoden zu reagieren – sie können zum Beispiel initState und dispose überschreiben und so Funktionen beim Erstellen oder beim Zerstören des Widgets aufrufen. Dies kann ein guter Grund sein, sich für ein StatefulWidget zu entscheiden. Wenn sich das Aussehen nicht ändern soll, ist ein StatelessWidget die einfachere Wahl, da man sich die State-Klasse spart. Lifecycle-Methoden ermöglichen das Abrufen von Zuständen – also States – eines Widgets. Während ein StatelessWidget lediglich die build-Methode als LifecycleMethode zur Verfügung stellt, arbeitet ein StatefulWidget mit createState, initState, didChangeDependencies, build, didUpdateWidget, setState, deactivate und dispose.
Es wächst ein Widget-Baum
Alle Widgets in Flutter sind wie ein Baum angeordnet. Das erste Widget, das aufgerufen wird, ist das MyApp-Widget. In ihm liegt das MaterialApp-Widget, und von hier aus verästelt sich die App. Dieser Baum ergibt sich, weil ein Widget höchstens ein sogenanntes »Parent Widget« haben kann, aber null, ein oder mehrere »Child Widgets«. Abbildung 10.3 skizziert einen exemplarischen Widget-Baum.
Abbildung 10.3: Ein Widget-Baum
Eine Flutter-App startet also in der main-Funktion und hier im MaterialApp-Widget entspringen alle anderen Widgets. Über den BuildContext kennt jedes Widget den Weg zurück zum MaterialApp-Widget und somit seinen Platz in der App.
Das MaterialApp-Widget erfüllt außerdem zwei wichtige Aufgaben: Theming und Routing. Der erste Screen, der von der App aufgerufen werden soll, wird hier definiert. Über den BuildContext ist es wiederum aus jedem Screen der App möglich, mithilfe der Navigator-Funktion zu einem anderen Screen zu wechseln. Sie werden mehr zum Thema Routing in Kapitel 14, »Wo gehts hier lang? Routing in Flutter-Apps«, erfahren. Das Theme der App, das die Standardfarben und Schriftarten festlegt, wird ebenfalls im MaterialApp-Widget definiert und ist allen Widgets über den BuildContext zugänglich. In Kapitel 15, »Mach alles blau – Theming für Ihre App«, werden Sie das Theming Ihrer App einrichten. Achten Sie darauf, dass Sie Code an der richtigen Stelle einsetzen, wenn Sie ihn in Ihre App kopieren, um ihn auszuprobieren. Das MaterialApp-Widget sollte nur einmal in Ihrem Widget-Baum vorkommen und alle App-Screens sollten darin liegen. Wenn Sie das MaterialApp-Widget aus Versehen doppeln, werden Sie wahrscheinlich schwer erkennbare Fehler im Theming und Routing Ihrer App verursachen! Zuerst werden Sie sich jetzt aber noch tiefer in die Widget-Welt stürzen und sich an den Screens der »Pummel The Fish«-App ausprobieren. Es sei denn, Sie sind daran interessiert, noch tiefer zu verstehen, wie Flutter funktioniert? Dann folgen Sie uns zunächst auf einen kleinen Exkurs …
Exkurs: Das Flutter-Framework – vor lauter Bäumen die App nicht mehr sehen Nun haben Sie gelernt, dass alles in Flutter ein Widget ist und Sie einen Widget-Baum bauen, wenn Sie Ihre App programmieren. Widgets sind aber nur eine Ebene im FlutterFramework – und es gibt noch weitere Ebenen und noch mehr Bäume zu entdecken. Auf diese wollen wir in diesem Exkurs eingehen, denn das Wissen, wie Flutter »under the hood« funktioniert, kann in vielen Fällen hilfreich und auch sehr interessant sein. Fühlen Sie sich frei, diesen Exkurs auch zunächst zu überspringen und später bei Interesse zurückzukehren.
Die verschiedenen Ebenen des Flutter-Frameworks Das ganze Kunstwerk in Abbildung 10.4 startet mit den zwei obersten Ebenen: »Material« und »Cupertino«. Diese Ebenen beinhalten die Widgets, die sich speziell an die unterschiedlichen Plattform-Designs Material von Google (also hauptsächlich Android) und Cupertino von Apple (also iOS und macOS) anpassen. Sie sind also eine Subgruppierung der darunterliegenden Widget-Ebene, die alle Widgets beinhaltet, die Flutter Ihnen zur Verfügung stellt. In dieser Ebene werden Sie sich fast ausschließlich
aufhalten. Denn so viel schon einmal vorweg: Es gibt sehr, sehr viele Widgets, von denen Sie Gebrauch machen können. Sie können Widgets miteinander kombinieren und anpassen, da bleiben kaum Wünsche offen.
Abbildung 10.4: Die Ebenen des Flutter-Frameworks
Eine Ebene tiefer wird es dann schon mysteriös, denn dort befindet sich die »Rendering«Ebene. Mit dieser kommen Sie bewusst – speziell zu Beginn Ihrer Flutter-Reise – vermutlich weniger in Berührung. Die Rendering-Ebene kümmert sich hauptsächlich um die sogenannten RenderObjects, die für das generelle Layout, Painting, Hit Testing und Accessibility verantwortlich sind. Somit ist zwar alles in Flutter ein Widget, aber gleichzeitig auch alles, was Sie auf dem Screen sehen, ein RenderObject. Die WidgetsEbene ist also einfach eine Abstraktionsebene über der Rendering-Ebene, um Ihnen das Leben einfacher zu machen. Um ein komplett eigenes Widget zu bauen, das sich nicht aus bestehenden Widgets zusammensetzt, könnten Sie die RenderObject-Klasse erweitern und die Methoden überschreiben. Keine Sorge – so tief tauchen wir hier nicht ein, Sie sollten an dieser Stelle nur wissen, dass es die Möglichkeit gibt, komplett eigene Widgets zu bauen. Die unterste Ebene – die Foundation-Ebene – beherbergt grundlegende Funktionalitäten, um Animationen, Painting und Gestures abzubilden und den darüberliegenden Ebenen zur Verfügung zu stellen. So viel also zum Thema Ebenen, aber wo bleibt der versprochene spannende Teil?
Flutter – die Recycling-Maschine Wenn Sie einen Widget-Baum in Flutter bauen, werden zeitgleich im Hintergrund ein Element-Baum und ein Render-Baum generiert (Abbildung 10.5). Hilfe, wieso denn drei Bäume? Den Widget-Baum kennen Sie bereits. Er enthält all Ihre Widgets, um der nutzenden Person die gewünschte App zu präsentieren. Pro WidgetInstanz wird jeweils ein zugehöriges Element-Objekt generiert. Dieses Element beinhaltet unter anderem die Referenz zum entsprechenden Widget sowie Referenzen zu dessen Parent- und Child-Widgets. Somit weiß ein Widget durch das zugehörige Element, wo genau es sich im Baum befindet. Der Element-Baum stellt also die grundlegende Struktur der App dar.
Abbildung 10.5: Flutters drei Bäume
Der Render-Baum beinhaltet die RenderObjects, von denen wir gerade gesprochen haben, und der Element-Baum kümmert sich darum, den Widget-Baum und den RenderBaum zu synchronisieren. Meistens können RenderObjects wiederverwendet werden, was der Performance Ihrer App natürlich zugutekommt.
Der BuildContext – der Kreis schließt sich Der BuildContext ist Ihnen bei den ersten Gehversuchen mit Flutter schon ein paar Mal in diesem Kapitel begegnet und wird auch weiterhin Ihr treuer Begleiter sein. Sie finden ihn in der build-Methode jedes Widgets, in der Ihr Widget zusammengebaut wird.
@override Widget build(BuildContext context) { // Hier bauen Sie Ihr Widget zusammen }
Listing 10.11: Die build-Methode In Zukunft werden Sie den BuildContext auch noch an verschiedenen anderen Orten antreffen und benötigen, wenn es zum Beispiel um die Navigation in der App oder das Theming geht. Aber wozu ist der BuildContext denn nun gut? Ein Widget ist eine Art Schablone für ein grafisches Element in Ihrer App. Sie können es an beliebig vielen Stellen im Widget-Baum – also Ihrer App – einsetzen. Innerhalb des Widgets gibt es aber nirgendwo Hinweise darauf, woher das Widget weiß, wo es sich im Baum einordnen soll. Woher kommt diese Information also? Woher genau soll das Widget wissen, in welchem Ast des Baumes und an welcher konkreten Position es sich befindet? Ein kurzer Sprung zurück in die Welt der Flutter-Bäume: Dort haben wir Ihnen von einem Element erzählt, das »under the hood« bei jedem Instanziieren eines Widget-Objekts automatisch generiert wird, woraus der Element-Tree entsteht. Tja, genau dieses Element hält nicht nur eine Referenz zu dem Widget, aus dem es entstammt, sondern auch Referenzen zu dessen jeweiligen Parent- und Child-Widgets. Somit kann es den richtigen Platz für das Widget-Objekt im Widget-Baum finden. Was hat das nun aber mit dem BuildContext zu tun? Wenn Sie sich nun die ElementKlasse genauer anschauen, können Sie wie in Abbildung 10.6 erkennen, dass es sich hierbei um einen speziellen Typ des BuildContext handelt.
Abbildung 10.6: Die Element-Klasse im Flutter-Framework
So schließt sich der Kreis. Der BuildContext gibt Infos darüber, wo genau sich das Widget-Objekt, das Sie in der build-Methode zusammenbauen, im Widget-Baum konkret befindet. Aber genug Theorie! Es wird Zeit, dass Sie ein paar Screens bauen …
Kapitel 11
Widgets über Widgets – wie werden daraus tolle App-Screens? IN DIESEM KAPITEL Bekommen Sie einen guten Überblick über die wichtigsten Widgets Üben Sie verschiedene Varianten, wie in Flutter ein Screen aufgebaut sein kann Lernen Sie, die häufigsten Fehler zu vermeiden
In diesem Kapitel werden Sie die folgenden vier App-Screens für Ihre »Pummel The Fish«-App aus verschiedenen Widgets zusammenbasteln. Der SplashScreen zeigt das Logo der App. Nach drei Sekunden wird automatisch zum HomeScreen navigiert. Auf dem HomeScreen wird eine Liste an Kuscheltieren, die zu adoptieren sind, aus der Datenbank angezeigt. Wird auf ein Element der Liste geklickt, gelangt die benutzende Person zum DetailPetScreen. Wird der FloatingActionButton geklickt, öffnet sich der CreatePetScreen. Auf dem DetailPetScreen können die Nutzenden mehr Details über das Kuscheltier erfahren. Mit einem Klick auf den Pfeil in der AppBar kann zurück zum HomeScreen navigiert werden. Mit dem CreatePetScreen kann ein Kuscheltier zur Liste auf dem HomeScreen hinzugefügt werden, indem eine Form ausgefüllt und gespeichert wird. Der Aufbau einer solchen App deckt damit die wichtigsten, typischen Basis-Elemente ab: die Anzeige von Daten in einer Liste, der Wechsel in eine Detail-Ansicht sowie das Anlegen von neuen Daten mithilfe einer Form.
Screens anlegen Lassen Sie uns noch mal kurz einen Blick auf das MaterialApp-Widget in der main.dartDatei werfen. Hier können Sie dem Named Parameter home ein Widget übergeben. Wie Sie schon wissen, sind auch die App-Screens selbst Widgets in Flutter. In der BeispielApp kann hier also das Widget MyHomePage übergeben werden, das weiter unten in
derselben Datei definiert wird: import "package:flutter/material.dart"; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: "Flutter Demo", theme: ThemeData( primarySwatch: Colors.blue, ), home: const MyHomePage( title: "Flutter Demo Home Page", ), ); } }
Listing 11.1: Durch den home-Parameter einen Start-Screen festlegen
Wenn Sie in Ihrer Entwicklungsumgebung über dem Namen eines Widgets schweben, zum Beispiel über dem MaterialApp-Widget, wird Ihnen angezeigt, welche Parameter Sie diesem Widget übergeben können, um es nach Ihren Wünschen anzupassen. Anhand der Klammern können Sie erkennen, ob Sie die Parameter als Named Parameter übergeben müssen oder als Positional Parameter. Das MyHomePage-Widget gibt mit seiner build-Methode ein Scaffold-Widget zurück – darum können wir es als »Screen« identifizieren, denn das Scaffold-Widget füllt den gesamten Screen eines Gerätes aus. Dass MyHomePage dem MaterialApp-Widget als homeParameter übergeben wird, führt dazu, dass dieser Screen als erster Screen Ihrer App angezeigt wird. Das sollen Sie jetzt ändern. Das Scaffold-Widget ist aus dem Material-Package und hat also eine AndroidOptik. Wenn Sie stattdessen eine spezifische iOS-Optik möchten, benutzen Sie am besten das CupertinoPageScaffold. Im Folgenden legen Sie erst mal vier Screens an, die Sie dann mit Widgets befüllen. Um die Screens im Emulator ansehen zu können, werden Sie diese anschließend nacheinander dem MaterialApp-Widget übergeben, so wie jetzt das MyHomePage-Widget übergeben
wird, den Emulator launchen und jeweils mit Hot-Restart einen neuen Screen anzeigen. Legen Sie erst einmal im lib-Ordner einen neuen Ordner an und benennen Sie ihn screens. In diesem Ordner werden Sie alle Screens kreieren, die Sie für Ihr App-Projekt brauchen. Ein sehr wichtiger und einfacher Tipp, um Ihre Projektstruktur für Sie selbst und andere lesbar zu halten: Nennen Sie Klassen immer so, dass sie mit dem Dateinamen korrespondieren. In jeder Datei sollte genau eine Klasse sein – es sei denn, die Klassen gehören eng zusammen, wie die beiden Klassen, die nötig sind, um ein StatefulWidget zu kreieren. Eventuell gibt es noch ein Enum oder ein Custom Widget mit eigener Klasse, das nur in dieser Klasse benutzt wird. Ansonsten sollte nicht mehr als eine Klasse in einer Datei sein. Bei der Benennung beachten: Dateiennamen werden kleingeschrieben und wenn sie aus mehreren Wörtern bestehen, werden die Wörter mit Unterstrich voneinander separiert. Klassennamen beginnen hingegen mit Großbuchstaben. Mehrere Wörter werden mit der sogenannten »CamelCase«-Schreibweise verbunden. Das bedeutet, dass die Wörter zusammengeschrieben werden, aber jedes neue Wort mit einem Großbuchstaben beginnt. Beispiele für Dateinamen und die jeweils beinhalteten Klassen: hallo_welt.dart und HalloWelt, home_screen.dart und HomeScreen.
Der erste eigene App-Screen Erstellen Sie hier eine neue Dart-Datei splash_screen.dart. Damit diese Datei auf die Flutter-Widget-Library Zugriff hat, importieren Sie als Erstes flutter/material.dart ganz oben als erste Zeile in der Datei. Dann schreiben Sie eine SplashScreen-Klasse, die von der StatelessWidget-Klasse erbt – denn Sie werden in diesem Screen keine State-Änderungen haben. Die build-Methode sollte ein Scaffold zurückgeben, erst mal nur mit einem AppBar-Widget, in der der Name des Screens steht. So sollte Ihre SplashScreen-Klasse dann aussehen: import "package:flutter/material.dart"; class SplashScreen extends StatelessWidget { const SplashScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("SplashScreen")), ); } }
Listing 11.2: Der SplashScreen Erstellen Sie drei weitere Screens, indem Sie die Dateien detail_pet_screen.dart, home_screen.dart und create_pet_screen.dart im screens-Ordner neu anlegen. Die DetailPetScreen- und die CreatePetScreen-Klassen sollen ebenfalls StatelessWidgets werden. Sie können den Code aus der schon erstellten SplashScreenKlasse einfach copy-pasten und umbenennen. Sie können das manuell machen oder einfacher mit Rechtsklick auf den Klassennamen und RENAME SYMBOL (siehe Abbildung 11.1) oder noch einfacher mit F2. Sie sollten auch jeweils den Text in der AppBar des Screens anpassen.
Abbildung 11.1: Rename Symbol
Der HomeScreen soll ein StatefulWidget werden. Der Aufbau eines StatefulWidgets ist etwas komplexer, es gehört auch immer eine State-Klasse hinzu. Wir empfehlen Ihnen, einfach »stf« zu tippen und im aufpoppenden Hilfemenü STATEFULW zu wählen und so den Code vom Flutter-Plug-in automatisch generieren zu lassen. Um stattdessen ein StatelessWidget zu generieren, tippen Sie »stl« und wählen STATELESSW im Hilfemenü. Natürlich können Sie auch einfach den Code schreiben – aber viele Flutter-Entwickelnde können das nach Jahren noch nicht auswendig schreiben. Haben wir gehört. Freunde haben uns das erzählt. Räusper … So sollte Ihre HomeScreen-Klasse dann aussehen: import "package:flutter/material.dart"; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @override State createState() => _HomeScreenState(); } class _HomeScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("HomeScreen")), ); } }
Listing 11.3: Der HomeScreen
In den PREFERENCES in Visual Studio Code können Sie unter EXTENSIONS Erweiterungen importieren. Wir legen Ihnen sehr die Erweiterung »Awesome Flutter Snippets« ans Herz. Diese wird Ihre Coding-Geschwindigkeit erheblich beschleunigen.
Screens importieren Wenn Sie die Screens erfolgreich angelegt haben, müssen Sie sie nur noch in der main.dart importieren, um sie sich ansehen zu können. Im MaterialApp-Widget in der main.dart-Datei ändern Sie das Widget, das Sie dem Parameter home übergeben, von MyHomePage zu SplashScreen. Wenn Sie anfangen zu schreiben, werden Ihnen von der Entwicklungsumgebung in einem
Popup mögliche Klassen angezeigt. Wenn Sie die SplashScreen-Klasse wählen, wird Ihre Code-Zeile auto-vervollständigt und auch das import-Statement generiert. Wenn Sie nichts auswählen, wird SplashScreen rot unterstrichen. Wenn Sie über diese unerkannte Referenz mit Ihrem Mauszeiger schweben oder hineinklicken und dann die Glühbirne anklicken, die vorne über der Code-Zeile erscheint, öffnet sich ein Tooltip. In diesem finden Sie die Aktion QUICK-FIX. Wenn Sie diese betätigen, werden Ihnen automatisch Fehlerbehebungsoptionen vorgeschlagen. Wählen Sie IMPORT LIBRARY "PACKAGE:PUMMEL_THE_FISH/SCREENS/SPLASH_SCREEN.DART", wie in Abbildung 11.2.
Abbildung 11.2: Quick-Fix-Import
Sie können für den Import von Dateien aus dem eigenen Projekt einen relativen oder einen absoluten Pfad wählen. Im QUICK-FIX werden Ihnen in der Regel beide Alternativen vorgeschlagen. Wählen Sie am besten den absoluten Pfad, dann funktioniert er auch, wenn Sie die Datei verschieben. Der Pfad package:pummel_the_fish/screens/splash_screen.dart ist also zu präferieren gegenüber ../screens/splash_screen.dart. Dies sollte das Problem lösen. Am Anfang der main.dart-Datei ist nun ein importStatement erzeugt worden und die Datei erkennt die Referenz. import "package:pummel_the_fish/screens/splash_screen.dart"; …
home: const SplashScreen(), …
Listing 11.4: SplashScreen importieren Den MyHomePage-Screen brauchen Sie nicht mehr, darum können Sie die gesamte Definition der MyHomePage-Klasse komplett aus der main.dart löschen. Die main.dart-Datei sollte generelle Angaben zu Routing und Theming enthalten. Sie sollten hier besser keinen Screen definieren, denn das wird schnell zu unübersichtlich. Im MaterialApp-Widget legen Sie zwar den ersten Screen fest, dieser aber sollte eine andere Datei referenzieren. Hot-restarten Sie Ihre App, um die frisch getätigten Änderungen sichtbar zu machen. Sie sollten nun einen weißen Screen sehen, auf dem ganz oben in der App-Bar der Titel »Splash Screen« steht, wie in Abbildung 11.3.
Abbildung 11.3: Der noch leere SplashScreen
Tauschen Sie den SplashScreen nacheinander gegen die anderen Screens aus und checken Sie, dass alle erkannt werden, angezeigt werden können und sich der Titel in der App-Bar jeweils ändert.
Pummel The Fish – der SplashScreen Im SplashScreen brauchen Sie allerdings gar keine AppBar. Sie wollen eigentlich einfach einen Screen, auf dem mittig der Name der App angezeigt wird, wie in Abbildung 11.4. Haben Sie schon eine Idee, wie Sie das bewerkstelligen könnten? Sie müssen den Named Parameter appBar plus Inhalt aus dem Scaffold entfernen und stattdessen den body-Parameter aufrufen und befüllen. Versuchen Sie einmal, ein TextWidget einzufügen und es mittig auszurichten. Wie man Widgets mittig ausrichtet, haben Sie schon im Beispiel-Screen gesehen.
Abbildung 11.4: So soll der SplashScreen aussehen.
Wenn Sie bei Ihrem Experiment Erfolg hatten, sollte der Code für den SplashScreen wie folgt aussehen: import "package:flutter/material.dart"; class SplashScreen extends StatelessWidget { const SplashScreen({super.key}); @override Widget build(BuildContext context) { return const Scaffold( body: Center( child: Text("Pummel The Fish"), ), ); } }
Listing 11.5: Der SplashScreen mit zentriertem Text
Scaffold – ein hübsches Skelett Ein Scaffold stellt die Basis für einen Screen zur Verfügung. Wenn Sie mit der Maus über das Scaffold-Widget wandern, können Sie sehen, welche optionalen Parameter Ihnen zur Verfügung gestellt werden. Am häufigsten werden Sie wahrscheinlich den bodyParameter benutzen. Hier fügen Sie alle Widgets ein, die auf dem Hauptteil des Screens sichtbar sein sollen. Der appBar-Parameter gibt Ihnen die Möglichkeit, eine AppBar hinzuzufügen. Auch diese kann mit Parametern, wie zum Beispiel einem Titel oder verschiedenen Actions konfiguriert werden. Das Scaffold selbst bietet allerdings auch noch weitere Parameter, mit denen zum Beispiel eine BottomBar, ein BottomSheet zum Herausziehen oder ein FloatingActionButton, der unten rechts über dem Screen-Inhalt schwebt, hinzugefügt werden können.
SafeArea – sicher ist sicher Das erste Widget, das in jedem body liegen sollte, ist das SafeArea-Widget. Es stellt sicher, dass bei einem Smartphone mit einer Notch, soll heißen einer Bildschirmaussparung um die Kamera herum wie in Abbildung 11.5, keine wichtigen Screen-Elemente überdeckt werden.
Abbildung 11.5: Bildschirmaussparung
Die SafeArea kann ein Widget über ihren child-Parameter aufnehmen und dient somit als Wrapper für den sichtbaren Screen-Inhalt: @override Widget build(BuildContext context) { return const Scaffold( body: SafeArea( child: Center( child: Text("Pummel The Fish"), ), ), ); }
Listing 11.6: Der SplashScreen mit SafeArea
Container – das Widget für jede Gelegenheit Eines der grundlegendsten und flexibelsten Widgets in Flutter ist der Container. Wenn Sie aus der Webentwicklung kommen, können Sie sich den Container wie ein div in HTML vorstellen. Er ist ein flexibles Element, mit dem Inhalte arrangiert und dekoriert werden können. Ein Container kann beispielsweise eine Höhe und eine Breite haben, Abstände nach innen und außen sowie verschiedene Dekorationsmerkmale wie eine Fülloder Randfarbe oder abgerundete Ecken. Auch der Container kann ein Widget über seinen child-Parameter aufnehmen. Wenn Sie nun zum Beispiel das Text-Widget in einem roten Quadrat angezeigt bekommen möchten, können Sie das Text-Widget mit einem Container-Widget wrappen und diesen mit Eigenschaften versehen. Entfernen Sie einmal das Center-Widget und setzen Sie an seiner Stelle ein Container-Widget. Geben Sie dem Container zum Beispiel eine Höhe und eine Breite von 200 und eine rote Hintergrundfarbe:
@override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: Container( height: 200, width: 200, color: Colors.red, child: const Text("Pummel The Fish"), ), ), ); }
Listing 11.7: Der SplashScreen mit rotem Container und Text Das Scaffold kann jetzt übrigens nicht mehr als const deklariert werden, da das Container-Widget keinen const-Konstruktor vorzuweisen hat. Entfernen Sie das constKeyword daher einfach. Wenn Sie jetzt einen Hot-Restart machen, sollten Sie sehen, wie sich der rote Container in der obere linke Ecke seines Parents, dem SafeArea-Widget, platziert. Auch das TextWidget richtet sich zur oberen linken Ecke seines Parents aus (siehe Abbildung 11.6). Wenn Sie einen Container haben, der nur den color-Parameter verwendet, können Sie stattdessen auch das ColoredBox-Widget verwenden. Wenn Sie einen Container haben, der nur den decoration-Parameter füllt, können Sie ein DecoratedBoxWidget statt des Container verwenden. Wenn Sie einen Container haben, der nur Höhen- und oder Breiten-Angaben benötigt, können Sie eine SizedBox verwenden. Sowohl ColoredBox als auch DecoratedBox und SizedBox besitzen einen constKonstruktor und müssen dadurch bei einem Rebuild nicht erneut gebaut werden. Das kann – je nach App-Größe – zu einer verbesserten Performance führen.
Abbildung 11.6: Ein roter Container
Jetzt sind Sie gerade schon so im Flow, fügen Sie noch ein Padding hinzu und entfernen Sie die Größenangabe des Containers, um zu schauen, was passiert: @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: Container( padding: EdgeInsets.all(20), color: Colors.red, child: const Text("Pummel The Fish"), ), ), ); }
Listing 11.8: Der rote Container ohne Größe Sie können nun sehen, dass, wie in Abbildung 11.7, der Container ohne Größenangaben die Größe seines child-Widgets annimmt – plus das Padding natürlich.
Abbildung 11.7: Der rote Container nimmt die Größe seines child-Widgets an.
Probieren Sie noch etwas herum mit den optionalen Parametern des Containers – verändern Sie Größe, Padding, Margin, Farbe und versuchen Sie, dem Container runde Ecken zu geben. Manchmal werden Sie in Flutter verzweifelt versuchen, Widgets eine bestimmte Größe zu geben. Vergeblich, denn viele Widgets besitzen keine height- und widthParameter und orientieren sich an der Größe ihres Parent- oder Child-Widgets. Manchmal möchten Sie vielleicht auch keine konkrete Größe vergeben, sondern erwarten eine automatische Anpassung der Größe an das jeweilige Gerät und dessen Auflösung – auch »responsive« genannt. An dieser Stelle möchten wir Ihnen zu ein wenig Geduld raten. Sie werden das unterschiedliche Verhalten mit der Zeit lernen, wenn Sie Screens und Widgets bauen. Wenn ein Widget sich nicht wie erwartet verhält, denken Sie daran, Parent- und Child-Widget beim Troubleshooting nicht außer acht zu lassen.
Center – finden Sie Ihre Mitte Das mit dem roten Container in der linken oberen Ecke ist ja ganz nett, aber sind wir mal ehrlich – zentriert würde das Ganze viel besser aussehen. Das Center-Widget haben Sie bereits verwendet, warum haben Sie das eigentlich entfernt? Wahrscheinlich, weil wir es Ihnen gesagt haben. Na ja, sei's drum, lassen Sie uns dem Container wieder eine Höhe und eine Breite geben und den Text darin zentrieren. Damit auch der rote Container selbst zentriert ist und nicht nur der Text darin, muss zusätzlich auch das Container-Widget von einem Center-Widget umklammert werden: body: SafeArea( child: Center( child: Container( height: 200, width: 200, color: Colors.red, child: const Center( child: Text("Pummel The Fish"), ), ), ), ),
Listing 11.9: Der SplashScreen-body mit rotem zentriertem Container und Text Sehr schön, jetzt sieht das fast schon aus, wie der Start-Screen einer App aussehen sollte (siehe Abbildung 11.8).
Abbildung 11.8: Der rote Container mit Text zentriert
Das war jetzt eine schöne Übung, um zu verstehen, wie man in Flutter mit Widgets einen Screen bauen kann. Aber brauchen tun wir das eigentlich gar nicht, was Sie hier gebaut haben. Denn noch netter als ein langweiliger roter Container wäre doch das passende App-Logo? Im folgenden Kapitel wollen wir darum unseren Container mit einem ImageWidget ersetzen.
Image – viele bunte Bilder Was wäre eine App ohne ein schönes Logo! Wir haben daher keine Kosten und Mühen gescheut und Ihnen ein Logo für Ihre »Pummel The Fish«-App gebastelt, das Sie gerne verwenden dürfen, um sie aufzuhübschen. Das App-Logo ist eine .png-Datei mit der URL https://losfluttern.de/pummelthefish/logo.png. Eine Version mit farbigem
Hintergrundverlauf liegt auf https://losfluttern.de/pummelthefish/logo-bg.png. Es gibt zwei beliebte Arten, Fotos per Widget einzubinden – entweder kann ein Foto aus dem Internet via einer URL dynamisch angezeigt werden oder Sie speichern ein Foto in dem assets-Ordner Ihrer App. Dafür hat das Image-Widget verschiedene Konstruktoren: Image.network und Image.asset.
Image.network Probieren Sie zuerst einmal aus, ein Foto aus dem Internet zu laden und direkt anzuzeigen. Dazu löschen Sie im body-Parameter des Scaffold-Widgets alles außer dem SafeArea-Widget. Sie sollen jetzt in die SafeArea ein Foto einfügen. Dafür geben Sie dem child-Parameter ein Image-Widget. Um ein Foto aus dem Internet anzuzeigen, wählen Sie den .network-Konstruktor. Dem Image-Widget übergeben Sie die eben genannte URL zu dem Logo mit Hintergrund. Sie können auch einfach nach Ihrem Lieblings-Tierbild googeln und es alternativ hier verlinken. body: SafeArea( child: Image.network( "https://losfluttern.de/pummelthefish/logo-bg.png" ), ),
Listing 11.10: Image.network ohne Größenangabe Das Foto nimmt jetzt in der Breite den gesamten vorhandenen Platz ein und füllt entsprechend viel von der Höhe, um die korrekte Ratio zu behalten. Will heißen, es wird maximal groß, aber dabei unverzerrt angezeigt. Um einmal genauer zu erkunden, wie ein Foto sich zu seinem Parent-Widget verhalten kann und wie Sie Größe und Ratio festlegen können, holen wir unseren roten Container zurück. Packen Sie das Foto in einen Container mit 300 Pixeln Höhe und Breite und
zentrieren Sie diesen. Geben Sie dem Container eine beliebige Farbe, zum Beispiel rot, damit sichtbar wird, wo das Foto ihn nicht bedeckt: body: SafeArea( child: Center( child: Container( height: 300, width: 300, color: Colors.red, child: Image.network( "https://losfluttern.de/pummelthefish/logo-bg.png" ), ), ), ),
Listing 11.11: Image.network in einem Container Wenn Sie nun noch einmal speichern und den Hot-Reload-Button betätigen, sehen Sie (Abbildung 11.9), dass das Bild im Container die volle Höhe ausfüllt, aber nicht die volle Breite (zumindest, wenn Sie unser Bild anzeigen – oder wenn Ihr gewähltes Bild ebenfalls höher als breiter ist).
Abbildung 11.9: Pummel im Container
Die Ratio ist hier wie gehabt in Abbildung 11.9, und das Bild ist nicht beschnitten. Das können Sie ändern, indem Sie dem Image-Widget einen fit-Parameter geben: Image.network( "https://losfluttern.de/pummelthefish/logo-bg.png", fit: BoxFit.fitWidth, ),
Listing 11.12: Image.network mit fit-Parameter Jetzt bedeckt das Foto die ganze Breite des Containers, wird aber in der Höhe entweder abgeschnitten oder füllt den Container nicht ganz aus – je nachdem, welche Ratio Ihr Foto hat. Das »Pummel The Fish«-Logo ist mehr hoch als breit und wird darum den quadratischen Container in der Höhe nicht ausfüllen (siehe Abbildung 11.10).
Abbildung 11.10: Pummel im Container mit fit-Parameter
Probieren Sie etwas mit dem fit-Parameter herum. Boxfit.fill wird das Bild verzerren, damit es passt. Boxfit.cover füllt den Container ebenfalls, aber schneidet das Foto ab, wo es übersteht, anstatt es zu verzerren. Probieren Sie auch einmal aus, was passiert, wenn Sie dem Image direkt eine Höhe geben, dem Container dagegen nicht. Es gibt viele Möglichkeiten, die Größe und Ratio eines Fotos zu beeinflussen. Was Ihnen hier wichtig sein sollte: Achten Sie auf Responsiveness. Das Foto muss auf verschiedenen Screen-Größen und vielleicht auch in vertikaler und horizontaler Ausrichtung gut aussehen. Am besten nicht Höhe und Breite setzen, sondern nur eines von beiden und einen fit-Parameter, der es dem Foto erlaubt, sich an das Parent-Widget anzupassen, ohne zu stretchen und sich damit zu verzerren.
Image.asset Wenn Sie sichergehen wollen, dass Ihr Bild auch bei wackeliger Internetverbindung schnell angezeigt wird, ist es besser, Sie importieren die Bilddatei in Ihre App. Alle Fotos, Grafiken, Fonts und andere UI-relevante Dateien, die für Ihre App benötigt werden, sammeln Sie am besten in einem dafür vorgesehenen Ordner namens assets. Platzieren Sie diesen Ordner neben dem lib-Ordner, direkt auf erster Ebene in Ihrem Projekt. Erstellen Sie darin einen Ordner namens images für Ihre Bilder. Laden Sie das »Pummel The Fish«-Logo ohne Hintergrund von dieser URL herunter: https://losfluttern.de/pummelthefish/logo.png. Ziehen Sie das Logo mit Dragand-drop in Ihren images-Ordner. Ihre Ordnerstruktur sollte nun aussehen wie in Abbildung 11.11.
Abbildung 11.11: Ordnerstruktur mit Logo im images-Ordner
Damit die App das Bild »kennt«, muss es in der pubspec.yaml-Datei gelistet werden. Vorher können Sie es nicht in Ihren Screens verwenden. Öffnen Sie die pubspec.yaml und in der Sektion unter flutter listen Sie Ihre hinzugefügte Datei auf. Vergessen Sie nicht, danach die Datei zu speichern, um ein automatisches flutter pub get auszulösen. flutter: assets: - assets/images/logo.png
Listing 11.13: In der pubspec.yaml Assets einzeln hinzufügen Sie können die Datei einzeln listen oder gleich den ganzen images-Ordner. flutter: assets: - assets/images/
Listing 11.14: In der pubspec.yaml den assets-Ordner hinzufügen
Bitte achten Sie auf die Einrückung in der pubspec.yaml-Datei, die muss hier auf das Leerzeichen genau sein, sonst gibt es Fehler.
Jetzt können Sie das Bild im SplashScreen einbinden und es zentrieren. Außerdem soll der Hintergrund des Scaffolds blau werden: body: SafeArea( child: Center( child: Image.asset( "assets/images/logo.png", ), ), ), backgroundColor: Colors.blue,
Listing 11.15: Image.asset Ihr SplashScreen sollte jetzt wie in Abbildung 11.12 aussehen.
Abbildung 11.12: Der SplashScreen
Padding – ein bisschen Polster hat noch nie jemandem geschadet Wir wollen ja kein Bodyshaming betreiben, aber Pummel sieht hier wirklich etwas breit aus. Es gibt verschiedene Möglichkeiten, damit das Logo etwas mehr Abstand zum Rand des Screens bekommt – einige haben Sie schon kennengelernt. Sie können dem ImageWidget beispielsweise selbst eine kleinere Größe geben oder es in einen Container wrappen und dem Container eine Größe oder ein Padding geben. Es gibt aber auch ein Widget, das extra dafür da ist, Padding zu definieren: das Padding-Widget: body: SafeArea( child: Center( child: Padding( padding: const EdgeInsets.all(64), child: Image.asset("assets/images/logo.png "), ), ), ),
Listing 11.16: Padding rundherum Starten Sie den Emulator und betrachten Sie Ihren wunderschönen SplashScreen! Er sollte nun der Abbildung 11.13 ähneln. Pummel sieht jetzt schlanker aus und das UI ist damit auch schon fertig. Später werden wir den SplashScreen noch nach einigen Sekunden automatisch zum HomeScreen navigieren lassen.
Abbildung 11.13: Der SplashScreen mit Padding
Sie können dem Padding-Widget denselben Pixelwert für alle Seiten geben, wie hier getan. Sie können aber auch das vertikale und horizontale Padding separat definieren oder auch für jede Seite einzelne Werte angeben. Probieren Sie gern etwas herum! padding: const EdgeInsets.symmetric( vertical: 24, horizontal: 96, ),
Listing 11.17: Ein Beispiel für vertikales und horizontales Padding padding: const EdgeInsets.only( bottom: 8, top: 32, right: 24, left: 16, ),
Listing 11.18: Ein Beispiel für einzeln definiertes Padding Als Nächstes wenden Sie sich dem Herzstück Ihrer App zu – Sie werden den HomeScreen bauen und dabei einige neue Widgets kennenlernen!
Pummel The Fish – der HomeScreen Um den HomeScreen auf Ihrem Emulator sehen zu können, müssen Sie den homeParameter in der main.dart von SplashScreen auf HomeScreen ändern und die dazugehörige Datei importieren. Auf dem HomeScreen Ihrer App soll später eine Liste von Kuscheltieren zu sehen sein, die zur Adoption freigegeben sind. Wenn Sie auf eines der Tiere klicken, sollen die Anwendenden später zum DetailPetScreen weitergeleitet werden, wo mehr Information zu dem jeweiligen Tier zu sehen sind. Außerdem gibt es einen FloatingActionButton, der per Klick den CreatePetScreen öffnen soll.
Die wichtigsten Layout-Widgets Sie haben bisher gelernt, wie einzelne Widgets ineinander geschachtelt werden. Für die meisten User Interfaces, und so auch den HomeScreen, werden Sie jedoch mehrere Widgets nebeneinander oder untereinander oder auch aufeinander anzeigen müssen. In diesem Kapitel werden Sie einige Widgets benutzen, die mehrere Children aufnehmen und anordnen.
Die Dreifaltigkeit – Column, Row und Stack Die wichtigsten Widgets, um mehrere Widgets zu ordnen und so den Screen zu layouten, sind das Row-Widget für horizontale Anordnung und das Column-Widget für vertikale
Anordnung. Beide Widgets können über ihren Parameter children eine Liste von Widgets aufnehmen. Fügen Sie eine Column in den body-Parameter des Scaffold-Widgets im HomeScreen ein. body: Column( children: [ const Text("Ein Text"), Container( height: 50, width: 30, color: Colors.blue, ), const Text("Noch ein Text"), ], ),
Listing 11.19: Ein Column-Widget Schauen Sie, was der Emulator Ihnen anzeigt – sieht Ihr Screen aus wie in Abbildung 11.14?
Abbildung 11.14: Das Column-Widget
Ändern Sie das Column-Widget zu einem Row-Widget, ohne die Widget-Liste im children-Parameter zu ändern. body: Row( children: [ const Text("Ein Text"), Container( height: 50, width: 30, color: Colors.blue, ), const Text("Noch ein Text"), ], ),
Listing 11.20: Das Row-Widget Die Widgets werden jetzt horizontal und nicht mehr vertikal angeordnet (siehe Abbildung 11.15).
Abbildung 11.15: Das Row-Widget
Für das Layout des HomeScreens werden wir erst einmal Columns und Rows verwenden. Wenn Widgets nicht nebeneinander, sondern übereinander – also auf der Z-Achse – angeordnet werden sollen, kann der Stack helfen, denn er stapelt Widgets aufeinander. body: Stack( children: [ const Text("Ein Text"), Container( height: 50, width: 30, color: Colors.blue, ), const Text("Noch ein Text"), ], ),
Listing 11.21: Das Stack-Widget Das sieht in diesem Fall etwas unschön aus, wie Sie in Abbildung 11.16 sehen.
Abbildung 11.16: Das Stack-Widget
Das müsste noch besser angeordnet werden – wie das funktioniert, erfahren Sie später noch. Das Stack-Widget werden Sie im Unterkapitel Pummel The Fish – der DetailPetScreen besser kennenlernen.
Dynamisch Widgets anzeigen Für dynamische Daten, also Daten, deren Menge sich zum Beispiel ändern kann, bietet Flutter einige Layout-Widgets mit einem .builder-Konstruktor. Dem kann man sagen, wie ein Datensatz aussieht und angeordnet werden soll. Alle Datensätze innerhalb eines solchen Layout-Widgets werden in dasselbe WidgetKonstrukt verpackt. Definieren Sie, dass jeweils ein roter Container mit dem Namen des Datensatzes angezeigt werden soll, gilt das für alle Elemente, die Sie dem Widget übergeben. Alle bekommen einen roten Container um ihren Namen. Ein ListView-Widget sieht ähnlich aus wie eine Column oder eine Row, je nach gewählter scrollDirection (Standard ist vertikales Scrollen), erlaubt aber mit dem .builderKonstruktor zusätzlich das Präsentieren dynamischer Daten. Definieren Sie eine Liste mit mehreren String-Variablen darin und zeigen Sie alle Listen-Elemente untereinander in gelben Containern an. Ändern Sie dafür die _HomeScreenState-Klasse wie folgt. class _HomeScreenState extends State { List names = [ "Pummel", "Bruno", "Leonie", "Harribart", ]; @override Widget build (BuildContext context) { return Scaffold( appBar: AppBar( title: const Text("HomeScreen"), ), body: ListView.builder( itemCount: names.length, itemBuilder: (BuildContext context, int index) { return Container( height: 50, width: 30, color: Colors.yellow, child: Text(names[index]), ); }, ), );
} }
Listing 11.22: Das ListView-Widget mit builder-Konstruktor Überprüfen Sie auf Ihrem Emulator, ob Ihr Screen der Abbildung 11.17 gleicht.
Abbildung 11.17: Der HomeScreen mit ListView
Schön, jetzt haben Sie schon einmal gesehen, wie der .builder-Konstruktor des ListView-Widgets funktioniert. Wir werden den HomeScreen später noch so umbauen, dass die Pet-Objekte in einem ListView angezeigt werden. Kurz erwähnt sei hier noch das GridView-Widget: Sie werden es für unsere App nicht benutzen, es ist aber zum Beispiel praktisch, wenn dynamische Daten in einem Gitter angeordnet werden sollen. Abstände zwischen den Elementen und Größen der Elemente lassen sich über die Parameter des GridView einstellen. Probieren Sie es doch einmal aus!
Die initState-Methode Der HomeScreen soll eine Liste von Tieren anzeigen. Es sollen vier Tiere in einer Spalte untereinander angezeigt werden, jeweils mit Namen und Alter pro Reihe. Das Pet-Model, welche alle Informationen für die Darstellung eines Tieres in der Liste enthält, haben Sie dazu schon in Kapitel 4, »Pfeilschnell programmieren mit Dart«, angelegt. Die dynamischen Daten integrieren Sie hier später, wenn Sie lernen, wie Sie externe Daten über Schnittstellen importieren. Für diese Übung reicht es aus, Fake-Daten zu verwenden, dafür können Sie das FakePetRepository nutzen, das Sie schon angelegt haben. Über die Methode getAllPets des FakePetRepositories bekommen Sie eine Liste mit vier Pet-Objekten. Aber wo genau sollen wir diese Methode verwenden, um eine Liste mit Tieren im HomeScreen für die spätere Darstellung zu befüllen? Für die Initialisierung von Variablen ist die initState-Methode am besten geeignet. Die initState-Methode ist eine der Lifecycle-Methoden, die in einem StatefulWidget überschrieben werden können. Die initState-Methode wird vor der build-Methode aufgerufen, sobald das Widget in den Widget-Baum eingepflegt wird. Die initStateMethode wird exakt einmal bei der Erstellung ausgeführt (im Gegensatz zur buildMethode, die häufiger aufgerufen werden kann). Darum eignet sie sich gut für unsere Zwecke, um eine pets-Liste zu befüllen. Die initState -Methode benötigt einen super.initState-Aufruf direkt zu Beginn. class _HomeScreenState extends State { final petRepository = FakePetRepository(); List pets = []; @override void initState() { super.initState(); pets = petRepository.getAllPets(); } @override Widget build(BuildContext context) { // Den Screen bauen
} }
Listing 11.23: Die getAllPets-Methode einer FakePetRepository-Instanz aufrufen Der Gegenspieler der initState-Methode ist übrigens die dispose-Methode. Sie wird aufgerufen, wenn das Widget aus dem Baum entfernt wird und dient dazu, möglicherweise geöffnete Streams oder Controller (zum Beispiel TextEditingController oder AnimationController) zu schließen. Die disposeMethode wird aufgerufen, wenn Sie den Screen verlassen.
Eine fancy AppBar Die AppBar werden Sie nun noch etwas aufhübschen: mit Pummel natürlich. Von ihm können wir gar nicht genug bekommen. Wir haben Ihnen unseren Lieblingsfisch noch einmal ohne Hintergrund und ohne Klimbim zur Verfügung gestellt: https://losfluttern.de/pummelthefish/pummel.png. Laden Sie sich das Bild herunter, speichern Sie es in Ihrem images-Ordner und deklarieren Sie es in der pubspec.yaml-Datei wie gehabt. Anschließend können Sie das Bild in die AppBar des HomeScreens (und später in auch in die AppBars der anderen Screens) einbauen. return Scaffold( appBar: AppBar( leading: Padding( padding: const EdgeInsets.only(left: 16), child: Image.asset("assets/images/pummel.png"), ), title: const Text("Pummel The Fish"), ), );
Listing 11.24: Der HomeScreen mit fancy AppBar
Columns und Rows – wir bauen einen Screen! Ihre pets-Liste ist mit Pet-Objekten gefüllt und Sie sind bereit: Sie können jetzt in der Widget build-Methode darauf zugreifen und die Pets geordnet anzeigen. Fügen Sie in den body-Parameter des Scaffolds eine SafeArea ein und darin eine Column mit vier Rows. In den Rows holen Sie die Pet-Objekte mithilfe ihres Index aus der Liste und zeigen jeweils ihren Namen und ihr Alter an. SafeArea( child: Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ Text(pets[0].name),
Text("${pets[0].age}"), ], ), Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ Text(pets[1].name), Text("${pets[1].age}"), ], ), Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ Text(pets[2].name), Text("${pets[2].age}"), ], ), Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ Text(pets[3].name), Text("${pets[3].age}"), ], ), ], ), ),
Listing 11.25: HomeScreen-body mit Column und Rows Ihr Screen sollte auf dem Emulator nun aussehen wie in Abbildung 11.18.
Abbildung 11.18: HomeScreen mit Column und Rows
Die AppBar sieht schon echt schick aus – aber am Body müssen Sie noch etwas arbeiten.
FloatingActionButton – und … Action! Der FloatingActionButton schwebt in der Regel rechts unten über dem Screen-Inhalt. Er kann als child ein beliebiges Widget aufnehmen. Üblich ist hier ein Text- oder ein IconWidget. Der Button kommt mit einigen Parametern, die das Aussehen des Buttons verändern können, und einem onPressed-Parameter, für den Sie eine Funktion definieren können, die bei Klick auf den Button ausgeführt wird. Fügen Sie dem HomeScreen einen FloatingActionButton hinzu. Der Button sollte ein »Plus«-Icon anzeigen und vorerst eine leere Funktion triggern. return Scaffold( appBar: …, body: …, floatingActionButton: FloatingActionButton( onPressed: () {}, child: const Icon(Icons.add), ), );
Listing 11.26: Der FloatingActionButton im HomeScreen Sie sollten den neuen Button jetzt auf dem Emulator sehen können, wie in Abbildung 11.19.
Abbildung 11.19: HomeScreen mit FloatingActionButton
Wenn wir später die Navigation besprechen, werden Sie die Funktion des Buttons noch anpassen, sodass ein Klick auf den Button die benutzende Person zum CreatePetScreen führt.
Icon – this is iconic! Wie Sie sehen, hat Flutter eine eigene Icon-Kollektion, aus der Sie sich im vorigen Beispiel bedienen, um das Plus auf dem FloatingActionButton anzuzeigen. Wenn Sie im Code das Wort add hinter Icons. entfernen, wird Ihnen ein Dropdown mit einer großen Auswahl an alternativen Icons angeboten. Probieren Sie doch ein paar aus! Zum Beispiel Icons.remove, Icons.edit, Icons.send – und viele andere. Wenn Sie für eine App eigene Icons verwenden wollen, können Sie diese natürlich auch einfach wie ein Bild mit Image.asset hinzufügen. Oder Sie suchen sich ein externes Open-Source-Package mit einer Icon-Sammlung, das Sie über die pubspec.yaml-Datei einbinden.
ListView.builder – der fleißige Builder nimmt Ihnen die Arbeit ab Wirklich schön sind die Listenelemente ja noch nicht. Bevor Sie jetzt anfangen, die einzelnen Rows aufzuhübschen, warten Sie noch einmal kurz ab. Nachhaltig ist dieser Ansatz nicht, denn für jedes Listenelement müsste man Code duplizieren. Was passiert außerdem, wenn es mehr Elemente in der Pet-Liste werden? Wenn Sie so viele Elemente in Ihre Column aufnehmen möchten, dass die Column höher wird als der Screen, sollten Sie diese scrollbar machen. Das ist möglich, indem Sie die Column mit einem SingleChildScrollView umschließen. Bei einer Pet-Liste mit einer festgelegten Anzahl an Elementen wäre das eine mögliche Herangehensweise. Ihre Pet-Liste soll aber dynamisch befüllt werden. Da Apps häufig mit dynamischen Daten arbeiten, hat Flutter eine elegantere Lösung für dieses Problem schon integriert. Sie haben sie oben schon kurz kennengelernt: Das ListView-Widget mit seinem .builder Konstruktor. Das ListTile-Widget eignet sich hervorragend, um die Listenelemente darzustellen. Das ListView-Widget ist automatisch scrollbar, wenn es höher als der Screen eines Gerätes wird. Wenn es sich um viele Elemente handelt, gibt es auch einen PerformanzVorteil gegenüber der Herangehensweise SingleChildScrollView plus Column: Ein ListView malt nur die Elemente, die gerade auf dem Screen sichtbar sind. Der .builder-Konstruktor hat eine integrierte for-Schleife, mit der Sie durch eine Liste iterieren und pro Element ein Widget bauen können. Das heißt, Sie können durch die PetListe iterieren und für jedes Pet-Objekt zum Beispiel eine Row auf die immer gleiche Art
und Weise darstellen. Statt einer Row bietet sich aber das eigens von Flutter hierfür angedachte ListTile-Widget an. Es bietet viele Parameter an, die ein typisches Listenelement meistens braucht. Der HomeScreen mit ListView und ListTiles sieht im body so aus: body: SafeArea( child: ListView.builder( padding: const EdgeInsets.all(20), itemCount: pets.length, itemBuilder: (context, index) { return ListTile( leading: const Icon(Icons.pets), title: Text(pets[index].name), subtitle: Text( "Alter: ${pets[index].age} Jahre ", ), trailing: const Icon( Icons.chevron_right_rounded, ), onTap: () {}, ); }, ), ),
Listing 11.27: HomeScreen mit ListView und ListTiles Der Emulator sollte Ihnen nun die Daten des FakePetRepository in einer schöneren Variante zeigen, wie hier in Abbildung 11.20.
Abbildung 11.20: Der HomeScreen mit ListView und ListTiles
Jetzt sieht Ihr HomeScreen schon richtig schick aus! Die ListTiles haben noch eine leere Funktion in ihrem onPressed-Parameter. Hier soll später der DetailPetScreen zu dem jeweiligen Pet aufgerufen werden. Diesen Screen werden Sie als Nächstes bauen.
Pummel The Fish – der DetailPetScreen Einige Attribute der Pet-Objekte werden schon in den ListTile-Widgets des HomeScreens angezeigt. Wenn die App-Anwendenden auf ein ListTile-Widget klicken, dann tun sie das, weil sie noch mehr Informationen zu dem jeweiligen Pet sehen wollen. Im DetailPetScreen sollen darum alle Attribute eines Pets angezeigt werden. Sie werden dazu einige Widgets benutzen, die Sie zum großen Teil schon kennengelernt haben.
Einem Screen Daten übergeben Der DetailPetScreen ist ein StatelessWidget. Er zeigt Details eines Pet-Objekts an. Im Konstruktor soll darum ein Pet-Objekt übergeben werden, das nicht null sein kann – denn ohne ein Pet ergibt dieser Screen keinen Sinn. Um das sicherzustellen, fügen Sie dem Konstruktor einen required-Parameter hinzu, der ein Pet-Objekt entgegennimmt, das nicht nullable ist. class DetailPetScreen extends StatelessWidget { final Pet pet; const DetailPetScreen({super.key, required this.pet}); …
Listing 11.28: Daten übergeben zum DetailPetScreen Wenn Sie später den DetailPetScreen vom HomeScreen aus aufrufen werden, werden Sie immer ein Pet-Objekt übergeben müssen, da der Parameter nicht optional ist. Da das zu übergebende Pet-Objekt auch nicht nullable ist, sind keine Nullchecks im DetailPetScreen mehr nötig.
Screen Layout mit alten Bekannten Auf dem DetailPetScreen soll ein Foto zu sehen sein mit einer darüberliegenden halbtransparenten Textbox und einigen Textinformationen unterhalb des Fotos. Der Screen kann einfach mit Columns, Rows und einem Stack-Widget gelayoutet werden. Der fertige DetailPetScreen soll aussehen wie in Abbildung 11.21. Sie beginnen zunächst mit dem Grundgerüst des Screens und arbeiten sich dann Detail für Detail weiter vor. Als Erstes geben Sie den Namen des Pet-Objekts, dass Sie über den Konstruktor bekommen haben, in der AppBar aus.
Abbildung 11.21: Der DetailPetScreen
Dann erstellen Sie im body-Parameter des Scaffolds eine Column und fügen als oberstes Child ein Image-Widget hinzu, das sich ein Foto von der URL https://losfluttern.de/pummelthefish/dog.jpg holt. Sie können entweder den .network- oder den .asset-Konstruktor benutzen – je nachdem, was Ihnen lieber ist. Wir benutzen hier den .asset-Konstruktor und legen das Bild wie bereits erklärt im assetFolder ab und referenzieren es in der pubspec.yaml. return Scaffold( appBar: AppBar( title: Text(pet.name), ), body: SafeArea( child: Column( children: [ Image.asset("assets/images/dog.png"), ], ), ), );
Listing 11.29: Column mit Image im DetailPetScreen Unter dem Bild sollen jetzt einige Zeilen Text untereinander angezeigt werden. Da Sie dem nun folgenden Inhalt ein Padding geben wollen, fügen Sie eine weitere Column ein und wrappen diese in ein Padding-Widget. Verwenden Sie den EdgeInsets.symmetricKonstruktor, um ein vertikales Padding von 40 Pixel und horizontales Padding von 24 Pixel zu erreichen. return Scaffold( appBar: AppBar( title: Text(pet.name), ), body: SafeArea( child: Column( children: [ Image.asset("assets/images/dog.png"), Padding( padding: const EdgeInsets.symmetric( vertical: 40, horizontal: 24, ), child: Column( children: [ // Hier kommen gleich noch 3 Rows hinein ], ), ), ],
), ), );
Listing 11.30: DetailPetScreen mit noch leerer Content-Column In der Column erstellen Sie nun drei Rows. In jeder Row sollen zwei Text-Widgets angezeigt werden, links ein Label und rechts der zugehörige Inhalt. Schreiben Sie fürs Erste einfach einen beliebigen Platzhaltertext hinein. Mit dem Parameter MainAxisAlignment.spaceBetween werden die Children der Row zu beiden horizontalen Enden der Row geschoben. Mit Klick auf ROW und die Glühbirne, die dann erscheint, rufen Sie das Menü auf und wrappen jede Row in ein Padding-Widget. Sie können den Default-Wert von 8 Pixeln übernehmen. Ihre Rows sollten wie folgt aussehen. Padding( padding: const EdgeInsets.all(8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: const [ Text("Platzhalter links"), Text("Platzhalter rechts"), ], ), ),
Listing 11.31: Row im DetailPetScreen In Abbildung 11.22 sehen Sie, wie Ihr DetailPetScreen nun aussieht.
Abbildung 11.22: Der DetailPetScreen ist halb fertig.
Schon ganz gut – aber das geht noch hübscher!
Card – das leicht abgehobene Widget Die Informationen sind jetzt sinnvoll auf dem Screen angeordnet und mit etwas Abstand sieht alles viel übersichtlicher aus – wenn auch noch etwas langweilig. Das ändern Sie nun mit einem sehr praktischen Widget: dem Card-Widget. Wrappen Sie die drei Padding-Widgets mit Ihren Rows jeweils wieder um in ein CardWidget. Damit wird der Inhalt optisch angehoben (auf Englisch: elevation) und die Ecken werden abgerundet. Jetzt können Sie auch den richtigen Inhalt einfügen – links das jeweilige Label und rechts Name, Alter, Größe und Gewicht des jeweiligen Pet-Objekts. Der Code ist jetzt schon beachtlich lang. Ihnen wird vielleicht auffallen, dass er einige Duplizierungen enthält. Das kümmert uns aber aktuell nicht, denn das werden Sie später noch eleganter umschreiben. Card( child: Padding( padding: const EdgeInsets.all(8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text("Name des Kuscheltiers:"), Text(pet.name), ], ), ), ), Card( child: Padding( padding: const EdgeInsets.all(8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text("Alter:"), Text("${pet.age} Jahre"), ], ), ), ), Card( child: Padding( padding: const EdgeInsets.all(8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
const Text("Größe & Gewicht:"), Text("${pet.height} cm / ${pet.weight} Gramm"), ], ), ), ),
Listing 11.32: Die drei Cards im DetailPetScreen Wenn Sie sich Ihren DetailPetScreen im Emulator anschauen möchten, ist das diesmal allerdings nicht ganz so einfach, denn Sie müssen dafür ein Pet-Objekt übergeben. Aber keine Sorge, wir eilen im nächsten Schritt zu Hilfe!
Aufrufen des Screens mit Datenübergabe Damit Sie den DetailPetScreen im Emulator sehen können, müssen Sie für den Aufruf in der main.dart vorher ein Pet-Objekt erstellen und mitgeben. Wie das funktioniert, sehen Sie im folgenden Code-Abschnitt: import "package:flutter/material.dart"; import "package:pummel_the_fish/data/models/pet.dart"; import "package:pummel_the_fish/screens/detail_pet_screen.dart"; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { const brunoTheDog = Pet( id: "2", name: "Bruno", species: Species.dog, weight: 320.0, height: 60.0, age: 4, isFemale: false, ); return MaterialApp( title: "Flutter Demo", theme: ThemeData( primarySwatch: Colors.blue, ), home: const DetailPetScreen(pet: brunoTheDog), ); } }
Listing 11.33: DetailPetScreen-Aufruf in der main.dart Jetzt sollten Sie Ihren DetailPetScreen auf dem Emulator bei Start der App sehen können! Ihr Screen sollte aussehen wie in Abbildung 11.23.
Stack Fast fertig. Auf das Image-Widget soll noch mithilfe des Stack-Widgets eine halbtransparente orangene Box mit dem Slogan »Adoptier mich!« gelegt werden. Diese Box können Sie aus bereits bekannten Widgets, nämlich einem Container- und einem Text-Widget, zusammenbauen. Wie Sie oben ausprobiert haben, nimmt ein Stack-Widget eine Liste an Widgets über seinen children-Parameter auf – analog zum Column- und Row- Widget. Es zeigt das erste Widget unten an und legt die folgenden jeweils darauf, auf der Z-Achse.
Abbildung 11.23: Der DetailPetScreen ist fast fertig.
Das Image-Widget sollte also das erste Widget in der Liste sein und das ContainerWidget darauffolgen. Dem Container geben Sie einen Hex-Wert mit Transparenz, damit das Foto leicht durchscheint. Mithilfe eines Positioned-Widgets kann ein Widget im Stack positioniert werden. Sie können den Container unten, links und rechts »andocken«. Es gibt mehrere Möglichkeiten, ein Widget im Stack zu positionieren. Probieren Sie auch das Positioned-Widget mit dem Positioned.filled-Konstruktor aus, das Align-Widget oder den alignment-Parameter des Stack-Widgets, wenn Sie ein bestimmtes Design umsetzen wollen und mit dem hier gezeigten Ansatz nicht weiterkommen. Ersetzen Sie das Image-Widget im DetailPetScreen mit folgendem Code: Stack( children: [ Image.asset("assets/images/dog.png"), Positioned( left: 0, right: 0, bottom: 0, child: Container( height: 40, color: const Color(0x88FFC942), child: const Center( child: Text( "Adoptier mich!", style: TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 20, ), ), ), ), ), ], ),
Listing 11.34: Stack-Widget Dem Container des Overlays ist keine explizite Breite gegeben. Damit er von links nach rechts spannt, wird in dem Positioned-Widget der left- und right-Parameter 0 gesetzt. Damit das Overlay unten mit dem Image abschließt, wird der bottom-Parameter ebenfalls auf 0 gesetzt.
Probieren Sie, noch ein weiteres Widget an einer anderen Stelle auf dem Foto mithilfe eines Positioned-Widget zu positionieren. So können Sie ein bisschen Übung mit dem Stack-Widget erlangen. Ihr DetailPetScreen sollte nun aussehen wie weiter vorne in Abbildung 11.21. Wunderschön! Jetzt müssen Sie nur noch den CreatePetScreen zusammenbasteln.
Pummel The Fish – der CreatePetScreen Der CreatePetScreen wird aufgerufen, wenn eine benutzende Person im HomeScreen den FloatingActionButton klickt. In diesem Screen soll die Person in der Lage sein, alle Attribute eines Pet-Objekts einzugeben, daraus ein neues Pet-Objekt zu erstellen, und dieses im Terminal auszugeben. In späteren Kapiteln werden Sie die Pet-Objekte dann innerhalb einer Datenbank speichern. Aber eines nach dem anderen, in diesem Kapitel geht es erst einmal darum, das UI zu bauen. So wie in Abbildung 11.24 soll Ihr CreatePetScreen schließlich aussehen. Na dann, los!
Abbildung 11.24: Der CreatePetScreen
Form – Eingabefelder brauchen auch ein Zuhause Um User-Input abzufangen und speichern zu können, arbeiten Sie in Flutter mit einem Form-Widget und entsprechenden Formelementen. An Formelementen gibt es zum Beispiel das TextFormField-Widget für Texteingaben, das DropdownButtonFormField, um aus einer Auswahl ein Element auszuwählen, und das CheckboxListTile, um eine Checkbox inklusive Label darzustellen. All diese Elemente werden Sie nun Schritt für Schritt in Ihrem CreatePetScreen integrieren. Um die eingegebenen Daten am Ende zu sammeln und später auch zu speichern, darf ein SPEICHERN-Button am unteren Ende des Formulars natürlich nicht fehlen. Bisher sieht der Code Ihres CreatePetScreen noch wie folgt aus. import "package:flutter/material.dart"; class CreatePetScreen extends StatelessWidget { const CreatePetScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text("CreatePetScreen"), ), ); } }
Listing 11.35: Grundgerüst CreatePetScreen Soweit noch nichts Unbekanntes zu sehen. Lassen Sie uns damit beginnen, den Titel des Screens in »Neues Tier anlegen« zu ändern. Sie fügen eine SafeArea hinzu, die das angekündigte Form-Widget umschließt. Dieses Form-Widget hat keinerlei Auswirkung auf das Design und gibt kein Layout vor, weshalb Sie noch eine Column im child-Parameter übergeben, die Sie im nächsten Schritt mit verschiedenen Formelementen ausstatten. Das gewohnte Padding drumherum darf natürlich auch nicht fehlen. @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text("Neues Tier anlegen"), ), body: SafeArea( child: Padding( padding: const EdgeInsets.all(24), child: Form( child: Column(
children: const [], ), ), ), ), ); }
Listing 11.36: Grundgerüst CreatePetScreen mit Form
TextFormField – hier könnte Ihr Text stehen! Bisher hat sich auf dem Screen noch nicht wirklich was getan, daher lassen Sie uns nun die Eingabefelder hinzufügen. Wenn Sie noch einmal einen kurzen Blick in die PetKlasse werfen, sollten Sie die folgenden Eigenschaften erkennen können, die einen Wert benötigen (also mit required markiert sind): Eindeutige Identifikation id als String Name name als String Art species als enum Alter age als int Größe und Gewicht als height und weight als double Hinzu kommen noch die optionalen Parameter: isFemale als bool owner als Owner
Die id ignorieren wir an dieser Stelle, da diese im Normalfall automatisch generiert werden sollte und nicht von der benutzenden Person eingegeben wird. Die Wahl der species bilden Sie anschließend mit einem Dropdown ab und den optionalen Wert isFemale ebenfalls im Anschluss als Checkbox. Der owner ist nullable und wir lassen ihn außer Acht. Konzentrieren Sie sich also fürs Erste auf name, age, height und weight. Texteingaben (egal, ob Text oder Zahl) lassen sich üblicherweise perfekt mit einem TextFormField abbilden, daher lassen Sie uns name, age, height und weight in solch ein Feld gießen.
TextFormFields anlegen Ein TextFormField ist ein umfangreiches Widget, welches mit nur einem requiredParameter in der Grundfunktionalität auskommt, aber mit vielen Funktionen durch weitere Parameter erweitert werden kann. Im ersten Schritt ist uns erst einmal wichtig, dass Sie ein Eingabefeld anzeigen, das mit einem Label ausgestattet ist. Schließlich ist es für die Benutzenden nicht ganz unwichtig, zu wissen, welche Informationen in das vorgesehene
Feld eingegeben werden sollen. Solch ein Label können Sie mit einem InputDecorationWidget erreichen, das innerhalb des decoration-Parameters übergeben wird. Form( child: Column( children: [ TextFormField( decoration: const InputDecoration( labelText: "Name", ), ), TextFormField( decoration: const InputDecoration( labelText: "Alter (Jahre)", ), ), TextFormField( decoration: const InputDecoration( labelText: "Höhe (cm)", ), ), TextFormField( decoration: const InputDecoration( labelText: "Gewicht (Gramm)", ), ), ], ), ),
Listing 11.37: Die Form mit TextFormField-Widgets Wenn Sie Ihre App nun starten und im main.dart den home-Parameter mit dem CreatePetScreen austauschen, sollten Sie ein ähnliches Ergebnis wie in Abbildung 11.25 erhalten.
Abbildung 11.25: CreatePetScreen mit TextFormFields
Bestimmt haben Sie bereits in die Felder hineingeklickt und versucht, Text einzugeben. Wenn nicht, ist jetzt der passende Zeitpunkt dafür! Sie sollten sehen, wie sich beim Klick in das Feld das Label nach oben schiebt, der Text des Labels außerdem kleiner wird und eine andere Farbe annimmt. Und die Tastatur erscheint. Dafür müssen Sie nichts zusätzlich programmieren, das kommt alles »out of the Box« mit!
Eingaben per onChanged-Methode abfangen Nur weil Sie nun Text eingeben, passiert aber noch nichts. Wäre es nicht super, wenn der Text, den Sie eingeben, direkt in der Debug Console ausgegeben würde? Nichts einfacher als das! Nutzen Sie hierfür den onChanged-Parameter im TextFormField. Er benachrichtigt Sie über den aktuellen Text, der sich im Feld befindet, und ruft die onChanged-Methode bei jeder Änderung automatisch auf. Da es sich um eine Callback-Methode handelt, die den aktuellen Text als String mitliefert, müssen Sie diesen nun nur noch per print ausgeben. TextFormField( decoration: const InputDecoration( labelText: "Name", ), onChanged: (value) { print(value); }, ),
Listing 11.38: Der onChanged-Parameter im TextFormField Wenn Sie nun Text in das Name-Eingabefeld schreiben, sollten Sie den aktuellen Wert als Ausgabe in der Debug Console in Visual Studio Code sehen können. Fügen Sie zu allen anderen TextFormField-Widgets den onChanged-Parameter auf dieselbe Weise hinzu und beobachten Sie die Ausgaben. Neben der Möglichkeit, per onChanged-Methode auf Änderungen bei der Texteingabe zu hören, gibt es auch noch die Möglichkeit, einen TextEditingController pro Eingabefeld zu definieren und dem Eingabefeld zuzuweisen. Das ist eine etwas komplexere Vorgehensweise, bietet aber auch mehr Möglichkeiten und Kontrolle der Eingaben. Für unseren Anwendungsfall ist die onChanged-Methode perfekt geeignet, aber falls diese für Ihren Anwendungsfall nicht mehr ausreicht, lohnt sich ein Blick in die Dokumentation des TextEditingControllers.
Hilfe, meine Tastatur verdeckt die Sicht Wenn Sie sich nun einmal durch alle Felder geklickt haben, ist Ihnen eventuell der gelb-
schwarze Balken aufgefallen, der angezeigt wird, sobald Sie das Smartphone-Keyboard für eine Eingabe öffnen. Je nachdem wie groß Ihr Emulator oder Ihr Smartphone ist, kann es sogar sein, dass das Keyboard das Eingabefeld komplett überdeckt und somit keine Eingabe möglich ist. Eine Lösung muss her, der ganze Screen muss scrollbar werden, sodass potenziell noch viel mehr Eingabefelder Platz haben. Tauschen Sie hierfür einfach das Padding-Widget unterhalb der SafeArea mit einem SingleChildScrollView-Widget aus. Moment, was passiert hier? body: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: Form(…), ), ),
Listing 11.39: SingleChildScrollView – es ist besser, wenn es scrollt. Das SingleChildScrollView-Widget bildet einen scrollbaren Container um ein beliebiges child-Widget – in Ihrem Fall das Form-Widget mit all seinen child-Elementen. Die gesamte Form ist somit nun scrollbar, sobald sie größer wird als der Screen. Das PaddingWidget drumherum wird nicht mehr benötigt, da das SingleChildScrollView-Widget einen eigenen padding-Parameter bereitstellt, der für genau diesen Zweck verwendet werden kann. Versuchen Sie nun erneut, alle Eingabefelder zu befüllen, indem Sie hineinklicken und einen Text eingeben. Der schwarz-gelbe Balken sollte Geschichte sein und das angeklickte Feld wird über die Tastatur gescrollt, sollte es zu weit unten angesiedelt sein.
DropdownButtonFormField – einmal wählen bitte Neben den Texteingaben soll noch die Tierart gewählt werden. Das lässt sich am sinnvollsten als Dropdown darstellen. Das nächste Ziel ist es nun, ein Dropdown-Feld zu erzeugen, das sich per Tap aufklappt und alle aktuell möglichen Tierarten anzeigt. Nach dem letzten TextFormField gliedern Sie nun ein DropdownButtonFormFieldWidget in die Column ein. Das DropdownButtonFormField-Widget hat zwei required Parameter, die Sie befüllen müssen: den items-Parameter, der gerne mit einer Liste von DropdownMenuItems befüllt werden möchte, und den onChanged-Parameter, den Sie bereits im TextFormField kennengelernt haben. Das Dropdown-Feld können Sie ebenfalls mit einer Art Label versehen, indem Sie den dafür vorgesehenen hint-Parameter befüllen. DropdownButtonFormField( hint: const Text("Bitte wählen Sie eine Tierart"), items: const [], onChanged: (Species? value) { print(value); },
),
Listing 11.40: Ein DropdownButtonFormField-Widget
Wichtig zu beachten ist hierbei, dass Dropdown-Felder nur mit einem bestimmten Datentyp befüllt werden können. Sie möchten das Dropdown mit dem Typ Species ausstatten, daher müssen Sie dies in den spitzen Klammern hinter dem WidgetNamen angeben. Das bedeutet aber gleichzeitig, dass alle DropdownMenuItems nur mit dem Typ Species umgehen können. Vergessen Sie außerdem nicht, die pet.dart-Datei zu importieren, die die Definition für den Species-Enum enthält. Beim Neuladen der App sollte nun ein Dropdown-Feld unterhalb der anderen Felder auftauchen. Öffnen lässt es sich aber noch nicht, denn bisher übergeben Sie nur eine leere Liste an Elementen, das Dropdown-Feld kann also noch keine Optionen anzeigen.
DropdownMenuItem – Auswahlmöglichkeiten definieren Die aktuell leere items-Liste befüllen Sie nun mit DropdownMenuItem-Widgets. Für jede Tierart exakt eines davon, denn das ist die Auswahl an Elementen, die Sie den Anwendenden zur Verfügung stellen möchten. Daher befüllen Sie den value-Parameter dieser Widgets mit dem konkreten Species-Wert und im child-Parameter geben Sie zunächst einfach den entsprechenden Namen aus. DropdownButtonFormField( hint: const Text("Bitte wählen Sie eine Tierart"), items: const [ DropdownMenuItem( value: Species.dog, child: Text("Hund"), ), DropdownMenuItem( value: Species.cat, child: Text("Katze"), ), DropdownMenuItem( value: Species.fish, child: Text("Fisch"), ), DropdownMenuItem( value: Species.bird, child: Text("Vogel"), ), ], onChanged: (Species? value) { print(value); }, ),
Listing 11.41: DropdownButtonFormField mit Items
Das Dropdown sollte sich jetzt öffnen lassen und die entsprechenden Tierarten zur Auswahl anbieten, analog zu Abbildung 11.26. Außerdem sollte beim Tap auf eine Tierart diese als ausgewählt erscheinen und zusätzlich eine print-Ausgabe im Terminal auftauchen. Wundervoll!
Abbildung 11.26: CreatePetScreen mit Dropdown
Versuchen Sie doch einmal, als kleine Zwischenübung die Dropdown-Items etwas hübscher zu gestalten, indem Sie zum Beispiel vor jeden Tiernamen ein Icon setzen. Das DropdownMenuItem-Widget kann als child-Parameter jedes beliebige Widget annehmen, somit sind Ihrer Fantasie bei der Gestaltung keine Grenzen gesetzt.
Checkbox– perfekt für Booleans Eine Angabe fehlt noch, um das angedachte Formular zu finalisieren. Genau, die Geschlechtsangabe. Hierfür haben Sie im Pet-Objekt die isFemale-Eigenschaft angelegt. Da es sich um einen Boolean handelt, der entweder den Wert true oder false annehmen kann, eignet sich für dieses Element eine Checkbox am besten. Ein passendes Widget steht natürlich auch hier in Flutter für Sie bereit: das CheckboxListTile-Widget. Es ist nicht einfach nur eine Checkbox (dieses Widget gibt es natürlich auch), sondern liefert auch ein kleines bisschen Layout mit, denn es versucht, sich als ListTile in einer Liste von Elementen einzugliedern. Was könnte besser für unseren Anwendungsfall geeignet sein? Siedeln Sie das CheckboxListTile unterhalb des DropdownButtonFormFields an und befüllen Sie die benötigten Parameter value und onChanged sowie den optionalen Parameter title zunächst wie folgt: CheckboxListTile( title: const Text("Weiblich"), value: true, onChanged: (bool? value) { print(value); }, ),
Listing 11.42: Das CheckboxListTile in Aktion Beim Betrachten des Ergebnisses fällt Ihnen nun wahrscheinlich auf, dass der Abstand sich von den anderen Elementen zumindest horizontal deutlich unterscheidet und dadurch ein etwas unsymmetrisches Bild entsteht. Das liegt daran, dass das CheckboxListTileWidget schon von Haus aus einen Default-Wert für Padding mitbringt. Aber es wäre ja nicht Flutter, wenn sich das nicht ändern ließe. Passen Sie daher den contentPaddingParameter einfach so an, dass der horizontale Abstand entfällt und der vertikale bei 16 liegt. CheckboxListTile( title: const Text("Weiblich"), contentPadding: const EdgeInsets.symmetric( horizontal: 0, vertical: 16,
), value: true, onChanged: (bool? value) { print(value); }, ),
Listing 11.43: Das CheckboxListTile ohne horizontales Default-Padding Schon besser! Das Endergebnis sollte dem in Abbildung 11.27 ähneln.
Warum tut sich nichts? So weit, so gut, aber wenn Sie nun die Checkbox deaktivieren wollen, wird sich nichts tun. Warum nicht? Weil Sie einen festen Wert von true als value-Parameter angegeben haben. Wenn Sie diesen auf false ändern, werden Sie sehen, wie die Checkbox sich deaktiviert. Aber auch in dem Fall ändert sich bei einem Tap nichts. Was Sie also nun benötigen, ist ein lokaler Wert, der sich beim Tappen auf das CheckboxListTile-Widget ändert, damit sich das CheckboxListTile-Widget abhaken und leeren lässt. Moment, das UI soll sich ändern? Da hatten Sie doch etwas drüber gelernt? Genau, das geht nicht so einfach, wenn Sie sich in einem StatelessWidget befinden. Lassen Sie uns darum zuerst den CreatePetScreen in ein StatefulWidget umwandeln.
Abbildung 11.27: CreatePetScreen mit CheckboxListTile
Am einfachsten geht das, indem Sie auf CreatePetScreen klicken und von der QuickFix-Lampe Gebrauch machen. Wählen Sie CONVERT TO STATEFULWIDGET. Lassen Sie uns nun oberhalb der build-Methode eine lokale Variable currentIsFemale vom Typ bool anlegen und ihr den initialen Wert false geben. Weisen Sie die Variable dem value-Parameter im CheckboxListTile-Widget zu. class CreatePetScreen extends StatefulWidget { const CreatePetScreen({super.key}); @override State createState() => _CreatePetScreenState(); } class _CreatePetScreenState extends State< CreatePetScreen> { bool currentIsFemale = false; @override Widget build (BuildContext context) { … CheckboxListTile( title: const Text("Weiblich"), contentPadding: const EdgeInsets.symmetric( vertical: 16, horizontal: 0, ), value: currentIsFemale, onChanged: (bool? value) { print(value); }, ), … } }
Listing 11.44: Der CreatePetScreen wird ein StatefulWidget. Zu guter Letzt passen Sie noch die onChanged-Methode innerhalb des CheckboxListTileWidgets an, denn Sie möchten nun nicht einfach nur den Wert ausgeben, sondern ihn beim Tappen auch invertieren. Aus true wird false, aus false wird true. Bevor Sie den Wert invertieren, sollten Sie allerdings prüfen, ob dieser null ist. Wie in der MethodenDeklaration vermerkt, kann der value-Wert nämlich neben einem bool-Wert auch null annehmen. Wrappen Sie außerdem die Wertänderung currentFemale = value in einen setState-Aufruf, um den Rebuild auszulösen, nachdem der Wert verändert wurde. Nun sollte Ihre Checkbox endlich wie geplant funktionieren. onChanged: (bool? value) { if (value != null) {
print(value); setState(() { currentIsFemale = value; }); } },
Listing 11.45: Die onChanged-Methode im CheckboxListTile
ElevatedButton – Speichern muss sein Das Grundgerüst der Form steht, aber um diese am Ende absenden zu können, fehlt noch ein essenzielles Element: der SPEICHERN-Button. Da Flutter von Haus aus vorgefertigte Buttons mitliefert, die sich am Material Design orientieren, halten wir uns hier nicht lange auf und verwenden ein ElevatedButton-Widget mit einem Speichern-Label, das durch ein Text-Widget im child-Parameter repräsentiert wird. Der Button soll sich direkt unter dem letzten Eingabefeld in Ihr Column-Widget eingliedern. ElevatedButton( onPressed: () { print("Speichern"); }, child: const Text("Speichern"), ),
Listing 11.46: Ein Speichern-Button als ElevatedButton Der Screen sollte nun der Abbildung 11.28 (am besten mehr als nur) ähneln. Wenn das bei Ihnen der Fall ist, klopfen Sie sich einmal kräftig auf die Schulter!
Abbildung 11.28: CreatePetScreen mit »Speichern«-Button
Eingaben sammeln und Pet-Objekt erstellen Ziel des Formulars ist es, am Ende alle eingegebenen Daten zu sammeln und ein PetObjekt daraus zu erstellen. Dieses geben Sie hier zunächst im Terminal aus, und im späteren Verlauf des Buches werden die Daten dann an eine Datenbank weitergeleitet und dort gespeichert. Um dieses Ziel zu erreichen, werden Sie nun für alle Angaben zunächst lokale Variablen anlegen – analog zu currentIsFemale. Direkt über der build-Methode ist der perfekte Platz dafür. Sie können an dieser Stelle auch initiale Werte vergeben, wie bei der currentIsFemale-Variable, müssen es aber nicht. bool currentIsFemale = false; String? currentName; int? currentAge; double? currentHeight; double? currentWeight; Species? currentSpecies; @override Widget build(BuildContext context) { … }
Listing 11.47: Lokale Variablen müssen her Diese Variablen werden nun jeweils in der onChanged-Methode der Eingabe-Widgets aktualisiert, sodass die lokalen Variablen immer den aktuellen Wert der User-Eingaben reflektieren. Ersetzen Sie hierzu die print-Ausgabe der Werte innerhalb der onChangeMethoden mit der Zuweisung der Werte zu der jeweiligen Variablen. Eine wichtige Information gibt es noch, bevor Sie damit starten. Die onChanged-Methode eines TextFormFields wird immer einen value vom Typ String für Sie bereithalten – auch wenn Sie Zahlen eingeben. Während das bei der currentName-Variable kein Problem darstellt, werden Sie bei der currentAge-Variable vom Typ int zunächst den String-Wert in einen int-Wert umwandeln müssen. Das können Sie mit dem Ausdruck int.tryParse(value) erreichen. Es wird versucht, den gegebenen Wert in einen int umzuwandeln, und sollte es fehlschlagen – zum Beispiel, weil ein Text anstatt einer Zahl eingegeben wurde –, wird null zurückgegeben. Dasselbe gilt für currentWeight und currentHeight. Beide Variablen erfordern einen Wert vom Typ double. Achten Sie also hier darauf, den value vom Typ String per double.tryParse(value) in einen double umzuwandeln. TextFormField( decoration: const InputDecoration( labelText: "Name", ), onChanged: (value) {
currentName = value; }, ), TextFormField( decoration: const InputDecoration( labelText: "Alter (Jahre)", ), onChanged: (value) { currentAge = int.tryParse(value); }, ), TextFormField( decoration: const InputDecoration( labelText: "Höhe (cm)", ), onChanged: (value) { currentHeight = double.tryParse(value); }, ), TextFormField( decoration: const InputDecoration( labelText: "Gewicht (Gramm)", ), onChanged: (value) { currentWeight = double.tryParse(value); }, ), DropdownButtonFormField( hint: const Text("Bitte wählen Sie eine Tierart"), items: const [ … ], onChanged: (Species? value) { currentSpecies = value; }, ),
Listing 11.48: Variablen-Werte durch die onChanged-Methode aktualisieren Wow, geschafft! Auf zum vorerst letzten Schritt. Wenn Sie sichergehen wollen, dass die Anwendenden die richtige Art von Input eingeben, können Sie die Tastatur mit dem keyboardType-Parameter im TextFormField-Widget entsprechend anpassen. In der onPressed-Methode des Speichern-Buttons möchten Sie nun die Werte aller Variablen sammeln und in ein Pet-Objekt gießen. Passen Sie die onPressed-Methode des ElevatedButton daher wie folgt an. ElevatedButton( onPressed: () { if(currentName != null && currentAge != null && currentSpecies != null
&& currentWeight != null && currentHeight != null) { final pet = Pet( id: "test", name: currentName!, species: currentSpecies!, age: currentAge!, weight: currentWeight!, height: currentHeight!, isFemale: currentIsFemale, ); print("$pet"); } }, child: const Text("Speichern"), ),
Listing 11.49: Ausgabe des frisch erstellten Pet-Objekts im Terminal Beachten Sie, dass Sie mögliche null-Vorkommnisse abfangen müssen. Dies lässt sich am leichtesten durch eine if-Abfrage lösen. Für die id können Sie aktuell einen beliebigen Wert verwenden. Wenn Sie das Formular nun vollständig befüllen und den Speichern-Button betätigen, sollte die folgende Ausgabe in Ihrem Debug-Console-Tab in VSCode erscheinen: >> [Instance of "Pet"]
Das zeigt schon mal, dass es funktioniert. Wenn Sie sich die konkreten Informationen des Pet-Objekts in der Debug Console ausgeben lassen möchten, können Sie das tun, indem Sie die toString-Methode in der Pet-Klasse überschreiben. Ändern Sie die Pet-Klasse wie folgt: class Pet { … @override String toString() { return "Pet( id: $id, name: $name, species: $species, weight: $weight, height: $height, age: $age, isFemale: $isFemale, owner: $owner, )"; } }
Listing 11.50: die toString-Methode innerhalb der pet.dart-Datei
Wenn Sie die Methode nun erneut ausführen, indem Sie den Button noch einmal betätigen, erhalten Sie die gewünschte Ausgabe in der Konsole: >> Pet(id: test, name: test, species: Species.fish, age: 1, weight: 1.0, height: 1.0, isFemale: true, owner: null)
Form-Validierung – Vertrauen ist gut, Kontrolle ist besser Unser Formular ist zwar nun voll funktionsfähig, jedoch könnten aus UX-Sicht noch ein paar Verbesserungen getätigt werden. Wenn die nutzende Person aktuell nicht alle Felder ausfüllt und auf SPEICHERN drückt, passiert beispielsweise gar nichts. Wenn leere oder falsche Werte eingegeben werden, wird das Feld einfach nicht befüllt. Das geht doch besser? Ein Fall für die Form-Validierung! Sie sorgt dafür, dass einzelne Felder mit Regeln ausgestattet werden können und bei falschem oder keinem Input ein Alarm ausgelöst und ein Fehler angezeigt wird. Sobald es zu einem Fehler kommt, ist entweder die ganze Form nicht mehr valide und die Daten werden nicht mehr versendet oder der SPEICHERN-Button lässt sich nicht mehr betätigen. Es gibt verschiedene Möglichkeiten, Eingabefelder zu validieren. Zum Beispiel können Sie ein Feld als Pflichtfeld markieren, das einen Input benötigt, oder Sie können eine Mindest- und Maximalanzahl an Zeichen verlangen. Beides lässt sich durch den validator-Parameter im TextFormField einbauen.
Pflichtfelder definieren Im aktuellen Fall möchten Sie einfach, dass alle Felder befüllt werden, daher können Sie die validator-Parameter bei allen Eingabefeldern hinzufügen und mit einer Fehlermeldung versehen. Der validator-Parameter enthält einen value – analog der onChanged-Methode – mit dem aktuellen Text, der eingegeben wurde. Sie prüfen also, ob dieser Wert null oder leer ist und falls eins von beidem zutrifft, geben Sie eine Fehlermeldung in Form eines Strings zurück. Ist die Eingabe valide, wird null zurückgegeben. Im Folgenden sehen Sie das Ganze in Aktion für das Name-Eingabefeld: TextFormField( decoration: const InputDecoration( labelText: "Name", ), onChanged: (value) { currentName = value; }, validator: (value) { if(value == null || value.isEmpty) { return "Bitte einen Namen eingeben"; } else { return null; } }, ),
Listing 11.51: Der validator-Parameter im TextFormField Fügen Sie den validator-Parameter nun zu allen anderen TextFormField-Widgets in Ihrer Form hinzu und passen die jeweilige Fehlermeldung entsprechend an. Nicht nur das TextFormField-Widget hat einen validator-Parameter, das DropdownButtonFormField ebenfalls. Passen Sie hier auch den validator-Parameter an. DropdownButtonFormField( hint: const Text("Bitte wählen Sie eine Tierart"), items: const […], onChanged: (Species? value) { currentSpecies = value; }, validator: (value) { if (value == null) { return "Bitte eine Spezies angeben"; } else { return null; } }, ),
Listing 11.52: DropdownButtonFormField mit validator
Der validator im DropdownButtonFormField eignet sich übrigens hervorragend für die abgekürzte if-else-Schreibweise und würde dann wie folgt aussehen: validator: (value) { return value == null ? "Bitte eine Spezies angeben" : null, }
Listing 11.53: validator-Methode mit ternärem Operator Da es sich nun innerhalb der validator-Methode nur noch um eine Zeile handelt, können Sie diese zusätzlich durch die Fat-Arrow-Schreibweise abkürzen: validator: (value) => value == null ? "Bitte eine Spezies angeben" : null,
Listing 11.54: validator-Methode mit Fat-Arrow-Schreibweise
Die Validierung anstoßen Bevor Sie Ihre App nun erneut starten und das Ganze ausprobieren können, ist noch eine weitere kleine Anpassung nötig, um die Validierung anzustoßen. Beim Drücken des Speichern-Buttons soll die gesamte Form überprüft werden, indem durch alle
Validierungsprüfungen durchgegangen wird. Hierzu legen Sie unterhalb der _CreatePetScreenState-Klasse eine Variable _formKey an, die den sogenannten Form-State repräsentiert und die Validierung der Form auslösen kann. Diese Variable muss dann im Form-Widget als key-Parameter eingefügt werden: class _CreatePetScreenState extends State { final _formKey = GlobalKey(); … @override Widget build(BuildContext context) { … Form( key: _formKey, … ), … } }
Listing 11.55: Einen GlobalKey erzeugen und der Form zuweisen Nun, da Sie auf den Form State über die _formKey-Variable zugreifen können, lässt sich die validate-Methode des Form-States im SPEICHERN-Button auslösen. Weil Sie durch den validator-Parameter überall prüfen, ob eine Variable null sein kann, können Sie sich die null-Abfrage der einzelnen Variablen sparen. ElevatedButton( onPressed: () { if (_formKey.currentState?.validate() ?? false) { final pet = Pet( id: "test", name: currentName!, species: currentSpecies!, age: currentAge!, weight: currentWeight!, height: currentHeight!, isFemale: currentIsFemale, ); print("$pet"); } }, child: const Text("Speichern"), ),
Listing 11.56: Die validate-Methode aufrufen Starten Sie nun die App und tappen Sie auf den SPEICHERN-Button, ohne irgendwelche Eingaben zu machen. Sie sollten für jedes Feld nun eine Fehlermeldung bekommen, wie in Abbildung 11.29 gezeigt.
Abbildung 11.29: Validierung der Eingabe beim Speichern
Aktuell werden alle Felder als Pflichtfelder validiert. Das ist ein guter Start, aber es ist auch möglich, bei Alter, Höhe und Gewicht einen Text anstatt einer Zahl einzutragen, um die Form valide erscheinen zu lassen. Versuchen Sie daher nun selbst, die Validierung für Alter, Höhe und Gewicht so zu erweitern, dass nur Zahlen als valide angesehen werden. Schauen Sie sich gegebenenfalls noch einmal die validator-Methode dieser Eingabefelder an. Sie können natürlich auch den keyboardType-Parameter anpassen, sodass die Anwendenden nur Zahlen auf der Tastatur auswählen können, dies ist auf jeden Fall auch die User-freundlichere Variante – aber versuchen Sie es der Übung halber zuerst einmal, ohne die Tastatur anzupassen. Obwohl es im Bereich der Eingabefelder und Formulare noch so viel zu entdecken gibt, müssen wir an dieser Stelle leider eine Grenze ziehen, denn alles zu behandeln, würde den Rahmen dieses Buches sprengen. Mit den Eingabefeldern und der Umgangsweise, die wir Ihnen gezeigt haben, können Sie bereits unglaublich viele Anwendungsfälle abdecken. Dennoch ermutigen wir Sie, an dieser Stelle wild herumzuexperimentieren. Probieren Sie verschiedene TextFormField-Parameter aus, gestalten Sie Ihre Form um, verwenden Sie andere Elemente, wie Slider oder ToggleButtons für die Eingabe, und tauchen Sie noch tiefer in das Thema Validierung ein. Die vier Screens haben Sie jetzt gebaut, und Sie sollten einen guten Überblick über die UI-Programmierung mit Flutter bekommen haben. Eine tolle Sache mit den Widgets ist, dass man seine Widget-Konstrukte auch als eigene Custom Widgets definieren und wiederverwenden kann. Darum soll es im nächsten Kapitel gehen.
Kapitel 12
Ein bisschen DIY zwischendurch – Custom Widgets IN DIESEM KAPITEL Schreiben Sie Ihr eigenes Widget Definieren Sie Widget-Parameter, die beim Aufruf übergeben werden sollen
Flutter stellt Ihnen schon eine riesige Auswahl an Widgets zur Verfügung, wie Sie im vorangegangenen Kapitel sehen konnten. Dennoch werden Sie wahrscheinlich früher als erwartet in die Lage kommen, ein eigenes Widget definieren zu wollen. Um nicht den unverzeihlichen Fauxpas der Code-Dopplung zu begehen, ist es oft ratsam, ein Custom Widget zu schreiben und wiederzuverwenden. Und für ein konsistentes, simples und schönes UI-Design ist Repetition von UI-Elementen wichtig. Indem Sie zum Beispiel immer denselben Custom Button verwenden, statt auf jedem Screen einen Button unterschiedlich umzusetzen, versteht die benutzende Person einfacher, wo ihre Interaktion erwartet wird.
Custom Widget – ja, nein, vielleicht? Die Screens der »Pummel The Fish«-App, die Sie bisher gebaut haben, sind sehr einfach. In der Regel wird Ihr UI-Code viel länger werden, weil die Designs komplexer sind. Durch die verschachtelte Widget-Struktur des Flutter-Codes geht Ihr Code in die Breite und nicht nur in die Länge. Wie sollen Sie da den Überblick behalten? Wenn ein Widget viel Platz einnimmt und wenig Variablen enthält (denn die müssten Sie ja alle jeweils als Parameter übergeben), kann es Sinn ergeben, ein privates Custom Widget daraus zu machen. So wird Ihr Code zwar insgesamt nicht kürzer, aber übersichtlicher, weil sie einen großen Code-Block aus der build-Methode herausnehmen können. Das Custom Widget können Sie in derselben Datei speichern und es mit vorangehendem Unterstrich als privat markieren, da es nur in derselben Datei verwendet wird. Auch wenn Sie ansonsten am besten nicht zwei Klassen in einer Datei anlegen – dies ist eine gute Ausnahme. Custom Widgets können Ihren Code kürzer und vor allem übersichtlicher machen. Wenn Sie in einem Screen ein UI-Element mehrmals verwenden, wie die Cards im
DetailPetScreen, ergibt es auf jeden Fall Sinn, diese Elemente in eine eigene private
Widget-Klasse auszulagern. Mit dieser Aufgabe, ein privates Custom Widget zu bauen, werden Sie gleich beginnen. Wenn ein Custom Widget in verschiedenen Klassen verwendet werden soll, ist es jedoch sinnvoller, es in einer separaten Datei zu definieren und es nicht privat zu setzen. Den SPEICHERN-Button im CreatePetScreen möchten wir zum Beispiel grafisch noch etwas anpassen und zu einem Custom Widget ausbauen. Bisweilen kommt der Button zwar nur einmal in Ihrer App vor, aber wenn Sie die App erweitern würden, können Sie davon ausgehen, dass Sie noch an anderen Stellen solch einen Button bräuchten. Darum ist es Best Practice, dieses Widget als Custom Widget zu extrahieren und in einem widgetOrdner zu speichern, in dem alle öfters gebrauchten, selbst geschriebenen Custom Widgets abgelegt werden und frei von allen Screens bei Bedarf verwendet werden können. Das werden Sie ebenfalls gleich ausprobieren.
_CustomWidget – das sollte lieber privat bleiben Wenn Sie sich den DetailPetScreen anschauen, springt Ihnen die Code-Dopplung hoffentlich auch in die Augen. Im unteren Teil des Screens haben Sie eine Column mit drei Card-Widgets, die jeweils genau gleich sind, bis auf die Textinhalte. Sie eignen sich perfekt, um ein Custom Widget aus ihnen zu machen.
Ein Widget extrahieren Mit VSCode geht es ganz einfach, ein Widget zu extrahieren. Sie platzieren den Cursor auf dem Namen des äußersten Widgets des Code-Teils, der extrahiert werden soll. Dann klicken Sie auf die Glühbirne und wählen den Menüpunkt EXTRACT WIDGET (siehe Abbildung 12.1). Direkt unter der Klasse, in der Sie sich befinden, wird eine WidgetKlasse erstellt. Beinhaltete Variablen werden automatisch zu Parametern konvertiert.
Abbildung 12.1: Ein Widget extrahieren
Öffnen Sie die detail_pet_screen.dart-Datei und wählen Sie das erste Card-Widget im Screen. Extrahieren Sie das Widget und benennen Sie Ihr Custom Widget InfoCard.
Denken Sie bei der Benennung an den Unterstrich vor dem Namen, damit die Klasse privat ist, das heißt nur aus dieser Datei aufgerufen werden kann. Die _InfoCard-Klasse wurde nun unter der DetailPetScreen-Klasse erstellt und da Sie im Card-Widget auf das Pet-Objekt zugreifen, hat das Widget einen Pet-Parameter bekommen. Der Aufruf in der CreatePetScreen-Klasse sieht dann wie folgt aus. _InfoCard(pet: pet),
Der Code des privaten Custom Widget wird mit einem pet-Parameter autogeneriert. class _InfoCard extends StatelessWidget { final Pet pet; const _InfoCard({ super.key, required this.pet, }); @override Widget build(BuildContext context) { return Card( child: Padding( padding: const EdgeInsets.all(8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text("Name des Kuscheltiers:"), Text(pet.name), ], ), ), ); } }
Listing 12.1: Custom Widget _InfoCard mit Pet-Parameter
Parameter anpassen Das hilft Ihnen jetzt natürlich nicht weiter, wenn Sie auch die anderen beiden CardWidgets ersetzen wollen – denn es wird jeweils auf ein anderes Attribut des Pet-Objekts zugegriffen und der Text »Name des Kuscheltiers:« verändert sich ebenfalls. Das müssen Sie also noch anpassen. Versuchen Sie es erst einmal selbst, bevor Sie sich den folgenden Code anschauen. So wie im folgenden Listing 12.3 wird der Aufruf in der DetailPetScreen-Klasse aussehen. _InfoCard( labelText: "Name des Kuscheltiers:", infoText: pet.name,
), _InfoCard( labelText: "Alter:", infoText: "${pet.age} Jahre", ), _InfoCard( labelText: "Größe & Gewicht:", infoText: "${pet.height} cm / ${pet.weight} Gramm", ),
Listing 12.2: Aufruf _InfoCard im DetailPetScreen Das private Custom Widget sollten Sie wie folgt anpassen. Class _InfoCard extends StatelessWidget { final String labelText; final String infoText; const _InfoCard({ super.key, required this.labelText, required this.infoText, }); @override Widget build(BuildContext context) { return Card( child: Padding( padding: const EdgeInsets.all(8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(labelText), Text(infoText), ], ), ), ); } }
Listing 12.3: Custom Widget _InfoCard mit String-Parametern Jetzt können Sie auch direkt die Vorteile dieser Konstruktion sehen: Der DetailPetScreen ist viel übersichtlicher geworden. Die sinnvolle Benennung des Widgets und der Parameter tragen einen wichtigen Teil dazu bei.
Ein Custom Widget für alle und überall! Wenn Sie ein professionell designtes App-Projekt angehen, werden Sie vermutlich viele repetitive Design-Elemente im UI finden. Für diese sollten Sie Custom Widgets anlegen,
die der gesamten App zur Verfügung stehen. Wir machen das gern gleich zu Anfang eines Projekts – dann sieht die App gleich nach was aus!
Ein Custom Widget anlegen Erstellen Sie den Ordner widgets im lib-Ordner Ihres Projektes mit einer Datei custom_button.dart. Erstellen Sie darin ein StatelessWidget. Überlegen Sie nun: Welche Parameter brauchen Sie? Wahrscheinlich eine Funktion und einen Text, der auf dem Button angezeigt werden soll. Legen Sie die Parameter an. Import "package:flutter/material.dart"; class CustomButton extends StatelessWidget { final Function onPressed; final String label; const CustomButton({ super.key, required this.onPressed, required this.label, }); @override Widget build(BuildContext context) { … } }
Listing 12.4: Parameter im CustomButton anlegen Sie können natürlich jetzt einfach in der build-Methode einen fertigen Button – zum Beispiel einen ElevatedButton oder einen TextButton – aus dem Flutter-Repertoire zurückgeben. Aber das wäre zu einfach! Stattdessen werden Sie einen eigenen Button mithilfe eines Container- und eines GestureDetector-Widgets selbst gestalten.
Der GestureDetector – das Widget mit Fingerspitzengefühl Der GestureDetector ist ein besonderes Widget. Seine zahlreichen Parameter erlauben Ihnen, auf verschiedene User-Interaktionen mit dem Auslösen einer Funktion zu reagieren. Sie können so jede Fläche Ihres Screens für User-Aktionen wie zum Beispiel Klick, Doppelklick, Swipe oder langes Tippen sensibilisieren. Der GestureDetector soll in Ihrer »Pummel The Fish«-App einen orangenen Container mit runden Ecken umschließen, sodass der CreatePetScreen anschließend wie in Abbildung 12.2 aussieht.
Abbildung 12.2: Der CustomButton im CreatePetScreen
Um das zu erreichen, fügen Sie folgende Codezeilen in die build-Methode Ihres CustomButton-Widgets ein. return GestureDetector( onTap: () => onPressed(), child: Container( width: 160, height: 50, decoration: const BoxDecoration( borderRadius: BorderRadius.all( Radius.circular(20), ), color: Color(0xFFFFC942), ), child: Center( child: Text(label, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white, ), ), ), ), );
Listing 12.5: Ein GestureDetector um einen Container gewrappt kann als Button funktionieren. Aufruf im CreatePetScreen: CustomButton( onPressed: () { … }, label: "Speichern", ),
Listing 12.6: CustomButton-Deklaration und Aufruf Lassen Sie die main-Funktion wieder den CreatePetScreen als ersten Screen aufrufen und führen Sie einen Hot-Reload durch, um Ihren neuen Button zu bewundern. Anstatt des GestureDetector-Widgets können Sie auch das InkWell-Widget benutzen. Es kann ebenfalls auf einige User-Gesten reagieren. Wenn Sie in das InkWell-Widget anstatt des Containers, den wir hier benutzen, ein Ink-Widget legen, erhalten Sie eine schöne Animation bei Button-Klick.
Ich bin je der Ordnung Freund gewesen (Goethe)
Wenn Sie komplexe Designs umsetzen, ist es sinnvoll, gleich am Anfang die wiederkehrenden Elemente zu identifizieren und Custom Widgets für Sie zu schreiben. Wenn Ihr Flutter-Code an Stellen sehr lang und sehr verschachtelt wird, überlegen Sie sich, ob Sie etwas extrahieren können. Aus Erfahrung können wir sagen, es ist immer sehr angenehm, wenn Sie in ein bestehendes Flutter-Projekt kommen und den ersten Screen öffnen und nicht einmal scrollen müssen, sondern sofort erfassen, worum es in diesem Screen geht. Ein komplexer Screen mit privaten und öffentlichen Custom Widgets könnte zum Beispiel so aussehen, wie im folgenden Listing 12.7. body: SafeArea( child: Column( children: [ _HeaderPicture(picture: "path/pic.png"), _Header(header: "Pets"), _ListPets(pets: pets), _Header(header: "Owners"), _ListOwners(owners: owners), CustomButton("Press here", myFunctionA), _Header(header: "How to adopt"), _BodyText(text: text), Row( children: [ CustomButton("Choose this", myFunctionB), CustomButton("Choose that", myFunctionC), ], ), ], ), floatingActionButton: CustomFloatingActionButton( onPressed: myFunctionD, ), }
Listing 12.7: Private und öffentliche Custom Widgets Es bleibt Ihnen überlassen, wie weit Sie es damit treiben wollen. Wie immer ist die Hauptsache, Sie bleiben dabei konsistent! Jetzt wissen Sie, wie Sie Screen-Elemente definieren und überall in Ihrem Projekt wiederverwenden können. Im nächsten Kapitel lernen Sie einen weiteren wichtigen Skill für die UI- Entwicklung: Die if-else-Verzweigung wird Ihnen ermöglichen, mit Ihrem UI auf Screen-Größe, Ausrichtung, Endgerät oder auch auf Ihren Datenfluss im Projekt flexibel zu reagieren.
Kapitel 13
Wenn das, dann das – oder das? IN DIESEM KAPITEL Benutzen Sie ein if-else-Anweisung in der UI, um Daten passend anzuzeigen Lernen Sie, das UI je nach Endgerät anzupassen Lernen Sie, das UI je nach Screen-Größe und Ausrichtung anzupassen
Flutter gibt Ihnen eine simple Möglichkeit, if-else-Anweisungen in der UI mit einem sogenannten »Ternary Operator« (zu deutsch: ternärer Operator) zu realisieren. Sie haben diese Kurzschreibweise schon in Kapitel 5, »Bedingte Anweisungen und Schleifen im Griff« kennengelernt. Der ternäre Operator checkt wie eine herkömmliche if-else-Anweisung den Wahrheitswert eines Statements und gibt dann mindestens zwei Varianten zur Auswahl (oder noch weiter geschachtelte Verzweigungen), was im Fall von true und was im Fall von false passieren soll. Hier wird im ersten Fall doThis ausgeführt und im zweiten Fall doThat: 1 == 1 ? doThis() : doThat(); 1 == 2 ? doThis() : doThat();
Listing 13.1: Ternary Operator In dieser verschachtelten Variante wird doAnotherThing ausgeführt: 1 == 2 ? doThis() : 2 == 3 ? doThat() : doAnotherThing();
Listing 13.2: Verschachtelter Ternary Operator In diesem Kapitel werden Sie ein paar Anwendungsfälle für den ternären Operator kennenlernen, indem Sie Ihre »Pummel The Fish«-App weiter ausbauen.
Wenn die Daten die UI bedingen sollen Je nachdem, welche Attribute ein Pet-Objekt hat, soll sich das UI anpassen. Zuerst passen Sie den HomeScreen und anschließend den DetailPetScreen entsprechend an.
Keine Angst vorm Gendern Im HomeScreen Ihrer App wird als leading-Parameter des ListTile-Widgets ein Icon
übergeben. Das Icon ist ein aktuell ein Pfotenabdruck. Sie sollen jetzt stattdessen ein Icon für Weiblichkeit oder Männlichkeit anzeigen, je nachdem, ob sich das Kuscheltier als weiblich oder als männlich identifiziert – soll heißen, das isFemale-Attribut des jeweiligen Pet-Objekts true oder false ist. Probieren Sie einmal selbst, ob Sie die Änderung hinbekommen! (Die Möglichkeit, dass sich das Kuscheltier als non-binär identifiziert, haben wir hier einmal weggelassen, da wir mit einem bool arbeiten wollten und Kuscheltiere unseres Wissens nach noch nicht darüber debattieren.) So sieht der body Ihres HomeScreens nach der Änderung aus: body: SafeArea( child: Padding( padding: const EdgeInsets.all(24), child: ListView.builder( itemCount: pets.length, itemBuilder: (context, index) { return ListTile( leading: Icon( pets[index].isFemale ? Icons.female : Icons.male, color: const Color(0xFFFFC942), size: 40, ), title: Text(pets[index].name), subtitle: Text( "Alter: ${pets[index].age} Jahre ", ), trailing: const Icon( Icons.chevron_right_rounded, ), onTap: () {}, ); }, ), ), ),
Listing 13.3: HomeScreen mit isFemale-Check
Nach Tierarten unterscheiden Im Moment zeigt der DetailPetScreen immer das Bild eines Hundes, egal welches Tier dem Screen übergeben wird. Dieses Bild soll jetzt dynamisch ausgetauscht werden, je nachdem, welche Spezies das Kuscheltier ist. Zusätzlich sollen im DetailPetScreen zwei weitere _InfoCard-Widgets hinzugefügt werden, mit dem Geschlecht und der Spezies des Kuscheltiers. Sie können Fotos Ihrer Wahl verwenden oder die Fotos benutzen, die wir Ihnen zur Verfügung stellen. https://losfluttern.de/pummelthefish/fish.jpg https://losfluttern.de/pummelthefish/dog.jpg
https://losfluttern.de/pummelthefish/cat.jpg https://losfluttern.de/pummelthefish/bird.jpg
Speichern Sie die Fotos in Ihrem assets-Ordner oder binden Sie die Fotos über die Links mit dem .network-Konstruktor ein. So sollte der body Ihres DetailPetScreens nun aussehen. body: SafeArea( child: Column( children: [ Stack( children: [ Image.asset( pet.species == Species.dog ? "assets/images/dog.png" : pet.species == Species.bird ? "assets/images/bird.png" : pet.species == Species.cat ? "assets/images/cat.png" : "assets/images/fish.png", ), Positioned(…) ], ), Padding( padding: const EdgeInsets.symmetric( vertical: 40, horizontal: 24, ), child: Column( children: [ _InfoCard( labelText: "Name des Kuscheltiers:", infoText: pet.name, ), _InfoCard( labelText: "Alter:", infoText: "${pet.age} Jahre", ), _InfoCard( labelText: "Größe & Gewicht:", infoText: "${pet.height} cm / ${pet.weight} Gramm", ), _InfoCard( labelText: "Geschlecht:", infoText: pet.isFemale ? "Weiblich" : "Männlich", ), _InfoCard( labelText: "Spezies:", infoText: pet.species == Species.dog ? "Hund" : pet.species == Species.bird
? "Vogel" : pet.species == Species.cat ? "Katze" : "Fisch", ), ], ), ), ], ), ),
Listing 13.4: DetailPetScreen mit Wenn-dann Verzweigung
Die Wenn-dann-Verzweigungen in der Widget build-Methode sind zwar ganz lustig, werden aber sehr schnell unübersichtlich. Es ist Best Practice, diese Logik in Helper-Funktionen auszulagern. Denken Sie daran, die main.dart anzupassen, wenn Sie sich den Screen im Emulator anschauen möchten. Definieren Sie diesmal ein anderes Pet-Objekt, um zu sehen, dass Ihre Änderungen funktionieren. Je nach gewähltem Objekt kann Ihr DetailPetScreen jetzt aussehen wie in Abbildung 13.1.
Abbildung 13.1: Der neue DetailPetScreen
Natives Design Flutter-Apps sehen auf Android und iOS-Geräten erst einmal gleich aus – es sei denn, Sie programmieren es anders. Sie können mithilfe des dart:io-Packages die Plattform des Gerätes abfragen. Neben dem Material-Design-Package, das Sie schon in jeder Ihrer UIDateien importiert haben, können Sie außerdem das Cupertino-Package importieren. Dann können Sie plattformspezifisches Design umsetzen. Erweitern Sie Ihre CustomButton-Klasse, sodass sie plattformspezifisch unterschiedliche Widgets zurückgibt: einen CupertinoButton, wenn die App von einem iOSBetriebssystem geöffnet wird, und den Button, so wie er jetzt aussieht, wenn die App auf einem beliebigen anderen Betriebssystem geöffnet wird. So kann Ihre CustomButton-Klasse mit den entsprechenden Änderungen aussehen: import "dart:io" show Platform; import "package:flutter/cupertino.dart"; import "package:flutter/material.dart"; class CustomButton extends StatelessWidget { final Function onPressed; final String label; const CustomButton({ super.key, required this.onPressed, required this.label, }); @override Widget build(BuildContext context) { return Platform.isIOS ? CupertinoButton( onPressed: () => onPressed(), child: Text(label), ) : GestureDetector( onTap: () => onPressed(), child: Container( width: 160, height: 50, decoration: const BoxDecoration( borderRadius: BorderRadius.all( Radius.circular(20), ), color: Color(0xFFFFC942), ), child: Center(
child: Text( label, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white, ), ), ), ), ); } }
Listing 13.5: Plattform-Check im CustomButton Passen Sie die main.dart-Datei an, damit der CreatePetScreen als erster Screen aufgerufen wird. Starten Sie die App nacheinander auf einem Android-Emulator (Abbildung 13.2) und auf einem iOS-Simulator (Abbildung 13.3) und vergleichen Sie das Ergebnis.
Abbildung 13.2: Der CreatePetScreen auf dem Android-Emulator
Abbildung 13.3: Der CreatePetScreen auf einem iOS-Simulator
Leider ist es nicht möglich, einen iOS-Simulator auf einem Windows-Rechner zu starten. Dieses Experiment können Sie daher nur durchführen, wenn Sie mit einem Mac arbeiten. Auf einem Mac können Sie sowohl Android-Emulator als auch iOSSimulator starten.
Responsiveness umsetzen Ein anderer Grund, if-else-Verzweigungen im UI-Code zu benutzen, kann Responsiveness sein. Um einen Screen »responsive« zu programmieren, bietet es sich zuallererst an, Widgets keine konkreten Größen zu geben, sondern relative Größen. Trotzdem werden Sie sicher manchmal in die Lage kommen, dass ein Widget eine feste Breite oder Höhe braucht. Dann kann es Probleme geben, wenn die App zum Beispiel auf einem besonders kleinen Screen geöffnet wird oder die Ausrichtung des Screens wechselt. Viele Apps, die für das Smartphone in vertikaler Ausführung designt wurden, sehen nicht mehr so schön aus, wenn Sie auf einem Tablet geöffnet werden, weil einzelne Elemente viel zu groß werden oder Flächen leer stehen. Es ist also sinnvoll, das Gerät und die Ausrichtung abzufragen und dann entsprechend die Größen der Widgets anzupassen.
Screen-Größe mit MediaQuery prüfen Öffnen Sie erneut Ihre CustomButton-Klasse. Ihr Button soll in der Größe angepasst werden, je nachdem, von welchem Gerät die App aufgerufen wird. Wenn die App auf einem Android-Tablet geöffnet wird, soll der CustomButton etwas breiter sein, als wenn sie auf einem Android-Smartphone geöffnet wird. Dafür passen Sie das Container-Widget im GestureDetector folgendermaßen an: Container( width: MediaQuery.of(context).size.width> 600 ? 300 : 160, height: 50, decoration: const BoxDecoration( borderRadius: BorderRadius.all( Radius.circular(20), ), color: Color(0xFFFFC942), ), child: Center( child: Text( label, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16,
color: Colors.white, ), ), ), ),
Listing 13.6: Screengröße-Check im CustomButton Über MediaQuery.of(context) bekommen Sie verschiedene Informationen zu dem Gerät beziehungsweise der tatsächlich auf dem bestimmten Gerät ausgelieferten App. In dem Code-Beispiel checken Sie die Breite des Gerätes und liefern ab einer bestimmten Breite einen breiteren Button aus. Mit MediaQuery.of(context) kann nicht nur die Screen-Größe herausgefunden werden, sondern beispielsweise auch Orientierung, Screen-Kontrast und Schriftgröße des Endgeräts. Öffnen Sie jetzt die App einmal in einem Android-Emulator (Abbildung 13.4) und einmal im Android-Tablet oder als Desktop-App (Abbildung 13.5).
Abbildung 13.4: Der Speichern-Button auf dem Smartphone
Abbildung 13.5: Der Speichern-Button auf der Desktop-App
Der SPEICHERN-Button ist wie gewünscht breiter, wenn die App als Desktop-App anstatt als Smartphone-App geöffnet wird.
Ausrichtung mit MediaQuery prüfen Wie Sie sehen, sind die einzelnen Form-Elemente des CreatePetScreens sehr lang, wenn die App im Desktop-Emulator geöffnet wird. Das legt nahe, dass dieser Screen auf einem horizontal ausgerichteten Smartphone oder Tablet auch nicht super aussieht. Darum könnten Sie der Form im CreatePetScreen ein größeres horizontales Padding geben, wenn er auf einem horizontal ausgerichteten Gerät angezeigt wird. Das horizontale Padding beträgt aktuell 24 Pixel und das soll für vertikal ausgerichtete Geräte weiterhin so bleiben. Für horizontale Ausrichtung soll das Padding 1/5 der Screen-Breite betragen und vertikal auf jeweils 40 erhöht werden. Ändern Sie den padding-Parameter Wert im SingleChildScrollView-Widget im CreatePetScreen wie folgt: SingleChildScrollView( padding: MediaQuery.of(context).orientation == Orientation.portrait ? const EdgeInsets.all(24) : EdgeInsets.symmetric( vertical: 40,
horizontal: MediaQuery.of(context).size.width / 5, ), child: … ),
Listing 13.6: Screengröße-Check im CustomButton Überprüfen Sie den Screen nun erneut auf einem horizontal ausgerichteten Emulator, beispielsweise als macOS-Desktop-App (Abbildung 13.6): Wie Sie sehen, ist das Padding auf einem horizontal ausgerichteten Endgerät wie dem macOS-Desktop jetzt größer und die Formelemente sind nicht mehr so breit. Mit MediaQuery.of(context) können Sie einige coole Sachen machen – aber es ist nicht unbedingt Best Practice, wenn es um umfassende Responsiveness einer App geht. Wie Sie schon wissen, gibt es in Flutter meistens mehrere Wege nach Rom. Wenn Sie Ihre App responsive programmieren möchten, empfehlen wir Ihnen, sich mit dem LayoutBuilder-Widget zu beschäftigen. Das LayoutBuilder-Widget checkt die Höhe oder Breite des Endgerätes, und das OrientationBuilder-Widget gibt Ihnen die Möglichkeit, elegant auf die Ausrichtung zu reagieren.
Abbildung 13.6: Der CreatePetScreen mit Padding-Anpassung
Jetzt haben Sie die Basics der UI-Entwicklung mit Flutter schon nicht mehr nur auf dem
Papier, sondern bereits in Ihrem Kopf! Fantastisch. Bis jetzt ist das aber alles sehr statisch. Wie wird aus den einzelnen Screens jetzt ein AppErlebnis? Dafür lernen sie im nächsten Kapitel die Grundlagen der Navigation (oder auch des »Routings«) in Flutter kennen. Schauen Sie sich alle Screens Ihrer App noch einmal auf einem Tablet-Emulator an, in horizontaler und vertikaler Ausrichtung. Was sieht gut aus und was nicht? Passen Sie das Layout an, wo es nötig ist, mit den Tools, die Sie gerade gelernt haben!
Kapitel 14
Wo gehts hier lang? Routing in FlutterApps IN DIESEM KAPITEL Triggern Sie Screen-Wechsel durch User-Input Bringen Sie Ordnung in Ihre Navigation mit Named Routing
In den vorangegangenen Kapiteln haben Sie gelernt, dass Ihre App ein riesiger, verzweigter Widget-Baum ist, der dem MaterialApp-Widget entspringt, und dass der Screen, der dort referenziert wird, der Eingangs-Screen Ihrer App ist. Aber wie können Sie auch die anderen Screens einbinden, sodass das MaterialApp-Widget sie kennt und zwischen ihnen navigieren kann? Dieses Navigieren zwischen Screens nennt man auch »Routing«. Es gibt verschiedene Routing-Ansätze bei Flutter, wir beginnen mit dem einfachsten und bauen ihn dann zu einem Named-Routing-Ansatz aus, mit dem Sie auch größere App-Projekte meist gut managen können. Zuletzt gibt es noch einen Ausblick auf komplexeres Routing.
Wie gehts zum nächsten Screen? Es gibt mehrere Wege, um von einem Screen zum nächsten zu gelangen. In der Regel warten Sie entweder auf User-Input, zum Beispiel ein Tappen auf ein bestimmtes Element, um die Navigation zu triggern, oder Sie warten darauf, dass bestimmte Daten geladen wurden, bevor Sie einen nächsten Screen aufrufen – oder beides. Der erste Screen Ihrer App soll der SplashScreen sein. Ändern Sie dafür den homeParameter des MaterialApp-Widgets in der main.dart-Datei so, dass der SplashScreen aufgerufen wird. Der SplashScreen soll wiederum den HomeScreen aufrufen. Schauen Sie sich ein paar andere Apps an, die Sie auf Ihrem Smartphone haben. Wenn Sie eine App öffnen, erscheint in der Regel ein Screen mit dem Logo der App, der nach ein paar Sekunden zum nächsten Screen wechselt, ohne dass Sie interagieren müssen. So soll das in der »Pummel The Fish« App auch umgesetzt werden. Aber da wir noch keine Daten laden, auf die wir warten können, mocken wir dieses Verhalten mit einem Timer. Sie starten einen Timer, der direkt bei Aufruf des Screens anfängt zu zählen und dann nach gewünschter Zeit die Funktion zum Wechsel des Screens aufruft.
Wenn Sie in Flutter einen Launch Screen anzeigen wollen (auch »Splash Screen« genannt), bevor Flutter initialisiert ist, können Sie dafür die nativen Launch-ScreenImplementationen der jeweiligen Plattformen verwenden. Mehr Informationen dazu finden Sie unter: https://docs.flutter.dev/development/ui/advanced/splashscreen. Beachten Sie jedoch, dass diese nativen Launch Screens lediglich ein Bild anzeigen können und automatisch zu Ihrem unter home definierten Screen navigieren, sobald das Flutter-Framework fertig initialisiert wurde. Möchten Sie komplexere Splash Screens mit Animationen, Text und Co. anzeigen oder möchten Sie den Splash-Screen so lange anzeigen, wie bestimmte Daten laden, verwenden Sie einen Custom Splash Screen analog zu dem, den Sie hier gebaut haben und nun integrieren.
Navigieren durch eine Timer-Funktion – der SplashScreen Der Plan lautet wie folgt: Der SplashScreen wird für drei Sekunden angezeigt und danach navigieren Sie automatisch, ohne zusätzlichen User-Input, zum HomeScreen. Um das zu realisieren, fügen Sie eine Timer-Funktion in Ihre build-Methode ein, sobald der Screen gebaut wird. Wenn der Timer nach drei Sekunden abgelaufen ist, wird die pushMethode des Navigators aufgerufen und weiter gehts zum HomeScreen. import "package:flutter/material.dart"; import "dart:async"; import "package:pummel_the_fish/screens/home_screen.dart"; class SplashScreen extends StatelessWidget { const SplashScreen({super.key}); @override Widget build(BuildContext context) { Timer(const Duration(seconds: 3), () { Navigator.push( context, MaterialPageRoute( builder: (_) => const HomeScreen(), ), ); }); return Scaffold( body: SafeArea( child: Center( child: Padding( padding: const EdgeInsets.all(64), child: Image.asset( "assets/images/logo.png", ), ), ), ),
backgroundColor: Colors.blue, ); } }
Listing 14.1: Der SplashScreen mit Timer Die home_screen.dart-Datei muss importiert werden, und damit die Timer-Funktion nicht mehr rot unterkringelt ist, müssen Sie zusätzliche Dart-Funktionalität einbinden. Dies machen Sie, indem Sie das dart:async-Package importieren. import "dart:async";
Starten Sie den Emulator erneut und beobachten Sie, wie der SplashScreen nach drei Sekunden automatisch zum HomeScreen wechselt. Das war einfach, oder?
Navigieren durch User-Input – der HomeScreen Wenden Sie sich nun dem HomeScreen zu. Hier wird eine Liste von Kuscheltieren angezeigt. Wenn eines der Elemente der Liste angeklickt wird, soll zum DetailPetScreen navigiert werden, der mehr Informationen zum ausgewählten Tier anzeigt. Um dieses Verhalten zu erreichen, rufen Sie die Navigator.push-Methode im onTapParameter des ListTile-Widgets auf. Wenn Sie zum DetailPetScreen navigieren, übergeben Sie dabei auch das Pet-Objekt, zu dem die anwendende Person die Informationen sehen möchte. onTap: () { Navigator.push( context, MaterialPageRoute( builder: (_) => DetailPetScreen(pet: pets[index]), ), ); },
Listing 14.2: Der HomeScreen navigiert zum DetailPetScreen So weit, so gut. Sie sind aber noch nicht ganz fertig mit diesem Screen. Der FloatingActionButton im HomeScreen soll den CreatePetScreen öffnen, damit die benutzende Person hier ein neues Pet zur Adoption einstellen kann. Dafür rufen Sie die Navigator.push-Methode im onPressed-Parameter des ActionButtons auf. floatingActionButton: FloatingActionButton( onPressed: () { Navigator.push( context, MaterialPageRoute( builder: (_) => const CreatePetScreen(), ),
); }, child: const Icon(Icons.add), ),
Listing 14.3: HomeScreen navigates to CreatePetScreen Hot-reloaden Sie jetzt den Emulator und bestaunen Sie Ihr neues Navigationskonzept! Versuchen Sie, es so einzurichten, dass der »Speichern«-Button auf dem CreatePetScreen zum HomeScreen navigiert.
Navigieren mit Back-Button – DetailPetScreen und CreatePetScreen Bei Ihrem ersten Navigationstestlauf ist Ihnen sicher aufgefallen, dass Sie dank eines kleinen Zurück-Pfeils in der linken oberen Ecke des DetailPetScreen und CreatePetScreen problemlos zurücknavigieren konnten. Diesen Pfeil haben Sie gar nicht wissentlich programmiert. Kann Flutter etwa Gedanken lesen? Anscheinend schon – oder zumindest mitdenken. Wenn Sie in Ihrem AppBar-Widget dem leading-Parameter kein Widget übergeben und schon mindestens ein Screen auf dem Backstack liegt, bekommt die AppBar automatisch einen Zurück-Pfeil als leadingWidget. Bei Klick auf den Pfeil wird der aktuelle Screen »gepoppt«, das heißt, er wird gelöscht und der Screen, der darunter auf dem Backstack liegt, wird erneut angezeigt. Was der Backstack ist und wie er funktioniert, werden Sie im Unterkapitel »Ich verfolge Sie auf Schritt und Klick – der Backstack« noch genauer erfahren. Falls Sie keinen Pfeil für die Rücknavigation in der AppBar anzeigen wollen, können Sie den AppBar-Parameter automaticallyImplyLeading auf false setzen.
Named Routing – beim Navigieren den Überblick behalten Wie bereits angekündigt bietet Flutter verschiedene Routing-Ansätze. Der Ansatz, den Sie gerade verwendet haben, wird eigentlich nur in Beispiel-Apps benutzt oder in sehr kleinen Projekten. Das Problem mit diesem Routing-Ansatz ist, dass er schnell unübersichtlich wird. Es gibt keine Übersicht über alle Screens, die in der App vertreten sind. Dieses Konzept werden Sie daher nun mit einem anderen Konzept ersetzen, dem sogenannten »Named Routing«. Es ist die einfachste und zugleich übersichtlichste Art und Weise, Routing in einer kleinen bis mittelgroßen App zu managen, und basiert auf einer übersichtlichen Liste aller in der App verfügbaren Routen und dazugehörigen
Screens.
Routen definieren Lassen Sie uns damit beginnen, alle benötigen Routen zu definieren. Hierfür machen Sie einen Ausflug in die main.dart ins MaterialApp-Widget. Dort werden Sie nun eine Map erstellen, die einem Routen-Name, welcher in Form eines Strings definiert wird, einen Screen zuordnet. Dieser Routen-Name kann dann später vom Navigator aufgerufen werden. Der Navigator macht also nichts anderes als die erstellte Map nach dem Namen zu durchsuchen und zum entsprechenden Screen zu navigieren. Suchen Sie im MaterialApp-Widget nach dem home-Parameter und ersetzen Sie diesen mit dem routes- und dem initialRoute-Parameter. Der routes-Parameter nimmt die gerade erwähnte Map entgegen. Den ersten Screen benennt man in der Regel mit einem einfachen Backslash, die anderen mit Backslash plus dem tatsächlichen Namen des Screens. Der initialRoute-Parameter referenziert dann auf den Routen-Namen, mit dem die App starten soll – analog dem bisher benutzten home-Parameter. MaterialApp( title: "Pummel The Fish", initialRoute: "/", routes: { "/": (context) => const SplashScreen(), "/home": (context) => const HomeScreen(), "/create": (context) => const CreatePetScreen(), }, );
Listing 14.4: MaterialApp Named Routing Anstatt Navigator.push benutzen Sie nun in den Screen-Klassen die Funktion Navigator.pushNamed und übergeben den entsprechenden String. Passen Sie die beiden Navigator.push-Aufrufe entsprechend an. In der splash_screen.dart: Timer(Duration(seconds: 3), () { Navigator.pushNamed(context, "/home"); });
Listing 14.5: SplashScreen Named Routing – Anpassung In der home_screen.dart: onTap: () { Navigator.pushNamed(context, "/create"); },
Listing 14.6: SplashScreen Named Routing – Anpassung Sie können jetzt auch die Importe der Screen-Dateien löschen. Damit haben Sie
erfolgreich Named Routing in Ihrer App implementiert. Nur der DetailPetScreen-Aufruf fehlt hier: Um Variablen an einen Screen zu übergeben, müsste man hier mit Argumenten arbeiten – das führt an dieser Stelle zu weit, können Sie aber gern selbstständig recherchieren. Die Flutter-Dokumentation erklärt das super. Sie können hier also wie gehabt zum DetailPetScreen navigieren, ohne Named Routing. Wenn Sie in Zukunft einen neuen Screen kreieren, denken Sie daran, ihn in der Map im MaterialApp-Widget zu benennen! Lernen Sie mit der Flutter-Dokumentation, Argumente an Named Routes zu übergeben: https://docs.flutter.dev/cookbook/navigation/navigate-witharguments. Wenden Sie das Gelernte auf den DetailPetScreen-Aufruf an.
Ich verfolge Sie auf Schritt und Klick – der Backstack Wenn Sie über die Navigator.push-Funktion einen neuen Screen »pushen« (unerheblich, ob mit einfachem oder mit Named Routing) wird dieser Screen im Backstack Ihrer App auf den vorherigen Screen gelegt. So bildet sich mit der Zeit ein Stapel an Screens. Von diesem Stapel können Sie Screens wieder herunternehmen. Wenn Sie die Funktion manuell aufrufen wollen, zum Beispiel wenn die nutzende Person einen Zurück-Button klickt, können Sie ihn »poppen«. Navigator.pop();
Die benutzende Person sieht nun den Screen, der auf dem Backstack darunter lag – also der Screen, auf dem Sie vorher waren. Diese Funktion wird automatisch aufgerufen, wenn der Android-User auf den in seinem Smartphone integrierten Zurück-Button klickt. Wenn Sie das AppBar-Widget bei Ihren Screens hinzufügen, wird bei Klick auf den automatisch hinzugefügten Zurück-Pfeil ebenfalls die Funktion Navigator.pop ausgeführt. Beachten Sie, dass iOS-Nutzende keinen integrierten Zurück-Button auf Ihren Smartphones haben, im Gegensatz zu Android-Nutzenden. Wenn ein Screen eine Sackgasse ist, von dem aus sich nicht weiternavigieren lässt (wie in unserem Beispiel der DetailPetScreen), sollten Sie immer darauf achten, dass es entweder eine AppBar mit integriertem Zurück-Button gibt oder eine andere Art und Weise, zurückzunavigieren. Wenn Sie eine App konzipieren, sollten Sie sich auch immer über den Backstack Gedanken machen. In unserer Beispiel-App stellt sich unter anderem die Frage, was passiert, wenn wir vom HomeScreen mehrmals in verschiedene Instanzen des
DetailPetScreens gehen. Nehmen wir an, die Bewegung einer Nutzerin ist wie in
Abbildung 14.1.
Abbildung 14.1: Der Weg einer Nutzerin
Zurück mit Navigator.pop() Wenn Sie alle Screens mit Navigator.pushNamed aufeinanderstapeln und dann mit Navigator.pop zurücknavigieren – zum Beispiel mithilfe des Pfeils in der AppBar oder des integrierten Zurück-Buttons auf dem Android-Smartphone – wäre der Rückweg Ihrer Nutzerin wie in Abbildung 14.2.
Abbildung 14.2: Der Rückweg der Nutzerin
Den DetailPetScreen mit Bruno und den mit Pummel würde Ihre Nutzerin nicht noch einmal zu Gesicht bekommen, weil diese vom Backstack entfernt wurden, als zum HomeScreen mit Navigator.pop zurücknavigiert wurde.
Bloß nicht zurück – Navigator.pushReplacementNamed() Dieses Backstack-Verhalten kommt dem gewünschten Verhalten in einer App schon nah. Bis auf ein Detail: Der SplashScreen sollte nicht gezeigt werden, wenn vom HomeScreen zurücknavigiert wird. Das heißt, wenn Sie vom SplashScreen zum HomeScreen navigieren, sollte der SplashScreen vom Stapel genommen werden, bevor der HomeScreen daraufgelegt wird. Dafür nutzen Sie einfach Navigator.pushReplacementNamed anstatt Navigator.pushNamed. Passen Sie das entsprechend in Ihrer »Pummel The Fish«-App an.
Timer(Duration(seconds: 3), () { Navigator.pushReplacementNamed(context, "/home"); });
Listing 14.7: pushReplacementNamed Der Rückweg Ihrer Nutzerin sollte nun ablaufen wie in Abbildung 14.3.
Abbildung 14.3: Der Rückweg der Nutzerin ohne SplashScreen
Jetzt haben Sie das gewünschte Backstack-Verhalten in Ihrer App integriert. Falls Sie sich einmal nicht sicher sind, wie dieses Verhalten am elegantesten aussehen sollte, checken Sie einfach, wie Ihre Lieblings-Apps das lösen. In der Übung oben haben Sie den SPEICHERN-Button im CreatePetScreen zum HomeScreen navigieren lassen. Das legt den HomeScreen erneut oben auf den Stapel. Üben Sie nun, wie Sie stattdessen mithilfe von Navigator.pop den CreatePetScreen wieder vom Backstack herunternehmen.
Zurück auch ohne Navigator.pop Um Ihr Toolkit zu erweitern und zu lernen, was mit dem Backstack sonst noch möglich ist, lassen Sie uns noch ein weiteres Szenario betrachten: Nehmen wir an, Sie wollen, dass Ihre Nutzerin – wie im Web üblich – bei häufigem Drücken des »Zurück«-Buttons einfach den gesamten Hinweg Schritt für Schritt wieder zurückgeht, wie hier in Abbildung 14.4 gezeigt.
Abbildung 14.4: Der Rückweg der Nutzerin wie im Web üblich
Diese Variante können Sie erreichen, wenn Sie alle Screens mit Navigator.pushNamed im Backstack aufeinanderlegen, wie anfangs umgesetzt, und außerdem die Navigator.popFunktion im DetailPetScreen überschreiben. Anstatt den automatisch generierten Zurück-Button in ihrer AppBar zu benutzen, können Sie im leading-Parameter manuell einen IconButton einfügen, der genauso aussieht. Dieser ruft aber Navigator.pushNamed statt Navigator.pop auf. So wird mit jeder Zurücknavigation nicht der DetailScreen vom Backstack gelöscht, sondern der HomeScreen erneut auf den Backstack gelegt. appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () { Navigator.pushNamed(context, "/home"); }, ), title: Text(pet.name), ),
Listing 14.8: IconButton in der AppBar des DetailPetScreens Aber wie können Sie die Navigator.pop-Methode überschreiben, die automatisch vom Zurück-Button von Android-Smartphones aufgerufen wird? Ein Widget muss her! Das WillPopScope-Widget. class _DetailPetScreenState extends State { @override Widget build(BuildContext context) { return WillPopScope( onWillPop: () async { Navigator.pushNamed(context, "/home"); return false; }, child: Scaffold(
appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () { Navigator.pushNamed(context, "/home"); }, ), title: Text(pet.name), ), body: …, ), ); } }
Listing 14.9: Das WillPopScope-Widget Das WillPopScope-Widget wrapped sich um das Scaffold-Widget. In seinem Parameter onWillPop fängt es die Zurücknavigation ab, wenn false zurückgegeben wird. Sie müssen hier nicht einfach false zurückgeben, Sie können auch verschiedene Varianten checken und dann entweder true oder false zurückgeben. Sie können zum Beispiel einen Dialog anzeigen und je nach Beantwortung durch die benutzende Person die Zurücknavigation entweder erlauben oder nicht. Sie sollten jetzt eine gute Grundkenntnis haben, wie Sie durch Ihre App navigieren können. Es ist noch viel mehr möglich – aber Sie kennen jetzt ein paar Rädchen, an denen Sie drehen können, und sollten jetzt schon ganz gut in der Lage sein, den nötigen Code für Ihren Anwendungsfall wie ein Profi zu ergoogeln.
Routing im Web und für Fortgeschrittene Auch wenn unsere Beispiel-App mit dem vorgestellten Routing und dem verwendeten Navigator prima auskommt, möchten wir dennoch den Hinweis geben, dass sich das schnell ändern kann, wenn Sie Ihre App zum Beispiel auch als Web-Version veröffentlichen möchten. Das Routing per Browser-Adressleiste – also dort, wo Sie die URL eingeben – funktioniert mit dem Named-Routing-Ansatz nämlich nicht immer. Nehmen Sie als Beispiel den DetailPetScreen und stellen Sie sich vor, Sie geben im Browser die URL https://pummelthefish.de/detail ein. Wo genau kommt das Objekt nun her, das Sie anzeigen möchten? Abseits davon gibt es noch weitere Fälle, wie zum Beispiel den Umgang mit Deeplinks und komplexeres Nested Routing, in denen Sie mit dem Navigator und dem Named Routing an Grenzen stoßen werden. Selbstverständlich bietet Flutter hier auch eine Lösung an, und zwar den Navigator 2. Während Sie mit dem Navigator 1, den Sie gerade kennengelernt haben, nur neue Screens auf den aktuellen Stack legen und von dort entfernen können, können Sie mit dem
Navigator 2 den gesamten Stack kontrollieren. Sobald Sie an diese oder ähnliche Grenzen mit dem aktuellen Navigationskonzept stoßen, werfen Sie also am besten einen Blick auf das Navigator-2-Konzept in der FlutterDokumentation oder ziehen Sie Packages wie go_router oder beamer in Betracht, die Ihnen das Leben vereinfachen werden, indem sie einen Wrapper um den Navigator-2Ansatz zur Verfügung stellen. Sobald Sie Routing in Ihrer App integriert haben – egal in welcher Art und Weise – wird Ihre App eine richtige App. Und Sie werden vielleicht merken, dass Design in Ihrer App nicht nur per Screen gedacht werden darf. Damit Ihre App »rund« aussieht, sollten nicht nur die einzelnen Screens gut aussehen, die Screens sollten auch untereinander zusammenpassen. Um eine Designkonsistenz auch für eine App mit vielen Screens gewährleisten zu können, sollten Sie sich Gedanken um das Theming machen. Dabei wird Ihnen das nächste Kapitel behilflich sein.
Kapitel 15
Mach alles blau – Theming für Ihre App IN DIESEM KAPITEL Lernen Sie die Material-Design-Prinzipien kennen Bekommen Sie einige Tipps und Tricks, um Ihre App einfach aufzuhübschen Legen Sie ein eigenes Theming an Lernen Sie, wie Sie Fonts in Ihre App integrieren können
Flutter macht es Ihnen einfach, ein Theming für die gesamte App zu implementieren. Das kann echt praktisch sein, wenn Sie sich nicht zu lange mit Gedanken über Design aufhalten wollen. In diesem Kapitel werden Sie lernen, wie Sie ein einfaches Theming einrichten können – aber auch, wie Sie ein spezifisches vorgegebenes, komplexes Design umsetzen können. Sie werden außerdem lernen, wie Sie Fonts importieren und benutzen können. Ein kleiner Einblick in bereits enthaltene Design-Assets von Flutter darf natürlich auch nicht fehlen. Flutter gibt Ihnen die Möglichkeit, jedes Design einfach umzusetzen. Sie müssen sich nicht an Android- oder iOS-Design-Guidelines halten – Sie können das aber, wenn Sie möchten. Wenn Sie sich aus den Design-Welten bedienen wollen, steht es Ihnen in Flutter frei, sich aus der Cupertino (Apple) oder Material Design Library zu bedienen. Da Flutter wie Android zu Google gehört, wird in Flutter-Tutorials und -Beispielen eher auf Material Design zurückgegriffen. Auch wir konzentrieren uns hier auf das Material-DesignKonzept. Bevor Sie eine App programmieren, bietet es sich an, ein Design zu skizzieren. Am einfachsten und pragmatischsten geht das mit Papier und Stift. Unterstützend dazu gibt es online Papierblöcke zu kaufen, auf denen schon Smartphone- und TabletUmrandungen vorgedruckt sind, und Schablonen, die beim Malen von oft verwendeten Icons helfen können. Zu oldschool für Sie? Na gut, wenn Sie das Ganze lieber online in einem professionellen Programm designen wollen, bietet sich zum Beispiel Figma als Prototyping-Tool in der Cloud an. Hier können Sie Designs und sogar klickbare Dummies erstellen und ganz einfach mit anderen teilen. Auch wenn Sie noch keinen Design-Background haben, finden Sie sich hier schnell zurecht. Wenn Sie Erfahrung mit der Adobe-Welt haben, ist Adobe XD ein etwas kostspieligeres Tool für denselben Zweck.
Wo das Theming in Ihrer App haust Das Theme einer Flutter-App wird im theme-Parameter des MaterialApp-Widgets in der main.dart definiert. Das Widget ThemeData wird hier übergeben. Mit den Parametern dieses Widgets können generelle Theme-Werte angegeben werden, die in der gesamten App dann automatisch verwendet oder aufgerufen werden können. Sie können Farben und Schriftarten definieren und recht spezifisch zuordnen sowie ein paar weitere Standard-Einstellungen festlegen. Wenn Sie zum Beispiel eine bestimmte Hintergrundfarbe Ihrer App-Screens festlegen, wird diese Farbe automatisch der DefaultWert in jedem Screen. Sie können diesen Default-Wert trotzdem jederzeit für einen speziellen Screen lokal überschreiben. Zum Zeitpunkt des Schreibens ist Material Design 3 gerade noch in der Einführung, und um es zu aktivieren, sollten Sie in Ihrem ThemeData-Widget manuell folgenden Parameter einbinden. useMaterial3: true,
Das mag allerdings zu dem Zeitpunkt, zu dem Sie dieses Buch lesen, schon obsolet und Material Design 3 Standard geworden sein. Das ThemeData-Widget wird ganz oben im MaterialApp-Widget angesiedelt und ist somit für alle Widget-Verästelungen verwendbar. Um das zu verdeutlichen, haben wir die Grafik des Widget-Baums, die Sie schon kennen, in Abbildung 15.1 für Sie angepasst. Das ThemeData-Widget befindet sich im Umbruch – und das schon seit einiger Zeit. Flutter orientiert sich hier an den Material Design Guidelines von Google. Wenn sich die Material Design Guidelines ändern, wird dies meist zuerst in der Android-Welt umgesetzt, und dann zieht Flutter kurz darauf nach. Und in den letzten Jahren hat sich hier einiges geändert. Wenn bei Ihnen im ThemeData-Widget etwas nicht funktioniert, achten Sie am besten darauf, dass Sie Ihre Informationen aus einem aktuellen Artikel oder Tutorial haben oder optimalerweise aus der offiziellen Dokumentation. Material Design ist eine Sammlung von Design Guidelines, Tipps und Werkzeugen von Google, um digitale User Interfaces in der mobilen und der Web-Welt zu gestalten. In Flutter importieren Sie diese Tools schon automatisch, wenn Sie folgende Zeile an den Beginn Ihrer Datei stellen: import "package:flutter/material.dart";. Material Design arbeitet mit Farbpaletten. Eine Farbe ist die Primärfarbe und ihre Schattierungen werden die primäre Farbpalette. Die Flutter-Beispiel-App basiert auf einer blauen Farbpalette mit dem typischen Flutter-Blau als Hauptfarbe. Eine einzige Farbpalette reicht schon, um eine gesamte App zu designen, wie die Flutter-Beispielapp zeigt. Wenn Sie für
Ihre App Material 3 aktivieren, kann die Farbpalette automatisch aus einer sogenannten »Seed«-Farbe generiert werden. Erfahren Sie mehr zu dem DesignKonzept unter https://m3.material.io/.
Abbildung 15.1: Widget-Baum mit ThemeData-Widget
Farbe bekennen! Die Material Design Library, die in Flutter integriert ist, stellt verschiedene Farbpaletten und verschiedene integrierte Farben zur Verfügung. An diesen können Sie sich bedienen, ohne auf Hex- oder RGB-Werte zurückgreifen zu müssen.
Color versus MaterialColor Ein MaterialColor-Widget ist keine einzelne Farbe, sondern eine sogenannte »Swatch«. Eine Swatch ist eine Farbpalette aus verschiedenen Schattierungen einer Grundfarbe. Diese einzelnen Farbschattierungen sind wiederum Objekte der Klasse Color. Mit Colors.red kann auf eine rote Farbpalette aus zehn Farben zugegriffen werden. Mit Colors.red.shade200 kann auf eine bestimmte Farbe der Klasse Color zugegriffen werden, die eine Schattierung der MaterialColor-Farbpalette Colors.red darstellt. Lassen Sie sich nicht verwirren! Colors.red bezeichnet sowohl die MaterialColor Rot mit ihren zehn Farbschattierungen als auch die Hauptfarbe derselben Palette: die Color Rot; genauer genommen kann es kurz stehen für Colors.red.shade500.
Einzelne Farben – oder gleich die ganze Palette? Im ThemeData-Widget werden entweder einzelnen Parametern einzelne Farben zugeordnet oder es wird eine Farbpalette für die ganze App definiert, aus der sich einzelne Parameter automatisch bedienen. Wenn Sie einzelnen Entitäten einzelne Farben zuweisen möchten (Abbildung 15.2 stellt einen zugegeben gewagten Versuch dar), übergeben Sie dem colorScheme-Parameter ein ColorScheme-Objekt und definieren dessen Parameter.
Abbildung 15.2: ColorScheme mit einzeln definierten Farben
ThemeData( useMaterial3: true, colorScheme: const ColorScheme( primary: Colors.pink, brightness: Brightness.dark, secondary: Colors.red, onSecondary: Colors.white, background: Colors.pinkAccent, … ),
Listing 15.1: Einzelfarben definieren im ThemeData-Widget Dieser Code muss noch von Ihnen ergänzt werden – Flutter möchte, dass Sie hier alle Parameter angeben. Der brightness-Parameter definiert im Übrigen, ob Ihr Farbschema eher hell oder eher dunkel ausfällt. Seien Sie froh, dass dieses Buch keine farbigen Fotos ermöglicht, unsere Farbzusammenstellung sieht nicht sehr schön aus. Abbildung 15.2 würde Ihre Augen zum Schmerzen bringen … Wir hoffen, Ihre App ist hübscher. Probieren Sie sich aus!
Farbpaletten vereinfachen die Sache Statt Farben einzeln zu definieren – was ein gewisses Farbverständnis und entsprechendes Konzept voraussetzt –, kann auch einfach eine Farbpalette automatisch generiert werden. Über den ColorScheme.fromSeed-Konstruktor kann als seedColor eine beliebige Farbe übergeben werden, aus der Flutter automatisch eine kontrastoptimierte, harmonische Farbpalette erstellt. Sie sind also nicht mehr auf die integrierten Farbpaletten beschränkt oder müssen kompliziert selbst einzelne Farben definieren. Es reicht, die Hauptfarbe zu setzen, und Flutter erledigt den Rest für Sie. ThemeData.from( useMaterial3: true, colorScheme: ColorScheme.fromSeed( seedColor: Color.fromARGB(0, 120, 0, 255), brightness: Brightness.dark, onSecondary: Colors.white, ), ),
Listing 15.2: Farbpalette definieren im ThemeData-Widget Sie können neben der seedColor auch einzelne Farben setzen, die dann die automatisch generierten Farbwerte überschreiben, wie hier im Beispiel mit onSecondary getan. Wenn Sie möchten, dass Ihre App ein helles oder ein dunkles Theme anzeigt, je
nachdem, was das Endgerät vorgibt, können Sie im darkTheme-Parameter des MaterialApp-Widgets ein dunkles Theme definieren. Der Konstruktor ThemeData.dark setzt ein Standard-Theme, falls Sie dieses nicht selbst definieren möchten. Das Gleiche gilt für ThemeData.light – es setzt ein Standard-Theme, das auf Hellblau basiert. return MaterialApp( darkTheme: ThemeData.dark(), theme: ThemeData.light(), );
Listing 15.3: Helles und dunkles Standard-Theme
Eigene Farben definieren Die in Flutter integrierten Farben und die Farbpaletten-Generierung zu benutzen, bietet sich an, wenn Sie es eilig haben. Wenn Sie hingegen professionell Apps entwickeln, werden Sie wahrscheinlich in die Situation kommen, Designs Ihrer Auftraggebenden einszu-eins umsetzen zu müssen. In dem Fall bietet es sich an, selbst einzelne Farbwerte in einer separaten Datei zu definieren und bei Bedarf auf sie zuzugreifen. Genau das werden Sie nun auch tun, indem Sie für Ihre App die Farben anlegen. Erstellen Sie im lib-Ordner einen neuen Ordner theme. Innerhalb dieses Ordners erstellen Sie eine neue Datei custom_colors.dart. Die Color-Klasse hat verschiedene Konstruktoren. Sie können eine Farbe zum Beispiel mit ihrem Hex-Wert oder als ARGB-Wert definieren. Color(0xff000000) und Color.fromARGB(255, 0, 0, 0) sind zwei Arten, die Farbe Schwarz zu definieren. Definieren Sie einige Farben in der CustomColors-Klasse mittels Ihrer Hex-Werte. Die folgenden Farben verwenden Sie für die »Pummel The Fish«-App: import "package:flutter/material.dart"; class CustomColors { static const Color blueLight = Color(0xff00a0ff); static const Color blueMedium = Color(0xff217caa); static const Color blueDark = Color(0xff003854); static const Color orange = Color(0xffffc942); static const Color orangeTransparent = Color(0x88ffc942); static const Color yellow = Color(0xffffeb00); static const Color red = Color(0xffea5a44); static const Color white = Color(0xffffffff); }
Listing 15.4: Die CustomColors-Klasse
Falls Sie keine Farbpalette von einem Designer bekommen und die automatisch
generierten Paletten nicht mögen, hier ein paar Tipps zum Erstellen einer eigenen Farbpalette. Für eine App sollten Sie eine Farbpalette mit circa sieben Farben anlegen. Coolors.co ist zum Beispiel ein super Online-Tool für die Zusammenstellung einer Farbpalette. Hier kann man nach bestimmten Farben suchen und sich von den Favoriten der anderen Benutzenden inspirieren lassen. Sie können sich kreativ ausleben, wie Sie möchten – aber falls Ihnen diese Designarbeit eher schwerfällt, hier ein paar einfache Regeln, an denen Sie sich orientieren können: 1. Nehmen Sie drei oder vier Farben, die recht ähnlich sind, nur in abgestufter Intensität und vielleicht leicht variierend – also zum Beispiel ein dunkles Blau, ein mittleres Blau und ein helles Blau. Und vielleicht noch ein Lila dazu, das nah am Blau ist. 2. Nehmen Sie eine Kontrastfarbe, die Komplementärfarbe zu den vier Farben ist – in diesem Fall ein Gelb (= komplementär zu Blau) oder Orange (= komplementär zu Lila). Diese Farbe wird ihre Aktionsfarbe, das heißt, mit dieser Farbe signalisieren Sie den App-Nutzenden, wo sie als Nächstes klicken sollen. 3. Jetzt brauchen Sie noch ein Weiß und ein Schwarz, damit ihre Farbpalette komplett ist: Weiß als neutralen Hintergrund, Schwarz als Font-Farbe. Das ist wichtig für einen guten Kontrast. Sie sollten immer auf einen hohen Kontrast achten, wenn Sie Schrift einsetzen. Aber nie einen 100%-Kontrast, also kein reines Schwarz auf reinem Weiß. Es macht Sinn, eine Farbe zu definieren, die fast weiß ist, oder wenn Sie ein reines Weiß nehmen wollen, definieren Sie dazu eine Farbe, die fast schwarz ist. Was sich hier bei diesem Beispiel anbieten würde: reines Weiß und als Fontfarbe ein ganz dunkles Blau, das fast schwarz aussieht und schön zu unseren anderen Farben passt. 4. Wichtig ist, dass Sie nicht auf die Idee kommen, mit Gelb auf Lila oder mit dem hellen Lila auf dem dunklen Blau zu schreiben. Das kann für kurze Headlines okay sein, aber immer, wenn der Text länger ist, achten Sie gut auf den Kontrast und die Lesbarkeit und verursachen Sie Ihren App-Anwendenden keinen Augenkrebs. 5. Denken Sie daran, eine rote Farbe mit aufzunehmen, für Fehlermeldungen in der App. Eventuell brauchen Sie auch eine grüne Farbe.
Das Theme für die »Pummel The Fish«-App einrichten Diese Farben benutzen Sie jetzt sowohl in Ihrem Theme als auch in einzelnen Screens, um beide Wege einmal kennenzulernen. return MaterialApp( title: "Pummel The Fish", theme: ThemeData( useMaterial3: true,
colorScheme: const ColorScheme( brightness: Brightness.light, primary: CustomColors.blueDark, onPrimary: CustomColors.white, secondary: CustomColors.orange, onSecondary: CustomColors.white, error: CustomColors.red, onError: CustomColors.white, background: CustomColors.white, onBackground: CustomColors.blueMedium, surface: CustomColors. blueLight, onSurface: CustomColors.white, ), ), initialRoute: "/", routes: { "/": (context) => const SplashScreen(), "/home": (context) => const HomeScreen(), "/create": (context) => const CreatePetScreen(), }, );
Listing 15.5: ThemeData-Widget mit CustomColors Öffnen Sie die App erneut auf Ihrem Emulator. Sie sieht ähnlich aus wie zuvor, weil der Hintergrund des AppBar-Widgets durch den surface-Parameter wie vorher auch eine hellblaue Farbe angenommen hat. Es ist aber ein etwas anderer Farbton. Und auch andere Elemente haben sich durch die Theme-Definition in ihren Farben verändert.
Die »Pummel The Fish«-App farblich nachjustieren Bessern Sie nun lokal einige Farben nach. Die Font-Farben lassen Sie dabei erst mal außer Acht, das werden Sie im nächsten Kapitel behandeln.
SplashScreen Im SplashScreen übergeben Sie dem Scaffold-Widget im backgroundColor-Parameter Ihr Hellblau aus der CustomColors-Klasse: backgroundColor: CustomColors.blueLight,
Wenn Sie planen, der nutzenden Person mehrere Themes zur Anzeige zu ermöglichen, sollten Sie einen anderen Ansatz für Ihre Farbgebung wählen und die Farben immer über Ihr Theme mithilfe von Theme.of(context).colorScheme auswählen, anstatt über die CustomColors-Klasse zu gehen. Dies bewirkt, dass sich alle Farben dynamisch anpassen, wenn Sie zum Beispiel zwischen Light- und DarkTheme wechseln. Dieser Ansatz ist gerade zu Beginn Ihrer Flutter-Reise aber vielleicht etwas zu kompliziert, weshalb wir hier auf den einfacheren Weg gesetzt haben. Ein Beispiel, wie das im aktuellen SplashScreen aussehen könnte, sehen Sie
hier. backgroundColor: Theme.of(context).colorScheme.surface,
Für die meisten Standard-Widgets wie zum Beispiel Card, ElevatedButton und InputDecoration gibt es innerhalb der Theme-Definition eine Option, Widgetbasierte Themes anzulegen, die App-übergreifend alle entsprechenden Widgets automatisch stylen. Für alle Card-Widgets würde das zum Beispiel wie folgt aussehen: ThemeData(cardTheme: CardTheme(…), …)
Und weil Ihnen das sehr viel Arbeit abnehmen kann, werden wir Ihnen diesen Ansatz anhand des InputDecoration-Widgets im folgenden Abschnitt CreatePetScreen zeigen.
HomeScreen Im HomeScreen passen Sie die Farben der beiden Icons im ListTile-Widget an. Das leading-Icon soll die Farbe CustomColors.orange bekommen. (Die Farbe hat es schon, aber damit Sie sauber programmieren, referenzieren Sie hier am besten Ihre FarbenKlasse, anstatt direkt mit Hex-Werten zu arbeiten.) leading: Icon( pets[index].isFemale ? Icons.female : Icons.male, color: CustomColors.orange, size: 40, ),
Listing 15.6: Custom-Orange für das ListTile-leading-Widget Dem trailing-Icon geben Sie die Farbe CustomColors.blueMedium. trailing: const Icon( Icons.chevron_right_rounded, color: CustomColors.blueMedium, ),
Listing 15.7: Custom-Blau für das ListTile-trailing-Widget
CreatePetScreen Im CreatePetScreen ist etwas mehr zu tun. Zuerst passen Sie den CustomButton in der custom_button.dart-Datei an. Die Farbe ist schon die richtige, aber Sie soll aus der CustomColors-Klasse kommen. color: CustomColors.orange,
Die Linien der Formfelder sind »verschwunden«, weil das Theme sie Weiß einfärbt. Die Farbe der Linie können Sie lokal über den decoration-Parameter einstellen. Da Sie einige
Formfelder haben, ist es aber am elegantesten, das InputDecoration-Widget, das hier übergeben werden soll, direkt über das Theme anzupassen. In der main.dart-Datei im ThemeData-Widget können Sie den inputDecorationTheme-Parameter wie folgt anpassen, um das Styling für alle jetzigen und zukünftigen Formfelder zu übernehmen. inputDecorationTheme: const InputDecorationTheme( enabledBorder: const UnderlineInputBorder( borderSide: BorderSide(color: CustomColors.blueDark), ), focusedBorder: const UnderlineInputBorder( borderSide: BorderSide( color: CustomColors.blueLight, ), ), errorBorder: const UnderlineInputBorder( borderSide: BorderSide(color: CustomColors.red), ), focusedErrorBorder: const UnderlineInputBorder( borderSide: BorderSide(color: CustomColors.red), ), ),
Listing 15.8: Das InputDecorationTheme im Theme Passen Sie noch die Icon-Farbe im Dropdown-Menü an. Icon( FontAwesomeIcons.fish, color: CustomColors.blueMedium, ),
Listing 15.9: Icon-Farbe im Dropdown anpassen
DetailPetScreen Im DetailPetScreen geben Sie Ihrem _InfoCard-Widget jetzt die Hintergrundfarbe CustomColors.blueMedium. return Card( color: CustomColors.blueMedium, … );
Listing 15.10: InfoCard-Widget farblich anpassen Der Container, der mit dem Foto auf einem Stack liegt, hat zwar schon die richtige Farbe, aber auch hier sollten Sie den Hex-Wert-Konstruktor gegen einen Aufruf der CustomColors-Klasse ersetzen. child: Container( height: 40, color: CustomColors.orangeTransparent, child: …
),
Listing 15.11: Foto-Overlay farblich anpassen Jetzt sieht Ihre App schon richtig edel aus! Eigentlich schade, dass Abbildung 15.3 nicht in Farbe ist – aber Sie können sich die App anschauen und unserer Kreativität huldigen, wenn Sie den Git-Branch zu diesem Kapitel mit VSCode öffnen und auf einem Emulator starten. #nofilter #nodesigner #selfmade!
Abbildung 15.3: Die App mit Custom-Farben – aber die Schrift hat sich versteckt!
Und das Tolle daran: Sollten Ihnen die Farben irgendwann nicht mehr gefallen, können Sie diese einfach in Ihrer CustomColors-Klasse ändern, und Ihre gesamte App wird automatisch die neuen Farben annehmen. Ihnen fällt aber sicher auf, dass hier etwas fehlt … Wo ist die Schrift hin? Der widmen wir uns im nächsten und letzten Kapitel dieses Teils.
Fun mit Fonts Neben der Farbwahl gibt die Auswahl der Schriftart(en) Ihnen eine weitere Möglichkeit, Ihre App zu individualisieren und einen starken Wiedererkennungswert zu schaffen. Die Definition der Schriftstile, die Sie in der App benutzen wollen, passiert optimalerweise ebenfalls im ThemeData-Widget. Sie können mit dem fontFamily-Parameter eine Schriftart für die ganze App festlegen, die automatisch in jedem Text-Widget verwendet wird. Im textTheme-Parameter können Sie ein TextTheme-Widget übergeben, in dem Sie verschiedene Textstile definieren. Diese werden nicht wie die Farben automatisch angewandt – aber sie können überall in der App aufgerufen werden. Ein Textstil kann zum Beispiel aus einem speziellen Font mit spezieller Größe und Dicke sowie Farbe bestehen. Wenn Sie diese Textstile hier festlegen und regelmäßig in Ihrer App referenzieren, ist es später für Sie nur die Änderung einer Codezeile, wenn Sie Stile ändern wollen. Stellen Sie sich zum Beispiel vor, Sie wollen die Farbe aller großen Überschriften ändern. Oder Sie haben, kurz bevor Sie die App veröffentlichen wollen, festgestellt, dass auf einem kleinen Android-Screen die Schrift andere Element überlappt und deshalb eine Größe kleiner sein sollte. Damit Sie solche Probleme einfach lösen können, empfiehlt es sich sehr, die Textstile hier zentral zu definieren und sie in der App nur zu referenzieren. Alternativ können Sie auch in jedem einzelnen Text-Widget einen spezifischen Schreibstil für das einzelne Widget festlegen. Text( "Ein Text", style: TextStyle( color: Colors.white, fontWeight: FontWeight.bold, ), ),
Listing 15.12: Einen TextStyle lokal definieren Das würden wir Ihnen aber nur in Ausnahmefällen empfehlen. Wie können Sie also die Textstile zentral definieren? Sie sind schon definiert. Wenn Sie
mit Ihrem Cursor über dem TextTheme-Widget schweben, können Sie sich die möglichen Parameter ansehen (siehe Abbildung 15.4). .
Abbildung 15.4: Die Parameter des TextTheme-Widgets
Alle hier gezeigten Schreibstile sind schon mit Default-Werten definiert (siehe Abbildung 15.5). Sie können sie auch direkt schon in der App referenzieren. Flutters Default-Fonts sind Roboto für Android und San Francisco (SF Pro Display) für iOS-Endgeräte. Der TextStyle bodyMedium ist beispielsweise schon festgelegt als der Standardtextstil für ein Flutter Text-Widget und labelLarge als der Textstil für Buttons.
Abbildung 15.5: Default TextTheme aus der offiziellen Flutter Dokumentation: https://api.flutter.dev/flutter/material/TextTheme-class.html
Diese Default-Textstile können Sie anpassen. Aber bevor Sie sich nun anschauen, wie Textstile im Theme angepasst und später referenziert werden können, lernen Sie zunächst, wie Sie einen Font Ihrer Wahl importieren können.
Importieren von Fonts
Für Ihre »Pummel The Fish«-App werden Sie nicht die Default-Textstile benutzen, sondern einige davon überschreiben. Dafür brauchen Sie zwei Fonts, die Sie zuerst importieren sollten. Eine unkomplizierte Quelle bei der Beschaffung eines solchen Fonts ist die Seite fonts.google.com. Suchen Sie dort nach der Schriftart »Titillium Web« und der Schriftart »Comfortaa« und laden Sie beide herunter (oder wählen Sie Ihre Lieblingsschriftarten). Erstellen Sie in Ihrem assets-Ordner neben dem images-Ordner einen weiteren Ordner namens fonts. Google Fonts ist eine Sammlung von Fonts, die frei verfügbar sind. Sie finden auf der Seite fonts.google.com eine große Auswahl an Schriftarten, können sie nach verschiedenen Kriterien filtern und herunterladen. Entpacken Sie die heruntergeladenen .zip-Dateien und wählen Sie aus dem TitilliumWeb-Ordner die Font-Dateien TitilliumWeb-Black.ttf und TitilliumWeb-Bold.ttf. Ziehen Sie die Font-Dateien per Drag-and-drop in den fonts-Ordner Ihres FlutterProjektes. Wählen Sie aus dem Comfortaa-Ordner die Dateien Comfortaa-Bold.ttf, Comfortaa-SemiBold.ttf und Comfortaa-Regular.ttf und ziehen Sie diese ebenfalls in den fonts-Ordner. Flutter kann .ttc-, .ttf- und .otf-Font-Dateien verarbeiten.
Deklaration der Font in der pubspec.yaml Damit Ihr Flutter-Projekt »weiß«, dass Sie Font-Dateien in Ihr App-Projekt importiert haben, müssen Sie den Pfad zu den Fonts in der pubspec.yaml-Datei deklarieren. Wenn Sie in der Datei in Ihrem Flutter-Beispielprojekt nach unten scrollen, sehen Sie auskommentiert, wie es angegeben werden soll. Denken Sie daran, dass in der pubspec.yaml das Einrücken der Codezeilen eine Funktion hat. Achten Sie also genau darauf, wie Sie hier einrücken, um Fehler zu vermeiden. flutter: uses-material-design: true fonts: - family: Comfortaa fonts: - asset: assets/fonts/Comfortaa-Regular.ttf weight: 400 - asset: assets/fonts/Comfortaa-SemiBold.ttf weight: 600 - asset: assets/fonts/Comfortaa-Bold.ttf weight: 700 - family: Titillium Web fonts: - asset: assets/fonts/TitilliumWeb-Bold.ttf weight: 700 - asset: assets/fonts/TitilliumWeb-Black.ttf
weight: 900
Listing 15.13: Font-Deklaration in der pubspec.yaml-Datei Vergessen Sie nicht, nach dem Ändern der pubspec.yaml zu speichern und somit eine automatische Aktualisierung der definierten Packages und Assets auszulösen.
Definition der Default-Font und einer Handvoll Textstile Jetzt, da die Fonts importiert sind, können Sie in der main.dart im ThemeData-Widget die generelle Font-Familie für Ihre App definieren und im TextTheme-Widget ein paar Textstile anlegen, die Sie anschließend in der App benutzen können. child: MaterialApp( title: "Pummel The Fish", theme: ThemeData( … fontFamily: "Comfortaa", textTheme: const TextTheme( headlineLarge: TextStyle( fontFamily: "Titillium Web", fontWeight: FontWeight.w700, fontSize: 20, color: CustomColors.blueDark, ), titleMedium: TextStyle( fontFamily: "Comfortaa", fontWeight: FontWeight.w600, fontSize: 18, color: CustomColors.blueMedium, ), bodyLarge: TextStyle( fontFamily: "Titillium Web", fontWeight: FontWeight.w700, fontSize: 16, color: CustomColors.blueDark, ), bodyMedium: TextStyle( fontFamily: "Titillium Web", fontWeight: FontWeight.w700, fontSize: 14, color: CustomColors.white, ), bodySmall: TextStyle( fontFamily: "Titillium Web", fontWeight: FontWeight.w700, fontSize: 12, color: CustomColors.blueDark, ), ), ), …
),
Listing 15.14: ThemeData-Widget mit eigenen Fonts
Textstile in der App verwenden Wie können Sie die definierten Schreibstile jetzt in der App verwenden? Ganz einfach: Sie greifen über den BuildContext auf das Theme zu, holen sich das TextTheme und den gewünschten Schriftstil. Öffnen Sie den HomeScreen und geben Sie dem Parameter title im ListTile-Widget den Schriftstil, den Sie als titleMedium definiert haben, und dem subtitle-Parameter den Schriftstil bodySmall. title: Text( pets[index].name, style: Theme.of(context).textTheme.titleMedium, ), subtitle: Text( "Alter: ${pets[index].age} Jahre", style: Theme.of(context).textTheme.bodySmall, ),
Listing 15.15: ListTile-Widget mit Theme-Fonts Damit die »Pummel The Fish«-App richtig schick wird, sollten Sie noch ein paar weitere ähnliche Anpassungen vornehmen. Im DetailPetScreen geben Sie dem Text-Widget innerhalb des Containers auf dem Foto den Schriftstil headlineLarge. Text( "Adoptier mich!", style: Theme.of(context).textTheme.headlineLarge, ),
Listing 15.16: Text-Widget mit Theme-Fonts Im _InfoCard-Widget in derselben Datei geben Sie den beiden Text-Widgets jeweils den Schriftstil bodyMedium. Text(labelText, style: Theme.of(context).textTheme.bodyMedium), Text(infoText, style: Theme.of(context).textTheme.bodyMedium),
Listing 15.17: InfoCard-Widget mit Theme-Fonts Im CreatePetScreen können wir den Textstil aller Label-Texte mit einem Streich anpassen, indem wir den Schriftstil in der InputDecorationTheme in der main.dart definieren. inputDecorationTheme: const InputDecorationTheme( labelStyle: TextStyle( fontFamily: "Titillium Web", fontWeight: FontWeight.w700,
fontSize: 16, color: CustomColors.blueDark, ), floatingLabelStyle: TextStyle( fontFamily: "Titillium Web", fontWeight: FontWeight.w700, fontSize: 12, color: CustomColors.blueDark, ), … ),
Listing 15.18: InputDecorationTheme mit Theme-Fonts Der floatingLabelStyle-Parameter bestimmt, wie die Labels der TextFormFields aussehen, wenn die benutzende Person ins Feld klickt. Passen Sie noch den hint-Text im DropdownButtonFormField an. DropdownButtonFormField( hint: Text("Bitte wählen Sie eine Spezies", style: Theme.of(context).textTheme.bodyLarge), … ),
Listing 15.19: DropdownButton mit Theme-Fonts Jetzt muss nur noch der Text der Checkbox angepasst werden, damit alle Texte auf dem Screen einheitlich aussehen. Setzen Sie auch noch die aktive Farbe und die Umrandung des CheckboxListTile-Widgets, das haben wir vorhin vergessen. CheckboxListTile( title: Text( "Weiblich", style: Theme.of(context).textTheme.bodyLarge, ), contentPadding: const EdgeInsets.symmetric( horizontal: 0, vertical: 16, ), value: currentIsFemale, activeColor: CustomColors.blueMedium, side: const BorderSide(color: CustomColors.blueDark), onChanged: (bool? value) { … }, ),
Listing 15.20: CheckboxListTile mit Theme-Fonts und Custom Farben
Noch mehr Textstile? Was tun, wenn Sie mehr Textstile brauchen? Sie können natürlich weitere Schriftstile in der TextTheme integrieren – aber die Anzahl der TextThemes, die Sie im Theme definieren
und somit überall in der App darauf verweisen können, ist begrenzt. Wenn Sie nur die Farbe oder einen einzelnen anderen Parameter ändern wollen, ist es zu empfehlen, den gewünschten Schriftstil zu verwenden und lokal per copyWith-Methode anzupassen. Öffnen Sie den CreatePetScreen und passen Sie alle TextFormField-Widgets entsprechend an, damit der Text, der in die Felder eingegeben wird, in einem helleren Blau angezeigt wird. TextFormField( style: Theme.of(context) .textTheme .bodyLarge! .copyWith(color: CustomColors.blueMedium), … ),
Listing 15.21: Angepasster Schriftstil aus dem Theme im CreatePetScreen Passen Sie auch die DropdownMenuItems an. DropdownMenuItem( value: Species.dog, child: Text( "Hund", style: Theme.of(context) .textTheme .bodyLarge! .copyWith(color: CustomColors.blueMedium), ), ),
Listing 15.22: DropdownMenuItem mit neuem TextStyle Im CustomButton brauchen wir ebenfalls den bodyLarge-Stil, aber in Weiß. Text( label, style: Theme.of(context) .textTheme .bodyLarge! .copyWith(color: CustomColors.white), ),
Listing 15.23: Angepasster Schriftstil aus dem Theme im CustomButton Die copyWith-Methode sorgt dafür, dass alle Eigenschaften aus dem vorangehenden Objekt – in diesem Fall also der bodyLarge-Textstil – übernommen werden, und nur die Eigenschaft, die in der Methode mitgegeben wird – in diesem Fall also color – überschrieben wird. Wenn Sie an einer Stelle Ihrer App einen Schriftstil verwenden wollen, der sonst nicht mehr auftauchen wird, können Sie dem Text-Widget einfach an Ort und Stelle einen
individuellen TextStyle geben. Versuchen Sie nur, dies selten zu tun, damit Ihre Codebase pflegeleicht anpassbar bleibt. const Text( "Text mit individuellem Stil", style: TextStyle( fontFamily: "Comfortaa", fontWeight: FontWeight.w600, fontSize: 26, color: CustomColors.yellow, ), ),
Listing 15.24: Ein individueller Schriftstil
Seit der Einführung von Material Design 3 gibt es im TextTheme noch mehr Möglichkeiten, Schriftstile festzulegen. Trotzdem ist es ratsam, nicht zu viele davon zu benutzen. Grafik-Designende empfehlen in der Regel, nicht mehr als zwei oder drei Fonts zu verwenden: eine für die Überschriften und eine, maximal zwei für alle Body-Schriftstile.
Recap: Wir bauen eine App Jetzt ist Ihre App schon richtig schick! Sie haben gelernt, wie Sie mit Widgets AppScreens bauen, wie Sie Ihre eigenen Widgets schreiben, Responsiveness bewerkstelligen und Navigation und Theming integrieren. Wenn Sie fleißig mitgecoded haben, sollte Ihre »Pummel The Fish«-App nun schon richtig weit sein und so aussehen wie in Abbildung 15.6. Falls sie nicht so aussieht, können Sie sich den aktuellen Stand von der entsprechenden Branch ziehen, sobald Sie mit dem Programmieren wieder einsteigen wollen. Das wäre jetzt ganz sinnvoll (es sei denn, Sie haben eine vergleichbare UI geschrieben), denn es geht im nächsten Teil ans Eingemachte – Sie wagen sich in die Welt der Daten vor und lernen, wie Sie eine Schnittstelle anbinden und selbst fix ein Backend aufsetzen können.
Abbildung 15.6: ColorScheme mit einzeln definierten Farben
Teil IV
REST und Firebase – externe Daten beziehen und managen
IN DIESEM TEIL … Frischen Sie Ihr Wissen zu REST-APIs auf Lernen Sie, wie man eine REST-Schnittstelle anbindet Erstellen Sie ein eigenes Backend mithilfe des Firebase SDK Lernen Sie, wie Sie Daten in Firebase speichern und abrufen können
Die meisten Apps heutzutage kommen früher oder später an den Punkt, an dem sie Daten speichern und wieder abrufen müssen. Da diese meist geräteübergreifend synchronisiert werden sollen, wird die Datenquelle – meistens eine Datenbank – in das Internet verlagert. Im Normalfall werden dafür entweder existierende Schnittstellen (= APIs) angesprochen oder neue programmiert. Eine Schnittstelle ermöglicht eine Kommunikation zwischen Frontend (in Ihrem Fall Ihrer App) und Backend (eine Online-Datenbank, in die Daten übertragen, gespeichert und von dort auch wieder abgerufen werden können). Wenn sie selbst kein Backend programmieren wollen, greifen App-Entwickelnde gern auf Firebase zurück – oder ein vergleichbares Backend-as-a-Service. Firebase gibt Ihnen die Möglichkeit, ganz leicht ein Backend mit Datenbank aufzusetzen und mithilfe des Firebase SDK mit ihr zu kommunizieren.
Kapitel 16
Schnittstellen anbinden IN DIESEM KAPITEL Bekommen Sie einen Crash-Kurs zur REST-API Erfahren Sie, was es mit Repositories auf sich hat Lernen Sie, wie unterschiedliche Zustände im UI dargestellt werden können
Das Internet ist voll von Schnittstellen, von denen Sie Daten für Ihre App beziehen können – manche sind offen, für manche müssen Sie bezahlen. Die meisten Schnittstellen haben gemein, dass sie RESTful sind. In diesem Kapitel werden wir Ihnen erklären, was das heißt, und wie die REST-Architektur mit Flutter umzusetzen ist. Wenn Sie mit dem Konzept vertraut sind, können Sie gern die folgenden Seiten überfliegen bis zum Unterkapitel »Los gehts – Daten per REST abrufen«.
Wer oder was ist eigentlich dieser REST? Und was hat er mit API vor? Der Begriff REST steht für »Representational State Transfer« und beschreibt ein Architekturkonzept zum Austausch von Informationen zwischen Client und Serversystemen. Es wurde im Jahre 2000 von Roy Fielding in seiner Dissertation beschrieben. REST in Kombination mit dem Begriff API (= Application Programming Interface) ergibt – große Überraschung – eine REST-API. Diese wird oft auch als »RESTful API« bezeichnet. Eine REST-API beschreibt eine zustandslose Client-Server-Architektur innerhalb von Netzwerken, deren Kommunikation hauptsächlich über HTTP-Anfragen (auch HTTPRequests genannt) stattfindet. Zustandslos bedeutet, dass alle Informationen, die für einen Request benötigt werden, vom Client mitgegeben werden müssen. Es kann serverseitig nicht auf vorherige Zustände zugegriffen werden, um Anfragen zu vervollständigen. REST bietet eine Vielzahl an Softwarearchitektur-Prinzipien, an denen sich Entwickelnde orientieren können, aber nicht müssen. Das ermöglicht eine flexible Implementierung. Eine ausführliche und gute Dokumentation und alle Softwarearchitektur-Prinzipien
finden Sie unter https://restfulapi.net.
Alternativen zu REST Ein früher, nahezu ausgestorbener Kollege von REST ist SOAP – Kurzform für »Simple Object Access Protocol«. SOAP ist aufgrund der Tatsache, dass es sich um ein Protokoll handelt, deutlich schwerfälliger, aufwendiger in der Implementierung und auch entsprechend langsamer als REST. Ein anderer – möglicherweise aufsteigender Konkurrent – ist GraphQL, eine sogenannte »Query Language«. Das Besondere an GraphQL ist, dass der Client – in diesem Fall unsere App – nach exakt den Informationen fragen kann, die er braucht, und auch nur diese zurückgeliefert bekommt. Somit kann der Client sich seine Requests selbst zusammenbauen und ist nicht von Backend-Anpassungen abhängig.
REST-Requests Sie haben bereits erfahren, dass bei der Verwendung einer REST-API mit HTTP-Requests gearbeitet wird. Lassen Sie uns kurz über den Aufbau dieser Requests sprechen. Ein Request benötigt immer eine URL. Diese URL ist meist eine Zusammensetzung aus einem Endpunkt und einem Path, der hinter dem Endpunkt angehängt wird. Ein RESTRequest verwendet außerdem eine der folgenden Methoden: GET, POST, PUT, PATCH, DELETE. Optional können ein Header mit unterschiedlichen Informationen sowie ein Body mit Daten mitgeschickt werden. Ein Beispiel für einen Request könnte also wie folgt aussehen: vollständige URL: https://ein-endpunkt.de/api/ein-pfad Methode: POST Header: Content-Type: application/json Body im JSON-Format: { "key1": "Ein String", "key2": 2, "key3": 3.0, }
Damit Sie mit einer echten REST-API etwas herumexperimentieren können, haben wir Ihnen eine speziell für das »Pummel The Fish«-App-Projekt zusammengebaut. Sie erreichen diese über die URL https://losfluttern.de/pummelthefish/api. Beachten Sie bitte, dass wir ein paar Sicherheitsvorkehrungen treffen mussten und manche Funktionsweisen etwas vereinfacht wurden. Diese Vereinfachungen sind
selbstverständlich im Verlaufe dieses Kapitels entsprechend gekennzeichnet.
Request-Endpunkt mit 0-n Parametern Ein Request-Endpunkt beinhaltet die URL, die angesprochen werden soll, sowie optional angehängte Parameter. Im Folgenden ein Beispiel für einen GET-Request an die pummelthefish API. Dieser Endpunkt soll eine Liste von maximal zehn männlichen PetObjekten zurückgeben. Der Endpunkt setzt sich somit aus den folgenden drei Teilen zusammen: die URL: https://losfluttern.de/pummelthefish/api der Path: /pets die Query-Parameter: ?limit=10 und is_female=false Zusammengesetzt ergibt sich hieraus die folgende URL: https://losfluttern.de/pummelthefish/api/pets?limit=10&is_female=false
Request-Methoden Nehmen Sie sich einen kurzen Moment Zeit und überlegen Sie, welche Aktionen man generell mit den Datensätzen – zum Beispiel einer Pet-Liste – einer Schnittstelle durchführen könnte. Vermutlich kommen Sie auf ein ähnliches Ergebnis: erstellen (englisch »create«) lesen oder abfragen (englisch »read«) bearbeiten (englisch »update«) löschen (englisch »delete«) Diese tauchen meist in Kurzform, abgeleitet von den englischen Begriffen, unter dem Begriff »CRUD« auf. Um die aufgelisteten Aktionen ausführen zu können, stellt REST Ihnen fünf verschiedene Methoden bereit: GET, POST, PUT, PATCH und DELETE. Einen GET-Request nutzen Sie, um Daten von einem Server abzufragen beziehungsweise zu lesen (= Read). POST wird verwendet, um Daten an einen Server zu senden und dort einen neuen
Eintrag zu erstellen (= Create). Mit PUT und PATCH können Sie bestehende Daten auf dem Server bearbeiten (= Update). Der Unterschied zwischen PUT und PATCH findet serverseitig statt. Während bei PUT der komplette Eintrag ersetzt wird, wird PATCH verwendet, um nur ein Teil des bestehenden Eintrags zu ersetzen.
Und zu guter Letzt – mit DELETE können Sie einen kompletten Eintrag vom Server löschen.
Request Headers Mit einem Header können zusätzliche Informationen zwischen Server und Client ausgetauscht werden. Es gibt verschiedene Header, zum Beispiel einen Request Header und einen Response Header. Der Request Header enthält zusätzliche Informationen über die angeforderten Informationen oder den Client, während der Response Header weitere Informationen über die Antwort (= Response) oder den Server enthält. Eigentlich ist ein Header nichts anderes als ein Key-Value-Paar, separiert durch einen Doppelpunkt. Ein Beispiel eines Request Headers, das den Server anweisen soll, die Informationen im JSON-Format zurückzugeben: Content-Type: application/json
Request Body Daten per GET-Request vom Server abzufragen, ist unkompliziert durch den Aufruf der URL – zum Beispiel https://losfluttern.de/pummelthefish/api/pets – möglich. Was aber, wenn Sie Daten per POST, PUT oder PATCH an den Server senden möchten und dementsprechend Daten bei Ihrer Anfrage irgendwo anhängen müssen? Könnte man die Daten einfach an die URL anhängen? Stellen Sie sich eine größere Liste mit 1000 PetObjekten vor, die an die URL hinten drangehängt werden, und Sie werden diese Idee schnell verwerfen. Aber auch hierfür bietet REST eine Lösung, und zwar den Request Body. Der Request Body wird bei den Request-Methoden POST, PUT, PATCH und teilweise DELETE verwendet, um die Daten zu transportieren. Diese Daten werden in den meisten Fällen im JSONFormat bereitgestellt. Stellen Sie sich vor, unser Kuscheltierheim hat Zulauf bekommen und Sie möchten den Ankömmling in der pummelthefish-API als Pet einspeisen. Hierfür könnten Sie den Request Body wie folgt formulieren: { "id": "6", "name": "Pummel the Second", "species": 3, "age_in_years": 1, "weight": 150.0, "height": 70.0, "is_female": true }
Listing 16.1: Beispiel für einen Request Body
Dieser Body wird dann an den POST-Request, der ebenfalls die URL https://losfluttern.de/pummelthefish/api/pets anspricht, angehängt. Wie das konkret im Code aussieht, erfahren Sie bald.
REST-Response Wenn man eine Anfrage sendet, möchte man im Normalfall auch eine Antwort darauf erhalten. Diese Antwort beinhaltet in der REST-Welt meist einen standardisierten StatusCode und einen Response Body – im besten Fall mit den angefragten Daten.
Die fünf Klassen der Status-Codes Die Status-Codes helfen bei der schnellen Klassifizierung einer Response und lassen sich laut Spezifikation RFC 7231 in fünf verschiedene Klassen unterteilen: 1. Status-Codes im Bereich 100–199 sind informative Antworten. 2. Status-Codes im Bereich 200–299 sind erfolgreiche Antworten. 3. Status-Codes im Bereich 300–399 sind Umleitungen. 4. Status-Codes im Bereich 400–499 sind Client-Fehler. 5. Status-Codes im Bereich 500–599 sind Server-Fehler. Wir möchten Ihnen kurz die häufigsten Status-Codes vorstellen, die Sie zum Überleben in der Anbindung-an-eine-API-Welt benötigen. 200 – OK: Ihre Anfrage war erfolgreich. 201 – Created: Das von Ihnen gesendete Objekt wurde erfolgreich angelegt. 204 – No Content: Das von Ihnen gewünschte Objekt wurde gelöscht und es sind keine Inhalte mehr verfügbar, die auf Ihre Löschanfrage passen. 400 – Bad Request: Oh, oh, bei Ihrer Anfrage ging etwas schief, da die Syntax der Anfrage ungültig war. 401 – Unauthorized und 403 – Forbidden: Sie haben versucht, auf ein Objekt zuzugreifen, auf das Sie nicht zugreifen dürfen. Dies tritt meistens auf, wenn eine Authentifizierung in Ihrem Backend eingebaut ist. 404 – Not found: Dieser Status-Code kommt Ihnen sicher bekannt vor, denn er taucht auch dann auf, wenn Sie zum Beispiel versuchen, eine Webseite zu öffnen, die es nicht gibt. 500 – Internal Server Error: Irgendwas ist auf dem Server schiefgelaufen, der Server möchte Ihnen aber keine näheren Infos dazu geben – meistens auch deshalb, weil er selbst nicht weiß, wie er damit umgehen soll.
503 – Service Unavailable: Dies tritt meistens dann auf, wenn die Schnittstelle gerade gewartet wird.
Wussten Sie, dass es auch einen Status-Code 418 gibt, der »I am a teapot« bedeutet und komplett nutzlos ist?
Wenn noch was dranhängt – der Response Body Neben einem Status-Code als Antwort erhalten Sie oft auch einen Response Body mit Daten. Wenn Sie zum Beispiel einen GET-Request absenden, hoffen Sie, die angefragten Elemente im Response Body zu finden und einen Status-Code 200 – OK. Wenn Sie per POST ein neues Objekt übersenden, erhoffen Sie sich einen Status-Code 201 und als Response Body das frisch angelegte Objekt, dem meistens dann noch eine ID zugeordnet wurde. Wenn Sie ein Objekt per DELETE löschen, sollte im besten Fall ein Status-Code 204 zurückkommen, der Body ist in diesem Fall leer. Wenn Sie die POST-, PATCH- und PUT-Methoden der pummelthefish-API verwenden, seien Sie sich darüber bewusst, dass Sie lediglich die entsprechenden Status-Codes als Antwort erhalten werden, die Daten in unserer Datenbank allerdings nicht aktualisiert werden. Leider müssen wir diesen Weg an dieser Stelle gehen, um Sie und uns vor gegebenenfalls unpassenden Tiernamen zu schützen.
Los gehts – Daten per REST abrufen Nachdem Sie nun die grobe Theorie hinter REST aufgefrischt haben, geht es ans Eingemachte und Sie werden Ihre »Pummel The Fish«-App mit einer REST-API verbinden. Sie werden mithilfe eines Packages eine Schnittstelle ansprechen, bestehende Datensätze abrufen und neue Datensätze einspeisen. Alles Schritt für Schritt.
Ohne das http-Package geht hier gar nichts Bevor Sie drauflos programmieren können, fehlt allerdings noch ein wichtiges Werkzeug. Da es auch möglich ist, sinnvolle Apps ohne Schnittstelle zu programmieren, müssen Sie bei der Anbindung einer Schnittstelle auf ein optionales externes Package namens http zugreifen. Um dieses zu Ihrem Projekt hinzuzufügen, können Sie im Terminal die zwei folgenden Befehle nacheinander ausführen: >> flutter pub add http >> flutter pub get
Wenn Sie nun die pubspec.yaml-Datei öffnen, sollten Sie sehen, dass sich automatisch ein neuer Eintrag hinzugefügt hat, der ähnlich aussieht: http: ^0.13.5
Repositories zur Datenverwaltung In Flutter verwendet man häufig sogenannte Repositories, um Daten zu verwalten. Die Daten können sowohl über eine REST-API, Firebase oder ähnliche Backends von extern in die App kommen, oder auch aus einer lokalen Datenbank in der App, zum Beispiel einer SQLite-Datenbank. Auch das bereits in Kapitel 4, »Pfeilschnell programmieren mit Dart«, erstellte FakePetRepository ist ein Repository. Repositories sind nichts weiter als separate Klassen, die sich auf das Ansprechen einer Datenquelle spezialisieren und diese Logik vom UI entkapseln. Sie konzentrieren sich dabei oft auf einen bestimmten Objekttyp. Wenn Sie zum Beispiel Pet-Objekte managen wollen, erstellen Sie ein PetRepository, wenn Sie User-Objekte managen wollen, ein UserRepository. Um dieses Wissen nun in die Praxis umzusetzen, begeben Sie sich bitte in den bereits erstellten Ordner lib/data/repositories. In diesem Ordner erstellen Sie dann eine neue Datei namens rest_pet_repository.dart, die für das Management Ihrer Pet-Objekte von und zur REST-API zuständig sein wird.
Das RestPetRepository Nun kommt der Moment, in dem wir unser Versprechen aus »Kapitel 8, Vererbung und weitere praktische Dart-Features«, einlösen. Sie erstellen die Klasse RestPetRepository, die vom PetRepository erbt und darum für Sie einfach aufzusetzen sein sollte. Sie können Ihr bereits erstelltes PetRepository-Interface an die frische RestPetRepository-Klasse dranhängen und den bereits definierten Vertrag über die benötigten Methoden vervollständigen lassen. Erstellen Sie hierfür die RestPetRepository-Klasse und implementieren Sie das PetRepository. Wählen Sie danach mithilfe der QUICK-FIX-Lampe CREATE 5 MISSING OVERRIDES (Abbildung 16.1).
Abbildung 16.1: Automatisch Overrides erstellen
Das Ergebnis sollte die Methodengerüste addPet, getAllPets, updatePet und deletePetById beinhalten und in etwa wie folgt aussehen. Die TODO-Kommentare haben wir für das bessere Verständnis etwas angepasst: class RestPetRepository implements PetRepository { @override void addPet(Pet pet) { // TODO: Pet an den Server senden und dort speichern throw UnimplementedError(); } @override List getAllPets() { // TODO: alle Pets vom Server abrufen und zurückgeben throw UnimplementedError(); } @override void updatePet(Pet pet) { // TODO: existierendes Pet auf dem Server aktualisieren throw UnimplementedError(); } @override void deletePetById(String id) { // TODO: existierendes Pet auf dem Server löschen throw UnimplementedError(); } }
Listing 16.2: Die RestPetRepository-Klasse Der erste Schritt ist getan, das Fundament steht. Lassen Sie uns die Klasse nun so ausarbeiten, dass sie mit einer REST-API sprechen kann.
Basis-Elemente der REST-API-Anbindung Als Erstes definieren Sie dafür die URL der Schnittstelle als Konstante, denn der Wert dieser Variable wird sich niemals ändern. Diese Variable enthält lediglich die Basis-URL der API, die Sie ansprechen möchten: https://losfluttern.de/pummelthefish/api. Am besten siedeln Sie diese Variable direkt oberhalb der Klassendefinition an. const baseUrl = "https://losfluttern.de/pummelthefish/api"; class RestPetRepository implements PetRepository { … }
Listing 16.3: URL einer Variablen zuweisen Das http-Package importieren Sie wie üblich und verwenden daraus den HttpClient. Anstatt in jeder Methode eine neue Instanz dieses HttpClients zu erzeugen, wenden Sie hier ein neues Konzept namens »Dependency Injection« an. Dafür legen Sie eine unveränderbare (final) Variable vom Typ HttpClient an und erwarten, dass eine Instanz dieses Typs bei der Erstellung des RestPetRepository mitgegeben wird. import "package:pummel_the_fish/data/models/pet.dart"; import "package:http/http.dart" as http; const baseUrl = "https://losfluttern.de/pummelthefish/api"; class RestPetRepository implements PetRepository { final http.Client httpClient; RestPetRepository({required this.httpClient}); .. }
Listing 16.4: Dependency Injection in der RestPetRepository-Klasse Das hat zum einen den Vorteil, dass Sie nun die httpClient-Variable in der gesamten Klasse verwenden können, ohne eine neue Instanz erstellen zu müssen, und zum anderen macht diese Vorgehensweise den httpClient austauschbar, was eine Voraussetzung für das Thema Testing in Kapitel 21, »Testing – wer, wie, was und wieso, weshalb, warum?«, ist. Außerdem ist das RestPetRepository nun nicht mehr in der Verantwortung, den HttpClient zu erzeugen – und manchmal ist das doch echt toll, wenn man ein Stück Verantwortung abgeben kann, sodass es einen später wieder einholt … *zwinker* … Spaß beiseite, Dependency Injection ist ein sehr wichtiges und verbreitetes Konzept in der Softwareentwicklung und spätestens im Testing-Kapitel werden Sie verstehen, warum! Die Methoden, die Sie verwenden wollen, haben Sie durch die Anbindung des Interfaces bereits integriert. Wenn Sie sich noch einmal den Begriff CRUD ins Gedächtnis rufen, sollte der ganze Plan, den wir verfolgen, langsam Sinn ergeben:
alle existierenden Pet-Objekte abrufen (Read, GET) = getAllPets ein spezifisches Pet-Objekt anhand seiner id abrufen (Read, GET) = getPetById ein neues Pet-Objekt erstellen (Create, POST) = addPet Daten eines bestimmten Pet-Objekts aktualisieren (Update, PUT oder PATCH) = updatePet
ein bestimmtes Pet-Objekt löschen (Delete, DELETE) = deletePetById Die meisten REST-APIs werden mindestens diese Methoden anbieten und verwenden.
GET – Daten von einer Schnittstelle abrufen Als ersten Schritt werden Sie bereits existierende Daten von der API durch die Verwendung der getAllPets-Methode abrufen. Diese soll durch einen GET-Request an die API alle existierenden Pet-Objekte als List zurückliefern. Hierfür müssen die folgenden Schritte getätigt werden: eine URI generieren, die die baseUrl und den path zusammenfügt den GET-Request an die API tätigen die Antwort der API auswerten und prüfen, ob der Request erfolgreich war die Antwort, die uns als String zurückgeliefert wird, zunächst in ein JSON-Objekt und dann in eine List umzuwandeln die umgewandelte Pet-Liste zurückgeben Los gehts! Eine URI (= Uniform Resource Identifier) ist am Ende nichts anderes als eine URL (= Uniform Resource Locator). Jedoch ist eine URL nur eine Teilmenge einer URI. Für gängige APIs wird Ihnen in der Regel die URL vorliegen. Die URL, die Sie hier ansprechen möchten, lautet: https://losfluttern.de/pummelthefish/api/pets. Sie setzt sich, wie weiter vorne im
Kapitel beschrieben, aus einer URL (baseUrl) und einem Pfad (/pets) zusammen. Wenn Sie nun versuchen, einen GET-Request per httpClient anzustoßen, indem Sie await httpClient.get eingeben, wird Ihnen angezeigt, dass Sie dieser Methode eine URI übergeben müssen. Diese erhalten Sie, wenn Sie die URL per Uri.parse umwandeln und anschließend der httpClient.get-Methode übergeben. Durch das await-Keyword müsste es außerdem bei Ihnen klingeln: Der asynchrone Aufruf hat zur Folge, dass der Rückgabewert der Methode von List auf Future geändert werden muss. Vergessen Sie außerdem nicht das async nach dem Methodennamen: @override Future getAllPets() async { final uri = Uri.parse("$baseUrl/pets");
final response = await httpClient.get(uri); }
Listing 16.5: getAllPets-Methode Völlig zurecht wird Ihre Entwicklungsumgebung nun meckern, denn der ursprüngliche Methodenvertrag erwartet einen List-Rückgabewert und nicht ein Future. Was Sie in diesem Fall tun können, ist, das PetRepository-Interface so anzupassen, dass es zusätzlich auch mit asynchronen Rückgabewerten umgehen kann. Hierfür eignet sich ein FutureOr. Da diese Änderung des zu erwartenden Rückgabewertes nicht nur auf die getAllPetsMethode, sondern auch auf alle anderen Methoden innerhalb des Interface zutrifft, können Sie diese Änderung direkt für alle Methoden anwenden. import "dart:async"; import "package:pummel_the_fish/data/models/pet.dart"; abstract class PetRepository { FutureOr getAllPets(); FutureOr addPet(Pet pet); FutureOr updatePetById(Pet pet); FutureOr deletePetById(Pet pet); }
Listing 16.6: Das PetRepository hat nun asynchrone und synchrone Rückgabewerte. Zurück zu unserer getAllPets-Methode in der RestPetRepository-Klasse: Sie sollten nun ein response-Objekt vom Typ Response als Server-Antwort erhalten. Um nun zu prüfen, welchen Status Ihnen die Antwort bereithält, verwenden Sie response.statusCode. Wie Sie bereits oben gelernt haben, bedeutet ein Status-Code 200, dass Ihre Anfrage erfolgreich war. Somit überprüfen Sie nun, ob dieser Fall eingetreten ist. Sollten Sie keinen Status-Code 200 erhalten haben, haben Sie in den meisten Fällen auch keine Daten zurückbekommen, mit denen Sie weiterarbeiten könnten. In diesem Fall sollten Sie einen Fehler in Form einer Exception werfen, welcher von den Objekten, die diese Methode aufrufen, entsprechend abgefangen und behandelt werden kann. Tun Sie das nicht, wird Ihre App höchstwahrscheinlich abstürzen. @override Future getAllPets() async { … if (response.statusCode == 200) { // TODO } else {
throw Exception( "Beim Laden der Kuscheltiere ging etwas schief.", ); } }
Listing 16.7: getAllPets-Methode mit Fehler-Handling Für den Fall, dass alles funktioniert hat und Sie den Status-Code 200 zurückerhalten, können Sie davon ausgehen, dass auch die entsprechenden Daten verpackt als String innerhalb des response.body-Objekts mitgeliefert wurden. Wenn Sie diese Anfrage an unsere Schnittstelle stellen und den response.body per print-Funktion im Terminal loggen, sollte dieser wie im folgenden Listing 16.8 aussehen. [ { "id": "1", "name": "Pummel", "species": 2, "weight": 200.0, "height": 20.0, "age_in_years": 3, "is_female": true }, { "id": "2", "name": "Bruno", "species": 0, "weight": 320.0, "height": 60.0, "age_in_years": 4, "is_female": false }, … ]
Listing 16.8: Erwünschte Pet-Liste als Response Aber wie wird aus diesem String-Konstrukt nun das gewünschte List-Objekt? Verschiedene Tools wie Postman, Insomnia oder auch der ThunderClient, der als VSCode-Extension verfügbar ist, können Ihnen helfen, Ihre Requests zu überprüfen, ohne sie davor zu implementieren. In diesen Tools können Sie beliebige Requests an Ihre API austesten und prüfen, ob das erwartete Ergebnis zurückkommt. Das hilft auch oft bei der Fehlersuche, wenn Sie sich nicht sicher sind, ob Sie Fehler in Ihrem Code haben oder die API einfach falsch auf Ihre Anfragen reagiert.
JSON-Daten konvertieren
Der aktuelle Plan ist also nun, den String zunächst in die JSON-Form zu bringen und dann in eine List umzuwandeln. Für die Umwandlung können Sie die Methode jsonDecode aus dem Dart-convert-Package verwenden. Der erste Schritt ist die Konvertierung des Strings in eine Liste mit mehreren dynamischen Elementen, um somit ein List-Objekt zu erhalten. Erreichen können Sie das, indem Sie den response.body in der jsonDecode-Methode mitgeben und das Ergebnis in einer neuen Variable dataList vom Typ List speichern: import "dart:convert"; @override Future getAllPets() async { final uri = Uri.parse("$baseUrl/pets"); final response = await httpClient.get(uri); if (response.statusCode == 200) { final List dataList = jsonDecode( response.body ); print(dataList); } else { throw Exception( "Beim Laden der Kuscheltierliste ging etwas schief.", ); } }
Listing 16.9: Umwandlung des response.body in eine List Sie haben damit aus dem String eine List gemacht und können nun durch die dynamic-Elemente iterieren. Allerdings ist der Plan, dass Sie am Ende eine List erzeugt haben. Wie genau soll das funktionieren? Wie macht man aus einem dynamicObjekt ein Pet-Objekt? Zur Erinnerung: solch ein dynamic-Element der Pet-Liste sieht wie folgt aus: { "id": "1", "name": "Pummel", "species": 3, "weight": 200.0, "height": 20.0, "age_in_years": 3, "is_female": true }
Listing 16.10: Ein dynamic-Element der Pet-Liste Wenn Sie sich das genauer anschauen, sollten Sie das gedanklich in eine Map-Form gießen können. Links finden Sie die Keys als Strings und jeweils nach dem Doppelpunkt unterschiedliche Values. Mal vom Typ String, mal als int und mal als double.
Sie müssen also eine Map in ein Pet-Objekt umwandeln. Hierzu ist eine Anpassung in der Pet-Klasse notwendig. Öffnen Sie diese und ergänzen Sie den Inhalt mit der folgenden Methode fromMap: import "dart:convert"; … factory Pet.fromMap(Map map) { return Pet( id: map["id"], name: map["name"], species: Species.values[map["species"]], weight: map["weight"] as double, height: map["height"] as double, age: map["age_in_years"] as int, isFemale: map["is_female"], owner: map["owner"] != null ? Owner.fromMap(map["owner"]) : null, ); }
Listing 16.11: Die fromMap-Methode in der Pet-Klasse
Der factory-Konstruktor in Dart ist eine Alternative zum herkömmlichen Konstruktor einer Klasse. Er wird benutzt, wenn der Konstruktor nicht unbedingt eine neue Instanz der Klasse erstellen muss. Es kann stattdessen eine bestehende Instanz zurückgegeben werden oder eine Unterklasse. Der factory-Konstruktor kann auch benutzt werden, um ein Singleton zu erstellen. Er kann aber auch einfach analog zum herkömmlichen Konstruktor benutzt werden. Durch das map-Objekt haben Sie Zugriff auf einzelne Elemente des Objekts und können diese per map[ELEMENT_NAME] ansprechen. Sie möchten zum Beispiel auf den Wert des name-Keys zugreifen? Verwenden Sie map["name"]. Sie versuchen nun, jeder in der PetKlasse definierten Eigenschaft den entsprechenden Wert aus der Map zuzuweisen oder – falls der Wert null sein sollte – einen Default-Wert zu hinterlegen. Das fertig gebaute Pet-Objekt wird am Ende zurückgegeben. Beachten Sie, dass sich die Key-Namen, die von der API kommen, von Ihren Variablennamen unterscheiden können. Oft werden auch andere Schreibweisen verwendet. Während in Ihrer App die CamelCase-Schreibweise verwendet wird (isFemale), werden Wörter auf der pummelthefish-API-Seite mit einem Unterstrich
voneinander separiert (is_female). Das kann für jede API allerdings unterschiedlich sein. Aber stopp: Wenn Sie den Code so eingegeben haben, wird Ihnen vermutlich noch ein Fehler entgegenfliegen, denn die Methode Owner.fromMap existiert noch nicht. Jedes Element, das sich in dem String, der von der API kommt, befindet, muss ebenfalls in ein entsprechendes Objekt umgewandelt werden. Daher müssen Sie dasselbe Spiel also auch für die Owner-Klasse wiederholen. import "dart:convert"; class Owner { final String id; final String name; const Owner({ required this.id, required this.name, }); factory Owner.fromMap(Map map) { return Owner( id: map["id"], name: map["name"], ); } }
Listing 16.12: Die fromMap-Methode in der Owner-Klasse
Beachten Sie auch, dass, nur weil Sie Elemente als required definieren, dies auf der API-Seite nicht unbedingt der Fall sein muss. Kommunikation mit den BackendEntwickelnden ist daher sehr wichtig. Sie müssen sich einigen, welche Felder null sein können und welche es niemals sein werden. Ihre fromMap-Methode sollte bei potenziellen null-Werten mit einem Default-Wert oder einem Fehler reagieren. Jetzt sollte es funktionieren. Zu guter Letzt müssen Sie die Methode natürlich noch aufrufen. Springen Sie hierfür zurück in das RestPetRepository und passen Sie die getAllPets-Methode erneut an. Sie werden nun durch die dataList per map-Methode iterieren und jedes Element innerhalb dieser Liste in ein Pet-Objekt umwandeln. Daraus entsteht dann endlich die finale Liste vom Typ List, die als Rückgabewert erwartet wird. if (response.statusCode == 200) { final List dataList = jsonDecode( response.body, );
final petList = dataList .map((petMap) => Pet.fromMap(petMap)) .toList(); return petList; } else { throw Exception( "Beim Laden der Kuscheltiere ging etwas schief.", ); }
Listing 16.13: Die finale Pet-Liste entsteht
Sie können diese Methoden alle von Hand schreiben oder Sie lassen sich die Methoden generieren. Zu empfehlen ist hier das Package json_serializer, das in ein paar Schritten eingebaut werden kann und die Methoden, die für die JSONSerialisierung notwendig sind, auf einen Befehl generiert. Alternativ dazu können Sie auch die VSCode-Extension Dart Data Class Generator installieren und per Quick-Fix-Option auf Ihrem Klassennamen den Befehl GENERATE JSON SERIALIZATION ausführen. Super wäre allerdings, wenn sie händisch in der Lage wären, diese Methoden zu schreiben, weil Sie das Grundprinzip dahinter verstanden haben.
GET-Request triggern – ein Button muss her Nun ist es endlich so weit! Sie können die Methode ausführen und überprüfen, ob die gewünschten Pet-Objekte ausgegeben werden. Hierzu erstellen Sie eine neue Methode _getAllPets im HomeScreen unterhalb der build-Methode. Future _getAllPets() async { final httpClient = http.Client(); final restPetRepository = RestPetRepository( httpClient: httpClient, ); final pets = await restPetRepository.getAllPets(); print(pets); }
Listing 16.14: Die fertige Methode, um alle Pets vom RestPetRepository zu bekommen Um diese Methode aufzurufen, fügen Sie am besten einen IconButton in der AppBar innerhalb des HomeScreens hinzu. Sie können dafür die actions verwenden und diese mit einem IconButton anreichern. Die fertige AppBar mit dem Aufruf der Methode sieht dann wie folgt aus:
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( leading: Padding( padding: const EdgeInsets.only(left: 16), child: Image.asset("assets/images/pummel.png"), ), title: const Text("Pummel The Fish"), actions: [ IconButton( onPressed: () => _getAllPets(), icon: const Icon(Icons.download), ), ], ), body: … ), )};
Listing 16.15: Ein IconButton zum Testen der _getAllPets-Methode im HomeScreen Wenn Sie Ihre App nun starten und den Button in der AppBar betätigen, sollte eine ähnliche Ausgabe in Ihrem Terminal-Tab in VSCode erscheinen. >> [Pet(id: 636e2e5f2a0a2522b9a69165, name: Pummel, species: Species.fish, age: 3, weight: 200.0, height: 200.0, isFemale: true, owner: null), Pet(id: 636e2e762a0a2522b9a69167, name: Bruno, species: Species.dog, age: 4, weight: 320.0, height: 60.0, isFemale: false, owner: null), Pet(id: 636e2e922a0a2522b9a69169, name: Leonie, species: Species.cat, age: 6, weight: 400.0, height: 45.0, isFemale: true, owner: null), Pet(id: 636e2ebf2a0a2522b9a6916b, name: Harribart, species: Species.bird, age: 1, weight: 220.0, height: 10.0, isFemale: false, owner: null)]
Listing 16.16: Ausgabe in der Debug-Konsole
Es gibt zwei GET-Methoden im RestPetRepository. Die getAllPets haben wir zusammen gemacht, die getPetById-Methode wurde noch nicht umgesetzt. Versuchen Sie daher nun, diese Methode eigenständig zu implementieren. Alle notwendigen Werkzeuge haben Sie bereits kennengelernt!
POST – Daten an die API senden Nachdem Sie das Grundprinzip eines GET-Requests mithilfe des httpClients kennengelernt haben, werden die anderen Requests ein Kinderspiel für Sie sein. Sie sind alle gleich aufgebaut und unterscheiden sich nur geringfügig. Haben Sie dieses Prinzip einmal verstanden, steht Ihnen die Welt der REST-API-Anbindungen offen!
Schauen Sie sich als Nächstes den POST-Request an. Hierzu modifizieren Sie die Methode addPet in der RestPetRepository-Klasse wie folgt: @override Future addPet(Pet pet) async { final uri = Uri.parse("$baseUrl/pets"); final response = await httpClient.post( uri, body: pet.toJson(), headers: { "Content-Type": "application/json", }, ); if (response.statusCode == 201) { print("Kuscheltier erfolgreich hinzugefügt"); return; } else { throw Exception("Beim Hinzufügen des Kuscheltiers ging etwas schief"); } }
Listing 16.17: addPet-Methode Die Anatomie der Methode ist der getAllPets-Methode sehr ähnlich. Zunächst wird die URI definiert, die Sie ansprechen möchten, danach wird der Request ausgeführt, und zum Schluss wird die Antwort ausgewertet. Beachten Sie, dass Sie gegebenenfalls einen Header mitgeben müssen, der den Content im JSON-Format definiert. Die meisten Backends werden das erwarten. Im Falle einer erfolgreichen Antwort – welche im POST-Request-Fall einem Status-Code von 201 entspricht – loggen Sie sich eine Erfolgsnachricht im Terminal und verlassen die Methode der Einfachheit halber mit einem return. Sie können im Normalfall davon ausgehen, dass die id des Objekts, das Sie an den Server schicken, meist serverseitig vergeben wird und Ihre App davon erst beim erneuten Abrufen des Datensatzes etwas mitbekommt. Oft liefern daher POST, PATCH und PUT-Requests das frisch mit der id oder anderen Eigenschaften – wie zum Beispiel created_date oder updated_date – angereicherte Objekt bei Erfolg zurück. Noch mal als Hinweis: Unsere pummelthefish API kann das leider aus den bereits angesprochenen Sicherheitsgründen nicht. Ein Problem gibt es allerdings noch, denn die toJson-Methode existiert weder in der PetKlasse noch in der Owner-Klasse. Diese wird allerdings benötigt, wenn Sie ein Pet-Objekt an die API übersenden wollen, denn die API kennt kein Objekt vom Typ Pet und wird es daher auch nicht akzeptieren.
Hier müssen Sie also im Gegensatz zur fromMap-Methode den Spieß umdrehen und das Pet-Objekt in einen String umwandeln, damit die API es handhaben kann. Fügen Sie daher in Ihrer Pet-Klasse die folgenden zwei Methoden toJson und toMap hinzu. Die toMap-Methode ist die umgekehrte Methode von fromMap, während die toJson-Methode nur dafür sorgt, dass die Map per jsonEncode in einen String verwandelt wird, der dann an die API gesendet werden kann: String toJson() => jsonEncode(toMap()); Map toMap() { final result = {}; result.addAll({"id": id}); result.addAll({"name": name}); result.addAll({"species": species.index}); result.addAll({"age_in_years": age}); result.addAll({"weight": weight}); result.addAll({"height": height}); result.addAll({"is_female": isFemale}); if (owner != null) { result.addAll({"owner": owner!.toMap()}); } return result; }
Listing 16.18: Die toJson- und toMap-Methode in der Pet-Klasse Diese Methoden müssen auch entsprechend für die Owner-Klasse hinzugefügt werden: String toJson() => jsonEncode(toMap()); Map toMap() { final result = {}; result.addAll({"id": id}); result.addAll({"name": name}); return result; }
Listing 16.19: Die toJson- und toMap-Methode in der Owner-Klasse
POST-Request triggern – noch ein Button Testen lässt sich der POST-Request, indem Sie analog zum Vorgehen im GET-Request zunächst eine neue Methode _addNewPet im HomeScreen erstellen: Future _addNewPet() async { final httpClient = http.Client(); final restPetRepository = RestPetRepository( httpClient: httpClient, );
const keksTheDog = Pet( id: "612", name: "Keks", species: Species.dog, weight: 250.0, height: 45.0, age: 10, ); await restPetRepository.addPet(keksTheDog); }
Listing 16.20: addNewPet-Methode in der main.dart Auch um diese Methode auszuführen, benötigen Sie wieder einen Test-Button innerhalb des HomeScreens. Ersetzen Sie dazu einfach den existierenden Button in den actions der AppBar und tauschen Sie den _getAllPets-Aufruf mit dem _addNewPet-Aufruf aus. Wenn Sie möchten, können Sie natürlich auch das Icon austauschen. AppBar( …, title: const Text("Pummel The Fish"), actions: [ IconButton( onPressed: () => _addNewPet(), icon: const Icon(Icons.add), ), ], ),
Listing 16.21: Der neue Button in der AppBar Als Ergebnis sollten Sie die folgende Ausgabe im Terminal erhalten. >> Kuscheltier erfolgreich hinzugefügt
An dieser Stelle noch einmal der Hinweis, dass Sie bei einem korrekt durchgeführten POST-Request zwar einen erfolgreichen Status-Code zurückbekommen, jedoch wird das Pet-Objekt serverseitig nicht bei uns angelegt. Beim Abrufen der Pet-Liste werden Ihre eigenen Pet-Objekte also nicht mitgeliefert werden.
PUT/PATCH, DELETE – Daten modifizieren Nachdem Sie nun kennengelernt haben, wie sich Daten von einer API abrufen und an sie senden lassen, füllen Sie die fehlenden Methoden der RestPetRepository-Klasse mit Leichtigkeit. Mit dem PUT-Request können Sie ein bestehendes Pet-Objekt aktualisieren. Achten Sie darauf, dass die id des Pet-Objekts, das Sie aktualisieren wollen, mit in die URI muss. @override Future updatePet(Pet pet) async {
final uri = Uri.parse("$baseUrl/pets/${pet.id}"); final response = await httpClient.put( uri, body: pet.toJson(), headers: { "Content-Type": "application/json", }, ); if (response.statusCode == 200) { print("Kuscheltier erfolgreich aktualisiert"); } else { throw Exception( "Beim Aktualisieren des Pets ging etwas schief", ); } }
Listing 16.22: Methode zum Aktualisieren eines Pet-Objekts Mit dem DELETE-Request können Sie ein bestehendes Pet-Objekt vom Server löschen. Hier gilt dasselbe Prinzip: Die id des zu löschenden Pet-Objekts muss in der URI stehen. @override Future deletePetById(String petId) async { final uri = Uri.parse("$baseUrl/pets/$petId"); final response = await httpClient.delete(uri); if (response.statusCode == 204) { print("Pet wurde erfolgreich gelöscht"); return; } else { throw Exception("Beim Löschen des Kuscheltiers ging etwas schief"); } }
Listing 16.23: Methode zum Löschen eines Pet-Objekts Der Grundstein für die Arbeit mit REST-APIs ist gelegt. Mit dieser Basis kann Ihnen die erste eigene kleine App gelingen, sofern Sie Zugriff auf eine API haben, die Sie anbinden möchten.
Asynchrone REST-API-Daten im Flutter-UI anzeigen Nun kommen Sie endlich zum spannenden Teil: Sie werden nun das, was Sie von der API zurückgeliefert bekommen haben, in der App anzeigen – und nicht mehr einfach
unspektakulär im Terminal ausgeben. Sie erinnern sich sicher an die Pet-Liste, die nun von der REST-API per _getAllPetsMethode abgerufen wird. Diese Pet-Liste werden Sie nun im UI anzeigen. Aktuell sollte sich bereits eine ListView im HomeScreen befinden, die eine Liste an Pet-Objekten anzeigt. Jedoch sind das nicht die Pet-Objekte, die Sie von der API abgerufen haben, sondern nur lokal angelegte Objekte aus dem FakePetRepository. Die _getAllPets-Methode gibt es im HomeScreen bereits. Bisher gibt sie alle Pet-Objekte im Terminal aus, also lassen Sie uns diese Methode kurz modifizieren, sodass die Methode selbst die Pet-Liste zurückgibt. Ändern Sie also den Rückgabewert der Methode von Future zu Future und anstatt dem print(pets) Befehl geben Sie die ganze Liste per return pets zurück: Future _getAllPets() async { final httpClient = http.Client(); final restPetRepository = RestPetRepository( httpClient: httpClient, ); final pets = await restPetRepository.getAllPets(); return pets; }
Listing 16.24: Die angepasste _getAllPets-Methode im HomeScreen
Daten vom Backend holen – aber wann und wo? Werfen Sie nun einen Blick auf Ihre initState-Methode im HomeScreen. Hier werden aktuell die Pet-Objekte von der getAllPets-Methode des FakePetRepository bezogen und in eine lokale pets-Liste geschrieben. Ziel ist es nun, das FakePetRepository mit dem RestPetRepository zu ersetzen. Löschen Sie dafür das FakePetRepository und erstellen Sie ein RestPetRepository über der initState-Methode, welches Sie dann in der initState-Methode instanziieren. Vergessen Sie nicht, dass der getAllPets-Aufruf nun vom restPetRepository ausgelöst wird: class _HomeScreenState extends State { late final RestPetRepository restPetRepository; List pets = []; @override void initState() {
super.initState(); final httpClient = http.Client(); restPetRepository = RestPetRepository( httpClient: httpClient, ); pets = await restPetRepository.getAllPets(); } … }
Listing 16.25: FakePetRepository durch das RestPetRepository austauschen Sie werden nun allerdings feststellen müssen, dass Sie Ihre App nicht mehr starten können, da das await vor dem getAllPets-Aufruf wie in Abbildung 16.2 rot unterkringelt ist.
Abbildung 16.2: await ist rot unterkringelt
Im Normalfall bedeutet das, dass die ganze Methode mit einem Future als Rückgabewert gewrappt und noch ein async ergänzt werden muss. Das würde dann so aussehen: @override Future initState() async { super.initState(); final petsFromApi = await _getAllPets(); pets.addAll(petsFromApi); }
Listing 16.26: Die neue initState-Methode – ob das funktioniert? Prima, keine Fehler mehr im Code! Lassen Sie uns die App starten. Das Ergebnis sollte auch jetzt wieder zu wünschen übrig lassen, denn Sie werden mit einer Exception wie in Abbildung 16.3 konfrontiert.
Abbildung 16.3: Exception, weil ein Future zurückgegeben wird
Wenn Sie sich die Fehlermeldung etwas genauer anschauen, dann können Sie dort erkennen, dass die initState-Methode ein Future zurückgibt. Das ist richtig, das hatten Sie ja angepasst. In der Beschreibung allerdings steht, dass initState eine void-Methode (also eine Methode ohne Rückgabewert) ohne Future sein muss und auch nicht async sein darf. Wie können Sie dieses Problem nun lösen?
Kein Problem ohne Ursache Bevor Sie das Problem lösen können, muss Ihnen erst einmal die Ursache klar werden. Die getAllPets-Methode des RestPetRepository liefert ein Future zurück. Das bedeutet, nachdem Sie die Methode aufgerufen haben, erhalten Sie zu einer unbestimmten Zeit in der Zukunft das Ergebnis in Form einer List. Es gibt also einen Zustand vor dem Ergebnis, in dem noch keinerlei Daten vorhanden sind, und somit hat die pets-Variable zwei verschiedene Zustände: einen Ladezustand, bevor die Daten vom Backend zurückgegeben wurden, und einen Zustand, sobald die Daten angekommen sind und angezeigt werden können. Diese Zustände müssen wir in unserer App bedenken und beispielsweise in eine kleine Ladeanimation verpacken, sodass die benutzende Person darüber informiert wird, dass sich hier vermutlich noch was tut. Außerdem muss auch Flutter wissen, wann sich das UI mit den entsprechend neuen Daten neu laden soll.
FutureBuilder to the Rescue! In Flutter gibt es für nahezu alles ein Widget, so auch hier. In diesem Fall ist der FutureBuilder das Widget unserer Wahl, denn er ermöglicht das Anzeigen von verschiedenen Zuständen, während eine Methode asynchron ausgeführt wird. Ändern Sie den Rückgabewert der initState-Methode wieder zurück und entfernen Sie das async Keyword. Die Zuweisung der pets-Variable bleibt bestehen, jedoch ändern Sie
den Typ der Variable von List in Future und deklarieren sie mit late. Die Variable erhält ihren Wert nämlich nun erst in der initState-Methode. late Future pets; @override void initState() { super.initState(); final httpClient = http.Client(); restPetRepository = RestPetRepository( httpClient: httpClient, ); pets = restPetRepository.getAllPets(); }
Listing 16.27: Die angepasste initState-Methode Das FutureBuilder-Widget siedeln Sie nun dort an, wo es gebraucht wird, und zwar beim Laden und Anzeigen der Pet-Objekt-Liste – also im body. Aktuell sollten Sie innerhalb des body ein ListView.builder-Widget vorfinden. Dieses benötigen Sie weiterhin, allerdings repräsentiert dieses Widget nur den »Erfolgreichgeladen-Zustand«. Sie benötigen mindestens noch einen weiteren Zustand – den Ladezustand. Außerdem gibt es in der Theorie auch noch einen Zustand, bevor die Methode aufgerufen wird – den initialen Zustand –, und einen Zustand, der eintritt, wenn ein Fehler beim Laden der Daten passiert. Noch mal zusammenfassend die vier Zustände, die Sie mithilfe des FutureBuilder-Widgets im UI abbilden wollen: initialer Zustand: Widget mit leerer Pet-Liste Ladezustand: Ladeanimation geladener Zustand: Liste der erfolgreich abgerufenen Pet-Objekte Fehlerzustand: Fehlermeldung Die Theorie ist nun klar, aber wie konkret sieht das in der Praxis aus? Im Prinzip sagt Ihnen das FutureBuilder-Widget genau, was es von Ihnen für die unterschiedlichen Parameter haben möchte: initalData: Geben Sie hier die initialen Daten an, in unserem Fall eine leere Liste mit Pet-Objekten. future: Die restPetRepository.getAllPets-Methode wird hier übergeben als ein Future. builder: Hier wird der aktuelle Zustand abgefragt und je nach Zustand ein anderes
Widget angezeigt.
Der aktuelle Zustand des FutureBuilders lässt sich in der builder-Methode per snapshot.connectionState abfragen und kann die Werte ConnectionState.none, ConnectionState.waiting, ConnectionState.active und ConnectionState.done annehmen. Wenn Daten vorhanden sind, erhalten Sie diese per snapshot.data, ebenfalls in der builder-Methode. Hier finden Sie im Erfolgsfall auch die fertig geladene List, die von der getAllPets-Methode zurückgegeben wurde. FutureBuilder( initialData: const [], future: pets, builder: (context, snapshot) { // Aktueller Zustand: snapshot.connectionState // Daten, wenn vorhanden: snapshot.data } );
Listing 16.28: Das FutureBuilder-Gerüst steht. Das ist also das Basiskonstrukt. Nun müssen Sie nur noch die Widgets für die unterschiedlichen Zustände ausgeben und das FutureBuilder-Widget im childParameter des Padding-Widgets ansiedeln. Am saubersten ist es, wenn Sie diese Widgets im gleichen Zuge jeweils in eine eigene Klasse auslagern, denn dann können Sie sie unkompliziert bei Bedarf an anderen Stellen wiederverwenden und Ihr Code wird schön übersichtlich.
Das PetListLoaded-Widget – ein Erfolgserlebnis! Bevor Sie den FutureBuilder-Code platzieren, können Sie das ListView-Widget, welches für den Erfolgszustand genauso wiederverwendet werden kann, mit einem kleinen Trick direkt in ein eigenes Widget auslagern. Hierzu klicken Sie auf ListView.builder, wählen den QUICK-FIX per Glühlampe und anschließend EXTRACT WIDGET. Geben Sie dem Widget den Namen »PetListLoaded« und kopieren Sie den Code dieser Klasse anschließend in eine neue Datei pet_list_loaded.dart im lib/widgets-Ordner. Fügen Sie wie gewohnt die fehlenden Imports hinzu und voilà, fertig ist schon einmal das Widget für einen der benötigten Zustände: import "package:flutter/material.dart"; import "package:pummel_the_fish/data/models/pet.dart"; import "package:pummel_the_fish/screens/detail_pet_screen.dart"; import "package:pummel_the_fish/theme/custom_colors.dart"; class PetListLoaded extends StatelessWidget { final List pets; const PetListLoaded({
super.key, required this.pets, }); @override Widget build(BuildContext context) { return ListView.builder( itemCount: pets.length, itemBuilder: (context, index) { return ListTile( leading: Icon( pets[index].isFemale ? Icons.female : Icons.male, color: CustomColors.orange, size: 40, ), title: Text( pets[index].name, style: Theme.of(context).textTheme.titleMedium, ), subtitle: Text( "Alter: ${pets[index].age} Jahre", style: Theme.of(context).textTheme.bodySmall, ), trailing: const Icon( Icons.chevron_right_rounded, color: CustomColors.blueDark, ), onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => DetailPetScreen( pet: pets[index], ), ), ); }, ); }, ); } }
Listing 16.29: Das fertige PetListLoaded-Widget Da Sie das Widget nun ausgelagert haben, müssen Sie es im HomeScreen importieren: import "package:pummel_the_fish/widgets/pet_list_loaded.dart";
Das PetListLoading-Widget – wenn es mal wieder länger dauert
Der Ladezustand soll zunächst nur eine Ladeanimation erhalten. Hierfür können Sie das Widget CircularProgressIndicator verwenden. Erstellen Sie eine neue Datei pet_list_loading.dart mit dem folgenden Inhalt und legen Sie diese ebenfalls im Ordner lib/widgets ab: import "package:flutter/material.dart"; class PetListLoading extends StatelessWidget { const PetListLoading({super.key}); @override Widget build(BuildContext context) { return const Center( child: CircularProgressIndicator(), ); } }
Listing 16.30: Das PetListLoading-Widget
Das PetListError-Widget – wenn es mal wieder nicht so funktioniert wie erwartet Der Fehlerzustand soll lediglich einen Fehlertext im UI ausgeben. Um das Widget wiederverwendbar zu machen, können Sie zusätzlich noch einen Parameter message deklarieren, der mit unterschiedlichen Fehlertexten umgehen kann. Erstellen Sie eine neue Datei pet_list_error.dart innerhalb des lib/widgets-Ordners und fügen Sie den folgenden Code ein: import "package:flutter/material.dart"; class PetListError extends StatelessWidget { final String message; const PetListError({ super.key, required this.message, }); @override Widget build(BuildContext context) { return Center( child: Text( message, style: Theme.of(context) .textTheme .bodyLarge! .copyWith(color: CustomColors.blueDark), ), ); }
}
Listing 16.31: Das PetListError-Widget mit message-Parameter
Der FutureBuilder und seine Widgets Die Zeit ist gekommen: Weisen Sie im FutureBuilder-Widget die entsprechenden Widgets den entsprechenden Zuständen zu. Die verschiedenen Zustände des ConnectionStates lassen sich wunderbar mit einem switch-Statement abbilden: body: SafeArea( child: Padding( padding: const EdgeInsets.all(24), child: FutureBuilder( initialData: const [], future: pets, builder: (context, snapshot) { switch (snapshot.connectionState) { case ConnectionState.none: case ConnectionState.waiting: case ConnectionState.active: return const PetListLoading(); case ConnectionState.done: if (snapshot.hasData) { return PetListLoaded(pets: snapshot.data!); } else { return const PetListError( message: "Fehler beim Laden der Tiere", ); } } }, ), ), ),
Listing 16.32: Der aufgeräumte FutureBuilder Wenn alles funktioniert hat, sollten Sie nun beim Neuladen der Seite zunächst kurz eine Ladeanimation sehen und dann die Liste der Pet-Objekte, die von der Schnittstelle kommen. Aber nicht nur das: Ihr Code ist nun auch sehr aufgeräumt, alles ist übersichtlich, lesbar und hat seinen Platz – und ist zudem wiederverwendbar aufgebaut. Sie haben sich vielleicht gewundert, warum wir für den Aufruf restPetRepository.getAllPets extra eine Variable gebaut haben und diese im initState der Methode zuweisen, obwohl Sie den Aufruf doch direkt dem FutureBuilder als future übergeben könnten. Der Grund dafür ist, dass, wenn Sie das nicht tun, bei jedem Rebuild der Widgets in der build-Methode durch beispielsweise eine andere State-Änderung – also einen setState-Aufruf – die
Methode erneut getriggert und ausgeführt werden würde. Stellen Sie sich ein TextFormField vor, dass bei jeder Eingabe die Methode erneut auslöst. Das ist unnötig und führt eventuell zu einer Überlastung Ihres Servers. Wenn Sie das Future stattdessen in eine Variable verpacken und die Methode dieser Variable in der initState-Methode zuweisen, wird die Methode beim ersten Bauen des States ausgelöst, bei jeder weiteren State-Änderung aber nicht mehr.
Daten aus dem Flutter-UI sammeln und an die REST-API senden In den meisten Fällen werden Sie in Ihren Apps nicht nur Daten von einer Schnittstelle abrufen, sondern auch neue Daten an die Schnittstelle senden. Dies haben Sie bereits in der _addNewPet-Methode im HomeScreen getan, jedoch haben Sie dort immer dasselbe Pet-Objekt übermittelt. Zu Testzwecken war das völlig in Ordnung, jedoch möchten Sie am Ende eine funktionale App haben und Kuscheltiere selbst anlegen können, ohne diese im Code einspeisen zu müssen.
Der CreatePetScreen wird funktional Daher werden Sie nun die Funktionalität der _addNewPet-Methode im HomeScreen in einen Screen auslagern, der viel besser für diesen Fall geeignet ist. Sie werden die UserEingaben sammeln und zu einem fertigen Pet-Objekt bündeln, um sie anschließend an die REST-API zu übermitteln. Welcher Screen würde für diesen Plan besser geeignet sein als der CreatePetScreen, den Sie bereits erstellt haben? In diesem Screen wird ein neues Pet-Objekt angelegt, indem die benutzende Person das Formular mit den benötigten Daten, um ein Tier zur Adoption freizugeben, ausfüllt. Das Ergebnis der ausgefüllten Felder ergibt ein Pet-Objekt, welches dann an die REST-API übertragen werden soll. Erstellen Sie analog zum HomeScreen ein RestPetRepository in der CreatePetScreenKlasse und instanziieren Sie dieses Objekt in der initState-Methode: class _CreatePetScreenState extends State { … late final RestPetRepository restPetRepository; @override void initState() { super.initState(); final httpClient = http.Client(); restPetRepository = RestPetRepository( httpClient: httpClient, ); }
… }
Listing 16.33: Ein RestPetRepository im CreatePetScreen Nun können Sie im CustomButton des CreatePetScreen die onPressed-Methode so anpassen, dass anstatt der print-Methode die restPetRepository.addPet-Methode aufgerufen wird. Dieser Methode übergeben Sie dann Ihr frisch erstelltes Pet-Objekt. Die Umsetzung finden Sie in Listing 16.34. CustomButton( onPressed: () async { if (_formKey.currentState?.validate() ?? false) { final pet = Pet( id: "test", name: currentName!, species: currentSpecies!, age: currentAge!, weight: currentWeight!, height: currentHeight!, isFemale: currentIsFemale, ); await restPetRepository.addPet(pet); Navigator.pop(context); } }, label: "Speichern", ),
Listing 16.34: Der angepasste Speichern-Button im CreatePetScreen
Der Code ist zwar nun voll funktional, sollte aber beim Navigator-Aufruf eine Warnung mit dem Inhalt »Do not use BuildContexts across async gaps« anzeigen. Um das zu lösen, schreiben Sie einfach ein if(!mounted) return; vor den Aufruf. Da Sie hier einen asynchronen Aufruf tätigen, könnte es sein, dass sich das Widget danach nicht mehr im Widget-Baum befindet. Durch die Prüfung, ob das Widget immer noch »mounted« ist, stellen Sie sicher, dass es sich noch im Widget-Baum befindet und man damit interagieren kann.
Der erste Testlauf Wenn Sie Ihre App nun starten, das Formular ausfüllen und durch den Klick auf den SPEICHERN-Button abschicken, sollte im Terminal die Ausgabe »Kuscheltier erfolgreich hinzugefügt« erscheinen. Das ist ein Anfang, jedoch kriegt die nutzende Person davon nichts mit, da es keinerlei visuelles Feedback gibt und das neue Kuscheltier auch nicht in der Liste auftaucht. Die
meisten App-Nutzenden werden daher versuchen, das Kuscheltier erneut anzulegen – erfolglos. Das wollen Sie natürlich verhindern, indem Sie den App-Nutzenden entsprechendes Feedback geben, ob ihre Daten erfolgreich versandt wurden und sie eine positive Antwort von der API erhalten haben oder nicht. Sie können dies am einfachsten tun, indem Sie eine SnackBar anzeigen. Eine SnackBar ist ein Widget, das wie eine Benachrichtigung aussieht. Es kann mit Erfolgs- oder Fehlernachrichten an die anwendende Person ausgestattet werden und verschwindet nach einigen Sekunden von selbst. Um eine SnackBar anzuzeigen, verwenden Sie den ScaffoldMessenger. Der ScaffoldMessenger ist ein InheritedWidget, sitzt ganz oben im Widget-Baum über allen Scaffold-Widgets und hat somit die Möglichkeit, SnackBars Screen-übergreifend anzuzeigen. Wenn Sie also direkt nach dem Anzeigen der SnackBar den Screen wechseln, wird die SnackBar Sie treu begleiten. Praktisch, oder? Falls das RestPetRepository beim Ansprechen der REST-API einem Fehler begegnen würde, würde es Ihnen eine Exception werfen. Diese sollten Sie entsprechend auffangen und behandeln, um die App nicht zum Abstürzen zu bringen. Das lässt sich einfach lösen, indem Sie den restPetRepository.addPet-Aufruf in der onPressed-Methode mit einem sogenannten try-catch absichern. Sie versuchen, innerhalb des try-Abschnitts einen Aufruf zu tätigen und im Falle, dass etwas schiefläuft, läuft Ihr Code im Exception-Abschnitt weiter. Im try-Abschnitt können Sie nach dem Aufruf der addPet-Methode dann die Erfolgs-SnackBar anzeigen, während Sie im Exception-Abschnitt eine Fehler-SnackBar anzeigen. Die onPressed-Methode wird langsam etwas lang, es lohnt sich spätestens jetzt, diese Methode in eine Helper-Methode auszulagern. Markieren Sie dazu den gesamten Code innerhalb der Methode und verwenden Sie die QUICK-FIX-Option und EXTRACT METHOD wie in Abbildung 16.4, um eine neue Methode _addPet anzulegen.
Abbildung 16.4: Einen Code-Abschnitt in eine separate Methode extrahieren
Future _addPet() async { if (_formKey.currentState?.validate() ?? false) { final pet = Pet( id: "test", name: currentName!, species: currentSpecies!, age: currentAge!, weight: currentWeight!, height: currentHeight!, isFemale: currentIsFemale, ); try { await restPetRepository.addPet(pet); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( backgroundColor: CustomColors.blueDark, content: Text( "Kuscheltier erfolgreich hinzugefügt", ), ), ); } on Exception { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( backgroundColor: CustomColors.red, content: Text( "Fehler beim Hinzufügen des Kuscheltiers", ), ), ); } if (!mounted) return; Navigator.pop(context); } }
Listing 16.35: Die _addPet-Methode mit Fehler-Handling
Da es sich bei der SnackBar um ein Widget handelt, können Sie dieses Element customizen. Beispielsweise können Sie die Hintergrundfarbe verändern, für den content kein Text-Widget, sondern ein Icon- und Text-Widget in einer Row gewrappt mitliefern oder die Anzeigedauer verändern. Spielen Sie ein bisschen mit dem SnackBar-Widget herum, es macht Spaß und bietet Ihren App-Nutzenden
wertvolles visuelles Feedback! Wenn Sie die App nun hot-reloaden und das Formular erneut ausfüllen und abschicken, sollten Sie eine SnackBar erkennen, die Ihnen mitteilt, dass alles funktioniert hat. Um die Fehler-SnackBar zu testen, können Sie im RestPetRepository die Status-Code-Abfrage in der addPet-Methode mit einem beliebig anderen Status-Code ersetzen und die Exception somit erzwingen. Vergessen Sie nicht, diese Änderung nach dem Test wieder rückgängig zu machen. Man munkelt, das könnte eine unschöne Bug-Suche hervorrufen. Auch beim Senden von Daten an eine API entstehen verschiedene Zustände. Sie können daher auch hier einen FutureBuilder verwenden, um auf verschiedene Zustände zu reagieren beziehungsweise ein entsprechendes UI anzuzeigen. Sie könnten beispielsweise den SPEICHERN-Button mit einem FutureBuilder wrappen und im Ladezustand einen nicht klickbaren Button mit Ladeanimation anzeigen.
Wo ist mein neu angelegtes Pet? Nachdem Sie ein neues Pet angelegt haben, erwarten Sie vermutlich, dieses in der Liste zu sehen. Doch wie es scheint, hat sich diese nicht automatisch neu geladen. Sie haben zwar mehrere Optionen, dieses Problem zu lösen, wir gehen hier aber nur auf die einfachste ein, indem Sie die Liste einfach nach dem Schließen des CreatePetScreens neu laden werden. Nur zur Erinnerung: Bitte beachten Sie, dass unsere zur Verfügung gestellte pummelthefish-API nur einen erfolgreichen Status-Code 201 beim POST eines PetObjekts zurückliefert, Ihr Pet aber nicht in die Liste mit aufnehmen wird.
Neuladen der Liste Wenn Sie die Pet-Liste einfach neu laden, sobald der CreatePetScreen geschlossen wurde, haben Sie den Vorteil, dass Sie die aktuellen Daten der API erhalten und keine lokalen Objekte manipulieren müssen. Lokale Objekte in der Liste können sich mittlerweile geändert haben oder Ihnen fehlen Eigenschaften, die erst im Backend zugewiesen werden, wie zum Beispiel die id. Der Nachteil beim Neuladen der Liste ist im Gegenzug, dass Sie alle Daten neu abfragen müssen. Das führt zu einer erhöhten Last bei Ihrem Backend. In diesem Fall lässt sich das allerdings getrost ignorieren. Für das Neuladen der Liste müssen Sie zwei Dinge wissen: zum einen, wie Sie etwas ausführen können, nachdem der CreatePetScreen verschwindet, und zum anderen, wie sich der FutureBuilder erneut ausführen lässt, der für das Laden und Anzeigen der Liste der Pet-Objekte verantwortlich ist.
Der Navigator als Future
Wenn Sie einmal über den Navigator.pushNamed mit Ihrer Maus hovern, sollten Sie die Dokumentation wie in Abbildung 16.5 angezeigt bekommen.
Abbildung 16.5: Dokumentation von pushNamed anzeigen
Diese besagt, dass die pushNamed-Methode ein Future zurückgibt, Sie können also auf den Abschluss der pushNamed-Methode warten, indem Sie ein await vor den Aufruf hängen und die ganze onPressed-Methode async machen. floatingActionButton: FloatingActionButton( onPressed: () async { await Navigator.pushNamed(context, "/create"); }, child: const Icon(Icons.add), ),
Listing 16.36: Warten, bis der Navigator fertig ist Der Navigator gilt dann als fertig, wenn der gepushte Screen wieder geschlossen wird. Praktisch, oder? Das bedeutet, Sie können die Pet-Liste erneut laden, wenn die pushNamed-Methode erfolgreich beendet ist. Die Liste laden Sie gleich, wie bereits im initState, jedoch ist wichtig, dass Sie den Aufruf in einen setState wrappen, da der FutureBuilder sonst nicht erneut gebaut und angestoßen wird. floatingActionButton: FloatingActionButton( onPressed: () async { await Navigator.pushNamed(context, "/create"); setState(() { pets = restPetRepository.getAllPets(); }); }, child: const Icon(Icons.add), ),
Listing 16.37: Die Liste wird neu geladen.
Ihnen ist bestimmt aufgefallen, dass die pushNamed-Methode des Navigators nicht irgendein Future zurückgibt, sondern eins vom Typ T. Der Typ T bedeutet, dass es jeden beliebigen Typ aufnehmen kann. Es kann sowohl ein Navigator.pushNamed als auch ein Navigator.pushNamed erzeugt werden. Das bedeutet, dass Sie mit einem Ergebnis des jeweiligen Typs rechnen können, wenn der pushNamed-Aufruf fertig ist. Ein Beispiel: final result = await Navigator.pushNamed( context, "/create", );
Listing 16.38: Die Navigator.pushNamed mit Rückgabewert Das result wird versuchen, den vom Navigator.pushNamed zurückgegebenen Wert in einen Typ bool umzuwandeln. Wichtig ist daher an dieser Stelle, dass Sie dafür sorgen, dass die pushNamed-Methode einen bool-Wert zurückgibt. Aber wie geht das? Hierfür müssen Sie in unserem Beispiel im CreatePetScreen beim Navigator.pop den gewünschten Wert mitliefern, zum Beispiel: Navigator.pop(context, false);
Geschafft! Sie haben nicht nur einen Crash-Kurs in REST erhalten, sondern Ihr Wissen auch direkt praktisch angewendet. Begriffe wie CRUD, API, Requests und Response sollten Ihnen nun keine Angst mehr machen. Sie sind nun fähig, eine einfache REST-API an Ihre Flutter-App anzubinden und mit ihr über ein Repository zu interagieren. Außerdem haben Sie gelernt, wie Sie Repository-Aufrufe im UI auslösen können, und auch, wie Sie asynchrone Daten in verschiedenen Zuständen anzeigen können. Blöd aber, dass unser Backend nicht alles kann, was Sie vielleicht damit umsetzen wollen. Im folgenden Kapitel werden Sie lernen, wie Sie sich schnell selbst ein Backend zaubern können – mit Firebase.
Kapitel 17
Firebase und der Cloud Firestore IN DIESEM KAPITEL Lernen Sie, warum Firebase mehr als nur eine Datenbank ist Erfahren Sie, was Vor- und Nachteile von Firebase sind und wann Sie lieber eine Alternative wählen sollten Bauen Sie Ihr eigenes No-Code-Backend mit dem Cloud Firestore
Wäre es nicht schön, wenn Sie nun Ihr eigenes kleines Backend ohne viel Aufwand anlegen könnten und somit dann die Kontrolle über Ihre Daten hätten? Endlich eigene Kuscheltiere anlegen, bearbeiten und auch löschen könnten? Es gibt mehrere Möglichkeiten, hier ohne große Backend-Programmierkünste ans Ziel zu kommen. Eine der gängigsten und bekanntesten ist Firebase. Firebase ist nicht mehr einfach nur eine Datenbank – Firebase ist ein komplettes »Backend-as-a-Service« mit vielen verschiedenen Features, die Ihren Alltag in der AppEntwicklung vereinfachen können. Da Firebase, wie Flutter, ebenfalls ein Produkt von Google ist, sind die Services sehr einfach in Flutter-Apps zu integrieren. Welche Services es gibt und wie Sie Ihr eigenes Backend mit dem Cloud-Firestore-Service von Firebase bauen und damit die extern gemanagte REST-API ersetzen können, erfahren Sie in diesem Kapitel.
Die eierlegende Wollmilchsau Wie bereits erwähnt, bietet Firebase verschiedene Services an. Ein paar davon möchten wir Ihnen hier kurz vorstellen. Firebase erweitert sein Sortiment allerdings stetig, es lohnt sich, ab und zu einen Blick auf die Firebase-Webseite zu werfen. Firestore Cloud Database: eine einfach ansteuerbare NoSQL-Datenbank, die Sie in diesem Teil kennenlernen werden Crashlytics: Erfassen und Einsehen von Crash-Reports aus angebundenen Apps. Mehr Infos dazu finden Sie in Kapitel 22, »Der Android-Build«, im Unterkapitel »Crashlytics und Analytics einbinden«. Analytics: Benutzerverhalten tracken und optimieren. Mehr Infos dazu finden Sie ebenfalls in »Crashlytics und Analytics einbinden«.
Authentication: einfache Anbindung eines ID-Providers, auch Log-in über Google, Apple, Meta und Co. möglich Remote Config: Configs, die unabhängig von einem App-Release angepasst werden können und in der Cloud liegen App Check: den Ursprung von Requests validieren, um sich vor Angreifenden zu schützen Cloud Functions: Schreiben Sie Ihre eigenen Backend-Funktionen. Cloud Messaging: Push-Notifications manuell im Firebase-Dashboard auslösen oder automatisch über Cloud Functions A/B Testing: Erweitern Sie Ihre App um die Möglichkeit, A/B-Tests zu schalten. App Distribution: Verteilen Sie neue App-Versionen unkompliziert an Ihre Testenden. Mehr Infos dazu in Kapitel 22, »Der Android-Build«, im Unterkapitel »Firebase App Distribution«.
Cloud-Firestore-Grundlagen Wir können nicht alle Services in diesem Buch behandeln und werden uns in diesem Kapitel ausschließlich auf den Cloud Firestore konzentrieren. Der Cloud Firestore stellt Ihnen eine einfach zu bedienende NoSQL-Datenbank zur Verfügung, in der Sie Ihre eigenen Pet-Objekte speichern, ändern und auslesen können. Ja, Sie haben richtig gelesen. Eine NoSQL-Datenbank. Was soll das denn sein?
SQL Vielleicht haben Sie schon einmal von relationalen Datenbanken gehört, die auf einem SQL-Schema basieren. SQL – oder auch »Structured Query Language« – ist eine Ansammlung von Befehlen, mit denen es möglich ist, Daten in einer relationalen Datenbank zu managen. Relationale Datenbanken werden in Tabellen und Spalten strukturiert und mit Verweisen zu anderen Tabellen versehen. Sie verfolgen dabei immer ein striktes Datenbankschema mit konkreten Regeln, wie Tabelleneinträge aufgebaut sein müssen. Zur Veranschaulichung hier noch ein kurzes Beispiel: Sie haben eine Tabelle »Pets«, in der sich alle Tiere tummeln, und eine Tabelle »Owner«, in der sich die entsprechenden Besitzer aufhalten (vergleiche Abbildung 17.1). Die »Pets«-Tabelle hat ein klares Schema: Es gibt ein Feld »name« vom Typ String, ein Feld »age« vom Typ int und ein Feld »height« vom Typ double. Alle Daten, die in diese Tabelle eingepflegt werden, müssen diesem Schema folgen. Sie wollen noch ein zusätzliches Feld »weight« vom Typ double hinzufügen? Kein Problem, aber nur wenn alle Datensätze auch dieses Feld erhalten. Der Wert kann zwar null sein, das Feld gibt es aber trotzdem.
Abbildung 17.1: Eine relationale Datenbank
NoSQL Im Gegensatz dazu arbeitet NoSQL – oder auch »Not only Structured Query Language« – mit einem flexiblen Datenbankschema und Wischi-Waschi-Regeln, die nicht zwingend eingehalten werden müssen. Es gibt keine Tabellen, sondern unterschiedliche Ausprägungen wie Key-Value-basierte Ansätze, dokumentenbasierte Ansätze oder wie bei Firestore sogenannte Collections. Im Firestore-Ansatz hätten Sie also zum Beispiel eine pets-Collection und eine ownersCollection. Oder, wenn Sie möchten, haben Sie nur eine Collection für alle Ihre verschiedenen Objekte. Alles ist möglich – wie Sie es organisieren möchten, liegt in Ihrer Hand. In einer Collection befinden sich Documents. Jedes Document ist am Ende nichts anderes als eine Map – also im Prinzip nichts anderes als eine JSON-Datei. Ein Document darf eine Größe von einem Megabyte nicht überschreiten, aber ansonsten gibt es nicht viele Regeln: Es kann zum Beispiel die Felder »name«, »age«, »height« und »weight« geben – oder auch nicht. Documents in derselben Collection können zudem komplett unterschiedliche Felder haben. In Abbildung 17.2 können Sie ein Beispiel einer nicht relationalen Datenbank, wie den Cloud Firestore, sehen.
Abbildung 17.2: Eine nicht relationale Datenbank
Das hat zum einen den Vorteil, dass es quasi keine Regeln für das Datenschema gibt, und zum anderen den Nachteil, dass es quasi keine Regeln für das Datenschema gibt. Im Rahmen mancher Software-Applikationen sind diese Regeln enorm wichtig, und in anderen ist es wichtiger, dass die Regeln flexibel aushebelbar sind. Hier muss von Anwendung zu Anwendung entschieden werden, was am meisten Sinn ergibt. Wenn Sie sich entscheiden, mit Firebase, also NoSQL, zu arbeiten, setzen Sie sich am besten eigene Regeln für die Benutzung Ihrer Datenbank und halten sich konsequent daran. Nur weil Ihnen von der Datenbank wenige Regeln vorgegeben werden, sollten Sie nicht strukturlos loslegen. Vielmehr sollten Sie sich bei NoSQL erst recht Gedanken über ein sinnvolles Datenbankschema machen. In Firebase gibt es also keine Tabellen und Tabelleneinträge, sondern Collections und Documents. Eine Collection kann mehrere Documents beinhalten und ein Document wiederum kann mehrere Collections beinhalten. Sie können diese Vorgehensweise mehrfach ineinander verschachteln, in unserem Fall legen Sie einfach alle Collections auf der obersten Ebene an. Innerhalb dieser Collections befinden sich dann die einzelnen Einträge als Documents. Wie genau das aussieht, erfahren Sie in der praktischen Umsetzung ein bisschen später in diesem Kapitel.
Die Vorteile einer Cloud-Firestore-Datenbank Der wohl größte Vorteil einer Cloud-Firestore-Datenbank und auch mit ein Grund, warum sie so viel genutzt wird, ist die komfortable Anbindung an Flutter-Apps. Sie werden bei der Installation merken, wie unfassbar schnell Sie damit vor allem für kleinere Apps und Prototypen ans Ziel kommen. Bis zu einem gewissen Kontingent ist der Cloud Firestore komplett kostenlos nutzbar,
etliche andere Firebase-Services (wie zum Beispiel Crashlytics und Analytics) bleiben auch dann kostenlos, wenn das kostenlose Cloud-Firestore-Kontingent überschritten wurde. Solange Sie die Bezahlversion nicht aktiviert haben, wird Ihnen auch nie etwas in Rechnung gestellt werden – das ist auch einer der Hauptgründe, warum wir uns in diesem Buch für Firebase entschieden haben. Die Datenbank ist ab einer gewissen Anzahl von Reads pro Tag dann einfach nicht mehr erreichbar. Falls Sie aus Versehen eine Schleife programmieren, müssen Sie also keine Angst vor einer riesigen Rechnung haben, solange Sie nicht explizit auf den bezahlten Blaze-Plan gewechselt sind. Ein weiteres tolles Feature einer Cloud-Firestore-Datenbank ist, dass sie offline verwendet werden kann. Die Daten, die auf Ihr Gerät heruntergeladen oder auf diesem erstellt wurden, sind vollständig offline verfügbar. Auch die Synchronisation, wenn Sie nach einer Offline-Zeit wieder online gehen, wird automatisch angestoßen und durchgeführt. Das ist ein Luxus, den kaum eine andere Datenbank bietet.
Die Nachteile einer Cloud-Firestore-Datenbank So schön die Vorteile der Cloud-Firestore-Datenbank auch sind, alles hat immer zwei Seiten.
TypeScript und NoSQL – das muss man mögen Zwischen den App-Nutzenden und der Datenbank liegt kein wirkliches Backend, das Sie selbst steuern können. Sie sind daher in Ihren Möglichkeiten, Daten zu kombinieren und abzufragen, etwas eingeschränkt. Allerdings gibt es die Möglichkeit, den CloudFunctions-Service anzubinden, um individualisierte Abfragen auszuführen. Diese müssen aber aktuell noch in JavaScript oder TypeScript geschrieben werden. In Zukunft werden diese auch in Dart geschrieben werden können, aber bis dahin müssen Sie erst einmal in JavaScript oder TypeScript eintauchen. Da es sich um eine NoSQL-Datenbank handelt, müssen Sie natürlich auch deren Nachteile in Betracht ziehen. NoSQL-Datenbanken setzen meist kein spezielles Datenschema voraus, weswegen Ihre Datenstruktur leichter chaotisch werden kann. Komplexere Abfragen sind außerdem nicht möglich, Sie sind an die Query-Auswahl, die der Cloud Firestore zur Verfügung stellt, gebunden.
Money Money Money Einer der wohl interessantesten – und ab einer gewissen App-Bekanntheit und Nutzung auch der wichtigste Aspekt – ist das Preismodell des Cloud Firestores. Zunächst ist es kostenlos, aber wie Sie bereits richtig vermuten, handelt es sich hier leider nicht um einen Wohltätigkeitsverein, sondern ein knallhartes Business. Firebase rechnet nach Nutzung ab. Das bedeutet, wenn Sie mit mehreren Nutzenden rechnen und dafür auf den Blaze-Plan wechseln, bezahlen Sie für die Anzahl Requests, die über Ihre Cloud-Firestore-Instanz laufen. Dazu zählen sowohl das Lesen von Datensätzen als auch das Schreiben und das Löschen. Wenn Sie in Ihrem Backend zusätzlich mit Dateien arbeiten wollen – also im
sogenannten Cloud Storage – dann wird zusätzlich pro GB Speicher berechnet. Sie möchten nicht nur einfach Requests abfeuern, sondern komplexere Berechnungen auf Basis Ihrer Daten durchführen oder automatisierte Skripte laufen lassen? Dann benötigen Sie zusätzlich die Cloud Functions, welche pro Funktionsaufruf zu Buche schlagen. Sie sehen schon, da kann ganz schön was zusammenkommen. Aber seien Sie beruhigt: Die Preise pro Einheit, nachdem das kostenlose Kontingent aufgebraucht ist, sind bezahlbar. Wenn Sie eine hohe Firebase-Rechnung erhalten, ohne dass Ihre App genügend Geld eingespielt hat, haben Sie vermutlich einen der folgenden Fehler begangen: Sie haben eine versteckte while-Schleife in Ihrem Code eingebaut, die dafür sorgt, dass Requests dauernd aufgerufen werden. Das ist natürlich ein Mysterium und ist noch nie jemandem aus unserem Bekanntenkreis passiert *räusper*. Sie haben kein langfristig tragbares Bezahlmodell für Ihre App ausgewählt oder bieten Ihre App kostenlos und ohne Werbung an. Sie haben nicht versucht, die Requests zu optimieren und nur die Daten abzufragen, die Sie wirklich brauchen. Sie haben Ihre Firebase-Instanz nicht geschützt, jemand hat sich Zugriff verschafft und versucht, Sie in den Ruin zu treiben. Das wohl größte Problem daran ist, dass Sie Ihre Firebase-Instanz sehr regelmäßig beobachten müssen, um unerwartete Kosten zu vermeiden, sobald Sie die kostenpflichtige Version aktiviert haben. Firebase bietet von Haus aus leider keine Bezahlgrenze an, bei der ab einem gewissen Betrag keine Requests mehr ausgeführt werden würden. Wenn Sie allerdings die Suchmaschine Ihres Vertrauens nutzen, werden Sie höchstwahrscheinlich ein Skript finden, welches Sie selbst einbauen können, um diese Funktion zu aktivieren.
Spaß mit Datenschutz Seit einigen Jahren ist es laut EU-Datenschutzrecht problematisch, US-amerikanischen Firmen Zugang zu europäischen Daten zu gewähren. Hintergrund ist hier, dass USamerikanische Firmen von ihrer Regierung gezwungen werden können, diese Daten herauszugeben. Die USA und die EU haben es nicht geschafft, sich auf ein einheitliches Recht für Privatbürger zu einigen. Da Google eine US-amerikanische Firma ist und Firebase zu Google gehört, hat eine US-
amerikanische Firma prinzipiell Zugriff auf die Daten, die Sie in Firebase ablegen. Sie können (und sollten) bei der Firebase-Einrichtung den Server-Standort »Frankfurt« wählen, aber das löst das Problem hier nicht. Dieses Problem bietet sich aber nicht nur bei der Wahl von Firebase als Backend-as-aService (kurz BaaS): Auch viele andere BaaS-Anbieter, zum Beispiel AWS, sind betroffen. Wie Sie sicherlich wissen, dürfen Sie auch keine Kundendaten mehr in DropBox oder Google Drive speichern. Es gibt noch wenig Erfahrungsdaten, was hier wie kritisch ist. Wenn Sie sensible, zum Beispiel medizinische, Daten oder andere kritische personenbezogene Daten Ihrer Nutzenden abfragen, sollten Sie sich nicht für Firebase entscheiden. Als Prototyping-Tool wird es aber gern und oft verwendet. Viele hegen die Hoffnung, dass entweder die Politik oder die BaaS-Unternehmen selbst in Zukunft auf eine bessere Lösung kommen. Wie Sie sehen, gibt es viele Gründe, die für den Cloud Firestore sprechen, und auch einige, die dagegen sprechen. Für unseren Fall ist der Cloud Firestore dennoch perfekt geeignet und zudem ist er weit verbreitet, sodass Sie bei Problemen auch leicht Hilfe im Internet finden werden.
Firebase-Installation und Einrichtung Sie haben nun einen ersten theoretischen Eindruck von Firebase und speziell dem Cloud Firestore bekommen. Jetzt kommen wir zum praktischen Teil, indem Sie Ihre eigene Firebase-Instanz aufsetzen, den Firestore-Cloud-Service aktivieren und installieren und schlussendlich Daten über die »Pummel The Fish«-App im Firestore ablegen, bearbeiten und abrufen werden. Das Ganze funktioniert mithilfe eines sogenannten »Software Development Kit« (kurz: SDK). Firebase stellt solch ein SDK nicht nur für Flutter zur Verfügung, sondern auch für Web, Android-native und iOS-native. Das SDK benötigen Sie, damit Firebase mit der entsprechenden Plattform oder Technologie kommunizieren kann. Was ist der Unterschied zwischen einer API und einem SDK? Als API bezeichnet man lediglich eine einfache Schnittstelle zu einem anderen System, um mit diesem interagieren zu können. Ein SDK hingegen stellt ein ganzes Toolset, Dokumentationen, Code-Beispiele und vorgefertigte Prozesse bereit, um mit einem anderen System zu interagieren.
Voraussetzungen Um das Firebase SDK in Ihre Flutter-App integrieren zu können, benötigen Sie einen Google-Account. Wenn Sie noch keinen angelegt haben, können Sie das unter https://accounts.google.com/signin tun. Loggen Sie sich anschließend in Ihren
Google-Account ein. Wenn Sie die Firebase-Seite unter https://console.firebase.google.com nun öffnen, sollte Sie die Überschrift »Willkommen bei Firebase!« willkommen heißen.
Firebase CLI installieren Mit der Firebase CLI (Command Line Interface) können Sie Ihre Firebase-Projekte mithilfe von ein paar Befehlen ganz einfach über Ihren Terminal erstellen, verwalten und mit Ihren Apps verknüpfen. Es ist auch möglich, all das ohne die Firebase CLI zu tun, jedoch ist der Aufwand deutlich höher und fehleranfälliger.
Windows Für Windows können Sie entweder die Installationsdatei herunterladen und installieren oder die Firebase CLI per npm installieren. Sollten Sie zuvor noch nicht von npm gehört haben, wählen Sie die erste Variante. npm ist der Package-Manager von Node.js und ist nicht zwingend notwendig, jedoch haben viele Entwickelnde npm bereits vorinstalliert und können daher easy davon Gebrauch machen.
macOS oder Linux Für macOs oder Linux können Sie zwischen drei Optionen wählen. 1. Installation per Ausführung eines Skripts im Terminal: curl -sL https://firebase.tools | bash
2. Herunterladen einer Installationsdatei 3. Installation per npm Wir empfehlen Ihnen Variante 1. Nachdem Sie die Firebase CLI installiert haben, überprüfen Sie, ob sich der Befehl firebase login im Terminal in VSCode ausführen lässt. Gegebenenfalls müssen Sie VSCode neu starten oder ein neues Terminal-Fenster öffnen. Mit einem letzten Befehl aktivieren Sie das sogenannte »FlutterFire«, das eine Brücke zwischen Flutter und Firebase darstellt und somit zu einer noch schnelleren Einbettung von Firebase in Ihrer App führt: dart pub global activate flutterfire_cli
Prüfen Sie in regelmäßigen Abständen, ob es möglicherweise ein Update der Firebase CLI gibt. Dies können Sie ganz einfach tun, indem Sie den Installationsweg, welchen auch immer Sie gewählt haben, erneut ausführen.
Ein neues Projekt anlegen Sind die Firebase CLI und FlutterFire erfolgreich installiert und konnten Sie sich in Ihren
Firebase-Account über das Terminal einloggen, so steht Ihrem ersten Projekt nichts mehr im Wege. 1. Navigieren Sie im Terminal in Ihre App – falls Sie sich nicht schon dort befinden – und führen Sie den Befehl flutterfire configure aus. 2. Nun werden Sie durch einen kleinen Wizard geleitet. Wählen Sie bei der Frage, welches Projekt Sie verwenden möchten, CREATE A NEW PROJECT aus. 3. Danach müssen Sie sich für einen eindeutigen Namen Ihres Projekts entscheiden. Wählen Sie »pummel-the-fish« für unsere Beispiel-App. 4. Nun können Sie die Plattformen wählen, für die Ihre App verfügbar sein sollen. Wählen Sie zunächst Android und iOS aus. Weitere Plattformen können auch in einem späteren Verlauf hinzugefügt werden. 5. Bei der Frage, ob die build.gradle-Dateien von Firebase angepasst werden dürfen, wählen Sie YES. Firebase wird nun das Projekt erstellen und mit Ihrer App verknüpfen. Eine neue Datei namens firebase_options.dart sollte in Ihrem lib-Ordner aufgetaucht sein. Immer wenn Sie eine Veränderung an Ihrem Firebase-Projekt vornehmen, zum Beispiel das Hinzufügen einer neuen Plattform oder von Firebase-Services, müssen Sie den flutterfire configure-Befehl erneut ausführen, damit sich Ihre lokale Konfigurationsdatei, also die firebase_options.dart, anpasst.
Firebase zur App hinzufügen Mittlerweile haben Sie bereits einige Packages zu Ihrer Flutter-App hinzugefügt, daher dürfte es keine Überraschung sein, dass Sie auch das firebase_core-Package über den folgenden Befehl im Terminal zu Ihrer App hinzufügen können: >> flutter pub add firebase_core
Vergessen Sie nicht, den Befehl flutterfire configure nun erneut auszuführen, um diese Anpassung auch in die Konfigurationsdatei zu übernehmen. Eine Auflistung aller verfügbaren Firebase Packages finden Sie unter https://firebase.google.com/docs/flutter/setup?hl=de#available-plugins.
Im letzten Schritt müssen Sie lediglich Firebase in Ihrer main.dart-Datei initialisieren. Dies erreichen Sie, indem Sie zu Beginn der main-Methode die folgenden Zeilen hinzufügen. Beachten Sie außerdem, dass die main-Methode nun asynchron wird und sich der Rückgabetyp zu Future ändert: import "package:firebase_core/firebase_core.dart";
import "package:pummel_the_fish/firebase_options.dart"; Future main async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); … }
Listing 17.1: Firebase in der main.dart initialisieren
WidgetsFlutterBinding wird benötigt, um mit der Flutter-Engine zu interagieren.
Dadurch, dass Firebase nativen Code aufruft, muss direkt zu Beginn gewährleistet sein, dass WidgetsFlutterBinding korrekt initialisiert wurde. Damit ist das Set-up abgeschlossen und Sie können nun verschiedene Firebase-Services an Ihre App anbinden.
Cloud-Firestore-Anbindung Dadurch, dass Firebase in den letzten Jahren sehr an Feature-Umfang dazu gewonnen hat, war es notwendig, diese in einzelne Services aufzuteilen. Oft werden Sie vermutlich einfach nur ein bis zwei der möglichen Services benötigen, daher reicht es, wenn Sie auch nur diese in Ihr Projekt importieren. Bisher haben Sie nur das core_firebase-Package in Ihrem Projekt installiert. Wenn Sie nun eine Cloud-Firestore-Datenbank anbinden wollen, sollten Sie zusätzlich das cloud_firestore-Package installieren. >> flutter pub add cloud_firestore
Wie bereits zuvor gelernt, lassen Sie im Anschluss die Konfiguration erneut einmal durchlaufen. >> flutterfire configure
So, nun kann es aber endlich losgehen. In den folgenden Abschnitten lernen Sie, wie Sie all das, was Sie bisher über die REST-API angebunden haben, ganz einfach auch über Firebase abbilden können. Sie können den Cloud Firestore auch über die REST-API anbinden und all das Wissen, das Sie in Kapitel 16, »Schnittstellen anbinden«, gelernt haben, dort anwenden. Das cloud_firestore-Package bietet Ihnen lediglich einen Wrapper – ein sogenanntes SDK – für Flutter, was die Datenbankaufrufe abstrahiert und Ihnen somit sehr viel Schreibarbeit
spart.
Vorbereitungen Legen Sie zunächst eine neue Datei mit dem Namen firestore_pet_repository.dart im Ordner lib/data/repositories an. Hier kommt, wie auch schon in Kapitel 16, »Schnittstellen anbinden«, das PetRepository-Interface ins Spiel, denn Sie benötigen dieselben Methoden. Legen Sie daher die FirestorePetRepository-Klasse an und implementieren Sie vom PetRepository, um alle Methoden-Grundgerüste einzubinden. Die Methoden können Sie wieder automatisch per QUICK-FIX und CREATE 5 MISSING OVERRIDES erzeugen lassen. import "package:pummel_the_fish/data/models/pet.dart"; import "package:pummel_the_fish/data/repositories/pet_repository.dart"; class FirestorePetRepository implements PetRepository { @override Future getAllPets() async { // TODO: alle Pets vom Firestore abrufen und zurückgeben } @override Future addPet(Pet pet) async { // TODO: Pet an den Firestore senden und dort speichern } @override Future updatePet(Pet pet) async { // TODO: Existierendes Pet im Firestore aktualisieren } @override Future getPetById(String petId) async { // TODO: Pet mit der id {petId} vom Firestore abrufen } @override Future deletePetById(String petId) async { // TODO: Pet mit der id {petId} vom Firestore löschen } }
Listing 17.2: Die FirestorePetRepository-Methoden Anstatt des http.Clients, den Sie im RestPetRepository verwendet haben, werden Sie nun eine Instanz der Klasse FirebaseFirestore verwenden, die aus dem frisch hinzugefügten cloud_firestore-Package kommt. Diese können Sie wie folgt in Ihren FirestorePetRepository »injecten«, auch das kennen Sie nun bereits: import "package:cloud_firestore/cloud_firestore.dart";
final FirebaseFirestore firestore; FirestorePetRepository({ required this.firestore, });
Listing 17.3: FirestorePetRepository mit Dependency Injection
Daten im Firestore speichern Um Daten abrufen zu können, müssen Daten vorhanden sein. Daher lassen Sie uns die Daten zunächst im Firestore speichern. Hierfür benötigen Sie die Collection, in der Sie das neue Document anlegen wollen. Die Collection benennen Sie nach den Objekten, die darin gespeichert werden sollen, also »pets«. Da wir den Collection-Namen häufiger verwenden werden, definieren Sie diesen – um Tippfehler zu vermeiden – über der FirestorePetRepository-Klasse als Konstante: const petCollection = "pets";
Auf Ihre Collection können Sie nun innerhalb der FirestorePetRepository-Klasse per firestore.collection(petCollection) zugreifen. Wie bereits weiter vorne erwähnt, besteht ein Document aus einem JSON-String, daher können Sie nicht einfach ein Pet-Objekt hineinwerfen, sondern müssen dieses zuvor noch in einen JSON-String umwandeln. Die toMap-Methode innerhalb der Pet-Klasse dafür haben Sie bereits in Kapitel 16, »Schnittstellen anbinden«, erstellt. Diese kann einfach wiederverwendet werden. Ein Problem gibt es allerdings noch, denn die Pet-ID wird aktuell noch mit einem Platzhalter-String »test« befüllt. Eine ID sollte ein Objekt aber eigentlich eindeutig identifizieren können. Es gibt unterschiedliche Methoden, eine eindeutige ID zu generieren, aber das Gute am Firestore ist, dass es jedem Dokument beim Erstellen automatisch eine eindeutige ID zuweist. Diese ID wird allerdings aktuell nicht in unser Pet-Objekt eingespeist, daher müssen Sie an dieser Stelle einen kleinen Umweg gehen, um das zu ermöglichen. Zunächst erstellen Sie ein leeres Firestore-Dokument. Dieses Dokument beinhaltet lediglich die automatisch generierte ID. Danach erstellen Sie ein neues Pet-Objekt mit dieser ID und den Daten des vorhandenen Pet-Objekts und füllen zu guter Letzt das leere Dokument mit den Daten des Pet-Objekts. Die addPet-Methode sollte danach wie folgt aussehen: @override Future addPet(Pet pet) async { final emptyDocument = firestore.collection(petCollection).add(); final petWithId = Pet( id: docId.id, name: pet.name,
species: pet.species, age: pet.age, weight: pet.weight, height: pet.height, isFemale: pet.isFemale, owner: pet.owner, ); emptyDocument.set(petWithId.toMap()); }
Listing 17.4: Die addPet-Methode im FirestorePetRepository
Anstatt ein komplett neues Pet-Objekt zu erstellen und mit bestehenden Daten anzureichern, können Sie auch eine sogenannte copyWith-Methode in Ihrer PetKlasse anlegen: Pet copyWith({ String? id, String? name, Species? species, int? age, double? weight, double? height, bool? isFemale, Owner? owner, }) { return Pet( id: id ?? this.id, name: name ?? this.name, species: species ?? this.species, age: age ?? this.age, weight: weight ?? this.weight, height: height ?? this.height, isFemale: isFemale ?? this.isFemale, owner: owner ?? this.owner, ); }
Listing 17.5: Die copyWith-Methode im Einsatz Wenn Sie nun die ID eines bestehenden Pet-Objekts anpassen möchten, wie hier in der addPet-Methode, können Sie das ganz einfach mit der Verwendung der copyWith-Methode lösen und nur die ID übergeben. Alle anderen Werte werden aus dem bisherigen Pet-Objekt übernommen. final petWithNewId = petWithOldId.copyWith(id: "123456");
Als nächsten Schritt müssen Sie die Methode ansprechen, wenn Sie das Formular im CreatePetScreen abschicken. Dafür wechseln Sie nun in den CreatePetScreen. Hier hatten Sie zuvor das RestPetRepository definiert und die addPet-Methode des Repositories aufgerufen. Ersetzen Sie das RestPetRepository durch das FirestorePetRepository und passen Sie die Initialisierung in der initState-Methode an. Passen Sie dann die rot leuchtende Methode _addNewPet an, indem Sie das restPetRepository mit dem firestorePetRepository austauschen. Wenn Sie nun ein neues Kuscheltier über das Formular anlegen, sollte zwar eine ErfolgsSnackBar erscheinen – aber der Eintrag taucht nicht auf. Das hat zwei Gründe: Der erste ist, dass die Pet-Liste, die im HomeScreen geladen wird, noch auf die REST-API verweist, und der andere Grund ist, dass Sie die pets-Collection in Firebase offiziell noch gar nicht angelegt haben. Letzteres müssen wir zuerst lösen. class _CreatePetScreenState extends State { … late final FirestorePetRepository firestorePetRepository; @override void initState() { super.initState(); firestorePetRepository = FirestorePetRepository( firestore: FirebaseFirestore.instance, ); } … }
Listing 17.6: Das FirestorePetRepository im CreatePetScreen Future _addPet() async { if (_formKey.currentState?.validate() ?? false) { final pet = Pet( id: "test", name: currentName!, species: currentSpecies!, age: currentAge!, weight: currentWeight!, height: currentHeight!, isFemale: currentIsFemale, ); try { await firestorePetRepository.addPet(pet); … } on Exception catch(ex) {
… } } }
Listing 17.7: Den RestPetProvider mit dem FirestorePetProvider austauschen
Daten in Cloud Firestore einsehen Die Erfolgs-SnackBar ist das eine, aber wäre es nicht prima, wenn Sie sich die Daten auch visuell anzeigen lassen könnten? Mit dem Cloud Firestore geht das ganz einfach, denn Sie können einfach einen Blick direkt in die Datenbank werfen. 1. Navigieren Sie hierzu im Browser zu Ihrer Firebase Console unter console.firebase.com. 2. Wenn Sie noch nicht eingeloggt sind, loggen Sie sich mit Ihrem Google-Konto ein. 3. Wählen Sie in der Übersicht Ihr Projekt »pummel-the-fish«. 4. Öffnen Sie im linken Reiter den Menüpunkt FIRESTORE DATABASE. 5. Wenn Sie noch keine Tabellenansicht sehen, wählen Sie CREATE DATABASE. 6. WÄHLEN SIE START IN TEST MODE. 7. Wählen Sie Ihre gewünschte Cloud Firestore Location. Wir empfehlen EUROPEWEST3 (FRANKFURT). 8. Klicken Sie auf ENABLE. 9. Klicken Sie auf START COLLECTION und geben ihr die ID »pets«. 10. Klicken Sie auf NEXT. 11. Legen Sie nun ein Testdokument an, indem Sie nur die Document ID per AUTO-ID generieren lassen und dann auf SAVE klicken.
Security Rules für Testzwecke anpassen Ihre Firebase-Instanz soll zunächst im Testmodus laufen. Das hatten Sie, wie in Abbildung 17.3 erkennbar, bereits angekündigt.
Abbildung 17.3: Cloud Firestore im Testmodus
Hierzu müssen Sie jedoch noch einen kurzen Blick in den RULES-Tab werfen und gegebenenfalls die sogenannten SECURITY RULES anpassen. Hier kann definiert werden, wer zu welchem Zeitpunkt und in welchem Zustand auf welche Daten zugreifen darf. Spätestens wenn eine Art User Authentication ins Spiel kommt, müssen Sie hier etwas tiefer eintauchen. Für den jetzigen Testzweck reicht es aber, die Daten allen zur Verfügung zu stellen. Verwenden Sie daher die folgenden Security Rules, um die Daten öffentlich zu machen: rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /{document=**} { allow read, write: if request.time < timestamp.date(2024, 6, 6); } } }
Listing 17.8: Security Rules mit Ablaufdatum Sie definieren hier konkret, dass alle Documents bis zum 6. Juni 2024 Lese- und
Schreibrechte haben. Passen Sie das Datum entsprechend an, indem Sie ein Datum circa 30 Tage in der Zukunft wählen. Achtung: Das ist zum Testen prima, hat aber nichts in einer Produktivumgebung zu suchen, also dann, wenn Ihre App Nutzenden zur Verfügung gestellt wird. Der Testmodus wird nach 30 Tagen automatisch beendet, Sie werden per E-Mail benachrichtigt. Spätestens dann müssen Sie mit den Security Rules in Firebase arbeiten, um Ihre Daten entsprechend zu schützen. Versuchen Sie nun, erneut ein Kuscheltier in Ihrer App per Formular anzulegen, und beobachten Sie nebenher, ob sich in der Cloud-Firestore-Konsole etwas tut. Wenn alles erfolgreich war, sollten Sie einen neuen Eintrag ähnlich dem in Abbildung 17.4 sehen.
Abbildung 17.4: Die Cloud Firestore Console mit der pets-Collection und dem ersten Eintrag
Das alte Document, das Sie zu Testzwecken angelegt hatten und das nur aus einer ID besteht, können Sie nun löschen, indem Sie den Eintrag auswählen, oben im Tabellen-
Header auf die drei Punkte klicken und DELETE DOCUMENT auswählen.
Daten aus der Firestore-Datenbank auslesen Die Pet-Liste, die Sie aktuell auf dem HomeScreen sehen, bezieht momentan noch die Daten aus der REST-API. Wenn Sie also nun ein neues Tier anlegen, taucht das in dieser Liste logischerweise nicht auf, da die REST-API und der Cloud Firestore nicht miteinander verbunden sind. Von der REST-API verabschieden wir uns in diesem Schritt auch und sprechen stattdessen unsere Cloud-Firestore-Datenbank an. Hierzu wechseln Sie erneut in die FirestorePetRepository-Klasse in die getAllPetsMethode. In dieser werden Sie nun alle Pet-Objekte innerhalb der Firestore petsCollection beziehen. Die Objekte, die Sie aus der pets-Collection zurückerhalten werden, auch Snapshots genannt, sind allerdings keine Pet-Objekte, sondern JSON-Strings. Das bedeutet, dass Sie diese zuerst in Pet-Objekte umwandeln müssen, um am Ende eine List zurückgeben zu können. Auch hierfür haben Sie die passende Methode schon in Kapitel 16, »Schnittstellen anbinden«, angelegt, die Sie dafür wiederverwenden können: die Pet.fromMap-Methode. @override Future getAllPets() async { final petSnapshots = await firestore.collection(petCollection).get(); final petList = petSnapshots.docs .map((snapshot) => Pet.fromMap(snapshot.data())) .toList(); return petList; }
Listing 17.9: Die getAllPets-Methode in der FirestorePetRepository-Klasse Zurück im HomeScreen müssen Sie nun das RestPetRepository mit dem FirestoreRepository austauschen und die initState-Methode anpassen. Zuletzt tauschen Sie noch das restPetRepository in der onPressed-Methode im FloatingActionButton aus. class _HomeScreenState extends State { late final FirestorePetRepository firestorePetRepository; late Future pets; @override void initState() { super.initState(); firestorePetRepository = FirestorePetRepository( firestore: FirebaseFirestore.instance, ); pets = firestorePetRepository.getAllPets();
} @override Widget build(BuildContext context) { return Scaffold( …, floatingActionButton: FloatingActionButton( onPressed: () async { await Navigator.pushNamed(context, "/create"); setState(() { pets = firestorePetRepository.getAllPets(); }); }, child: const Icon(Icons.add), ), ); }
Listing 17.10: Das FirestorePetRepository im HomeScreen Das war es schon! Wenn Sie die App nun erneut hot-restarten, sollten Sie nur noch Ihr zuvor angelegtes Pet in der Liste vorfinden und nicht die Liste der REST-API. Nun können Sie endlich Ihre eigenen Kuscheltiere hinzufügen! Hoffentlich konnten wir Ihnen das Schema, um einen Cloud-Firestore-Request auszuführen, nahebringen. Damit Sie nun Ihre frisch erlernten Fähigkeiten direkt in die Praxis umsetzen können, schlagen wir Ihnen vor, die folgenden zwei Funktionen einzubauen: 1. Erstellen Sie einen neuen Button (zum Beispiel mithilfe des IconButtonWidgets) innerhalb des AppBar-Widgets im DetailPetScreens. Wenn Sie auf diesen Button klicken, löschen Sie das aktuell geöffnete Pet-Objekt und navigieren zurück zur Übersicht. Das Pet sollte dann nicht mehr in der Liste angezeigt werden. 2. Erstellen Sie einen weiteren neuen Screen EditPetScreen. In diesen kopieren Sie den gesamten Inhalt des CreatePetScreen und passen den Klassennamen entsprechend an. Danach fügen Sie einen IconButton in der AppBar auf dem DetailPetScreen hinzu, der zum neuen EditPetScreen navigiert und das aktuelle Pet-Objekt übergibt. Innerhalb dieses EditPetScreens befüllen Sie alle Input-Felder mit den Daten des übergebenen Pet-Objekts. Wenn Sie auf den SPEICHERN-Button am Ende des Screens tippen, soll kein neues Pet angelegt werden, sondern das bestehende über die update-Methode innerhalb des FirestorePetRepositorys aktualisiert werden. Navigieren Sie anschließend zum DetailPetScreen zurück und aktualisieren Sie die Daten mit den frisch angelegten Daten.
Versuchen Sie, diese Übung zunächst alleine zu lösen. Wenn Sie Hilfe benötigen, fragen Sie zunächst die Suchmaschine Ihres Vertrauens um Rat. Sollte es dennoch nicht klappen, haben wir Ihnen den Lösungscode wie immer im GitHub-Repository zur Verfügung gestellt.
Schön der Reihe nach – Daten filtern und Queries Das Cloud-Firestore-SDK bietet gegenüber einer normalen REST-API-Anbindung noch den Vorteil (und auch gleichzeitig den Nachteil), dass Sie spezielle Datenabfragen machen können. Bei einer REST-API müssten Sie diese zusätzlich programmieren. Beim Cloud Firestore hingegen könnten Sie nach Pet-Objekten fragen, die eine gewisse Spezies haben. Wenn Sie also alle Kuscheltierfische vom Cloud Firestore auslesen möchten, wäre das möglich, indem Sie direkt an die collection einen where-Operator hängen und Ihre Bedingung spezifizieren. Über eine neue Methode getPetBySpecies im FirestorePetRepository können Sie das verwirklichen. Future getPetBySpecies(Species species) async { final petsSnapshot = await firestore .collection(petCollection) .where("species", isEqualTo: species.index) .get(); final petList = petsSnapshot.docs .map((doc) => Pet.fromJson( jsonEncode(doc.data()), )) .toList(); return petList; }
Listing 17.11: Die pet-Liste nach Spezies Eine andere Möglichkeit wäre, die Pet-Objekte nach ihrer Größe absteigend sortiert auszugeben. Dies können Sie anstatt mit einem where mit einem orderBy-Operator lösen. Future getPetsOrderedByHeight() async { final petsSnapshot = await _firestore .collection(petCollection) .orderBy("height", descending: true) .get(); final petList = petsSnapshot.docs .map((doc) => Pet.fromJson(jsonEncode(doc.data()))) .toList(); return petList; }
Listing 17.12: Die pet-Liste nach der Größe eines Kuscheltiers sortieren
Reaktive Daten – was ist das? Die Cloud-Firestore-Datenbank gibt Ihnen zusätzlich noch ein Feature an die Hand, das aus dem Leben einer App-entwickelnden Person kaum noch wegzudenken ist: reaktive Daten. Aber was genau ist das? Sie erinnern sich daran, wie Sie die Pet-Liste komplett neu laden mussten, nachdem Sie ein neues Pet-Objekt erstellt haben. Dieser Prozess ist nicht nur mühsam, sondern auch etwas kostspielig, wenn – wie beim Cloud Firestore – die Kosten nach Aufrufen berechnet werden. Das wollen Sie also im besten Fall vermeiden. Wäre es nicht schöner, wenn die Datensätze, die sich geändert haben oder neu dazugekommen sind, sich automatisch in die Liste eingliedern und aktualisieren? Der Cloud Firestore bietet Ihnen genau das mit sogenannten »Snapshots« an. Diese sind nichts anderes als ein Stream, den Sie mit einem Listener versehen können, der bei einer Datenänderung ausgelöst wird und die aktualisierten Daten zurückgibt: also eine Subscription auf einen Stream. Mit Streams hatten Sie in Kapitel 4, »Pfeilschnell programmieren mit Dart«, schon theoretisch Bekanntschaft gemacht. Nun kommt ein solcher Stream endlich praktisch zum Einsatz! Sobald vom Cloud Firestore die Information kommt, dass sich Daten geändert haben, wird der Listener ausgelöst, die neuen und aktualisierten Daten werden bezogen und das UI kann automatisch aktualisiert werden.
Ein StreamBuilder muss her Soweit die Theorie, aber wie sieht das konkret in der Praxis aus? Mit dem eingebauten FutureBuilder im HomeScreen haben Sie schon den ersten Schritt gemacht. Im Prinzip können Sie diesen nun einfach durch ein StreamBuilder-Widget ersetzen. Während der FutureBuilder als Parameter ein future annimmt, nimmt der StreamBuilder als Parameter einen stream entgegen. FutureBuilder( future: Future, initialData: InitialData, builder: (context, snapshot) { // Widgets je nach snapshot.ConnectionState und snapshot.data zurückgeben }, ), StreamBuilder( stream: Stream, initialData: InitialData, builder: (context, snapshot) { // Widgets je nach snapshot.ConnectionState und snapshot.data zurückgeben
}, ),
Listing 17.13: Direkter Vergleich FutureBuilder und StreamBuilder Ein Stream ist ein kontinuierlicher Fluss an Daten. Wann die Daten konkret kommen, ist unklar. Ein FutureBuilder hingegen gibt nach dem Aufruf im Normalfall ein einziges Mal Daten zurück. Um aktualisierte oder neue Daten zu erhalten, muss man ihn erneut auslösen. Ein StreamBuilder verhält sich da anders. Einmal initialisiert, hört er so lange auf den zugewiesenen Stream und transformiert hereinkommende Daten in Widgets, bis man ihm sagt, dass er stoppen soll. Lassen Sie uns also nun den FutureBuilder im HomeScreen durch einen StreamBuilder ersetzen. Legen Sie dazu eine neue Variable petStream an und initialisieren Sie diese in der initState-Methode. late Stream petStream; @override void initState() { super.initState(); firestorePetRepository = FirestorePetRepository( firestore: FirebaseFirestore.instance, ); pets = firestorePetRepository.getAllPetsAsStream(); }
Listing 17.14: Der StreamBuilder wird erstellt. Den Stream mit der Pet-Liste erhalten Sie auch hier wieder vom FirestorePetRepository. Die Methode getPetStream gibt es aber noch nicht, daher lassen Sie uns diese nun zunächst im FirestorePetRepository definieren. Stream getPetsStream() { return firestore .collection(petCollection) .snapshots() .map((snapshot) => snapshot .docs .map((doc) => Pet.fromMap(doc.data())) .toList(), ); }
Listing 17.15: Die getPetsStream-Methode im FirestorePetRepository Nun können Sie den StreamBuilder anstelle des FutureBuilders einbauen. Achten Sie darauf, dass sowohl der ConnectionState.active als auch der ConnectionState.done
nun die Pet-Liste zurückliefern. body: SafeArea( child: Padding( padding: const EdgeInsets.all(24), child: StreamBuilder( stream: petStream, initialData: const [], builder: (context, snapshot) { switch (snapshot.connectionState) { case ConnectionState.none: case ConnectionState.waiting: return const PetListLoading(); case ConnectionState.active: case ConnectionState.done: if (snapshot.hasData) { return PetListLoaded(pets: snapshot.data!); } else { return const PetListError( message: "Fehler beim Laden der Kuscheltiere", ); } } }, ), ), ),
Listing 17.16: Der fertige StreamBuilder Wenn Sie also nun den FutureBuilder durch den StreamBuilder ersetzt haben, können Sie den setState-Aufruf mitsamt dem Repository-Aufruf in der onPressed-Methode des FloatingActionButton entfernen. Sie müssen den Abruf der Liste nicht mehr manuell auslösen, der Abruf der Liste passiert automatisch! floatingActionButton: FloatingActionButton( onPressed: () { Navigator.pushNamed(context, "/create"); }, child: const Icon(Icons.add), ),
Listing 17.17: Vereinfachung des FloatingActionButton im HomeScreen dank ScreenBuilder Wenn Sie Ihre App hot-restarten, sollte die Pet-Liste mit Daten aus Firebase weiterhin angezeigt werden. Fügen Sie nun über das Formular im CreatePetScreen ein neues PetObjekt hinzu und wechseln Sie zurück auf den HomeScreen, sollte das neue Pet-Objekt automatisch erscheinen. Auch wenn Sie Documents in der Cloud Firestore Console direkt bearbeiten und Ihre App beobachten, sollten Sie sehen, wie die Daten sich ohne Ihr Zutun
automatisch aktualisieren. Wie Magie! Dasselbe gilt auch, wenn Sie ein Pet-Objekt löschen oder bearbeiten.
Cloud-Firestore-Alternativen – ja, aber wann und warum? Wenn man sich das erste Mal mit Firebase beschäftigt – und so war es damals auch bei uns – dann hat man das Gefühl, dass Firebase als Gesamtpaket keine Wünsche offenlässt. Dennoch gibt es oft gute Gründe, Alternativen in Betracht zu ziehen, speziell wenn es um die Cloud-Firestore-Datenbank geht.
Das Preismodell oder »Hilfe, warum bin ich plötzlich arm?« Über das Preismodell hatten wir bereits im Abschnitt »Die Nachteile des Cloud Firestore« aufgeklärt. Eine Alternative, die auf ein anderes Bezahlmodell setzt, wäre zum Beispiel AppWrite. Mit AppWrite kann das Backend selbst gehostet werden. Somit entstehen nur die Server-Kosten, die Sie je nach Bedarf skalieren können. Auch Supabase oder Back4App können eine Alternative zu Firebase sein, da diese ebenfalls mit einem anderen Preismodell arbeiten.
No NoSQL? Ein nächster Punkt, weswegen Sie – je nach Anwendungsfall – über eine Alternative nachdenken könnten, ist das Datenschema der Cloud-Firestore-Datenbank. Die FirebaseDatenbank basiert, wie weiter vorne im Kapitel bereits erwähnt, auf NoSQL. Für manche Anwendungen mag ein anderes Datenschema sinniger sein. Nennenswerte Alternativen, die auf ein relationales Datenschema setzt, wären zum Beispiel die eben genannten Supabase und AppWrite. Eine weitere NoSQL-Alternative ist ObjectBox. Natürlich können Sie auch ein Custom-Backend in der Sprache Ihrer Wahl schreiben und dann auch über NoSQL oder SQL einfach selbst entscheiden.
Recap: REST und Firebase – externe Daten beziehen und managen Sie haben in diesem Teil gelernt, wie Sie mit REST Daten über eine Schnittstelle beziehen oder sogar ein eigenes Backend aufsetzen können mit dem Backend-as-a-Service Firebase. Sie haben jetzt schon alle Tools, um selbst eine kleine App zu bauen. Sie können Screens gestalten, navigieren und Daten extern speichern und in Ihrer App anzeigen. Wenn Sie sich an komplexere App-Projekte wagen wollen, fehlt Ihnen aber noch ein wichtiges Puzzleteil, ohne welches Ihr App-Code ganz schnell ganz unübersichtlich und
fehleranfällig wird: ein vernünftiges State-Management. Was das ist und wie Sie es einbauen können, erklärt Ihnen der nächste Teil.
Teil V
State-Management
IN DIESEM TEIL … Kurzer Exkurs: Basiswissen Architektur Erfahren Sie, was State-Management ist und wann und warum Sie es benötigen Lernen Sie die Basisprinzipien, die jedem State-Management unterliegen Wenden Sie State-Management praktisch mit Hilfe von Cubits an Lernen Sie, wie State-Management Ihre App strukturierter machen kann
Willkommen zum am meisten diskutierten Thema in der Flutter-Community: State-Management. Wenn Sie diesen Begriff unter Flutter-Entwickelnden erwähnen, werden Sie vermutlich eine heiße und auch spannende Diskussion mit sehr vielen starken Meinungen lostreten. Wir möchten Sie in diesem Teil in die wundervolle Welt des State-Management in Flutter entführen und Ihnen Schritt-für-Schritt zeigen, was State-Management eigentlich bedeutet, wofür Sie es benötigen, welche Möglichkeiten Sie haben, und auch gemeinsam mit Ihnen unsere App mit dem bloc-Package ausbauen. Wenn Sie die Grundlagen verstanden haben, sind Sie jederzeit bereit, auf ein xbeliebiges State-Management Ihrer Wahl zu wechseln. Daher anschnallen, umblättern und los geht's!
Kapitel 18
Stein auf Stein – App-Architektur in Flutter IN DIESEM KAPITEL Erfahren Sie, was eine Architektur ausmacht und womit sie Ihnen hilft Lernen Sie verschiedene Ordnerstrukturen kennen Legen Sie die Basis für eine skalierbare App-Architektur
Äh, Moment? Ich dachte, wir starten direkt ins heiße Thema State-Management? Tun wir, keine Sorge, aber zuerst gibt es einen kleinen Exkurs zum Thema Architektur. StateManagement ohne Architektur ist zwar möglich, sorgt aber bei einem wachsenden Projekt schnell zu Spaghetti-Code und Chaos. Außerdem können Ihnen die Regeln, die wir Ihnen mit dieser Architektur an die Hand geben, den Weg zum Verständnis ebnen. Es gibt in der Software-Entwicklung generell unglaublich viele verschiedene Architekturen und es lohnt sich, sich damit auseinanderzusetzen. Wenn Sie bereits in der Programmierung tätig waren, sind Ihnen Begriffe wie MVC (Model-View-Controller) und MVVM (Model-View-ViewModel) sowie Clean Architecture sicher schon mal über den Weg gelaufen. Im Folgenden wollen wir Ihnen ein skalierbares Architektur-Grundgerüst an die Hand geben, das Sie für kleinere, mittlere und auch größer werdende Flutter-Apps verwenden können.
Was ist eine Architektur überhaupt? Eine Architektur in der Software-Entwicklung ist eine Basis, bestehend aus unterschiedlichen Regeln und Konventionen, die definieren, wie Code in einem Projekt geschrieben werden soll. Dabei sollen die folgenden Ziele erreicht werden: Wartbarkeit – Änderungen am Code sollen so einfach wie möglich umgesetzt werden können. Lesbarkeit – durch klare Regeln und Strukturen wird der Code lesbarer und erleichtert somit den Einstieg für neue entwickelnde Personen im Projekt sowie die ZweiWochen-in-der-Zukunft-Einzelperson, die sich im Code zurechtfinden muss. Robustheit – Sie alle wissen, was passiert, wenn ein Windstoß auf ein Haus trifft, das
auf einem wackeligen Fundament steht. Skalierbarkeit – neue Funktionalitäten sollten problemlos in die bestehende CodeBasis integriert werden können. Testbarkeit – wenn Sie Ihren Code nicht mit Tests absichern können, liegt das meistens an einer undefinierten oder mangelhaften Architektur. Warum Tests generell sinnvoll sind, erfahren Sie in Kapitel 21, »Testing – wer, wie, was und wieso, weshalb, warum«.
Das Chaos im Griff mit Ordnerstrukturen Ziel einer Ordnerstruktur ist es, das Projekt lesbarer zu machen und die Dateien und Klassen, die man sucht, leicht auffinden zu können. Direkt zu Beginn: Es gibt es keine richtige und keine falsche Ordnerstruktur, denn am Ende hängt alles vom Projekt, den Anforderungen und den Vereinbarungen im Team ab. Wichtig ist nur eins: Konsistenz. Wenn Sie sich für ein Modell entscheiden, versuchen Sie es durchzuziehen, so gut es geht. Bevor Sie nun in die gängigsten Ordnerstrukturen etwas tiefer eintauchen, müssen wir den Begriff »Layer« noch kurz einmal aufgreifen und erklären, denn die meisten Ordnerstrukturen orientieren sich daran und arbeiten damit.
In Schichten denken – Struktur durch Layer Es gibt sehr viele verschiedene Möglichkeiten, Layer in Flutter zu definieren, wir arbeiten für die Erklärung der folgenden Ordnerstrukturen mit den folgenden. Presentation Layer – Widgets und Screens (zum Beispiel HomeScreen, PetsListSuccess, …) Application Layer – Business-Logik (zum Beispiel mithilfe eines State-Managements, mit dem Sie in Kapitel 20, »State-Management mit Bloc und Cubit«, Bekanntschaft machen werden) Data Layer – Models (zum Beispiel Pet und Owner) und Repositories (zum Beispiel RestPetRepository und FirestorePetRepository) Data Provider Layer – API-Anbindungen zum Managen von Daten (zum Beispiel die aus Packages importierten Klassen FirebaseFirestore und HttpClient) Für diese Architektur gelten die folgenden Regeln: Das UI (Presentation Layer) reagiert auf Zurufe der Business-Logik (Application Layer). Das State-Management handelt mit Daten durch ein Repository (Data Layer) und das Repository bezieht und sendet Daten über die Data Provider (Data Provider Layer), wie in Abbildung 18.1 skizziert.
Abbildung 18.1: Der Datenfluss in einer App
Die »Pummel The Fish«-Struktur und der Screen-FirstAnsatz In unserer App haben wir uns aufgrund der Größe und des Umfangs der App auf eine relativ einfache, aber dennoch übersichtliche Struktur wie in Abbildung 18.2 geeinigt und das Projekt auch bereits von Anfang an danach aufgebaut. Diese reicht für kleine Projekte vollkommen aus und ist sehr Klassentyp-orientiert. Bei jedem Ordner sollte klar sein, was sich darin befindet.
Abbildung 18.2: Die Ordnerstruktur der »Pummel The Fish«-App
Wenn Sie nun aber Screens haben, die deutlich komplexer sind als die, die wir Ihnen gezeigt haben, kann es sich lohnen, für jeden Screen im screens-Ordner einen eigenen Unterordner anzulegen und dort weitere Unterordner für die Screen-relevanten Widgets und sonstigen zugehörigen Klassen anzulegen. Das wäre dann eher ein Screen-orientierter Ansatz. Zusätzlich dazu hätten Sie noch einen commons-Ordner, der unterhalb des libOrdners angesiedelt ist und alle Screen-übergreifenden Klassen aufnimmt wie in Abbildung 18.3 beispielsweise gezeigt. In der Flutter-Welt der größeren und komplexeren Apps haben sich bisher außerdem zwei weitere Ansätze für eine skalierbare Ordnerstruktur durchgesetzt: der Layer-First Ansatz und der Feature-First Ansatz.
Abbildung 18.3: Der Screen-orientierte Ansatz
Der Layer-First-Ansatz Der Layer-First-Ansatz basiert auf einem Ordnerkonstrukt, das die unterschiedlichen Layer widerspiegelt. Innerhalb jedes Layers befinden sich dann Feature-Unterordner, und alles, was sich nicht in einem Feature unterbringen lässt und eher allgemeingültig ist, landet im common-Ordner im entsprechenden Layer wie in Abbildung 18.4.
Abbildung 18.4: Der Layer-First-Ansatz
Der Feature-First-Ansatz Der Feature-First-Ansatz hingegen orientiert sich an den Features. Dabei erhält jedes Feature seine eigenen Layer-Unterordner. Das größte Ziel dabei ist es, ein Feature so einfach wie möglich entfernen zu können. Im besten Fall lässt sich das durch ein Löschen des entsprechenden Feature-Ordners lösen. Eine Ordnerstruktur für den Feature-FirstAnsatz könnte in einem Projekt wie in Abbildung 18.5 aussehen.
Abbildung 18.5: Der Feature-First-Ansatz
Wichtig zu beachten beim Feature-First-Ansatz ist, dass ein Feature nicht unbedingt einen Screen widerspiegelt. Ein Feature besteht meist aus mehreren Screens und sollte daher auch in denselben Ordner gelegt werden. Auch in diesem Fall gibt es Dateien und Klassen, die sich nicht einem einzigen Feature zuordnen lassen und daher in einen common-Ordner verlagert werden.
Welcher Ansatz gewinnt? Alle genannten Ordnerstrukturen haben Vor- und Nachteile und somit ihre Daseinsberechtigung. Überlegen Sie sich – vielleicht sogar projektabhängig – welche Ordnerstruktur die bessere Wahl für Sie ist. Falls Ihnen alle drei Strukturen nicht zusagen, entwickeln Sie einfach mit der Zeit eine Struktur, die zu Ihnen und Ihrem Projekt passt.
Pragmatisch, praktisch, gut – unser Architektur-Vorschlag Wir möchten Ihnen in diesem Abschnitt einen Architektur-Vorschlag an die Hand geben, den Sie für zukünftige Projekte übernehmen können. Dieser Vorschlag basiert auf den zu Beginn des Kapitels bereits angesprochenen Layern. Selbstverständlich können Sie diesen nach Ihren Wünschen erweitern oder anpassen, aber er dient in jedem Fall als gute Basis. Im Laufe der Jahre habe ich (Verena) diese Struktur für mich entdeckt und lieben gelernt. Sie ist – wie alles auf dieser Welt – nicht perfekt, aber bietet einen guten Trade-off zwischen Pragmatismus, Zeitaufwand und Nützlichkeit. Ausgehend von der bekannten Clean-Architecture von Uncle Bob wurden hier und da ein paar Wege verkürzt oder abgezwackt und somit vereinfacht. Dieser Architektur-Vorschlag dient auch zur Heranführung an ein wichtiges Thema, das wir im nächsten Kapitel behandeln werden: State-Management. Auch wenn Sie noch keine Ahnung haben, was es mit dem Begriff »State-Management« auf sich hat – wichtig ist, dass Sie verstanden haben, dass Sie die Komponenten in Ihrem Projekt in Layer unterteilen können. Dabei hat jeder Layer seine eigene, in sich abgeschlossene Aufgabe und muss sich um andere Aufgaben keine Sorgen machen, da diese von den anderen Layern übernommen werden. Diese Layer-Architektur wird in ähnlicher Form auch vom bloc-Package, das Sie im folgenden Kapitel kennenlernen werden, vorgeschlagen und angewendet und harmoniert mit der Verwendung des von uns vorgeschlagenen StateManagements sehr gut. Wie genau diese Aufgaben aussehen, haben wir Ihnen im Folgenden noch einmal kurz zusammengefasst.
Presentation Layer – Screens und Widgets Meistens fängt alles mit einem Screen und diversen Widgets an. Man versucht, ein
Design, das zuvor in einem Design-Tool oder im eigenen Kopf entworfen wurde, in einen Screen und diverse Widgets zu gießen. Die Aufgabe des Presentation Layers ist somit ganz klar das Anzeigen von Design beziehungsweise Widgets.
Application Layer – State-Management Manchmal reicht ein statisches Design aus, manchmal soll das Design zum Leben erweckt werden und unterschiedliche Zustände und Inhalte annehmen. Dann ist der Zeitpunkt gekommen, eine Brücke zwischen Design und Business-Logik zu bauen, um die unterschiedlichen States steuern zu können. Hierzu können Sie – wie Sie in Kapitel 20, »State-Management mit Bloc und Cubit«, lernen werden – zum Beispiel Blocs und Cubits verwenden oder auf andere State-Management-Strategien zurückgreifen. Die Aufgabe dieses Layers ist somit hauptsächlich, die Kommunikation zwischen Presentation Layer und den Data Layer zu übernehmen.
Data Layer – Models und Repositories Wenn die Business-Logik mit Daten agieren soll – zum Beispiel von einer API oder einem lokalen Datenspeicher – kommt ein weiterer Layer ins Spiel: der Data Layer. Dieser beinhaltet für gewöhnlich Models und Repositories. Sowohl Models als auch Repositories bestehen im Normalfall aus purem Dart-Code und sorgen dafür, dass Anfragen für Daten (in Form von Models) an den richtigen Data Provider weitergeleitet werden. Models geben Auskunft darüber, wie Objekte aufgebaut sind und welche Eigenschaften sie zur Verfügung stellen. Die Pet- und Owner-Klassen sind perfekte Beispiele dafür. Repositories dienen als Vermittler zwischen Business-Logik und ein oder mehreren Data Providern. In unserer »Pummel The Fish«-App haben Sie bereits mehrere Repositories mit unterschiedlichen Data Providern verwendet: das RestPetRepository mit dem HttpClient als Data Provider und das FirestorePetRepository mit dem FirebaseFirestore als Data Provider.
Data Provider Layer – FirebaseFirestore, HttpClient und Co. Ein Data Provider ist lediglich dazu da, Anfragen an eine API oder lokale Datenbank zu tätigen und die entsprechenden Ergebnisse unverändert zurückzuliefern. Sowohl der FirebaseFirestore als auch der HttpClient zählen zu den Data Provider Layern. Sie sollte die unterste Ebene sein und arbeitet meistens mit typischen CRUD-Operationen aus Kapitel 16, »Schnittstellen anbinden«. Die Aufgabe dieses Layers ist das Managen der Daten und Weitergeben an ein oder mehrere Repositories. Anstatt unsere verwendeten Data Provider (FirebaseFirestore und HttpClient) direkt in einem Repository zu verwenden, könnten Sie diese noch einmal in eine separate Klasse kapseln, um Methoden hinzuzufügen oder nur die Methoden dem Repository zur Verfügung zu stellen, die es auch wirklich brauchen wird. class PetRestApiDataProvider {
final HttpClient httpClient; Future getAllPets() async { return httpClient.get(…); } }
Listing 18.1: Eine Data-Provider-Klasse, die beispielhaft mit einem HttpClient spricht
Architektur ist oft ein Prozess Wenn Sie diese Architektur befolgen möchten, haben Sie den Grundstein für eine saubere und wartbare Projektstruktur gelegt. Schauen Sie sich aber gerne weitere ArchitekturMöglichkeiten an, probieren Sie rum und seien Sie sich darüber bewusst, dass eine Architektur-Entscheidung keine lebenslange Entscheidung sein muss. Je mehr Sie mit der Zeit dazulernen, je mehr Sie ein tief greifendes Verständnis entwickeln und je mehr Erfahrung Sie sammeln, desto eher finden Sie Ihren Weg durch den ArchitekturDschungel, mit dem Sie am Ende glücklich sind. Wir hoffen, der Exkurs war erfolgreich und Sie sind nun mit dem bestmöglichen Wissen ausgestattet, um sich an der nächsten Liane endlich in Richtung State-Management zu schwingen.
Kapitel 19
State-Management IN DIESEM KAPITEL Lernen Sie, was State-Management überhaupt bedeutet und warum Sie es brauchen Bauen Sie ein InheritedWidget
Ihre erste Berührung mit State-Management. Das wird klasse! Sicher sind Sie auch schon gespannt wie ein Flitzebogen und wollen direkt loslegen. Daher lassen wir Sie nur kurz in die Theorie eintauchen und danach direkt mit dem Bau eines State-Management-Ansatzes mithilfe eines InheritedWidget durchstarten.
Was ist State-Management? Wozu braucht man das? Was ein State – also ein Zustand – ist, haben Sie bereits an unterschiedlichen Stellen in diesem Buch gelernt. Die Checkbox im CreatePetScreen zum Beispiel hat verschiedene Zustände, die sie annehmen kann: einen deaktivierten Zustand (isChecked = false) und einen aktivierten Zustand (isChecked = true). Wenn Sie die Checkbox nun antippen, ändert sich ihr Zustand. Je nachdem, welchen Zustand die Checkbox dann annimmt, soll sich das UI verändern – denn Sie möchten ja, dass diese State-Änderung für die benutzende Person ersichtlich ist. Somit müssen die betroffenen UI-Komponenten über diese Zustandsänderungen informiert werden und entsprechend darauf reagieren. State-Management ist damit also die Beziehung zwischen State und UI. Ein wichtiger Punkt in Flutter dabei ist, dass sich nur das UI der Elemente rebuilden soll, dessen State auch geändert wurde oder auf den es Einfluss hatte. Wenn Sie also die Checkbox im CreatePetScreen antippen, soll sich auch nur die Checkbox neu bauen, nicht der ganze Screen, geschweige denn die ganze App.
Lokaler und globaler State State lässt sich zusätzlich noch in zwei unterschiedliche Formen einteilen: den Ephemeral State – auch »lokaler Zustand« genannt – und den App State – auch »globaler State« genannt. Der lokale State betrifft nur die Widgets innerhalb einer Klasse und lässt sich meist über setState abbilden. Das CheckboxListTile im CreatePetScreen arbeitet zum Beispiel mit einem lokalen State. Der globale State ist Widget-übergreifend und wirkt sich
auf mehrere Teile oder gar die gesamte App aus. Ein passendes Beispiel hierfür ist ein User-Log-in. Basierend darauf, ob die nutzende Person eingeloggt ist oder nicht, verhält sich die App meist unterschiedlich und zeigt auch ein unterschiedliches UI an. Beim Einoder Ausloggen der benutzenden Person ändert sich daraufhin das komplette UI der App und andere Screens werden angezeigt und zugänglich gemacht. Für den globalen State müssen somit mehr Widgets neu gebaut werden als für den lokalen State.
Die Grenzen von setState Stellen Sie sich vor, Sie haben die »Pummel The Fish«-App offen und Sie sehen all die Kuscheltiere, die ein neues Zuhause benötigen. Sie haben zu Hause Platz für eine ganze Kuscheltierfarm und sind daher bereit, möglichst vielen Kuscheltieren ein neues Zuhause zu geben. Um den Überblick nicht zu verlieren, soll es in Ihrer App daher eine »Adoptionstasche« geben, die eine Anzahl der aktuell zur Adoption ausgewählten Tiere der benutzenden Person anzeigt. Diese soll sich ganz oben rechts in der AppBar des HomeScreens befinden. Das Widget soll AdoptionBag heißen. Erstellen Sie eine neue Datei adoption_bag.dart innerhalb des lib/widgets-Ordners und befüllen Sie diese mit dem entsprechenden Code für das gewünschte Widget: import "package:flutter/material.dart"; class AdoptionBag extends StatelessWidget { final int petCount; const AdoptionBag({ super.key, required this.petCount, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(right: 16), child: CircleAvatar( backgroundColor: CustomColors.orange, child: Text( "$petCount", style: Theme.of(context).textTheme.bodyLarge, ), ), ); } }
Listing 19.1: Das AdoptionBag-Widget Verwenden Sie dieses Widget danach im HomeScreen in der AppBar. Ein geeigneter Platz
hierfür ist wieder der actions-Parameter, den Sie bereits für die Testbuttons in Kapitel 16, »Schnittstellen anbinden«, verwendet haben. Sofern Sie diese Buttons noch in Ihrem Code vorfinden, können Sie sie nun entfernen und stattdessen das AdoptionBag-Widget hinzufügen. Für den petCount erstellen Sie eine Variable und initialisieren sie mit 0. Der Plan ist nun, dass, wenn Sie ein Kuscheltier zum Adoptieren auswählen, es der Adoptionstasche hinzugefügt wird und der dargestellte petCount sich um eins erhöht. Wenn Sie sich umentscheiden müssen, wird es aus der Adoptionstasche wieder entfernt und der petCount-Wert verringert sich um eins. Ein ähnliches Verhalten kennen Sie sicher bereits von Warenkörben. import "package:pummel_the_fish/widgets/adoption_bag.dart"; … int petCount = 0; … @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( leading: Padding( padding: const EdgeInsets.only(left: 16), child: Image.asset("assets/images/pummel.png"), ), title: const Text("Pummel The Fish"), actions: const [ AdoptionBag(petCount: petCount), ], ), body: …, ); }
Listing 19.2: Das AdoptionBag-Widget im HomeScreen Nach Integration des AdoptionBag-Widgets sollte Ihre AppBar im HomeScreen aussehen wie in Abbildung 19.1.
Abbildung 19.1: Die AppBar im HomeScreen mit AdoptionBag-Widget
Der HomeScreen ist bereits ein StatefulWidget, daher müssen Sie nun nur den richtigen Punkt für den Aufruf der setState-Methode herausfinden, um den petCount beim Hinzufügen eines Kuscheltiers um eins zu erhöhen. Hm. Nun gibt es aber ein Problem, denn der Button, um ein Kuscheltier zu adoptieren und somit in die AdoptionBag aufzunehmen, befindet sich nicht im HomeScreen, sondern im DetailPetScreen. Wie sollen Sie also setState im DetailScreen aufrufen, um den petCount für das AdoptionBag-Widget im HomeScreen zu verändern? Stellen Sie sich außerdem vor, das Hinzufügen und Entfernen von Pet-Objekten aus der Adoptionstasche in weiteren Screens und tieferliegenden Widgets auslösen zu wollen. Sie müssten jedes Mal den petCount durch den Konstruktor aller Child-Widgets schleusen. Gibt es hier keine einfachere Lösung?
Kurzer Exkurs – das InheritedWidget Doch, die gibt es! Das Konzept dahinter lautet InheritedWidget. InheritedWidgets gehören zu den Basisklassen, die im Flutter-Framework mitgeliefert werden, und ermöglichen Ihnen, die Eigenschaften eines Widgets im Widget-Baum in all seinen ChildWidgets zugänglich zu machen. In unserem Beispiel siedeln Sie also den InheritedAdoptionBag ganz unten im Stamm des Widget-Baums in der MaterialApp an und können dann in allen Verästelungen, in allen entspringenden Screens unkompliziert darauf zugreifen, ohne den petCount durchschleusen zu müssen. Viele State-Managements basieren auf InheritedWidgets und bieten nur Wrapper drumherum, um Ihnen das Leben einfacher zu machen. Wenn Sie dieses Prinzip verstanden haben, stehen die Chancen außerordentlich gut, dass Sie sich sehr schnell in alle ähnlichen State-Management-Methoden einfinden können.
Wie baut man ein InheritedWidget? Der folgende Code erstellt eine Klasse InheritedAdoptionBag (Achtung, nicht mit dem AdoptionBag-Widget verwechseln!), die von der Klasse InheritedWidget erbt und den petCount zur Verfügung stellt. Auf diese Zahl wollen Sie überall in den Child-Elementen dieses Widgets im Widget-Baum zugreifen können, ohne sie durch verschiedene Konstruktoren durchschleusen zu müssen. Dadurch, dass Ihre Klasse InheritedAdoptionBag von InheritedWidget erbt, haben Sie Zugriff auf eine wichtige Methode: updateShouldNotify, die alle abhängigen Widgets benachrichtigen soll, sobald sich der petCount verändert hat. Außerdem können Sie nun context.dependOnInheritedWidgetOfExactType aufrufen und über eine statische ofFunktion allen Klassen zur Verfügung stellen. Damit können Sie per InheritedAdoptionBag.of(context) überall darauf zugreifen.
Na, kommt Ihnen das irgendwoher bekannt vor? Ja, genau! Zum Beispiel von unserem Theme.of(context)- und Navigator.of(context)-Konstrukt, die Sie bereits häufiger verwendet haben. Diese basieren ebenfalls auf InheritedWidgets, und die sind wie bereits erwähnt in Flutter integriert und erfordern daher kein separat angebundenes Package. Stellen Sie sich am besten einen Ast vor, der geschüttelt wird. Dieser Ast stellt Ihr InheritedAdoptionBag-Widget dar, von dem diverse Child-Äste entspringen, welche entsprechend mitgeschüttelt werden. import "package:flutter/material.dart"; class InheritedAdoptionBag extends InheritedWidget { final int petCount; const InheritedAdoptionBag({ super.key, required this.petCount, required super.child, }); @override bool updateShouldNotify(InheritedAdoptionBag oldWidget) => true; static InheritedAdoptionBag of(BuildContext context) { return context.dependOnInheritedWidgetOfExactType ()!; } }
Listing 19.3: Das InheritedAdoptionBag-Widget verwaltet die Anzahl der Kuscheltiere in der Adoptionstasche.
Den petCount aktualisieren Nun hatten wir Ihnen versprochen, dass Sie mit InheritedWidgets in der Lage sind, den petCount zu updaten, wenn Kuscheltiere zur Adoptionstasche hinzugefügt werden. Wichtig zu beachten ist hier, dass die Eigenschaften, die das InheritedWidget aufnehmen und zur Verfügung stellen kann, immer immutable (= unveränderbar) und damit final sein müssen. Das bedeutet, Sie können diesen keinen neuen Wert zuweisen: // das geht nicht InheritedAdoptionBag.of(context).petCount = 5;
Dieser petCount lässt sich per setState updaten. Aber wie Sie bereits wissen, lässt sich ein setState nur auf einem StatefulWidget ausführen. Das InheritedAdoptionBagWidget ist aber ein InheritedWidget und stellt setState nicht bereit. Auch hierfür gibt es eine Lösung: Sie wrappen die InheritedAdoptionBag einfach in ein StatefulWidget mit dem Namen AdoptionBagWrapper und lösen dort die setStateAufrufe aus, indem Sie von dem InheritedAdoptionBag-Widget einen Callback addPet erwarten, in welchem setState aufgerufen werden kann. import "package:flutter/material.dart"; import "package:pummel_the_fish/widgets/inherited_adoption_bag.dart"; class AdoptionBagWrapper extends StatefulWidget { final Widget child; const AdoptionBagWrapper({ super.key, required this.child, }); @override State createState() => _AdoptionBagWrapperState(); } class _AdoptionBagWrapperState extends State { int _petCount = 0; @override Widget build(BuildContext context) { return InheritedAdoptionBag( petCount: _petCount, addPet: () { setState(() { _petCount++; }); }, child: widget.child, ); } }
Listing 19.4: Das AdoptionBagWidget als StatefulWidget Ihr Code wird nun sicherlich rot leuchten und Sie mit Fehlern bewerfen, denn die addPetMethode ist im InheritedAdoptionBag noch nicht verfügbar. Um auf addPet durch das InheritedWidget Zugriff zu haben, müssen Sie diese als Callbacks durchschleusen. Ein Callback ist genau das, was der Name verspricht. Wird ein Callback aufgerufen, wird die darin verpackte Funktion (hier der setState-Aufruf) ausgeführt. Sie können die Callbacks in Konstruktoren, aber auch Methoden und Funktionen verwenden.
Passen Sie die Parameter und den Konstruktor der InheritedAdoptionBag-Klasse wie folgt an: class InheritedAdoptionBag extends InheritedWidget { final int petCount; final VoidCallback addPet; const InheritedAdoptionBag({ super.key, required this.petCount, required super.child, required this.addPet, }); … }
Listing 19.5: Das InheritedAdoptionBag erhält eine Callback-Funktion addPet.
Ansiedeln des InheritedWidgets im Widget-Baum Nur eine Klasse zu erstellen, reicht natürlich nicht. Sie müssen Ihre frisch erstellte Klasse auch am richtigen Platz in den Widget-Baum eingliedern, um überall auf den petCount und die addPet-Methode zugreifen zu können. Wenn Sie, wie beim Theme, überall in der App darauf Zugriff haben möchten, wäre die AdoptionBagWrapper innerhalb der MyAppKlasse über dem MaterialApp-Widget anzusiedeln. class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return AdoptionBagWrapper( child: MaterialApp( title: "Pummel The Fish", … ), ); } }
Listing 19.6: Die AdoptionBag überall verfügbar machen Wenn Sie das getan haben, können Sie von überall per InheritedAdoptionBag.of(context) auf den petCount und die addPet-Methode zugreifen. Für den Fall, dass Sie das AdoptionBagWrapper-Widget nur innerhalb des HomeScreens und dessen Child-Widgets benötigen und verwenden und nicht in separaten Screens darauf zugreifen möchten, sollten Sie es auch nur dort ansiedeln.
Bei einer Veränderung des petCount lösen Sie sonst einen Rebuild des gesamten Widget-Baums aus. Sie möchten aber versuchen, nicht den ganzen Baum zu schütteln, sondern nur den Ast mit seinen Verzweigungen, der am Ende auch davon betroffen sein soll. Ihre AppBar im HomeScreen könnte nun wie folgt aussehen, um den aktuellen petCount zu repräsentieren. AppBar( title: const Text("Pummel The Fish"), actions: [ AdoptionBag( petCount: InheritedAdoptionBag. of(context).petCount, ), ], ),
Listing 19.7: Die AppBar mit petCount im HomeScreen
Nachdem Sie nun gelernt haben, wie die InheritedAdoptionBag aussieht und verwendet werden kann, sollten Sie nun in der Lage sein, den petCount per InheritedAdoptionBag.of(context).addPet selbst beeinflussen zu können. Packen Sie den Aufruf dafür im DetailPetScreen in einen neuen CustomButton, den Sie unterhalb der Detailliste ansiedeln können. Beobachten Sie dann, wie sich die Zahl in der AppBar im HomeScreen verändert, wenn Sie den Button auslösen.
Das Builder-Widget Wenn Sie, wie in folgendem Beispiel, ein InheritedWidget direkt in der build-Methode einpflegen und dann auf die Eigenschaften (wie zum Beispiel hier den petCount) per InheritedAdoptionBag.of zugreifen wollen, werden Sie mit dem folgenden Fehler konfrontiert: No InheritedAdoptionBag found in context. @override Widget build(BuildContext context) { return InheritedAdoptionBag( petCount: 5, child: Text("${InheritedAdoptionBag.of(context).petCount}"), … ); }
Listing 19.8: No InheritedAdoptionBag found – das wird nicht funktionieren. Der Grund für diesen Fehler ist, dass das InheritedAdoptionBag-Widget zu dem Zeitpunkt noch nicht in den context eingepflegt ist. Das können Sie ändern, indem Sie
ein Builder-Widget dazwischenschalten. @override Widget build(BuildContext context) { return InheritedAdoptionBag( petCount: 5, child: Builder( builder: (context) { return Text("${InheritedAdoptionBag.of(context).petCount}"); }, ), … ); }
Listing 19.9: Mit dem Builder-Widget funktioniert es. Durch das Builder-Widget wird zuerst das Parent-Widget – in diesem Fall das InheritedAdoptionBag-Widget – fertig gebaut und erst anschließend der Inhalt des Builder-Widgets. Dadurch ist das InheritedAdoptionBag-Widget innerhalb des Builder context dann auffindbar, wenn es benötigt wird. Eine andere Möglichkeit ist, das Child-Widget des InheritedAdoptionBag-Widgets als Custom Widget auszulagern, sodass es seine eigene build-Methode bekommt. class PetCountWidget extends StatelessWidget { const PetCountWidget({super.key}); @override Widget build(BuildContext context) { return Text("${InheritedAdoptionBag.of(context).petCount}"); } }
Listing 19.10: Das PetCountWidget als Custom Widget Das Custom Widget kann anschließend ohne Builder-Widget als Child verwendet werden. class TestWidget extends StatelessWidget { const TestWidget({super.key}); @override Widget build(BuildContext context) { return InheritedAdoptionBag( petCount: 5, child: const PetCountWidget(), … ); }
}
Listing 19.11: Alternative zum Builder-Widget – ein eigenes Widget schreiben
Warum nicht eine ganze App mit InheritedWidgets bauen? Prinzipiell ist es möglich, mit InheritedWidgets den gesamten State einer App zu managen, ohne ein anderes Package einzubinden. Allerdings ist das Konzept in etwas komplexeren Anwendungsfällen auch etwas fortgeschrittener, benötigt deutlich mehr Code und macht alles etwas umständlich – weswegen das InheritedWidget selten als State-Management-Lösung in Betracht gezogen wird. Auch das Flutter-Team verweist bei den Empfehlungen eher Richtung bloc-, riverpodund provider-Package, welche letzten Endes auf dem InheritedWidget basieren, die Verwendung aber deutlich einfacher machen und um ein paar Funktionen erweitern. Wenn Sie nichtsdestotrotz tiefer in die InheritedWidget-Welt eintauchen möchten, weil Sie eventuell keine separate Abhängigkeit durch ein Package in Ihrem Projekt haben möchten, empfehlen wir Ihnen einen Blick in die offizielle Flutter-Dokumentation.
Kapitel 20
State-Management mit Bloc und Cubit IN DIESEM KAPITEL Erläutern wir, warum wir Ihnen gerade dieses State-Management ans Herz legen wollen Lernen Sie alle Elemente des bloc-Packages kennen und bauen Sie Ihren ersten Cubit Verstehen Sie den Unterschied zwischen Blocs und Cubits und wann Sie was verwenden
Im Februar 2020 erblickte ein Package namens »bloc« von Felix Angelov das Licht der Welt. Es wurde über die Jahre gehegt und gepflegt und entwickelte sich bis heute hin zu einer der populärsten State-Management-Methoden. Es wird von vielen bekannten und erfolgreichen Firmen, aber auch bei kleineren Projekten in der Flutter-Entwicklung eingesetzt und gilt als einfache, aber mächtige, skalier- und testbare Möglichkeit, State in Flutter-Apps zu managen und zusätzlich eine strukturierte App-Architektur aufzubauen. Warum wir uns für dieses Package entschieden haben und wie Sie damit vollumfänglich Ihren State managen können, erfahren Sie in diesem Kapitel.
Meine Straße, mein Zuhause, mein Bloc? Wir können uns an dieser Stelle leider nicht mit allen existierenden State-ManagementLösungen ausführlich befassen und haben uns daher nach langer Überlegung für das blocPackage entschieden. Allerdings verwenden wir für die praktische Umsetzung Cubits. Cubits sind eine leichtgewichtigere Version von Blocs, die später in 2020 das Licht der Welt erblickten und fester Bestandteil des bloc-Packages sind. Wenn wir Begriffe mit »Bloc« verwenden, sind Cubits im Normalfall mitgemeint, lassen Sie sich dadurch also nicht verwirren.
Warum ausgerechnet das bloc-Package? Die Entscheidung für diese Option wurde auf Basis von vielen verschiedenen Faktoren getroffen. Um einige davon zu nennen: Skalierbarkeit und leichter Einstieg: Mit dem bloc-Package können Sie sowohl kleinere, einfachere States mithilfe von Cubits handhaben als auch komplexere mit Verwendung von Blocs. Einer Skalierung Ihrer App steht nichts im Wege, denn Cubits
lassen sich unkompliziert in Blocs umwandeln, sollte der Bedarf da sein. 80–90 % der State-Management-Fälle lassen sich (unserer Erfahrung nach) mithilfe eines Cubits abdecken. Selbstverständlich können Sie auch einfach aus Konsistenzgründen immer auf Blocs setzen, denn alles, was Sie mit Cubits abdecken können, können Sie auch mit Blocs abdecken. Überall gibt es Hilfe: Die Dokumentation ist ausgesprochen gut, es gibt schnellen Support bei Fragen (entweder in GitHub oder im Bloc-Discord-Channel) und mittlerweile sind auch sehr viele Projekte, die das bloc-Package verwenden, Open Source. Das bloc-Package ist außerdem ein Flutter-Favorite-Package und muss daher einen entsprechenden Qualitätsanspruch erfüllen. Persönliche Erfahrung: Ich (Verena) arbeite seit 2019 nahezu täglich mit Blocs und Cubits und habe bisher alle Apps damit zuverlässig und strukturiert umsetzen können. In Miras Flutter-Agentur ist Bloc seit 2020 der Standard in neuen Projekten – das einzige State-Management, auf das sich alle Mitarbeitenden guten Gewissens einigen konnten. Bekanntheit und Validierung: Das bloc-Package wird von vielen bekannten AppDevelopment-Agenturen (wie zum Beispiel Very Good Ventures) verwendet und stetig weiterentwickelt. App-Architektur als Bonus: Das bloc-Package ermöglicht es Ihnen, eine saubere AppArchitektur aufzubauen, indem es einen der wichtigsten Punkte einer sauberen Architektur unterstützt – nämlich »Separation of Concerns«. Eines der Ziele von Separation of Concerns in der Flutter-Welt ist es, die Logik vom UI abzukapseln. Testbarkeit: Das bloc_test-Package bietet zusätzlich eine einfache und saubere Lösung, Blocs und Cubits zu testen. Auf dieses Thema gehen wir in Kapitel 21, »Testing – wer, wie, was und wieso, weshalb, warum?«, noch einmal genauer ein.
Flutter-Favorite-Packages sind Packages, die speziell vom Flutter-Team ausgewählt wurden und einem gewissen Qualitätsanspruch unterliegen. Wenn Sie die Wahl haben zwischen einem Package mit unbekanntem Autor und einem, das als »Flutter Favorite« gekennzeichnet wurde, empfehlen wir Ihnen, Letzteres zu verwenden.
Kurze Einführung in Bloc und Cubit Bevor Sie sich der praktischen Verwendung des bloc-Packages zuwenden, sollten Sie zunächst einen kurzen theoretischen Einblick erhalten. Bloc – oder auch BloC – ist eigentlich als Pattern bekannt und steht für »Business Logic of Components«. Es soll sich zwischen einer Datenquelle und dem Widget, das für das Anzeigen der Daten gebraucht wird, eingliedern. Ziel ist es, die Business-Logik von dem UI loszulösen und damit einen performanten, wiederverwendbaren, und einfach testbaren Code zu produzieren. Das fällt
auch unter den Aspekt »Separation of Concerns«, der einen wichtigen Stützpfeiler einer sauberen App-Architektur bildet. »Separation of Concerns« bezeichnet ein generelles Konzept in der Programmierung, mit dem sich der Code in unterschiedliche Abschnitte, basierend auf deren Verantwortlichkeit, einteilen lässt. Jeder Abschnitt soll nur für eine bestimmte Aufgabe zuständig sein. Dieses Konzept definiert die Basis, um strukturierten, skalierbaren und testbaren Code zu schreiben. Wir werden in diesem Buch die praktische Umsetzung ausschließlich mit Cubits behandeln, da diese für unseren Zweck vollkommen ausreichen und einen idealen Einstiegspunkt bieten. Dennoch ist es wichtig zu wissen, worin sich Blocs und Cubits unterscheiden und welche Auswirkungen eine Entscheidung für das eine oder das andere haben kann. Im Folgenden erklären wir Ihnen zunächst die Basics beider Komponenten und nach einem praktischen Beispiel gehen wir etwas mehr auf die wichtigsten Vor- und Nachteile von Blocs und Cubits ein.
Aufbau Bloc Ein Bloc besteht aus Logik, die verschiedene Inputs zu verschiedenen Outputs verarbeitet. Inputs werden als Events bezeichnet und triggern den Bloc, ein oder mehrere Aktionen auszuführen. Das kann zum Beispiel eine Datenabfrage über eine API sein. Ab dem Moment, in dem ein Event getriggert wird, kann der Bloc entsprechende Outputs in Form von States zurückgeben (auch »emitten« genannt). Ziel des ganzen Ablaufs ist es, das entsprechend verknüpfte UI bei einer State-Änderung neu zu bauen. Kurz gesagt: Blocs erhalten Events, konvertieren diese in States und geben diese zurück, wie in Abbildung 20.1 zu erkennen ist.
Abbildung 20.1: Blocs erhalten Events und geben States zurück.
Sowohl Events als auch States liegen bei Blocs als Stream vor und sind somit allzeit bereit, Daten entgegenzunehmen und zurückzugeben. Der Event-Stream ist dafür verantwortlich, in der UI ausgelöste Events – zum Beispiel einen Klick auf einen Button oder eine Eingabe in ein Suchfeld – in den Bloc zu transportieren. Der State-Stream sorgt dafür, dass das UI ein Update erhält und sich neu baut, sobald ein neuer Wert durchgeschleust wurde.
Ein Bloc beinhaltet drei verschiedene Klassen: eine Event-Klasse, die alle möglichen Events auflistet, eine State-Klasse, die alle möglichen States auflistet, und eine BlocKlasse, die die gelisteten Events entgegennimmt, die entsprechende Logik ausführt und das Ergebnis als State zurückliefert. Am Beispiel der PetListe wären folgende Events denkbar. GetAllPetsEvent: alle Pet-Objekte von der API abrufen, ausgelöst durch das Betreten des HomeScreen oder einen Button-Klick der benutzenden Person DeletePetEvent: ein Pet-Objekt aus der Liste löschen, ausgelöst durch einen ButtonKlick im DetailPetScreen der benutzenden Person AddPetEvent: ein neues Pet-Objekt zur Liste hinzufügen, ausgelöst durch den SPEICHERN-Button im CreatePetScreen
Diese Events haben wir bisher direkt in Form von Funktionen innerhalb unseres UIs aufgerufen, zum Beispiel mithilfe von Buttons. Wenn Sie Bloc in Ihre App integrieren, werden diese Events nun an einen Bloc weitergeleitet, dort verarbeitet und als States wieder an das UI zurückgegeben, sodass die States für die nutzende Person repräsentiert werden können. Folgende States könnten aus diesen Events resultieren: InitialState: bevor ein Event ausgelöst wurde, sozusagen ein initialer Zustand LoadingState: nachdem ein Event ausgelöst wurde, aber noch kein Ergebnis oder
Fehler zurückkam, zum Beispiel beim Warten auf die Antwort der API SuccessState: sobald ein Event abgeschlossen wurde und Daten, zum Beispiel von
der API, vorliegen ErrorState: nachdem ein Event abgeschlossen wurde, aber ein Fehler aufgetaucht ist,
beispielsweise durch einen Server-Error Jeder dieser States lässt sich über das UI abbilden. Sie haben das bereits bei der Arbeit mit dem FutureBuilder und dem StreamBuilder geübt, indem Sie jeden State in ein separates Widget (PetListLoading, PetListLoaded, PetListError) ausgelagert haben.
Aufbau Cubit Während Blocs mit Event-Streams und State-Streams arbeiten, sind bei Cubits EventStreams durch Funktionen ersetzt. Ein Cubit kommt darum mit nur zwei Dateien aus und macht Ihren Code etwas schlanker: eine Datei mit den State-Klassen, die alle möglichen States auflistet, und eine Datei mit der Cubit-Klasse, in der Funktionen definiert und aufgerufen werden können, die bestimmen, welcher State ausgegeben wird. Das lässt sich anhand der Abbildung 20.2 gut erkennen.
Abbildung 20.2: Cubits empfangen Funktionen und geben States zurück.
Mögliche Methoden im Cubit wären in obigem Fall: getAllPets deletePet addPet
Die States sind identisch zu den Bloc-States: InitialState LoadingState SuccessState ErrorState
Ihren ersten Cubit anlegen Das Ziel dieses Kapitels wird es sein, den existierenden HomeScreen, der aktuell auf einem FutureBuilder/StreamBuilder basiert, so umzubauen, dass dieser Cubits verwendet. Das bedeutet, dass Sie die Logik aus den Screens in einen Cubit extrahieren und den Screen dazu bringen werden, auf die Anweisungen des Cubits zu hören.
Installation Springen wir ins Geschehen! Zunächst installieren Sie das flutter_bloc und das equatable-Package. >> flutter pub add flutter_bloc >> flutter pub add equatable
Damit haben Sie Zugriff auf alles, was Sie benötigen. Außerdem würden wir Ihnen empfehlen, zusätzlich die VSCode-Erweiterung »Bloc« (https://marketplace.visualstudio.com/items?itemName=FelixAngelov.bloc) zu installieren. Diese bietet viele Möglichkeiten, um Ihnen Schreibarbeit zu ersparen, und kommt im nächsten Abschnitt direkt zum Einsatz.
equatable ist ein Package, welches den Getter props überschreibt und dafür sorgt,
dass States nur dann zu einem UI-Rebuild führen, wenn sie sich vom vorherigen State unterscheiden. Wenn Sie equatable nicht verwenden, werden States auf Basis ihrer Hash-Codes verglichen. Jede neue State-Instanz erhält einen neuen Hash-Wert, was zu unnötig vielen Rebuilds führen kann. Durch equatable können Sie definieren, welche Eigenschaften einer Klasse dafür zuständig sind, einen State einzigartig zu machen.
Der ManagePetsCubit Zunächst erstellen Sie einen neuen Ordner namens cubits innerhalb des lib/logicOrdners. Mit einem Rechtsklick auf den cubits-Ordner sollten Sie nun im Kontextmenü CUBIT: NEW CUBIT auswählen können. Nennen Sie diesen manage_pets, sobald ein Eingabefeld aufpoppt (Abbildung 20.3). Dies funktioniert nur, wenn Sie die VSCode Erweiterung »Bloc«, die wir weiter vorne empfohlen haben, installiert haben. Falls Sie diese Erweiterung nicht nutzen möchten, können Sie die entsprechenden Dateien einfach manuell anlegen und befüllen.
Abbildung 20.3: Einen neuen Cubit erstellen
Es sollten nun automatisch zwei Dateien für Sie angelegt worden sein: manage_pets_cubit.dart und manage_pets_state.dart. Lassen Sie uns zuerst einen Blick in die manage_pets_cubit.dart-Datei werfen.
Die manage_pets_cubit.dart Zu Beginn sollten Sie zwei Importe sehen: Einmal wird das flutter_bloc-Package
importiert, einmal das equatable-Package. Außerdem wird durch die Zeile beginnend mit part definiert, dass die Datei manage_pets_state.dart ein Teil dieser Klasse ist und diese unmittelbar zusammenhängen. Falls Ihre generierte Datei nicht so aussehen sollte, passen Sie sie entsprechend an. import "package:equatable/equatable.dart"; import "package:flutter_bloc/flutter_bloc.dart"; part "manage_pets_state.dart"; class ManagePetsCubit extends Cubit { ManagePetsCubit() : super(ManagePetsInitial()); }
Listing 20.1: Die ManagePetsCubit-Klasse Bisher passiert in dieser Klasse noch nichts Spektakuläres. Auffallen sollte, dass Ihre Klasse von der Cubit-Klasse vom Typ ManagePetsState erbt und einen initialen Zustand namens ManagePetsInitial als super-Argument weiterreicht.
Die manage_pets_state.dart Die manage_pets_state.dart-Datei enthält zunächst zwei Klassen: eine abstrakte Klasse ManagePetsState und eine Klasse ManagePetsInitial, die von der abstrakten Klasse erbt. Die ManagePetsState-Klasse selbst erbt von equatable und muss daher den Getter props überschreiben. Je nach Einstellung der VSCode-Bloc-Extension müssen Sie den Code mit equatable selbstständig erweitern, um das folgende Ergebnis zu sehen. part of "manage_pets_cubit.dart"; abstract class ManagePetsState extends Equatable { const ManagePetsState(); @override List get props => []; } class ManagePetsInitial extends ManagePetsState {}
Listing 20.2: Die manage_pets_state.dart-Datei Weil Sie von equatable erben, müssen Sie den Getter props überschreiben. Um herauszufinden, ob sich ein State vom vorigen unterscheidet, müssen alle Properties, die dafür verantwortlich sind, eine eindeutige State-Instanz zu identifizieren, in dieser Liste angegeben werden. Aktuell gibt es noch keine Properties innerhalb der ManagePetsInitial-Klasse, die hier beachtet werden müssen, deshalb gehen wir später noch einmal genauer darauf ein. Weiter vorne im Kapitel hatten Sie bereits einen Plan geschmiedet, um vier verschiedene
States – Initial, Loading, Success und Error – einzubauen. Diese bilden Sie nun in Ihrer ManagePetsState-Klasse wie folgt ab. Initial = ManagePetsInitial Loading = ManagePetsLoading Success = ManagePetsSuccess Error = ManagePetsError Analog zur ManagePetsInitial-Klasse können Sie die Klassen darunter in der manage_pets_state.dart-Datei ergänzen. Das Ergebnis sollte dann wie folgt aussehen: class ManagePetsInitial extends ManagePetsState {} class ManagePetsLoading extends ManagePetsState {} class ManagePetsSuccess extends ManagePetsState {} class ManagePetsError extends ManagePetsState {}
Listing 20.3: States-Klassen definieren in der manage_pets_state.dart Diese States stehen Ihnen nun zur Verwendung zur Verfügung. Sie könnten den ManagePetsInitial-State an dieser Stelle auch weglassen, da Sie beim Öffnen des Screens direkt Daten beziehen.
ManagePetsCubit und ManagePetsState vereinen Um diese States auszulösen, fehlt allerdings noch eine wichtige Sache. Genau, mindestens eine Funktion. Lassen Sie uns kurz überlegen, was Sie konkret erreichen wollen und mit was für einer Methode sich das darstellen lässt. Eine Pet-Liste soll von Ihrer API abgerufen werden. Hierzu verwenden Sie das bereits in Kapitel 17, »Firebase und der Cloud Firestore«, angelegte FirestorePetRepository und die entsprechende Methode getAllPets. Bevor die Pet-Liste abgerufen wird, soll der State ManagePetsInitial aktiv sein. Dies ist schon der Fall, da Sie den ManagePetsInitial-State als super-Argument durchgereicht haben. Während Sie auf die Antwort warten – und im UI entsprechend eine Ladeanzeige dargestellt werden soll –, soll der ManagePetsLoading-State aktiv sein. Sobald die Pet-Liste von der API da ist und das UI die Liste mit den Daten abbildet, ist der ManagePetsSuccess-State aktiv. Falls es bei der API-Abfrage zu einem Fehler kommt, soll im UI ein Fehler angezeigt werden und der passende State ManagePetsError-State angezeigt werden. Um dieses Verhalten umzusetzen, öffnen Sie erneut die ManagePetsCubit-Klasse. Erstellen Sie eine Variable vom Typ FirestorePetRepository, die Sie per Dependency Injection durchreichen. Anschließend schreiben Sie eine Methode getAllPets, die alle Kuscheltiere vom Firestore abruft. Geben Sie basierend auf der Antwort der Methode die passenden States entsprechend durch einen emit-Aufruf aus:
import "package:equatable/equatable.dart"; import "package:flutter_bloc/flutter_bloc.dart"; import "package:pummel_the_fish/data/models/pet.dart"; import "package:pummel_the_fish/data/repositories/firestore_pet_repository.dart"; part "manage_pets_state.dart"; class ManagePetsCubit extends Cubit { final FirestorePetRepository firestorePetRepository; ManagePetsCubit(this.firestorePetRepository) : super(ManagePetsInitial()); Future getAllPets() async { emit(ManagePetsLoading()); try { final pets = await firestorePetRepository.getAllPets(); emit(ManagePetsSuccess(pets: pets)); } on Exception { emit(ManagePetsError()); } } }
Listing 20.4: Die getAllPets-Methode im ManagePetsCubit Damit ist der ManagePetsCubit zunächst fertig und bereit, in das UI integriert und von dort aufgerufen zu werden.
Kommunikation zwischen UI und Cubit Um nun den ManagePetsCubit der App zugänglich zu machen, benötigen Sie Komponenten, die eine Kommunikation zwischen UI und Cubits herstellen können. Dazu zählen in erster Linie die Widgets BlocProvider, BlocBuilder und BlocListener. Lassen Sie uns das weitere Vorhaben in ein paar kleinere Schritte einteilen. 1.
ManagePetCubit dem HomeScreen zur Verfügung stellen
2. basierend auf den ausgegebenen States des ManagePetCubits unterschiedliche Widgets im HomeScreen anzeigen 3.
ManagePetCubit-Methode getAllPets beim Öffnen des HomeScreens aufrufen
4. Erfolgs-SnackBar anzeigen, wenn die Pet-Liste erfolgreich abgerufen wurde
5. Fehler-SnackBar anzeigen, wenn beim Abrufen der Pet-Liste etwas schiefging
Parallelen zum InheritedWidget? Vielleicht erinnern Sie sich noch an das InheritedAdoptionBag InheritedWidget. Dieses mussten Sie nach der Erstellung oberhalb der MaterialApp ansiedeln, um es für alle darunterliegenden Child-Widgets aufrufbar zu machen. Der BlocProvider funktioniert nach dem exakt selben Prinzip. Er steht mitsamt seinen Eigenschaften all seinen ChildWidgets zur Verfügung und kann in diesen entsprechend verwendet und aufgerufen werden. Analog zum InheritedWidget können Sie per BlocProvider.of(context) auf den naheliegendsten BlocProvider des Typs ManagePetsCubit zugreifen. Wow, nun sollten Sie verstehen, warum uns das InheritedWidget-Verständnis so wichtig war. Jetzt können Sie hier nämlich glänzen!
BlocProvider – die Ansiedlung im HomeScreen Was Sie in Kapitel 19, »State-Management« bereits ebenfalls gelernt haben, ist, dass Sie es nur dort verwenden sollten, wo es auch benötigt wird. Aktuell möchten Sie lediglich das UI des HomeScreens damit beeinflussen, weswegen Sie den BlocProvider auch dort ansiedeln sollten. Dies können Sie ganz einfach tun, indem Sie einen Rechtsklick auf das Scaffold-Widget machen, REFACTOR anklicken und dann WRAP WITH BLOCPROVIDER auswählen (siehe Abbildung 20.4). Diese Option steht Ihnen nur zur Verfügung, wenn Sie das VSCode-Plug-in »Bloc« installiert haben. Natürlich können Sie den Code auch händisch anpassen.
Abbildung 20.4: Context-Menü für praktische Bloc-Shortcuts
Vergessen Sie auch nicht, das Builder-Widget als Child-Element hinzuzufügen, damit sich der ManagePetsCubit im context ansiedeln kann. Das FirestorePetRepository steht Ihnen im HomeScreen bereit zur Verfügung, daher binden Sie dieses einfach ein. Das Ergebnis sollte daraufhin wie folgt aussehen. import "package:flutter_bloc/flutter_bloc.dart"; import "package:pummel_the_fish/cubits/manage_pets_cubit.dart"; … class _HomeScreenState extends State { late final FirestorePetRepository firestorePetRepository; … @override Widget build(BuildContext context) { return BlocProvider( create: (context) => ManagePetsCubit( firestorePetRepository, ), child: Builder( builder: (context) { return Scaffold( … ), }, ) ); } }
Listing 20.5: Einen gesamten Screen mit einem BlocProvider wrappen In dem gesamten HomeScreen sowie allen darunterliegenden Widgets ist der ManagePetsCubit nun für Sie per BlocProvider.of(context) erreichbar. Bitte rufen Sie sich noch einmal ins Gedächtnis, dass Sie den BlocProvider, wenn er im HomeScreen in den Widget-Baum eingespeist wurde, nicht im DetailPetScreen oder CreatePetScreen verwenden können, da diese Screens keine Child-Widgets des HomeScreens sind. Der Grund, warum wir das so oft wiederholen, ist, dass es hier immer wieder zu Missverständnissen kommt und ein wichtiges Konzept ist, das manchmal etwas länger braucht, bis es fest im Gehirn verankert ist. Mithilfe des Widget-Inspektors, den Sie in Kapitel 17, »Firebase und der Cloud Firestore«, kennengelernt haben, können Sie sich Ihren Widget-Baum anzeigen
lassen und entsprechend auch sehen, in welcher Verzweigung Ihr BlocProvider verwendet werden kann.
BlocBuilder – States im UI anzeigen Der BlocBuilder weiß zu jeder Zeit, in welchem State sich der aktuelle Cubit befindet. Das ist das Praktische an einem Stream. Basierend auf dem aktuellen State kann dann das entsprechend gewünschte Widget ausgegeben werden. Der BlocBuilder ist also eine Kommunikationsbrücke zwischen Cubit und UI. Vom Aufbau her ist der BlocBuilder ähnlich dem des FutureBuilders beziehungsweise StreamBuilders. Den StreamBuilder im body des HomeScreens können Sie daher ganz einfach mit einem BlocBuilder austauschen. Sie können danach auch die petStreamVariable und deren Initialisierung in der initState-Methode entfernen, diese benötigen Sie nicht mehr. Anstatt Widgets basierend auf dem ConnectionState anzuzeigen, zeigen Sie Widgets basierend auf den von Ihnen definierten States des ManagePetsCubits an. body: SafeArea( child: Padding( padding: const EdgeInsets.all(24), child: BlocBuilder( builder: (context, state) { if (state is ManagePetsInitial) { return const PetListError( message: "Keine Kuscheltiere zur Adoption freigegeben", ); } else if (state is ManagePetsLoading) { return const PetListLoading(); } else if (state is ManagePetsSuccess) { return PetListLoaded(pets: snapshot.data!); } else { return const PetListError( message: "Fehler beim Laden der Kuscheltiere", ); } }, ), ), ),
Listing 20.6: Der BlocBuilder ersetzt den StreamBuilder. States mit Eigenschaften anreichern
Auffallen sollte Ihnen nun, dass es ohne StreamBuilder auch kein Zugriff mehr auf das snapshot.data-Objekt gibt, in dem sich die Pet-Objekte befanden. Das hat zur Folge, dass der Dart-Compiler Ihnen dieses Objekt rot markiert. Um auf die Pet-Liste zugreifen zu können, müssen Sie diese in den States definieren, in denen Sie sie benötigen, in diesem Fall also im ManagePetsSuccessState. Passen Sie diesen wie folgt in der manage_pets_state.dart an, um eine Pet-Liste zu verwalten.
Vergessen Sie dabei nicht, die Eigenschaft in den props aufzulisten, denn das sorgt dafür, dass der ManagePetsSuccess-State als verändert angesehen wird, wenn sich die Elemente der Pet-Liste geändert haben. class ManagePetsSuccess extends ManagePetsState { final List pets; const ManagePetsSuccess({required this.pets}); @override List get props => [pets]; }
Listing 20.7: Anpassung des ManagePetsSuccess-States In Ihrer ManagePetsCubit-Klasse sollte nun eine Beschwerde auftauchen, dass ManagePets-Success ein pets-Objekt mitgegeben haben möchte. Diesen Wunsch erfüllen wir ihm natürlich, indem wir den ManagePetsState mitsamt der Pet-Liste ausgeben, die Sie vom FirestorePetRepository abgerufen haben. final pets = await firestorePetRepository.getAllPets(); emit(ManagePetsSuccess(pets: pets));
Listing 20.8: Pet-Liste mit dem State ausliefern Nun springen Sie zurück in Ihr BlocBuilder-Widget und tauschen das snapshot.data mit einem state.pets aus, denn der ManagePetsSuccessState liefert nun die Pet-Liste wie gewünscht mit. else if (state is ManagePetsSuccess) { return PetListLoaded(pets: state.pets); }
Listing 20.9: Die Pet-Liste vom State beziehen Wenn Sie die App nun erneut hot-restarten, sollten Sie den folgenden Text angezeigt bekommen: »Aktuell keine Tiere zur Adoption verfügbar«. Das ist absolut korrekt, denn bisher rufen Sie die getAllPets-Methode des ManagePetCubit, die für das Abrufen der Pet-Liste zuständig ist, noch nicht auf.
Aufrufen von Cubit-Funktionen Neben dem Zurverfügungstellen des Cubits lassen sich mithilfe des BlocProviders auch die Funktionen des Cubits aufrufen, die dann eine entsprechende State-Änderung auslösen können. Dafür muss der ausgewählte Cubit allerdings durch das BlocProvider-Widget schon bereitgestellt worden sein. Um die getAllPets-Methode des Cubits aufzurufen, verwenden Sie im Normalfall den folgenden Aufruf: BlocProvider.of(context).getAllPets();
Listing 20.10: Cubit-Funktion aufrufen Die gängige Kurzschreibweise dafür: context.read().getAllPets();
Listing 20.11: Kurzschreibweise, um Cubit-Funktion aufzurufen Die einfachste Möglichkeit, den Aufruf beim Öffnen des Screens zu feuern, ist, den Funktionsaufruf einfach direkt hinter den BlocProvider zu hängen. Dies lässt sich elegant mit dem sogenannten Chaining – definiert durch zwei aufeinanderfolgende Punkte – lösen. Damit wird zuerst eine Instanz des ManagePetCubit erstellt und diese dann direkt verwendet, um die Methode aufzurufen. BlocProvider( create: (context) => ManagePetsCubit( firestorePetRepository, )..getAllPets(), child: … ),
Listing 20.12: Cubit-Funktionen durch Chaining aufrufen Dieses Chaining ist eine Kurzschreibweise für: BlocProvider( create: (context) { final cubit = ManagePetsCubit( firestorePetRepository, ); cubit.getAllPets(); return cubit; }, child: … ),
Listing 20.13: Lange Version des Aufrufs der Cubit-Funktionen Wenn Sie den HomeScreen nun erneut hot-reloaden, sollten Sie eine Liste mit von Ihnen angelegten Pet-Objekten sehen können.
BlocListener – die benutzende Person informieren Früher oder später werden Sie in die Situation kommen, dass Sie bei einem bestimmten State eine Funktion ausführen wollen. Vielleicht möchten Sie im Fehlerfall eine Snackbar anzeigen oder nach einem erfolgreichen Speichern von eingegebenen Daten den Screen wechseln. Was spricht dagegen, diesen Funktionsaufruf direkt in den passenden State innerhalb des BlocBuilders zu schreiben, kurz bevor das entsprechende Widget zurückgegeben wird? Versuchen Sie zum Beispiel einmal, eine Snackbar im ManagePetsError-Zweig im
BlocBuilder aufzurufen. Beim Ausführen werden Sie allerdings mit der in Abbildung
20.5 gezeigten Fehlermeldung konfrontiert, die besagt, dass Snackbars während des Bauens nicht aufgerufen werden können. Die schlechte Nachricht: Der BlocBuilder ist offensichtlich nicht die richtige Wahl an dieser Stelle. Die gute Nachricht: Hierfür bietet das bloc-Package stattdessen die BlocListener-Komponente an. Der BlocListener wird einmalig getriggert, sobald eine Transition von einem State zum anderen stattgefunden hat. Aber wie wird er angewendet? Im Moment zeigen Sie im Fehlerfall – also wenn ManagePetsError zurückgegeben wird – einen Fehlertext auf dem Screen an. Wenn Sie zusätzlich eine Snackbar mit dem Fehler anzeigen möchten, können Sie den BlocBuilder in einen BlocListener wrappen und eine Snackbar auslösen, sobald in den ManagePetsError gewechselt wurde.
Abbildung 20.5: Snackbar im BlocBuilder? Das funktioniert nicht.
child: BlocListener( listener: (context, state) { if (state is ManagePetsError) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text("Es ist ein Fehler aufgetreten."), ), ), ); } }, child: BlocBuilder( builder: (context, state) { … }, ),
Listing 20.14: Die anwendende Person über Fehler per Snackbar informieren
BlocListener + BlocBuilder = BlocConsumer Da es häufiger vorkommt, dass BlocListener und BlocBuilder gemeinsam auftauchen, gibt es eine verkürzte Schreibweise namens BlocConsumer. BlocConsumer( listener: (context, state) { … }, builder: (context, state) { … }, );
Listing 20.15: Aufbau eines BlocConsumers Mehr Magie passiert hier nicht.
BlocSelector – ein Profi-Widget zur Performance-Optimierung Eine weitere unterschätzte Komponente, die vor allem im Bereich PerformanceOptimierung eine große Rolle spielen kann, ist das BlocSelector-Widget. Dieses hört nur auf die Aktualisierung einer bestimmten Eigenschaft innerhalb eines States. Stellen Sie sich zum Beispiel vor, der ManagePetsSuccess-State hätte neben der Pet-List zusätzlich noch eine currentPetAmount Eigenschaft vom Typ int. Dann könnten Sie ein bestimmtes Widget – möglicherweise ein Text-Widget – explizit nur dann neu bauen lassen, wenn der currentPetAmount verändert wurde, indem Sie einen BlocSelector darum wrappen. BlocSelector selector: (state) { if (state is ManagePetsSuccess) { return state.currentPetAmount; } return 0; }, builder: (context, currentPetAmount) { return const Text("$currentPetAmount"); }, ),
Listing 20.16: Der BlocSelector an einem Beispiel Herzlichen Glückwunsch! Sie haben Ihren ersten Cubit gebaut und in Ihre UI integriert. Sollte das Konzept noch nicht in Fleisch und Blut übergegangen sein, machen Sie sich bitte keine Sorgen, das braucht etwas Übung.
Repositories zentral zur Verfügung stellen mit RepositoryProvider Durch die Einbindung des flutter_bloc-Packages haben Sie nicht nur Zugriff auf BlocProvider, BlocBuilder und Co. erhalten, sondern auch auf die sogenannten RepositoryProvider. Was Repositories sind, wissen Sie ja bereits, und auch die
Verwendung sollte klar sein. Bisher haben Sie die Repositories in den Screens erstellt, die das Repository benötigt haben, zum Beispiel im HomeScreen. class _HomeScreenState extends State { late final FirestorePetRepository firestorePetRepository; @override void initState() { super.initState(); firestorePetRepository = FirestorePetRepository( firestore: FirebaseFirestore.instance, ); } }
Listing 20.17: Erstellung des FirestorePetRepository im HomeScreen Im CreatePetScreen haben Sie dasselbe Schema angewandt. Das ist erst mal nicht verwerflich, kann aber mithilfe des RepositoryProviders noch weiter optimiert werden. Diesen können Sie ganz oben über Ihrer MaterialApp ansiedeln, um in der App benötigte Repositories zu erstellen und durch ein – Sie ahnen es schon – RepositoryProvider.of(context) überall im Widget-Baum darauf zuzugreifen. Dies hat den zusätzlichen Vorteil, dass Repositories nur einmal erstellt werden. import "package:cloud_firestore/cloud_firestore.dart"; import "package:pummel_the_fish/data/repositories/firestore_pet_repository .dart"; class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return RepositoryProvider( create: (context) => FirestorePetRepository( firebaseFirestore: FirebaseFirestore.instance, ), child: MaterialApp(…), ); } }
Listing 20.18: Der RepositoryProvider erstellt das FirestorePetRepository. Tadaa! Das war es schon. Nun können Sie im HomeScreen innerhalb des BlocProviders das in der MyApp erstellte Repository entweder per RepositoryProvider.of(context) oder die Kurzschreibweise context.read() einspeisen und die lokale
firestorePetRepository-Variable sowie die initState-Methode löschen. class _HomeScreenState extends State { int petCount = 0; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => ManagePetsCubit( context.read(), )..getAllPets(), child: Builder(…), ); } }
Listing 20.19: Das FirestorePetRepository vom RepositoryProvider beziehen
Ein Repository kommt selten allein. Wenn Sie mehrere RepositoryProvider haben möchten, können Sie einfach einen MultiRepositoryProvider verwenden.
Cubits weiter vereinfachen – mit einem Enum-State ans Ziel Neben der oben beschriebenen gängigen Methode gibt es auch noch eine weitere Möglichkeit, auf einfach Art einen Cubit zu definieren. Hierzu wird anstatt der StateKlassen ein Enum verwendet, um den State zu identifizieren.
Den State als Enum definieren Modifizieren Sie die ManagePetsState-Klasse, indem Sie das abstract-Keyword und alle State-Klassen, die unterhalb der ManagePetsState-Klasse definiert wurden, entfernen. Sie werden nur mit dieser einen State-Klasse arbeiten, ihr allerdings Eigenschaften zuweisen, die für die unterschiedlichen States benötigt werden. Die Eigenschaften sind die folgenden: Ein Status, definiert als Enum, kann die Werte initial, loading, success und error annehmen. Repräsentiert damit genau das, was bisher die unterschiedlichen ManagePetsState-Klassen ausgedrückt haben. Eine Liste mit Pet-Objekten soll im Success-Fall zurückgegeben werden. eine optionale Fehlermeldung, falls etwas schiefgelaufen ist Zusammengebastelt sollte Ihre ManagePetsState-Klasse dann wie folgt aussehen:
part of "manage_pets_cubit.dart"; enum ManagePetsStatus { initial, loading, success, error, } class ManagePetsState extends Equatable { final ManagePetsStatus status; final List pets; final String? errorMessage; const ManagePetsState({ this.status = ManagePetsStatus.initial, this.pets = const [], this.errorMessage, }); @override List get props => [ status, pets, errorMessage, ]; }
Listing 20.20: Die ManagePetsState-Klasse in vereinfachter Schreibweise Zu beachten ist, dass Sie den Eigenschaften status und pets einen initialen Wert zuweisen, die errorMessage allerdings null sein kann. Der Konstruktor sollte den initialen State der Klasse widerspiegeln. Auch wichtig ist, dass Sie alle angegebenen Eigenschaften in der props-Getter aufführen, um einen klar identifizierbaren State zu erhalten. Im Vergleich zu den bisherigen State-Klassen wirkt alles nun etwas kompakter.
Die copyWith-Methode – Ihr neuer Begleiter Um mit nur einer State-Klasse mehrere unterschiedliche Zustände repräsentieren zu können, wird noch eine weitere Methode benötigt, die Ihnen in Kapitel 17, »Firebase und der Cloud Firestore«, bereits kurz begegnet ist. Es handelt sich um die copyWithMethode, die es Ihnen erlaubt, ein Objekt komplett zu »kopieren« und dabei aber einzelne Eigenschaften anzupassen. Fügen Sie folgende Methode in Ihre ManagePetsState-Klasse ein: ManagePetsState copyWith({ ManagePetsStatus? status, List? pets, String? errorMessage, }) { return ManagePetsState(
status: status ?? this.status, pets: pets ?? this.pets, errorMessage: errorMessage ?? this.errorMessage, ); }
Listing 20.21: copyWith-Methode Wenn Sie sich diese Methode noch einmal genauer anschauen, erkennen Sie vermutlich, was hier passiert. Jede in der Klasse vorhandene Eigenschaft kann, aber muss nicht eingespeist werden. Daraus wird dann ein neues ManagePetsState-Objekt erstellt und zurückgegeben. Das Besondere daran ist, dass alle Eigenschaften, die nicht im Konstruktor mitgegeben wurden, den derzeitigen Wert der Eigenschaft innerhalb des Objekts annehmen. Klingt verwirrend, lässt sich aber mithilfe eines kleinen Beispiels leicht erklären. Stellen Sie sich einen successState vor, der eine Liste an Pet-Objekten beinhaltet. final successState = ManagePetsState( status: ManagePetsStatus.success, pets: [Pet(…), Pet(…), Pet(…)], errorMessage: null, );
Listing 20.22: Wie ein möglicher success-State aussehen könnte Wenn Sie nun den State ändern wollen würden, die Pet-Objekte aber behalten wollen, können Sie das wie folgt tun: final errorStateWithPets = successState.copyWith( status: ManagePetsStatus.error, errorMessage:"Hier ging etwas schief", );
Listing 20.23: ErrorState mit Pet-Liste aus dem success-State Geben Sie nun den errorStateWithPets im Terminal aus, sollte Ihnen dieses Objekt angezeigt werden: ManagePetsState(status: ManagePetsStatus.error, pets: [Pet(…), Pet(…), Pet(…)], errorMessage: "Hier ging etwas schief");
Listing 20.24: errorStateWithPets im Terminal ausgegeben Obwohl Sie die Pet-Liste nicht übergeben haben, wird sie hier ausgegeben, da das gesamte successState-Objekt als Basis dient und nur die Eigenschaften angepasst werden, die mitgegeben wurden.
Anpassungen im Cubit Um den ManagePetsCubit an die neue ManagePetsState-Klasse anzupassen, tauschen Sie
zunächst das super-Argument ManagePetsInitialState mit dem ManagePetsState aus, um diesen zum initialen State zu machen. Außerdem kommt die frisch angelegte copyWith-Methode nun mehrfach zur Anwendung, um alle Werte des vorigen States behalten zu können. Haben Sie die Pet-Liste einmal erfolgreich abgerufen und in den State eingespeist, haben Sie die gesamte ManagePetsCubit-Lebenszeit Zugriff darauf. Selbst wenn sich der Status des ManagePetStates von einem ManagepetsStatus.success bei einer erneuten Abfrage in einen ManagePetsStatus.error ändern würde, weil beispielsweise die Internetverbindung abgebrochen ist oder andere Fehler im ManagePetsCubit auftraten, bleibt die Liste dank der Verwendung von copyWith bestehen. class ManagePetsCubit extends Cubit { final FirestorePetRepository petRepository; ManagePetsCubit({required this.petRepository}) : super(const ManagePetsState()); Future getAllPets() async { emit( state.copyWith(status: ManagePetsStatus.loading), ); try { final pets = await firestorePetRepository.getAllPets(); emit( state.copyWith( status: ManagePetsStatus.success, pets: pets, ), ); } on Exception catch (ex) { emit( state.copyWith( status: ManagePetsStatus.error, errorMessage: ex.toString(), ), ); } } }
Listing 20.25: ManagePetsCubit mit copyWith
Last but not least – der ManagePetsStatus im UI Der BlocConsumer kann nun angepasst werden, da die ManagePetsState-Klassen durch den ManagePetsStatus-Enum eingetauscht wurden. Das Tolle an einem Enum ist, dass man wunderbar durch ihn durchiterieren kann – automatisiert! Springen Sie hierzu in den
HomeScreen zum BlocConsumer in die builder-Methode und starten Sie mit dem switchStatement, indem Sie switch(state.status) eintippen.
Nun sollte dieser Ausdruck bereits farbig umkringelt sein, wie in Abbildung 20.6, und Ihnen anbieten, per QUICK-FIX alle cases automatisch aufzulisten. Dieses Angebot nehmen Sie natürlich dankend an.
Abbildung 20.6: Cases automatisch auflisten
Transferieren Sie anschließend die Zweige im if-else-Statement in das switchStatement. Passen Sie danach auch noch den listener-Teil des BlocConsumers entsprechend an. Am Ende sollte Ihr BlocConsumer wie folgt aussehen: child: BlocConsumer( listener: (context, state) { if (state.status == ManagePetsStatus.error) { ScaffoldMessenger.of(context).showSnackBar(…); } }, builder: (context, state) { switch (state.status) { case ManagePetsStatus.initial: return const PetListError( message: "Keine Kuscheltiere zur Adoption freigegeben", ); case ManagePetsStatus.loading: return const PetListLoading(); case ManagePetsStatus.success: return PetListLoaded(pets: state.pets); case ManagePetsStatus.error: return const PetListError( message: "Fehler beim Laden der Kuscheltiere", ); } }, ),
Listing 20.26: Der BlocBuilder erstrahlt in neuem Glanz. Wenn Sie nun einen neuen Enum-Wert im Status innerhalb des ManagePetStatus
hinzufügen, wird das switch-Statement sich melden und Ihnen den Hinweis geben, dass ein case nicht behandelt wurde. Das würde im if-else-Statement weiter oben nicht passieren.
Mehrere Wege führen nach Rom, welcher ist der richtige? Diese Möglichkeit bietet ein paar Vor- und auch ein paar Nachteile. Ein Vorteil ist, dass mit dieser Schreibweise switch-Cases verwendet werden, um unterschiedliche UIs anzuzeigen. Ein weiterer Vorteil ist, dass bereits abgerufene Daten nicht verloren gehen, sollte bei einer weiteren Abfrage ein Fehler auftreten. Wie immer im Leben gibt es auch aber mit diesem Ansatz, einen Cubit zu strukturieren, Nachteile und man sollte gut überlegen, ob dieser Ansatz für den jeweiligen Anwendungsfall geeignet ist. Sobald sich innerhalb eines States eine Eigenschaft befindet, die den Wert null annehmen kann – wie zum Beispiel die errorMessage in dem oben definierten ManagePetsState – muss bei der Verwendung dieses States entweder auf null überprüft werden oder durch ein Ausrufezeichen signalisiert werden, dass in diesem Fall die Eigenschaft nicht null sein kann. Das beste Beispiel hierfür ist der case ManagePetsStatus.error im BlocBuilder. In diesem konkreten Fall wissen Sie, dass es eine errorMessage gibt, und können dies entsprechend mit einem Ausrufezeichen kennzeichnen. Zu empfehlen ist der Ansatz mit einer State-Klasse und einem Status-Enum daher nicht bei komplexeren Cubits mit mehreren möglichen null-Werten. Dies verkürzt und erleichtert die Schreibweise nicht, sondern führt zu einer höheren Komplexität und Fehleranfälligkeit durch häufige null-Prüfungen.
Bloc und Cubit – so unterschiedlich und doch so gleich Um dieses Kapitel abzurunden, möchten wir Sie noch auf die versprochenen Unterschiede zwischen Blocs und Cubits hinweisen. Nun haben Sie bereits mit einem Cubit gearbeitet und vermutlich werden die Punkte dadurch greifbarer. Cubits sind meist schneller und einfacher in der Implementierung und reichen für die meisten Anwendungsfälle aus. Es wird weniger Code benötigt, da es Funktionen anstelle von Events gibt. Im Gegenzug bieten Events aber auch mehr Kontrolle und Informationen als reine Funktionsaufrufe. In den meisten Fällen ist es am sinnvollsten, einfach mit einem Cubit zu starten und, falls Sie merken, dass Sie einen Bloc für Ihren Anwendungsfall brauchen, den Cubit einfach in einen Bloc umzuschreiben. Ein paar weitere Unterschiede, die Sie gerne immer wieder nachschlagen können, falls sie nicht direkt einleuchten, finden Sie in den folgenden Abschnitten.
Traceability Bei der Verwendung von Blocs haben Sie die Möglichkeit herauszufinden, in welchem State sich der Bloc aktuell befindet, welches Event den kommenden State Change ausgelöst hat, und was der kommende State sein wird. Dies können Sie tun, indem Sie die onTransition-Methode innerhalb der Bloc-Klasse ansprechen. @override void onTransition(Transition transition) { super.onTransition(transition); print(transition); }
Listing 20.27: Die onTransition-Funktion überschreiben Ausgabe im Terminal: >> Transition { currentState: 0, event: Increment, nextState: 1 }
Das kann beispielsweise für Crash-Reporting, Debugging und Analytics-Zwecke hilfreich sein. Bei Cubits hingegen können Sie lediglich auf den aktuellen State und den nächsten State über die onChange-Methode zugreifen. Was genau die State-Änderung ausgelöst hat, ist hierbei unklar. @override void onChange(Change change) { super.onChange(change); print(change); }
Listing 20.28: Die onChange-Funktion überschreiben Ausgabe im Terminal: >> Change { currentState: 0, nextState: 1 }
Event Transformations Wenn Sie einen Bloc verwenden, haben Sie zusätzlich die Möglichkeit, mit Events zu agieren. Da diese ebenfalls ein Stream sind, lassen sich Operatoren wie beispielsweise »buffer«, »debounceTime« und »throttle« verwenden. Diese Funktionen können zum Beispiel bei einer Suche interessant sein, wenn Sie nicht nach jedem eingetippten Buchstaben einen API-Request feuern wollen, sondern durch debounceTime ein paar Millisekunden warten möchten, bevor der nächste Aufruf getriggert wird.
Race Conditions in Cubits Bei Cubits muss Ihnen außerdem bewusst sein, dass, wenn Sie eine asynchrone Methode mehrfach nacheinander aufrufen und in dieser beispielsweise Daten von einer API bezogen werden, es hier zu Konflikten kommen kann. Es ist nämlich unklar, ob die
Ergebnisse in der richtigen Reihenfolge ankommen. Der erste Aufruf, die Antwort benötigt fünf Sekunden: context.read.getAllPets();
Der nächste Aufruf, die Antwort benötigt drei Sekunden: context.read.getAllPets();
Die States werden wie folgt ausgegeben: // ausgelöst durch den ersten Aufruf LoadingState(); // ausgelöst durch den zweiten Aufruf LoadingState(); // ausgelöst durch den zweiten Aufruf (API hat schneller reagiert) SuccessState(); // ausgelöst durch den ersten Aufruf, überschreibt quasi das Ergebnis des zweiten Aufrufs ErrorState();
Listing 20.29: Race Conditions in Cubits
Good to Know und weiterführendes Wissen Mit dem, was Sie oben gelernt haben, sollten Sie schon sehr weit kommen. Die folgenden Themen können Ihnen bei Performance-Optimierung und komplexeren State-Fällen jedoch weiterhelfen, daher wollen wir sie zumindest kurz ansprechen.
BlocProvider.value Es besteht die Möglichkeit, dass Sie einen BlocProvider mit einem bereits instanziierten Bloc oder Cubit füttern möchten. Dies können Sie ganz einfach tun, indem Sie die BlocProvider.value Funktionalität nutzen. final petCubit = context.read(); return BlocProvider.value(value: petCubit, child: …);
Listing 20.30: BlocProvider.value
States filtern mit listenWhen und buildWhen Sie können Ihrem BlocListener durch die listenWhen-Property sagen, dass er nur dann zuhören soll, wenn ein bestimmter vorangegangener State existiert oder der aktuelle State ein bestimmter ist. Soll der BlocListener zum Beispiel nur dann anspringen, wenn der aktuelle State der ManagePetsLoading-State ist, würde das wie folgt aussehen: BlocListener(
listenWhen: (previous, current) { return current is ManagePetsLoading; }, listener: (context, state) { … }, child: … ),
Listing 20.31: listenWhen im BlocListener Das exakt selbe Prinzip funktioniert auch für den BlocBuilder, nur dass dieser eine buildWhen-Eigenschaft anbietet. BlocBuilder( buildWhen: (previous, current) { return current is ManagePetsLoading; }, builder: (context, state) { … }, ),
Listing 20.32: buildWhen im BlocBuilder Sie haben jetzt die Grundstruktur eines Cubits kennengelernt und wie man sie mithilfe eines Enums bei Bedarf noch weiter vereinfachen kann. Sie haben außerdem gelernt, wie Sie diesen Cubit aus dem UI aufrufen, auf State-Änderungen reagieren und diese auslösen können. Das Repository, und darüber Ihre externe Datenquelle, sprechen Sie jetzt aus dem Cubit heraus an und nicht mehr aus dem UI. Damit sind schon einmal die Grundlagen einer sauberen App-Architektur angelegt. Wie genau so eine Architektur aussehen kann, lernen Sie im nächsten Kapitel.
Recap: State-Management Puh, geschafft! Jetzt haben Sie alle wichtigen Bausteine beisammen, um Ihre App zu bauen. Auch ohne State-Management bekommen Sie eine App hingebastelt – aber wir legen Ihnen sehr nahe, es direkt mit dem Bloc-State-Management zu versuchen, um schon gleich am Anfang das Fundament für eine gute App-Entwicklungspraxis zu schaffen. Jetzt heißt es üben, üben, üben. Erweitern Sie die »Pummel The Fish«-App nach Belieben und probieren Sie eigene Projekte aus. Im folgenden Teil geben wir Ihnen noch ein paar Tools für die Qualitätssicherung an die Hand und zeigen Ihnen, wie Sie Ihre App in die Stores bringen können.
Teil VI
Testen, builden und veröffentlichen
IN DIESEM TEIL … Lernen Sie, wie Sie Ihre App automatisiert testen können und warum das wichtig ist Erstellen Sie einen Android- und einen iOS-Build Laden Sie Testpersonen ein, Ihre App zu testen Lernen Sie, wie Sie Ihre App im Apple und im Google Play Store veröffentlichen
Sie haben Ihre App fertig programmiert. Was nun? Zuerst müssen Sie sichergehen, dass Ihr Code auch wie gewünscht funktioniert. Dazu müssen Sie ihn testen. Wenn alles sitzt, gehts ans Veröffentlichen! In diesem Teil schreiben Sie verschiedene Formen von Tests, kompilieren Ihren Code zu einem Debug- und zu einem Release-Build, distribuieren Ihre App an Testpersonen und schließlich veröffentlichen Sie Ihre App im Apple und im Google Play Store. Wie immer begleiten wir Sie dabei, Schritt für Schritt.
Kapitel 21
Testing – wer, wie, was und wieso, weshalb, warum? IN DIESEM KAPITEL Wenden Sie die unterschiedlichen Testarten an Lernen Sie, was eine Test-Coverage ist und wie Sie diese messen können Erfahren Sie, was Sie wie testen sollten
Machen Sie sich bereit für eine Reise in ein Land, dem leider oft zu wenig Aufmerksamkeit und Zeit geschenkt wird – und wenn, dann meist erst, wenn das Chaos schon ausgebrochen ist. Willkommen im »Testen-ist-sinnvoll-und-bewahrt-uns-vielleichtvor-dem-Chaos-Land«.
Warum testen? Sie haben bestimmt schon davon gehört, dass Produkte, bevor sie auf den Markt geworfen werden, unterschiedlichsten Tests unterzogen werden. Das betrifft nicht nur SoftwareProdukte, sondern eigentlich jegliche Art von Produkt: vom Auto bis zur Waschmaschine sowie Nahrungsmittel. Durch ausführliche Tests soll sichergestellt werden, dass das Produkt seinen Zweck erfüllt und funktioniert.
Warum testen, wenn man auch auf Fehler reagieren kann? Wie gerne würden Sie sich in ein Auto setzen, dessen Bremsen zuvor nicht getestet wurden? Bestimmt nicht so gerne. Wie gerne würden Sie Nahrungsmittel oder Arzneimittel zu sich nehmen, die zuvor nicht ausführlich getestet wurden und möglicherweise Ihre Gesundheit oder gar Ihr Leben gefährden könnten? Gar nicht. Wie gerne würden Sie wichtige Berechnungen über eine ungetestete App laufen lassen, die Ihr Leben beeinflussen können? Wohl auch eher nicht so gerne. Auch digitale Produkte wie Apps erfüllen eine gewisse Funktion und sollten auf exakt diese auch getestet werden. Dabei muss es sich nicht um Apps handeln, die über Leben und Tod entscheiden, sondern auch Apps, die eigentlich zum Ziel haben, den Benutzenden etwas zu ermöglichen oder zu erleichtern. Tun Sie das nicht, und es kommt zu diversen Fehlverhalten, könnten die Nutzenden Ihrer App verärgert oder verwirrt reagieren. Das wiederum wird sich wahrscheinlich in Ihrem App-Rating widerspiegeln. Je schlechter Ihr
App-Rating ist, desto schwieriger wird es, neue Kundschaft zu gewinnen. Sie können Ihre Reaktion beim Downloaden einer App im Play Store oder Apple Store ja nächstes Mal beobachten, wenn Sie sich zwischen einer 4.8-Sterne-App und einer 2.4-Sterne-App entscheiden wollen.
Test Driven Development Ein Grund, warum Tests meistens hinten runterfallen, ist der Satz »Ach, testen können wir auch noch später«. Das mag funktionieren, einfacher ist es jedoch, den Testing-Prozess – wenn er für das Projekt sinnvoll ist – frühzeitig zu etablieren. Was wäre also, wenn man die Tests nicht hinterher schreibt, sondern sogar vorher? Diese Praxis wird »Test Driven Development« genannt. Es werden zuerst Tests geschrieben, bevor gecodet wird, und der Code dann so lange angepasst, bis alle gewünschten Tests mit allen erforderlichen Kombinationen durchlaufen, ohne Fehler zu werfen. Nicht nur, dass man dann nicht mehr vergisst, Tests später hinzuzufügen – auch hilft dies enorm dabei, sauberen Code zu schreiben und Sonderfälle im Vorfeld zu bedenken. Zugegebenermaßen dauert es allerdings auch ein bisschen, bis man sich mit dieser Methode wirklich wohlfühlt. Auf lange Sicht betrachtet ist dieser Ansatz allerdings sehr wertvoll. Da Sie Ihre App bereits geschrieben haben und das Thema »Test Driven Development« ein eigenes Buch füllen kann, werden wir diese Methode hier nicht weiter beleuchten. Wichtig ist uns aber, dass Sie zumindest einmal davon gehört haben und sich gegebenenfalls bei Ihren zukünftig anstehenden Software-Projekten daran erinnern und dem Ansatz vielleicht einmal eine Chance geben.
Warum wird oft nicht getestet? Durch Testen können Sie sich absichern, dass Ihr Produkt die Funktionen erfüllt, die implementiert wurden und weitestgehend garantieren, dass alte Funktionen weiterhin funktionieren, obwohl neue Funktionen hinzukamen oder Code generell verändert wurde – Stichwort Seiteneffekte. Das klingt ja prima, aber warum wird Software dann nicht immer einfach getestet? Na ja, ganz einfach, weil das Testen sehr zeitaufwendig ist, manchmal auch äußerst komplex und der Nutzen bei frühzeitigem Testen quasi nicht ersichtlich ist. Testen ist wie Zähneputzen: Sie investieren früh Zeit, damit Sie später nicht sehr viel mehr Zeit investieren müssen (und Geld für teure Zahnarztrechnungen). Wenn Sie schon mal Bugs gejagt haben, wissen Sie, dass das ein sehr zeit- und damit kostspieliges Unterfangen sein kann. Warum also nicht prophylaktisch versuchen, keine Bugs zu erzeugen? Viele Auftraggebende lassen sich nur schwer von Tests überzeugen, denn mit der Zeit und letztendlich dem Geld, das man da hineininvestiert, könnte man auch viele tolle neue
Features entwickeln. Tests werden daher oft erst dann gefordert, wenn es bereits zu einem oder mehreren Fehlern kam und die Nutzenden am Toben sind.
Wann sind (automatisierte) Tests sinnvoll? Das bedeutet allerdings nicht, dass alle Apps immer auf Herz und Nieren durchgetestet werden müssen, mit allen Möglichkeiten, die zur Verfügung stehen. Es hängt von verschiedenen Faktoren ab, wann Sie wirklich viel Zeit in Tests investieren sollten. Was für eine App bauen Sie? Eine Gesundheits-App oder eine Spiele-App? Ist Ihre App kostenpflichtig oder kostenlos erwerbbar? Werden Sie für die Entwicklung bezahlt oder ist es ein Hobby-Projekt? Haben Sie genug Kapazität im Team oder sind Sie allein, um eine grobe Testabdeckung zu gewährleisten? In welchem Zustand befindet sich Ihre App? Ist es eine App mit einer wachsenden Zahl von Nutzenden in den Stores oder ein Prototyp, ein MVP? Im Folgenden werden Sie die möglichen Testarten kennenlernen, die Sie anwenden können – sollten Sie entscheiden, dass es sich für Ihr App-Projekt lohnt.
Manuell oder automatisiert? Es gibt unterschiedliche Formen von Testen in der Software-Entwicklung. Hauptsächlich lassen sich diese in zwei Kategorien einteilen: manuelle User-Tests und automatisierte Tests.
Einfach mal durchklicken – manuelle User-Tests Bei den manuellen User-Tests geht es darum, dass Nutzende – oder spezielle Testende – sich durch die App durchklicken. Meist passiert dies mithilfe eines vorgefertigten und erweiterbaren Testfall-Dokuments, das alle Schritte, Anforderungen und Ergebniserwartungen auflistet. Dieses kann dann von einer oder mehreren Personen durchgearbeitet werden. Idealerweise wird die App auf unterschiedlichen Endgeräten mit unterschiedlichen Betriebssystemversionen getestet. Je nach Größe der App und Anzahl der Testfälle ist dies sehr zeitaufwendig. Es bietet sich hier an, nicht die Entwickelnden selbst, sondern Außenstehende beziehungsweise professionelle QA-Ingenieure testen zu lassen. Wenn Sie als entwickelnde Person der App manuelle Tests durchführen, werden Sie aufgrund Ihrer Betriebsblindheit gar nicht alle Übeltäter finden. Es mag auch schon passiert sein, dass man aus Angst, einen Bug auszulösen, diverse Funktionen nur mit Idealwerten und nicht realen Werten ausgeführt hat. Das ist wahrscheinlich jeder Person, die Software entwickelt und diese selbst testen muss, schon passiert. Mindestens unterbewusst.
Durchklicken lassen – automatisierte Tests Bei automatisierten Tests werden Testfälle programmiert. Es werden also konkrete Aktionen per Source-Code definiert und das Ergebnis mit dem erwarteten Ergebnis verglichen. Diese automatisierten Tests können dann mit einem Klick oder sogar automatisch durch einen CI/CD-Prozess ausgelöst werden. Bei Flutter gibt es mehrere Testarten, um die unterschiedlichen Komponenten und Ebenen abzudecken. Dazu zählen: Unit-Tests Bloc-Tests Widget-Tests Golden Tests Integration-Tests Auf diese automatisierten Testarten werden wir nun konkreter eingehen und Ihnen anhand der eigenen App demonstrieren, was Sie wie testen können. Im Folgenden werden Sie erst die Logik Ihrer App testen, mit Unit-Tests und Bloc-Tests. Anschließend werden Sie mit Widget-Tests und Golden Tests Ihre UI auf Herz und Nieren prüfen. Zuletzt testen Sie einen User-Flow mit den Integration-Tests.
Logik testen Der Logikbereich Ihrer Apps sollte sich – wenn Sie einen Blick auf unsere AppArchitektur werfen – im Bereich der Repositories und Blocs beziehungsweise Cubits befinden. Auch Helper-Klassen, in denen Funktionen gesammelt werden, die klassenübergreifend verwendet werden, gehören zur Kategorie Logik. Wenn Sie die Logik Ihrer App testen wollen, werden meist Unit-Tests dafür verwendet. Diese werden, wie Ihr Code auch, in Dart geschrieben. Alle Klassen, in denen hauptsächlich Dart Code verwendet wird und in die keine Flutter-Komponenten mit einfließen, können im Normalfall mit Unit-Tests abgedeckt werden.
Unit-Tests in der Theorie Ihre Tests werden Sie in Ihrem Projekt im tests-Ordner schreiben, der standardmäßig auf einer Ebene neben dem lib-Ordner mit einer Beispieldatei darin schon angelegt ist. Aber bevor Sie mit dem Testschreiben loslegen, sollten Sie erst einmal verstehen, wie UnitTests aufgebaut sind. Das lernen Sie in diesem Kapitel. Beim Unit-Testen soll spezifisch eine sehr kleine Unit, also Code-Einheit, isoliert getestet werden. Meist wird mit unterschiedlichen Eingabeparametern gearbeitet, um alle möglichen Fälle abdecken zu können. Am besten ist es hier, ein besonderes Augenmerk auf Sonderfälle und Grenzfälle zu legen, denn diese sind oft die Kandidaten, die man
initial beim Programmieren nicht bedenkt und die damit zu Problemen führen können.
Aller Anfang ist leicht Um Sie nicht direkt ins eiskalte Wasser zu werfen, starten wir mit einem kleinen UnitTest-Beispiel. Stellen Sie sich eine Klasse Calculator mit einer Methode vor, die zwei Zahlen zusammenrechnen soll und das Ergebnis zurückgibt. Die zusammengerechnete Summe soll allerdings nur dann zurückgegeben werden, wenn sie nicht größer als 10 ist. Wenn die Summe größer als 10 ist, soll 10 als Maximalwert zurückgegeben werden. Die Methode könnte wie folgt aussehen: class Calculator { int sumAB(int a, int b) { final sum = a+b; if(sum> 10) { return 10; } return a+b; } }
Listing 21.1: sumAB-Methode In diesem Fall würden Sie mindestens zwei Unit-Tests schreiben, um beide möglichen Optionen zu überprüfen. Wichtig wären hier zusätzlich die angesprochenen Grenz- und Sonderfälle: Was passiert zum Beispiel, wenn Sie mit negativen Zahlen arbeiten?
Aufbau einer Testdatei Eine Testdatei besteht immer aus einer main-Methode, die auch hier als Eintrittspunkt des Programms fungiert: void main() {}
Innerhalb dieser Funktion können Sie mit Gruppen und Testfällen arbeiten. Gruppen helfen dabei, Ihre Testfälle zu strukturieren und somit einen besseren Überblick zu erhalten. Es bietet sich an, die Funktionen Ihrer Klasse jeweils als Gruppe zu definieren. Eine Gruppe kann mehrere Testfälle umfassen, die sich dem Testen der einen Funktion widmen: void main() { group("function1()", () { test("should test abc", () {}); test("should test def", () {}); }); group("function2()", () { test("should test xyz", () {}); }); }
Listing 21.2: Aufbau mit Gruppen und Testfällen Außerdem gibt es noch verschiedene Methoden, die vor und nach einzelnen Testfällen oder allen Testfällen ausgeführt werden können, auf die wir noch kurz eingehen wollen: setUp, setUpAll, tearDown, tearDownAll. Diese Methoden können innerhalb der mainMethode definiert werden oder innerhalb von Gruppen (in dem Fall sind sie dann auch nur innerhalb dieser Gruppe gültig). setUp Die setUp-Methode wird zu Beginn jedes einzelnen Testfalls durchlaufen. Das ist hilfreich, um jeden Testfall mit demselben initialen Zustand auszuführen. Hier können beispielsweise Objekte – wie Ihre Calculator-Klasse – instanziiert werden. Achten Sie darauf, dass Sie das Objekt mit late markieren, da es erst in der setUp-Methode erstellt wird. void main() { late Calculator calculator; setUp(() { calculator = Calculator(); }); group("function1()", () { test("should test abc", () {}); test("should test def", () {}); }); group("function2()", () { test("should test xyz", () {}); }); }
Listing 21.3: Die setUp-Methode kommt zum Einsatz. setUpAll Die setUpAll-Methode wird einmalig zu Beginn vor allen Tests durchlaufen und erinnert damit an die initState-Methode. Wenn Sie in der setUpAll-Methode zum Beispiel eine Liste initialisieren und diese mit Werten befüllen, in einem Test diese Liste dann verändern, wird sie vor dem nächsten Testfall nicht erneut initialisiert, sondern weiterverwendet. Sie sollten die setUp-Methode der setUpAll-Methode, wenn möglich, vorziehen, da es leicht passieren kann, dass Sie damit aus Versehen testübergreifende Abhängigkeiten generieren. Unit-Tests sollten immer isoliert funktionieren und nicht von anderen Unit-Tests abhängig sein: void main() { setUpAll(() {}); test("should test xyz", () {});
}
Listing 21.4: Die setUpAll-Methode kommt zum Einsatz. tearDown Die tearDown-Methode wird nach jedem durchlaufenen Test ausgeführt: tearDown(() {});
tearDownAll Die tearDownAll-Methode wird nach Abschluss aller Tests durchlaufen: tearDownAll(() {});
Aufbau eines Tests Nachdem das Basiskonstrukt der Testdatei mit den frisch gelernten Methoden wie setUp und tearDown aufgebaut wurde, geht es an die einzelnen Testfälle. Der Aufbau eines UnitTests ist immer gleich. Er basiert auf dem folgenden Grundgerüst: 1. Set-up: Der Set-up-Teil (nicht zu verwechseln mit der setUp- und setUpAll-Methode) dient dazu, Objekte, die speziell für diesen Test benötigt werden, innerhalb des UnitTests vorzubereiten. Auch das Verhalten von Mocks – denen wir im nächsten Kapitel begegnen – wird in diesem Schritt definiert. 2. Act: Danach führen Sie die Operation aus, deren Verhalten Sie überprüfen möchten, in diesem Fall die sumAB-Methode. 3. Expect: Das Ergebnis der Operation speichern Sie in einem Objekt, das Sie dann mit Ihrer definierten Erwartungshaltung vergleichen. 4. Stimmt dieses Objekt mit der Erwartung überein, war Ihr Test erfolgreich. Stimmen Sie nicht überein, wird Ihr Test fehlschlagen. Jeder Testfall wird innerhalb eines eigenen Testabschnitts mit entsprechender Beschreibung, was konkret der Test überprüft, definiert. Der erste Fall wäre, dass a und b zusammenaddiert nicht größer als 10 ergeben. Mit a = 2 und b = 3 wäre somit 5 das zu erwartende Ergebnis: test("should return sum if sum < 10", () { // setup const int a = 2; const int b = 3; // act final result = calculator.sumAB(a, b); // expect expect(result, 5); });
Listing 21.5: Unit-Test für die sumAB-Methode
Im zweiten Fall würden Sie größere a- und/oder b-Werte wählen, sodass eine Summe über 10 erreicht wird – beispielsweise a = 7 und b = 5: test("should return 10 if sum> 10", () { // setup const int a = 7; const int b = 5; // act final result = calculator.sumAB(a, b); // expect expect(result, 10); });
Listing 21.6: Ein weiterer Unit-Test für die sumAB-Methode Das war es schon! Damit haben Sie beide Fälle abgedeckt. Als Übung könnten Sie nun noch die sogenannten Grenz- und Sonderfälle hinzufügen. Ein möglicher Grenzfall in diesem Beispiel wäre, wenn Sie a und b so wählen, dass exakt der Wert 10 erreicht wird. Ein Sonderfall wäre, wenn Sie negative Werte für a und b überprüfen.
Unit-Tests in der Praxis Nachdem Sie nun Ihren ersten einfachen Unit-Test konzipiert haben, wollen wir uns an das nächste Schwierigkeitslevel wagen. Die meisten Unit-Test-Tutorials bieten nur das absolute Basiswissen an, dabei sind die Unit-Tests, die Sie schreiben werden, selten so einfach wie die obigen. Darum begeben Sie sich jetzt in Ihre »Pummel The Fish«-App. Spannend wird es nämlich an dem Punkt, an dem Abhängigkeiten ins Spiel kommen und nicht nur Methoden der eigenen Klasse aufgerufen werden, sondern auch andere Klassen involviert sind. Das beste Beispiel dafür ist die RestPetRepository-Klasse, weshalb Sie diese nun testspezifisch genauer unter die Lupe nehmen werden. Hierfür legen Sie zunächst eine neue Testdatei für diese Klasse an, indem Sie per Rechtsklick auf die rest_pet_repository.dart-Datei GO TO TESTS auswählen, wie in Abbildung 21.1.
Abbildung 21.1: Neue Testdatei automatisch generieren lassen
Daraufhin werden Sie gefragt, ob Sie eine neue Testdatei anlegen wollen. Dies bejahen Sie. Dieser Prozess legt nicht nur die Testdatei an, sondern baut auch die gesamte Ordnerstruktur drumherum auf, sodass die Testordnerstruktur sich der Projektstruktur angleicht. Schön komfortabel, oder? Die frisch angelegte Datei besteht bisher nur aus einem flutter_test-Package-Import, einer main-Methode und einer leeren testWidgets-Methode: import "package:flutter_test/flutter_test.dart"; void main() { testWidgets("rest pet repository …", (tester) async { // TODO: Implement test }); }
Listing 21.7: Die neue Testdatei Die testWidgets-Methode ist hier fehl am Platz, denn Sie wollen hier noch keinen Widget-Test schreiben, sondern einen Unit-Test. Löschen Sie diese Methode daher.
Ein bisschen Struktur muss sein Innerhalb der main-Methode sollen sich alle Testfälle für die entsprechende Klasse befinden. Um dem Ganzen etwas mehr Struktur zu verleihen, bietet es sich an, nicht einfach nur Testfälle in die Datei zu werfen, sondern diese nach Methoden zu gruppieren.
Dies können Sie ganz einfach tun, indem Sie innerhalb der main-Methode group schreiben und dann automatisch ein Gruppenkonstrukt wie in Abbildung 21.2 erstellen lassen.
Abbildung 21.2: Methoden gruppieren
Benennen Sie die Gruppe nach der ersten Methode innerhalb der RestPetRepository, also getAllPets. Dasselbe Schema gilt für alle weiteren Methoden, und schon hat Ihre Testdatei einen groben Rahmen: void main() { group("getAllPets()", () {}); group("addPet()", () {}); group("updatePet()", () {}); group("deletePetById()", () {}); group("getPetById()", () {}); }
Listing 21.8: Grobe Teststruktur basierend auf Methoden Schauen Sie sich nun noch einmal die Implementierung der getAllPets-Methode an und überlegen Sie sich, welche Testfälle Sie hier benötigen, um das Verhalten dieser Methode zu prüfen. 1. Die Methode soll einen HttpClient ansprechen und alle existierenden Pet-Objekte in Form einer Liste abrufen und zurückgeben. 2. Im Falle eines Status-Codes von 200 haben Sie Daten erhalten und können diese in die Liste einsortieren und zurückgeben. 3. Im Falle eines beliebigen anderen Status-Codes haben Sie keine Daten erhalten und werfen eine Exception. Sie müssen also hier – wie in den meisten Fällen – mindestens zwei Pfade abdecken: den Erfolgspfad und den Fehlerpfad. Erstellen Sie daher als Nächstes zwei Testfälle: group("getAllPets()", () { test("should return a List successfully", () {}); test("should throw an Exception when something went wrong", () {}); });
Listing 21.9: Das Grundgerüst der getAllPets-Testmethode So weit so gut. Weiter vorne in diesem Teil haben Sie gelernt, dass Sie die Klasse, mit der Sie interagieren werden, innerhalb der setUp-Methode instanziieren können. Lassen Sie uns das hier ebenfalls anwenden, indem Sie eine Instanz der RestPetRepository-Klasse erstellen: import "package:http/http.dart" as http; import "package:pummel_the_fish/data/repositories/rest_pet_repository.dart"; void main() { late http.Client httpClient; late RestPetRepository restPetRepository; setUp(() { httpClient = http.Client(); restPetRepository = RestPetRepository( httpClient: httpClient, ); }); … }
Listing 21.10: Die setUp-Methode in Aktion Damit sind Sie bereit für den ersten Testfall, in dem Sie Daten von einem HttpClient abrufen und diese Liste erfolgreich zurückgeben werden: test("should return a List successfully", () async { final result = await restPetRepository.getAllPets(); expect(result, ???); });
Listing 21.11: Die getAllPets-Testmethode mit getAllPets-Aufruf Tja, und was genau erwarten wir denn als Ergebnis? Welche Pet-Liste soll hier denn ankommen? Die Pet-Liste ändert sich ja ständig, sobald eine benutzende Person damit interagiert? Und wie sollen Sie einen Fehlerfall produzieren? Stellen Sie den Server einfach ab und lassen dann die Tests durchlaufen? Das klingt nicht sehr realistisch. Sie ahnen also schon, dass hier noch ein Puzzleteil fehlt.
Daten und Aufrufe mocken Das benötigte Puzzleteil nennt sich Mocking. Mit Mocking können Sie verschiedenen Komponenten vorgeben, wie sie sich verhalten sollen. In diesem Fall müssten Sie den HttpClient dazu bewegen, Ihnen einmal einen Status-Code von 200 sowie eine vordefinierte Pet-Liste zurückzugeben und einmal einen beliebigen anderen Status-Code und eine Exception.
Um dieses Konzept umzusetzen, benötigen Sie ein neues Package in Ihrer pubspec.yaml, das Package mocktail. >> flutter pub add mocktail --dev
Die --dev-Flag bedeutet hier, dass das Package zur dev_dependencies-Sektion in der pubspec.yaml hinzugefügt wird. Die Packages, die Sie dort vorfinden, werden in Ihrer Produktiv-App am Ende nicht mitgeliefert. Wechseln Sie danach zurück in Ihre RestPetRepository-Testdatei und erstellen Sie Ihr erstes Mock-Objekt oberhalb der main-Methode: import "package:mocktail/mocktail.dart"; class MockHttpClient extends Mock implements http.Client {}
Listing 21.12: Einen Http-Klienten mit mocktail mocken Anstatt dass Sie nun einen echten HttpClient erstellen, tauschen Sie diesen mit einer Instanz des MockHttpClient aus und geben ihn bei der Initialisierung an das RestPetRepository weiter: late MockHttpClient mockHttpClient; late RestPetRepository restPetRepository; setUp(() { mockHttpClient = MockHttpClient(); restPetRepository = RestPetRepository( httpClient: mockHttpClient, ); });
Listing 21.13: Eine MockHttpClient-Instanz erstellen und durchreichen Zusätzlich dazu erstellen Sie noch eine Test-JSON-Antwort, die von der REST-API zurückkommen könnte, sowie eine dazu passende Pet-Liste, die Sie als Ergebnis erwarten würden: late String tPetsJson; late List tPetList; setUp(() { … tPetsJson = '''[ { "id": "1", "name": "Kira", "species": 0, "weight": 250.0,
"height": 20.0, "age_in_years": 10, "is_female": true }, { "id": "2", "name": "Harribart", "species": 3, "weight": 400.0, "height": 40.0, "age_in_years": 3, "is_female": false } ]'''; tPetList = [ const Pet( id: "1", name: "Kira", species: Species.dog, age: 10, weight: 250.0, height: 20.0, isFemale: true, owner: null, ), const Pet( id: "2", name: "Harribart", species: Species.fish, age: 3, weight: 400.0, height: 40.0, isFemale: false, owner: null, ) ]; });
Listing 21.14: Eine Test-JSON und Pet-Liste erstellen Danach können Sie diese Pet-Liste als erwartetes Ergebnis innerhalb des expect-Bereichs eintragen: test("should return a List successfully", () async { final result = await restPetRepository.getAllPets(); expect(result, tPets); });
Listing 21.15: Im Unit-Test die Pet-Liste erwarten
Um den Test nun durchlaufen zu lassen, haben Sie drei Möglichkeiten: 1. Sie führen flutter test im Terminal aus. 2. Sie öffnen den TESTING-Tab in VSCode durch Klick auf das Labor-Icon in der linken Leiste. 3. Sie klicken auf RUN direkt über der test(…)-Definition. Starten Sie den Test mit unterschiedlichen Methoden, um herauszufinden, welche Ihnen am besten gefällt. Egal, welche Methode Sie anwenden, Sie sollte Sie zum folgenden Fehler in Abbildung 21.3 führen.
Abbildung 21.3: Null is not a subtype of type Future
Dieser Fehler wird Ihnen vermutlich häufiger begegnen, nämlich immer genau dann, wenn Sie Ihrem Mock nicht gesagt haben, was er zu tun hat. Erwartet wird eigentlich ein Future-Objekt – Sie erhalten aber null.
Wenn-dann in der Theorie Lösen lässt sich das, indem Sie konkret definieren, was der MockHttpClient zurückliefern soll, bevor die getAllPets-Methode diesen verwendet. Hierfür verwenden Sie eine Wenn-dann-Schreibweise. Es gibt drei verschiedene Typen, auf die Sie dank des mocktail-Packages Zugriff haben. 1. Wenn eine Methode methodXY aufgerufen wird, wird ein Ergebnis zurückgegeben: when(() => class.methodXY()).thenReturn(ERGEBNIS);
2. Wenn eine asynchrone Methode methodXY aufgerufen wird, wird ein Ergebnis zurückgegeben: when(() => class.methodXY()).thenAnswer((_) async => ERGEBNIS);
3. Wenn eine Methode methodXY aufgerufen wird, wird eine Exception geworfen: when(() => class.methodXY()).thenThrow(Exception("xyz"));
Wenn-dann in der Praxis Wenn Sie sich nun noch einmal die Methode anschauen, die Sie mocken wollen, stellen Sie fest, dass es sich um eine asynchrone Methode handelt. Da Sie ein Ergebnis und keine
Exception zurückgeben wollen, handelt es sich also um die Methode von Typ zwei. import "package:mocktail/mocktail.dart"; … test("should return a List successfully", () async { when( () => mockHttpClient.get(Uri.parse("$baseUrl/pets")), ).thenAnswer( (_) async => http.Response(tPetsJson, 200), ); final result = await restPetRepository.getAllPets(); expect(result, tPetList); });
Listing 21.16: Der fertige Testfall
Neben dem Überprüfen des Testergebnisses mithilfe von expect gibt es auch noch eine Möglichkeit zu überprüfen, ob Ihre definierten Mock-Methoden überhaupt aufgerufen wurden, und wenn ja, wie oft. Dies können Sie ganz einfach mit einem sogenannten verify erreichen, das auf den expect-Aufruf folgt und im Grundgerüst wie folgt aussieht: verify(() => MOCK_METHODE).called(ANZAHL_AUFRUFE);
Innerhalb der called-Methode wird mit einem Integer die Anzahl der erwarteten Methodenaufrufe angegeben. Im aktuellen Test würde das wie folgt aussehen: verify(() => mockHttpClient.get(Uri.parse("$baseUrl/pets")), ).called(1);
Zusätzlich dazu können Sie mit verifyNever überprüfen, dass eine Methode nicht aufgerufen wurde: verifyNever(() => MOCK_METHODE);
Neben verify und verifyNever gibt es noch weitere Absicherungen, die Sie tätigen können. Es lohnt sich definitiv, sich langfristig damit näher zu beschäftigen. Lassen Sie den Test erneut rennen, lehnen Sie sich zurück und … oh nein, der Test ist immer noch rot? Was ist denn nun schon wieder los?
Abbildung 21.4: Expected und Actual unterscheiden sich – aber warum?
Wenn Sie die Abbildung 21.4 als Suchbild, bei dem man die Unterschiede zwischen »Expected« und »Actual« finden soll, bei einer Zeitung einreichen würden, würden diese Sie für verrückt erklären. Es gibt keinen Unterschied. Oder doch? Vielleicht ist er nur nicht sichtbar? Lassen Sie uns hierfür einen kurzen Ausflug in die toString-Methode in der Pet-Klasse machen und ein klitzekleines Detail ergänzen. Den HashCode: @override String toString() { return "Pet( hashCode: $hashCode, id: $id, name: $name, species: $species, age: $age, weight: $weight, height: $height, isFemale: $isFemale, owner: $owner )"; }
Listing 21.17: Die toString-Methode wird mit dem hashCode ergänzt. Lassen Sie den Test nun erneut durchlaufen und vergleichen Sie die HashCode-Werte der »Expected«- und »Actual«-Ausgabe. Der HashCode als Spielverderber
Die Detektivarbeit hat sich gelohnt, der HashCode konnte als Übeltäter identifiziert werden. Der HashCode ist eine Eigenschaft, die jeder neu generierten Instanz zugewiesen wird. Ein HashCode ist einmalig. Um zwei eigentlich identische Pet-Objekte als identisch zu identifizieren, müssen Sie die HashCode-Methode deshalb überschreiben und eigene Regeln aufstellen. Dies können Sie tun, indem Sie die Eigenschaften definieren, mit denen Objekte verglichen werden sollen. Im Folgenden zeigen wir Ihnen zwei einfache Methoden, wie Sie dieses Problem lösen können.
Den HashCode manuell überschreiben Sie können den HashCode manuell überschreiben, indem Sie innerhalb der Pet-Klasse auf den Text »Pet« klicken und per gelber QUICK-FIX Lampe oder über den Shortcut + das Menü öffnen. Dort wählen Sie dann GENERATE EQUALITY aus. Dies sollte zwei längere und komplex aussehende Methoden am Fuße der Datei erzeugen, die die Identität eines Pet-Objekts basierend auf den definierten Eigenschaften überprüft: @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is Pet && other.id == id && other.name == name && other.species == species && other.weight == weight && other.height == height && other.age == age && other.isFemale == isFemale && other.owner == owner; } @override int get hashCode { return id.hashCode ^ name.hashCode ^ species.hashCode ^ weight.hashCode ^ height.hashCode ^ age.hashCode ^ isFemale.hashCode ^ owner.hashCode; }
Listing 21.18: Die erzeugte hashCode-Methode Den HashCode mit Equatable überschreiben Die zweite Möglichkeit ist, das equatable-Package zu verwenden, das Sie bereits im Bloc-Kapitel installiert haben. Wenn Sie Ihre Pet-Klasse mit der Equatable-Klasse erweitern, müssen Sie eine neue Methode props generieren. Das können Sie ebenfalls tun, indem Sie auf »Pet« innerhalb der Pet-Klasse klicken, per QUICK-FIX das Menü öffnen und GENERATE EQUATABLE auswählen. Der generierte props-Getter sollte wie folgt aussehen: @override List get props { return [ id, name,
species, weight, height, age, isFemale, owner, ]; }
Listing 21.19: Den HashCode mit Equatable überschreiben Egal, für welche Option Sie sich entscheiden, in beiden Fällen sollte Ihr Test nun erfolgreich durchlaufen. Wenn Sie nun Lust haben, das frisch gelernte Wissen anzuwenden, versuchen Sie doch mal, die anderen Methoden der RestPetRepository-Klasse zu testen. Versuchen Sie, dabei auch den Fehlerfall abzudecken: Was, wenn eine Exception geworfen werden würde? Wenn Sie nicht weiterkommen, schauen Sie einfach in den zur Verfügung gestellten Lösungscode. Alle Methoden haben wir für Sie mit Tests abgedeckt. Nur zum Spicken natürlich!
Blocs und Cubits testen Bloc-Tests sind eine Form der Unit-Tests und können mit dem bloc_test-Package hinzugefügt werden: >> flutter pub add bloc_test --dev
Mithilfe dieses Packages können Sie einen Bloc oder Cubit erstellen, eine bestimmte Funktion oder ein Event triggern und Ihre Erwartungshaltung formulieren. Wenn der Test gestartet wird, werden die Aktionen ausgeführt und die Ergebnisse mit den Erwartungshaltungen verglichen. In so einem Test testen Sie nur das, was innerhalb der Bloc-Funktion passiert. Lassen Sie uns auch diese Form eines Tests anhand eines Beispiels durchgehen, indem Sie die Methode getAllPets innerhalb des ManagePetsCubit-Testen. Zunächst erstellen Sie wieder eine Testdatei, indem Sie per Rechtsklick auf manage_pets_cubit.dart das Menü öffnen und dort GO TO TESTS auswählen. Löschen Sie den testWidgets-Abschnitt und erstellen Sie stattdessen eine Gruppe getAllPets(). Anstatt von zwei normalen Testfällen erstellen Sie nun mithilfe einer kleinen Generierungsmethode wie in Abbildung 21.5 zwei blocTest-Testfälle.
Abbildung 21.5: blocTest-Testfälle erstellen
Danach kümmern Sie sich wieder um die setUp-Methode und instanziieren alle Objekte, die Sie benötigen: den ManagePetsCubit, eine Testliste mit Pet-Objekten sowie eine FakePetRepository-Klasse, die ein Mock der FirestorePetRepository-Klasse ist. Dieser Aufbau unterscheidet sich somit nicht von dem eines normalen Unit-Tests und Sie können all das, was Sie bereits gelernt haben, direkt anwenden. import "package:flutter_test/flutter_test.dart"; import "package:mocktail/mocktail.dart"; import "package:pummel_the_fish/data/models/pet.dart"; import "package:pummel_the_fish/data/repositories/firestore_pet_repository.dart"; import "package:pummel_the_fish/cubits/manage_pets_cubit.dart"; class MockFirestorePetRepository extends Mock implements FirestorePetRepository {} void main() { late ManagePetsCubit cubit; late MockFirestorePetRepository mockFirestorePetRepository; late List tPetList; setUp(() { mockFirestorePetRepository = MockFirestorePetRepository(); cubit = ManagePetsCubit(mockFirestorePetRepository); tPetList = [ const Pet( id: "1", name: "Kira", species: Species.dog, age: 10,
weight: 250.0, height: 20.0, ), const Pet( id: "2", name: "Space", species: Species.fish, age: 3, weight: 10.0, height: 10.0, ), ]; }); group("getAllPets()", () { … }); }
Listing 21.20: Basisaufbau der manage_pets_cubit_test.dart Passen Sie danach den blocTest basierend auf Ihrem Wissen an, indem Sie dem Testfall einen Namen geben, eine Wenn-dann-Anweisung an das FakePetRepository schicken und sich überlegen, welche States Sie von dem Cubit erwarten würden, wenn Sie die Methode getAllPets aufrufen. blocTest( "emits [ManagePetsStatus.loading, ManagePetsStatus.success] when getAllPets() is called successfully.", setUp: () { when(() => mockFirestorePetRepository.getAllPets()) .thenAnswer((_) async => tPetList); }, build: () => cubit, act: (cubit) => cubit.getAllPets(), expect: () => [ const ManagePetsState( status: ManagePetsStatus.loading, ), ManagePetsState( status: ManagePetsStatus.success, pets: tPetList, ), ], verify: (_) => verify(() => mockFirestorePetRepository.getAllPets()).called(1), );
Listing 21.21: Der fertige Cubit-Test für die getAllPets Wie Sie sehen, unterscheiden sich Bloc-Tests nicht sehr stark von Unit-Tests. Sie bieten Ihnen eine schöne, klare Struktur, an der Sie sich entlanghangeln können.
User Interface testen Neben Logiktests spielen User-Interface-Tests eine wichtige Rolle, denn nicht alles, was sich logisch korrekt verhält, wird auch im User Interface korrekt angezeigt.
Widget-Tests Mit Widget-Tests können Sie einzelne Widgets oder auch eine Widget-Kombination testen. Dabei liegt der Fokus mehr auf Designelementen als auf Logik. Mithilfe von Widget-Tests kann man zum Beispiel herausfinden, ob die Widgets und Texte, von denen man annimmt, dass sie sich im ausgewählten Element befinden, auch wirklich da sind. Man kann außerdem mit den Widgets interagieren (zum Beispiel mit Buttons und Checkboxen) und gegenchecken, ob das gewünschte Ergebnis (zum Beispiel ein Zustandswechsel) eingetreten ist. Auch hierfür benötigen wir ein kleines praktisches Beispiel. Lassen Sie uns dafür das PetListLoaded-Widget verwenden. Navigieren Sie zu der entsprechenden Klasse und erstellen Sie, wie bei den Unit-Tests auch, eine Testdatei durch Rechtsklick und GO TO TEST. Da es sich dieses Mal tatsächlich um einen Widget-Test handelt, werden Sie die testWidgets-Methode nicht löschen, sondern behalten und modifizieren, indem Sie ihr zunächst einen Namen geben. import "package:flutter_test/flutter_test.dart"; void main() { testWidgets( "should display all given Pets", (tester) async {}, ); }
Listing 21.22: Namen für die testWidgets-Methode vergeben Bei einem Widget-Test müssen Sie als Allererstes das Widget zur Verfügung stellen, das Sie testen möchten, in diesem Fall also das PetListLoaded-Widget. Da wir, um dieses zu erzeugen, eine Liste an Pet-Objekten benötigen, lassen Sie uns innerhalb der setUpMethode wieder eine Testliste mit Pet-Objekten erstellen. Sie können auch die Liste, die Sie in den Unit-Tests verwendet haben, einfach kopieren und einfügen. void main() { late List tPets; setUp(() { tPets = [ const Pet( id: "1", name: "Kira", species: Species.dog,
age: 10, weight: 250.0, height: 20.0, isFemale: true, ), const Pet( id: "2", name: "Harribart", species: Species.fish, age: 3, weight: 100.0, height: 10.0, isFemale: true, ), ]; }); … }
Listing 21.23: SetUp des Widget-Tests Danach wenden Sie sich der testWidgets-Methode zu und »pumpen« das zu testende Widget in einen komplett leeren Widget-Baum, denn Sie wollen es isoliert von Ihrer App überprüfen. Dies erreichen Sie durch die Verwendung der Methode pumpWidget. Da Sie testen möchten, wie sich das Widget innerhalb eines Screens verhält, müssen Sie es zusätzlich in eine MaterialApp und ein Scaffold wrappen. Ansonsten werden Sie Fehler wie No Material widget found. Widget XY requires a Material widget ancestor. erhalten. await tester.pumpWidget( MaterialApp( home: Scaffold( body: PetListLoaded(pets: tPets), ), ), );
Listing 21.24: Die PetListLoaded isoliert in einem Widget-Test testen Das Widget ist nun bereit und stellt sich den Erwartungshaltungen, die Sie formulieren. Sie können also das Widget nun auf bestimmte Elemente untersuchen und prüfen, ob es Ihren Erwartungshaltungen entspricht. Hierfür verwenden Sie, wie in Unit-Tests auch, ein expect und suchen durch ein CommonFinders-Objekt namens find nach bestimmten Elementen, wie zum Beispiel: Textelementen (find.text) bestimmten Widgets (find.byType)
Keys (find.byKey) Icons (find.byIcon) Sie können dann formulieren, wie viele Widgets Sie erwarten würden, zum Beispiel: exakt ein Widget (findsOneWidget) eine bestimmte Anzahl an Widgets (findsNWidgets(ANZAHL)) mindestens eine bestimmte Anzahl an Widgets (findsAtLeastNWidgets) kein Widget (findsNothing) mindestens ein Widget (findsWidgets) Der Testfall für dieses Widget könnte beispielsweise prüfen, ob die übergebenen Tiernamen angezeigt werden, ein ListView-Widget gerendert wird und zwei weibliche Icons gefunden wurden. testWidgets("should display all given Pets", (tester) async { await tester.pumpWidget( MaterialApp( home: Scaffold( body: PetListLoaded(pets: tPets), ), ), ); expect(find.text("Kira"), findsOneWidget); expect(find.text("Space"), findsOneWidget); expect(find.byType(ListView), findsOneWidget); expect(find.byIcon(Icons.female), findsNWidgets(2)); });
Listing 21.25: Der Widget-Test funktioniert. Wenn Sie diesen Test nun starten, sollte er erfolgreich durchlaufen. Eine weitere Möglichkeit, ein Element auf seine Existenz im Widget-Baum zu überprüfen, ist die Verwendung eines eindeutigen Keys – zum Beispiel einem ValueKey mit einem eindeutigen String, wie einer ID. Hierfür können Sie beispielsweise im PetListLoaded-Widget innerhalb des ListTileWidgets einen key vergeben: return ListTile( key: ValueKey("pet-${pets[index].id}"), leading: Icon( pets[index].isFemale ? Icons.female : Icons.male, color: CustomColors.orange, size: 40, ),
);
Listing 21.26: Einen Key für jedes ListTile-Element im PetListLoaded-Widget vergeben Nach Widgets mit diesen Keys können Sie nun innerhalb des Widget-Tests fragen, indem Sie die find.byKey-Methode verwenden: expect(find.byKey(const ValueKey("pet-1")), findsOneWidget);
Golden Tests Golden Tests basieren auf Widget-Tests. Sie können zunächst einen Screenshot generieren und abspeichern. Dieser wird dann beim Durchlaufen der Widget-Tests mit dem Renderergebnis verglichen. Weicht das Renderergebnis vom Screenshot ab, schlägt der Golden Test Alarm. Sie können einen existierenden Widget-Test um einen Golden Test erweitern, indem Sie beispielsweise im PetListLoaded-Test am Ende die folgenden Zeilen hinzufügen. await expectLater( find.byType(MaterialApp), matchesGoldenFile("pet_list_loaded.png"), );
Listing 21.27: Aus einem Widget-Test einen Golden Test machen Wenn Sie den Test nun erneut laufen lassen, wird er allerdings fehlschlagen, da bisher noch kein Screenshot generiert wurde, mit dem man das Renderergebnis vergleichen könnte. Um die Screenshots für die Golden Tests zu generieren, führen Sie den folgenden Befehl im Terminal aus. >> flutter test –-update-goldens
Sie sollten daraufhin unterhalb Ihrer pet_list_loaded_test.dart einen generierten Screenshot pet_list_loaded.png entdecken, wie in Abbildung 21.6. Lassen Sie den Test nun erneut laufen, sollte dieser erfolgreich sein.
Abbildung 21.6: Der generierte Golden-Test-Screenshot
Obwohl Flutter von Haus aus die Möglichkeit für Golden Tests mitbringt, lohnt es sich, ein Blick in das Package golden_toolkit von eBay oder alchemist von Very
Good Ventures und Betterment zu werfen. Diese bieten noch ein paar Erweiterungen wie zum Beispiel die Einbindung von Custom Fonts und vereinfachten Schreibweisen an. Damit können Screenshots generiert werden, die genauso aussehen, wie das Widget auf einem echten Gerät aussehen würde.
Einen Flow mithilfe von Integration-Tests testen Integration-Tests sind dazu da, komplette Flows zu testen. Also nicht nur einzelne Screens und Widgets, sondern möglicherweise einen Log-in-Prozess, eine Registrierung, einen Ablauf innerhalb eines bestimmten Features, das über einen oder mehrere Screens funktioniert. Sie sind ähnlich aufgebaut wie die Widget-Tests, verwenden in der Regel jedoch keine Mock-Abhängigkeiten, um Aufrufe zu Schnittstellen zu simulieren, sondern arbeiten mit Aufrufen an das echte Backend wie zum Beispiel Firebase und die verwendete REST-API.
Installation und erste Schritte Integration-Tests werden nicht von Haus aus mitgeliefert, daher müssen Sie dafür zunächst ein neues Package namens integration_test hinzufügen. Dieses tragen Sie am besten händisch in der pubspec.yaml unterhalb der dev_dependencies ein. dev_dependencies: integration_test: sdk: flutter
Listing 21.28: Das integration_test-Package hinzufügen Durch ein flutter pub get oder Speichern der pubspec.yaml laden Sie sich dieses dann wie üblich herunter. Im Anschluss daran erstellen Sie einen neuen Ordner namens integration_test auf Höhe des lib-Ordners. Dort erstellen Sie dann Ihre erste Integration-Testdatei namens app_test.dart. Innerhalb dieser Datei müssen Sie, um den Test als Integration-Test zu definieren, zu Beginn der main-Methode den Aufruf IntegrationTestWidgetsFlutterBinding.ensureInitialized einfügen und Firebase initialisieren: import "package:firebase_core/firebase_core.dart"; import "package:pummel_the_fish/firebase_options.dart"; import "package:integration_test/integration_test.dart"; Future main() async { IntegrationTestWidgetsFlutterBinding .ensureInitialized(); await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform, ); }
Listing 21.29: Die main-Methode im Integration-Test
Der Testfall Nun können Sie mit Ihrem frisch erlangten Widget-Test-Wissen einen Testfall erstellen, der die App startet, prüft, ob der SplashScreen angezeigt wird und weiter prüft, ob nach drei Sekunden automatisch zum HomeScreen navigiert wird. Der fertige Testfall sieht dann wie folgt aus: import "package:flutter_test/flutter_test.dart"; import "package:integration_test/integration_test.dart"; import "package:pummel_the_fish/main.dart"; import "package:pummel_the_fish/screens/home_screen.dart"; import "package:pummel_the_fish/screens/splash_screen.dart"; void main() { … testWidgets( "SplashScreen shows first and changes to HomeScreen after 3 seconds", (tester) async { await tester.pumpWidget(const MyApp()); await tester.pumpAndSettle(); // SplashScreen expect(find.byType(SplashScreen), findsOneWidget); await tester.pump(const Duration(seconds: 3)); await tester.pumpAndSettle(); // HomeScreen expect(find.byType(HomeScreen), findsOneWidget); }); }
Listing 21.30: Der erste Integration-Test Zwei bisher unbekannte pump-Methoden fallen hier auf: pumpAndSettle und pump. Während pump exakt einen Frame weiter rendert, nachdem die übergebene Zeit abgelaufen ist, wird pumpAndSettle so viele pump-Aufrufe tätigen wie nötig, bis alle eingereihten Frame-Render-Aufrufe abgearbeitet sind. Sobald Sie in irgendeiner Form eine Änderung des Zustands eines Widgets erwarten, versuchen Sie es zunächst mit einem pump, um den nächsten Frame zu rendern. Speziell bei Animationen und dem Wechsel von Screens ist meist ein pumpAndSettle angebracht.
Integration-Tests laufen lassen
Ein Integration-Test sollte im besten Fall auf einem physischen Gerät laufen. Wenn Sie kein Gerät zur Verfügung haben, können Sie auch den Android-Emulator verwenden. Hierzu müssen Sie lediglich den Integration-Test ausführen, den Sie laufen lassen möchten, indem Sie den folgenden Befehl im Terminal eingeben: >> flutter test integration_test/app_test.dart
Sie können selbstverständlich auch alle existierenden Integration-Tests nacheinander ausführen. >> flutter test integration_test
In beiden Fällen sollte Ihr Gerät angeschlossen oder der Emulator bereits gestartet sein. Wenn Sie den Test starten, sollten Sie auf dem Gerät oder Emulator genau beobachten können, wie der Test sich verhält. Wenn Sie den Integration-Test auf einem Android-Gerät starten, kann es sein, dass Sie in den Fehler Flutter multidex handling is disabled. If you wish to let the tool configure multidex, use the --multidex flag rennen. Um das zu lösen, müssen Sie gegebenenfalls noch eine kleine Anpassung in der Datei build.gradle im android/app-Ordner durchführen und multiDex aktivieren. defaultConfig { … multiDexEnabled }
Sehr gut, Ihre App ist jetzt schon mit einigen Tests ausgestattet! Aber sind das schon genug?
Messbarkeit von Tests: die Test-Coverage Die Test-Coverage ist ein hilfreiches Tool zur Kontrolle und Visualisierung Ihrer mit Tests abgedeckten Codezeilen. Dabei wird überprüft, welche Codezeilen Ihre Tests durchlaufen mussten, um den Test auszuführen. Wird eine Zeile durchlaufen, gilt sie als abgedeckt, wird eine Zeile nicht durchlaufen, fehlt dafür ein Testfall. Viele Projekte, die generell Tests etablieren, zielen auf eine 100%-Test-Coverage ab. Das bedeutet, dass jede einzelne Zeile Code, die Sie geschrieben haben, durch einen oder mehrere Testfälle abgedeckt wurde.
Es ist nicht alles Gold, was glänzt Die Falle, in die beim Thema Test-Coverage häufig getreten wird, ist, dass eine 100%Test-Coverage nicht zwingend auf sinnvolle Tests hinweist. Die Coverage gibt somit lediglich eine Aussage darüber, wie viele Codezeilen getestet wurden, aber nicht, wie gut.
Häufig werden Sie für bestimmte Funktionen oder Codezeilen mehrere Testfälle benötigen, um wirklich alle möglichen Fälle, speziell auch Sonderfälle, abdecken zu können. Das berücksichtigt die Test-Coverage nicht – es gibt lediglich ein »Ja, hier bin ich durchgegangen.« oder »Nein, hier bin ich nicht durchgegangen.«. Nichtsdestotrotz – eine höhere Test-Coverage-Zahl bedeutet meist, dass auch mehr getestet wurde. Wenn Sie sich im Bereich 70–80 % aufhalten, ist das schon beachtlich, und wenn Sie die Codezeilen, die Sie damit überprüft haben, sorgfältig ausgewählt und mit unterschiedlichen Fällen abgedeckt haben, ist das oft mehr wert, als stupide auf die 100 % hinzuarbeiten, dafür die wichtigsten Sonderfälle aber außer Acht gelassen zu haben.
Visualisierung der Test-Coverage mit Coverage Gutters Führen Sie zunächst den folgenden Befehl im Terminal aus, um alle Tests durchlaufen und daraus eine Test-Coverage generieren zu lassen: >> flutter test --coverage
Mithilfe einer VSCode-Erweiterung namens »Coverage Gutters« (https://marketplace.visualstudio.com/items?itemName=ryanluker.vscodecoverage-gutters) können Sie nun Ihre Test-Coverage wunderbar visualisieren. Um diese zu installieren, navigieren Sie innerhalb von VSCode in der linken Seitenleiste auf den EXTENSIONS-Tab und suchen nach »Coverage Gutters«. Installieren Sie die Erweiterung per Klick auf INSTALL und starten Sie VSCode gegebenenfalls neu. Nun sollte in der linken unteren Leiste in VSCode ein kleines Auge mit dem Namen WATCH auftauchen. Wenn Sie mit dem Mauszeiger darüber fahren, sollte der folgende Satz erscheinen: COVERAGE GUTTERS: CLICK TO WATCH WORKSPACE. Na los, klicken Sie drauf! Nun können Sie in jede beliebige Dart-Datei innerhalb des lib-Ordners springen und die Test-Coverage überprüfen. Die roten Markierungen links neben den Zeilenzahlen deuten auf eine geringe Test-Coverage hin, während die grünen Markierungen eine ausreichende Test-Coverage signalisieren (Abbildung 21.7). Um eine 100%ige Coverage zu erreichen, müssten Sie alle rot markierten Codezeilen mit weiteren Tests absichern.
Abbildung 21.7: Die Test-Coverage in der ManagePetsCubit-Klasse
Die Test-Coverage im Überblick mit Flutter Coverage Ein weiteres Tool zur Visualisierung der Test-Coverage ist »Flutter Coverage« (https://marketplace.visualstudio.com/items?itemName=Flutterando.fluttercoverage). Damit können Sie die Test-Coverage für Ihre Dateien im VSCode-TESTINGTab anzeigen lassen, ohne jede Datei einzeln öffnen zu müssen. Außerdem können Sie auf den ersten Blick erkennen, wie es um Ihre kumulierte Test-Coverage innerhalb von Ordnern bestellt ist (siehe Abbildung 21.8).
Abbildung 21.8: Die Flutter-Coverage aufgeschlüsselt
Letzte Tipps Wie Sie nun erfahren haben, ist Testing ein umfangreiches Thema und bedarf bei der Umsetzung auch etwas Übung. Wenn Sie Ihren Code jedoch nach Testbarkeit schreiben – oder sogar die Möglichkeit haben, Test Driven Development anzuwenden – ist das ein gutes Zeichen, dass Sie sauberen Code fabriziert haben. Je schwieriger Ihr Code zu testen ist, desto eher haben Sie grundlegende Architektur- oder Best-Practice-Konzepte, die wir
Ihnen in diesem Buch vorgestellt haben, nicht beachtet. Testing kostet beim Schreiben zwar sehr viel Zeit, kann Sie aber im Gegenzug vor eingeschlichenen Bugs, Seiteneffekten und Refactoring-Chaos bewahren. Wie viele und welche Tests Sie schreiben sollten, hängt von vielen verschiedenen Faktoren und vor allem von dem Projekt ab. Falls Sie limitierte Zeit für Tests haben, sollten Sie versuchen, Tests für die wichtigsten und komplexesten Features zu bevorzugen. Für mobile Apps ganz wichtig ist außerdem das manuelle Testen auf verschiedenen Geräten, wie oben schon erwähnt – am besten mit Betriebssystemen in verschiedenen Versionen. Dafür muss erst mal ein Build erstellt werden. In den nächsten beiden Kapiteln werden Sie lernen, wie Sie für Android und für iOS einen Build erstellen, die App an Testende ausrollen und sie schließlich veröffentlichen können. Falls Sie Pair Programming praktizieren, können Sie mit Ihrem Partner bzw. Ihrer Partnerin Folgendes ausprobieren: Einer von Ihnen schreibt einen Test für Ihr gemeinsames Projekt und die andere versucht, den Test mit Code zu erfüllen.
Kapitel 22
Der Android-Build IN DIESEM KAPITEL Fügen Sie ein Launcher-Icon hinzu und bereiten Sie Ihren ersten Build vor Erfahren Sie, wie Sie einen Android-Build erstellen Lernen Sie den Unterschied zwischen .apk- und .aab-Dateien kennen Bekommen Sie einen Überblick über die Schritte vom Build über den Beta-Test bis hin zur Veröffentlichung im Play Store
Nach so viel Programmieren und automatisiertem Testen soll Ihre App jetzt endlich gebaut werden. Der iOS-Build ist in der Regel immer etwas zickiger als der AndroidBuild: Genau darum würden wir Ihnen empfehlen, mit dem Android-Build zu beginnen! Wenn Sie den zum Funktionieren bringen, haben Sie schon einmal die groben Fehler Ihrer App ausgebessert und ein erstes Erfolgserlebnis. Damit können Sie sich dann, in Ihrem Selbstbewusstsein gestärkt, in die Apple-Welt wagen. Sie können einige der im Folgenden beschriebenen Schritte mit Ihrer »Pummel The Fish«-App gehen, die Veröffentlichung wird Ihnen damit aber wahrscheinlich nicht gelingen. Der Package-Name kann zum Beispiel nicht doppelt verwendet werden. Außerdem prüfen beide Stores Ihre App eventuell vor Veröffentlichung auf ihren Mehrwert und ob es ähnliche Apps schon im Store gibt.
Vorbereitung eines Builds (Android und iOS) Bevor Sie mit der Erstellung der Builds beginnen, sollten Sie Ihre App darauf vorbereiten – zumindest, wenn Sie die App an Testende verbreiten oder in einen Store laden möchten. Folgende Punkte sind zu beachten, egal ob Sie einen Android- oder einen iOS-Build planen.
Das Launcher-Icon Das Launcher-Icon ist das Icon, das auf dem Home-Screen eines Smartphones angezeigt wird und bei Klick Ihre App öffnet. Jede Flutter-App hat ein Default-Launcher-Icon, ein weißes Quadrat mit dem Flutter-Logo darauf. Wenn Sie das Icon anpassen wollen, geht das am einfachsten mit dem Package flutter_launcher_icons. Fügen Sie dieses nun
über die VSCode-Konsole mit diesem Befehl zu Ihrer App hinzu: >> flutter pub add flutter_launcher_icons
Vielleicht fragen Sie sich jetzt, warum Sie gleich ein Package brauchen, um ein einfaches Icon einzurichten? Sie brauchen das Launcher-Icon in vielen verschiedenen Versionen für die diversen Geräte. Das Package erstellt für Sie die Versionen der Launcher-Grafik automatisch und legt sie schon in der benötigten Ordnerstruktur an. Das Package können Sie aus Ihrer pubspec.yaml wieder entfernen, sobald Ihre Icons generiert wurden. Sie benötigen für Ihr Launcher-Icon ein quadratisches Icon mit 1024 × 1024 Pixeln Größe. Die png-Datei darf nicht transparent sein: Achten Sie darauf, dass sie keinen Alpha-Channel hat (Eigenschaften der Datei überprüfen). Ansonsten kann es sein, dass der Build anschließend nicht funktioniert. Im Normalfall sollte das Package Ihnen aber eine Warnung in der Konsole ausgeben, falls etwas schiefgelaufen ist, und meistens auch Lösungsansätzen vorschlagen. Legen Sie das Icon im images-Ordner im assets-Ordner ab und integrieren Sie das flutter_launcher_icons-Package in der pubspec.yaml-Datei wie folgt: flutter_icons: image_path: "assets/images/launcher_icon.png" android: true ios: true
Listing 22.1: flutter_launcher_icons-Package in der pubspec-Datei Wenn Android und iOS unterschiedliche Icons bekommen sollen, ändern Sie einfach true zu dem jeweiligen Icon-Pfad. Mit folgendem Befehl im Terminal können Sie nun die Icons generieren: >> flutter pub get >> flutter pub run flutter_launcher_icons:main
Versionierung bedenken In der pubspec.yaml-Datei finden Sie die Versionsnummer Ihrer App: version: 1.0.0+1
Die Versionsnummer ist semantisch nach dem Schema MAJOR.MINOR.PATCH+BUILD aufgebaut. MAJOR, MINOR und PATCH sind ein gängiges Schema in der SoftwareEntwicklung, das Ihnen sicher schon mal begegnet ist. Die MAJOR-Zahl wird hochgezählt, wenn es gravierende Änderungen in der App gegeben hat, die MINOR-Zahl wird hochgezählt, wenn Funktionalität hinzukam und sich größere Implementierungen geändert haben, während die PATCH-Zahl für Bug-Fixes und kleinere Anpassungen hochgezählt wird. Die BUILD-Zahl nach dem Plus wird bei jeder Versionsanpassung hochgezählt, egal wie gravierend die Änderung war. Bei jeder Veröffentlichung im Store, ob Testversion für manuell Testende oder finales
Production Release, müssen Sie die Versionsnummer anpassen. Zwei unterschiedliche Builds mit derselben Versionsnummer werden sowohl vom Google Play Store als auch vom Apple App Store abgelehnt. Generell kann es auch hilfreich sein, die aktuelle Versionsnummer innerhalb Ihrer App sichtbar (zum Beispiel im Seitenmenü) anzuzeigen, um Fehlerberichte von Nutzenden immer einfach abfragen und zuordnen zu können. Die Versionsnummer können Sie entweder jedes Mal händisch im Code an der jeweiligen Stelle anpassen oder durch ein Package wie package_info_plus automatisch von der pubspec.yaml beziehen lassen. Ein Schritt weniger, an den Sie beim Veröffentlichen denken müssen. Sie können versionieren, wie Sie lustig sind, solange Sie sich an das vorgegebene Schema halten. Für die ersten Test-Releases, die vermutlich noch Verbesserungen erwarten, können Sie zum Beispiel mit 0.0.1+1 starten. Bei jedem neuen Test-Release müssen mindestens PATCH- und BUILD-Nummer hochgezählt werden. Wenn Sie 34 Test-Releases hatten, könnte die aktuelle Versionsnummer in der pubspec.yaml also so aussehen: 0.0.34+34. Sobald die Tests abgeschlossen sind und ein erstes Production Release ansteht, ändern Sie die Versionsnummer auf 1.0.0+35. Achten Sie darauf, dass die BUILD-Nummer nie schrumpfen kann. Sie muss jedes Mal erhöht werden. Je länger Sie an einer App arbeiten, desto mehr lohnt es sich, ein bisschen Zeit in eine CHANGELOG.md-Datei zu investieren. Diese Datei im Markdown-Format erstellen Sie am besten auf Ebene des lib-Ordners und befüllen sie mit den verschiedenen Versionen Ihrer App. Zu jeder Version schreiben Sie dann, was neu hinzukam. Da Sie diese Angaben sowieso in den Stores mitliefern sollten, ist es nur ein geringfügiger Mehraufwand, kann aber helfen, wenn Sie einem unerklärbaren Bug begegnen oder nicht mehr wissen, an was Sie zuletzt gearbeitet haben. Ein kurzes Beispiel: # Changelog ## 1.0.0+35 – 22.11.2022 ### ADDED Neues Feature XYZ ### CHANGED ### FIXED Es wurde Bug XYZ behoben
Listing 22.2: Changelog.md-Datei
Crashlytics und Analytics einbinden
Bevor Sie Ihre App auf die Welt loslassen, also an manuell Testende ausrollen oder veröffentlichen, bietet es sich an, ein Crashlogging-Tool wie Crashlytics und ein AnalyseTool wie Analytics zu installieren. Sowohl Crashlytics als auch Analytics sind in Firebase integriert, kostenfrei und stellen Ihnen Daten über die Benutzung Ihrer App in einem Dashboard in Firebase zur Verfügung.
Crashlytics Auf ein Crashlogging-Tool wie Crashlytics sollten Sie nicht verzichten. Wenn Sie Ihre App im Emulator starten und etwas nicht funktioniert, können Sie debuggen und den Fehler suchen. Wenn Sie Ihre App an Anwendende verteilt haben, können Sie das nicht mehr, Sie müssen sich darauf verlassen, dass Ihnen von den Anwendenden berichtet wird, dass Ihre App sich fehlerhaft verhalten hat oder gar nicht funktioniert. Erfahrungsgemäß passiert diese Form der proaktiven Berichterstattung leider nicht so oft. Und selbst wenn ein Fehlerbericht bei Ihnen eintrudelt, müssen meist noch etliche Informationen zu Betriebssystem, Gerät, Auflösung, und was eigentlich genau getan wurde, von Ihnen mühevoll abgefragt werden. Auf diese Art von Feedback-Zirkel lassen sich vielleicht noch professionell Testende ein, aber End-Usern ist das (verständlicherweise) oft zu aufwendig. Tools wie Crashlytics können Ihnen da aushelfen und diesen Prozess automatisieren. Sentry stellt eine datenschutzkonformere Variante zu Firebase Crashlytics dar und kann somit als Alternative in Betracht gezogen werden. Es lässt sich ebenso einfach in Ihre App integrieren, bietet ein sehr ausführliches Dashboard und verschiedene Preismodelle an. Mit dem kostenfreien Plan können kleinere bis mittelgroße Projekte problemlos abgedeckt werden. Es gibt noch zahlreiche weitere Tools für CrashReporting und Analyse, wir haben Ihnen hier Crashlytics und Analytics vorgestellt, weil beide kostenfrei sind und praktisch zu handhaben, wenn Sie sowieso schon ein Firebase-Projekt für Ihre App aufgesetzt haben. Um Crashlytics zu aktivieren, installieren Sie die Firebase Crashlytics SDK in Ihrer App, und führen Sie einen Crash herbei. Folgen Sie dafür folgenden Schritten: Öffnen Sie die Firebase Console, die Sie auch schon in Kapitel 17, »Firebase und der Cloud Firestore«, verwendet und eingerichtet haben. Dort finden Sie im linken Menü unter VERÖFFENTLICHEN UND BEOBACHTEN den Eintrag CRASHLYTICS. Klicken Sie darauf und befolgen Sie die Schritte, die beim Klick auf den Button SDK HINZUFÜGEN folgen (siehe Abbildung 22.1).
Abbildung 22.1: Crashlytics aktivieren
Wir listen Ihnen die Schritte hier nicht noch einmal auf, wir könnten es nicht besser beschreiben, nur veralteter. Nach dem Hinzufügen des SDKs können Sie einen TestButton in Ihre App integrieren, um einen Test-Crash auszulösen, den Sie dann kurze Zeit später im Firebase-Crashlytics-Dashboard finden können. Wenn das funktioniert hat, werden alle zukünftigen Crashes hier auftauchen. Das macht es Ihnen möglich, schnell zu reagieren, wenn in der Zukunft beispielsweise ein Update eines Betriebssystems Ihre App in die Knie zwingt. Vorsicht: Crashlytics und Analytics müssen beide in der Datenschutzerklärung erwähnt werden. Die Datenschutzerklärung muss im Web öffentlich zur Verfügung stehen und der Link muss bei Upload in den Apple oder den Google Play Store angegeben werden. Beide Dienste können und sollten von Ihnen so integriert werden, dass sie für die benutzende Person abwählbar sind. Prüfen Sie bitte selbstständig, wie zurzeit die Gesetzeslage in Deutschland ist und was bei der Integration der Dienste zu beachten ist, um diese zu erfüllen. Wir können Ihnen an dieser Stelle leider keine Rechtsberatung geben.
Firebase Analytics Firebase Analytics gibt Ihnen Informationen über Ihre Nutzenden und ist dafür gedacht, mit solchen Daten bei strategischem Marketing und der Weiterentwicklung des Produkts zu unterstützen. Aber auch für Sie als entwickelnde Person ist Analytics interessant – denn eine Integration von Analytics erweitert auch das Crashlytics-Dashboard. Ihnen
werden Daten zum Nutzerverhalten zugänglich gemacht, auch ohne dass ein Crash stattgefunden hat. In der Firebase Console finden Sie im linken Menü unter ANALYTICS den ersten Eintrag DASHBOARD. Klicken Sie darauf und anschließend auf den Button GOOGLE ANALYTICS AKTIVIEREN (siehe Abbildung 22.2).
Abbildung 22.2: Analytics aktivieren
Nachdem Sie den Schritten gefolgt sind und die SDKs für iOS und/oder für Android integriert haben, können Sie das Analytics-Dashboard sehen, wie in Abbildung 22.3. Hier können Sie nun Einblick in die Nutzung Ihrer App erhalten, in die Umsätze, die Geräte, mit denen Ihre Anwendenden die App nutzen und vieles mehr.
Abbildung 22.3: Das Analytics-Dashboard
Apps an Testpersonen verteilen Bevor Sie Ihre App veröffentlichen, sollten Sie die App auf richtigen Geräten manuell testen (lassen). Dazu verteilen Sie sie am besten an mehrere auserwählte Testende. Um eine Android-App zu testen, gibt es mehrere Möglichkeiten: Sie können die .apk-Datei zum Beispiel händisch verteilen oder die Verteilung einem Tool namens »Firebase App Distribution« überlassen. In beiden Fällen müssen Sie den App-Build noch nicht signieren. Wenn Sie Firebase App Distribution für Ihre manuellen Testpersonen nutzen, ist das Verteilen ganz einfach. Ob manuell oder per App Distribution, in beiden Fällen müssen Sie eine .apk-Datei erzeugen. Geben Sie dazu folgende Zeilen in Ihr Terminal ein: >> flutter clean >> flutter build apk --debug
Sie finden die erstellte .apk-Datei unter build/app/outputs/flutter-apk/appdebug.apk und können diese nun einfach an Ihre Testenden verteilen oder sie im nächsten Abschnitt einfach per Firebase App Distribution allen Testenden automatisch zur Verfügung stellen. Einige Fehler treten beim Builden besonders häufig auf. Wenn Ihr Android-Build nicht erfolgreich gebaut wird, kann das mit folgenden Punkten zusammenhängen:
In der pubspec.yaml passen verschieden Packages nicht zusammen. Sie müssen eventuell eines Ihrer installierten Packages in einer anderen Version laden. Um welche Packages es sich handelt, wird zum Glück in der Fehlermeldung mitgeteilt. Die minSDK in der build.gradle-Datei muss eventuell hochgesetzt werden. Diese Datei befindet sich im android/app-Ordner. Hier wird über die Zahl nach der minSdkVersion festgelegt, welche SDK-Version vorausgesetzt wird, um die App zu verwenden. Je höher die minSdkVersion, desto mehr ältere Smartphones werden ausgeschlossen. Manche Plug-ins und Packages verlangen eine gewisse minSDK und können so einen erfolgreichen Build verhindern. Auch diese Fehler sollten Ihnen in der Konsole angezeigt werden. Wie immer bei Fehlern gilt: Ruhe bewahren und googeln, googeln, googeln.
Firebase App Distribution In der Firebase Console finden Sie im linken Menü unter VERÖFFENTLICHEN UND BEOBACHTEN den Eintrag APP DISTRIBUTION. Klicken Sie auf JETZT STARTEN (vergleiche Abbildung 22.4).
Abbildung 22.4: App Distribution aktivieren
Sie können Ihre neu erstellte .apk-Datei nun einfach mit Drag-and-drop in Ihr AppDistribution-Dashboard auf Firebase ziehen (siehe Abbildung 22.5). Jetzt müssen Sie nur noch die E-Mail-Adressen Ihrer Testenden eintragen. Diese erhalten anschließend eine E-
Mail und können sich die Firebase-App-Distribution-App herunterladen. In der FirebaseApp-Distribution-App sieht die testende Person alle Versionen Ihrer App, die Sie ihr zugänglich gemacht haben. Sie kann die App-Versionen herunterladen und testen.
Abbildung 22.5: .apk-Datei hochladen
Der große Vorteil von Firebase App Distribution ist, dass es umsonst ist und Sie so viele Testende einladen können wie gewünscht. Außerdem wird Ihr Build sofort distribuiert, nicht erst nach Prüfung durch einen Store. Flutter-Entwickelnde nutzen gerne Firebase, weil sie oft für den Android- und iOS-Build einer App zugleich verantwortlich sind. Bei Firebase App Distribution können Apps für beide Betriebssysteme verteilt werden. Gegen Firebase App Distribution spricht, dass die Testenden der iOS-App ihre UDIDNummer (eine eindeutige Nummer zur Identifikation des Geräts) zur Verfügung stellen müssen, die nicht ganz einfach zu finden ist und den Prozess etwas umständlich macht. Die Veröffentlichung in den Stores kann außerdem einfacher sein und schneller ablaufen, wenn Sie den Testlauf schon vorher über die Stores gemacht haben und bereits Feedback vom Store-Team zu Ihrer App bekommen und behandelt haben. Wie Sie Ihre App über den Google Play Store distribuieren können, erfahren Sie im nächsten Abschnitt. Mit Tools wie Fastlane, Codemagic oder AppCircle können Sie Continuous Integration und Continuous Delivery (CI/CD) für Ihre App einrichten. Sie können zum Beispiel einrichten, dass mit jedem Push auf einen bestimmte Git-Branch
automatisch Tests durchlaufen werden, ein Android-Build erstellt, auf Firebase App Distribution hochgeladen und an bestimmte Testende distribuiert wird. Besonders für größere Projekte mit vielen involvierten Entwickelnden und regelmäßigen Releases ist so ein Set-up sehr empfehlenswert. Geheimtipp: CI/CD lohnt sich auch, wenn Sie nicht auf einem Mac programmieren, denn iOS-Builds können über CD-Tools automatisiert erstellt und sogar automatisch in die Stores hochgeladen werden. Eine Kollegin mit Apple-Gerät kann die CD-Pipeline initial für Sie einrichten, und Sie können dann einen iOS-Build triggern, ohne selbst über Apple-Hardware mit XcodeInstallation zu verfügen.
Eine .apk- oder lieber eine .aab-Datei? Ein Android-Build kann eine .apk-Datei oder eine .aab-Datei sein. Beide Dateiformate können mit Ihrer Entwicklungsumgebung erstellt werden. Eine .apk-Datei ist das Verpackungsformat einer Android-App, die Datei, die schließlich auf dem Endgerät installiert wird. Wenn Sie Ihre App auf einem Android-Emulator laufen lassen, wird dafür von Ihrer Entwicklungsumgebung eine .apk-Datei im Debug-Modus erstellt. Vor einigen Jahren wurde dieses Dateiformat im Release-Modus direkt in den App Store hochgeladen, wenn eine Android-App veröffentlicht werden sollte. Seit 2018 gibt es das App-Bundle-Format, die .aab-Datei – ein Veröffentlichungsformat. Seit 2021 ist dieses Dateiformat das Einzige, das Sie in den Google Play Store hochladen können. Der Google Play Store benutzt App-Bundles, um daraus maßgeschneiderte .apkDateien zu generieren, angepasst an die Konfiguration des Endgeräts (Abbildung 22.6). Somit werden nur der Code und die Ressourcen heruntergeladen, die auch benötigt werden, was in kleineren und optimierten Downloads resultiert (durchschnittlich 35 % kleiner).
Abbildung 22.6: .aab- und .apk-Dateien
Während .apk-Dateien direkt von dem bzw. der Entwickelnden mit einem Developer Key signiert werden, liegen bei den .aab-Dateien die Schlüssel bei Google. Das erhöht die Sicherheit, denn bei Verlust können Sie über den Google Play Store erneut Zugriff anfordern. Wenn Sie einen Android-Build zu Testzwecken erstellen, können Sie eine .apk-Datei im Debug-Modus erstellen – aber wenn Sie nach erfolgreichem Testen anschließend einen Release Build in den Play Store hochladen möchten, erstellen Sie eine .aab-Datei mit einem Upload Key. Mehr dazu im nächsten Abschnitt.
Verteilung über den Google Play Store Wenn Sie einen Android-Build über den Play Store distribuieren möchten, ganz gleich ob als Test-Release oder finales Production Release, müssen die erstellten App-Bundles signiert werden, bevor Sie diese hochladen können.
App-Bundles signieren Um ein App-Bundle für den Play Store zu signieren, benötigen Sie eine Upload-KeystoreDatei. Erstellen Sie diese mithilfe des Terminals. Dieser Schritt muss nur einmal ausgeführt werden, die Datei wird danach für jeden weiteren Build zum Signieren verwendet. Mac/Linux: >> keytool -genkey -v -keystore ˜/upload-keystore.jks -keyalg RSA -keysize
2048 -validity 10000 -alias upload
Windows: >> keytool -genkey -v -keystore %userprofile%\upload-keystore.jks storetype JKS -keyalg RSA -keysize 2048 -validity 10000 -alias upload
Anschließend referenzieren Sie die erstellte upload-keystore.jks-Datei in Ihrer App. Dafür erstellen Sie eine neue Datei key.properties im android-Ordner Ihres Projekts mit folgendem Inhalt; der Pfad sollte dem Pfad zu Ihrer upload-keystore.jks-Datei entsprechen: storePassword = IhrKeystorePasswort keyPassword = IhrKeyPasswort keyAlias = upload storeFile= /Users//upload-keystore.jks
Listing 22.3: key.properties-Datei Als Nächstes öffnen Sie die build.gradle-Datei im android/app-Ordner und fügen die folgenden Zeilen hinzu, um die Daten aus der angelegten key.properties-Datei zu beziehen. def keystoreProperties = new Properties() def keystorePropertiesFile = rootProject.file('key.properties') if (keystorePropertiesFile.exists()) { keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) } android { … }
Listing 22.4: Angepasste build.gradle-Datei In derselben Datei müssen Sie zusätzlich noch den buildTypes-Block ersetzen. Der aktuelle Zustand sollte der Folgende sein: buildTypes { release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, // so `flutter run --release` works. signingConfig signingConfigs.debug } }
Listing 22.5: Alter buildTypes-Block Sie sollten ihn wie folgt anpassen: signingConfigs { release {
keyAlias keystoreProperties["keyAlias"] keyPassword keystoreProperties["keyPassword"] storeFile keystoreProperties["storeFile"] ? file(keystoreProperties["storeFile"]) : null storePassword keystoreProperties["storePassword"] } } buildTypes { release { signingConfig signingConfigs.release } }
Listing 22.6: Neuer buildTypes-Block Jedes Mal, wenn Sie nun ein neues App-Bundle Ihrer App erstellen möchten, die zum Hochladen in den Google Play Store bestimmt ist, geben Sie den Befehl flutter build appbundle in den Terminal ein. Ihre erstellte .appbundle-Datei wird nun automatisch signiert und ist damit bereit, über den Google Play Store hochgeladen zu werden.
Eine neue App im Google Play Store anlegen Im Google Play Store können Sie Ihre frisch generierten und signierten App-Bundles sowohl als Test-Release als auch als Production Release zur Verfügung stellen. Dafür müssen Sie unter https://play.google.com/console einen Google Play Account erstellen, der einmalig 25 Dollar kostet. Nachdem Sie einige Fragen beantwortet haben, haben Sie Zugriff auf Ihre Google Play Console. Wenn Sie unter ALL APPS den Button CREATE APP klicken, können Sie eine neue App anlegen wie in Abbildung 22.7. Sobald Sie die App angelegt haben, sehen Sie auf der linken Seite ein anderes Menü – Sie sind jetzt im Menü Ihrer neu erstellten App (siehe Abbildung 22.7). Auf dem Dashboard in Abbildung 22.8 sehen Sie alle offenen To-dos, die Sie abarbeiten müssen, um Ihre App final veröffentlichen zu können. Darunter zählen zum Beispiel das zur Verfügungstellen einer Datenschutzerklärung, die Vorbereitung des Google-Play-Store-Eintrags mit Texten und Screenshots sowie die Beantwortung einiger Fragen nach dem Inhalt der App, um gegebenenfalls Altersempfehlungen anzupassen.
Abbildung 22.7: Die Google Play Console
Abbildung 22.8: Eine neue App in der Google Play Console
Eine Testversion per Google Play Store erstellen und verteilen Bei Google Play haben Sie mehrere Möglichkeiten, Ihre App Testenden zur Verfügung zu stellen. Im linken Menü (in Abbildung 22.7 zu sehen) sollte Ihnen ein ganzes Menü mit
Testing-Möglichkeiten angezeigt werden. Open Testing: Hier können Sie Beta-Versionen Ihrer App allen zugänglich machen, die sich dafür interessieren. Im Google Play Store wird ein Button angezeigt, bei dem sich Interessierte für die Beta-Versionen anmelden können und dann jedes BetaUpdate automatisch zur Verfügung gestellt bekommen. Closed Testing: In diesem Abschnitt können Sie eigene Test-Tracks definieren, aktivieren oder deaktivieren. Internal Testing: Die Anzahl der möglichen Testenden im Internal Testing ist auf 100 beschränkt. Jede Testperson muss extra eingeladen werden, um Zugriff auf die von Ihnen zur Verfügung gestellten Testversionen zu bekommen. Pre-Registration: Dieser Menüpunkt gibt Ihnen die Möglichkeit, die App bereits vor der Veröffentlichung zu bewerben. Der Play-Store-Eintrag der App wird vor dem Release bereits freigeschaltet und Interessierte können sich benachrichtigen und die App automatisch herunterladen lassen, sobald sie öffentlich verfügbar ist. Sowohl Open, Closed als auch Internal Testing funktionieren bei der Erstellung einer neuen Version gleich. Wenn Sie links unter TESTING beispielsweise INTERNAL TESTING wählen wie in Abbildung 22.9, können Sie hier Ihr signiertes App-Bundle per Drag-and-drop hochladen und intern an einige Vertraute ausrollen, die Sie per E-MailAdresse einladen können.
Abbildung 22.9: Einen internen Test-Release erstellen
Gegebenenfalls kann es etwas dauern, bis der Build nach dem Upload den Testenden zur Verfügung gestellt wird – das ist der Nachteil gegenüber der Firebase App Distribution.
Ein Production Release per Google Play Store erstellen und verteilen Um Ihre App für den Production Release vorzubereiten, werden Sie – falls Sie sich noch nicht mit den Aufgaben auf dem Dashboard beschäftigt haben (Prokrastination hallo!) – jetzt einige Fragen zu Ihrer App beantworten müssen, einige Texte, Screenshots Ihrer App und Grafiken bereitstellen sowie einen Link zu einer Webseite, die Ihre Datenschutzerklärung enthält. Alle offenen To-dos finden Sie in Ihrem Dashboard, das Sie per Klick im linken Menü öffnen können. Die Punkte sind im Normalfall zügig abgearbeitet und müssen auch nur beim ersten Release angegeben werden. Sobald sich etwas ändert, zum Beispiel der Link Ihrer Datenschutzerklärung, sind Sie verpflichtet, die Angaben entsprechend anzupassen.
Bestehendes Test-Release befördern Wenn Sie ein Test-Release haben, das Sie gerne als Production Release veröffentlichen möchten, können Sie einfach das Test-Release per Klick in den Production-Track schieben, wie in Abbildung 22.10 zu erkennen ist.
Abbildung 22.10: Testversion zur Production-Version hochstufen
Neues Production Release erstellen Wenn Sie generell ein neues Release erstellen und direkt als Production Release veröffentlichen möchten, können Sie das selbstverständlich auch tun, indem Sie ein neues App-Bundle erstellen und dieses wie gehabt per Drag-and-drop in einen frisch angelegten Production Release ziehen. Nachdem Sie das neue Production Release veröffentlicht haben, kann es noch ein bis zwei Tage dauern, bis das Google-Play-Store-Team Ihre App durchwinkt – oder Ihnen eine E-
Mail mit nötigen Änderungen schickt. Nehmen Sie sich etwas Zeit, vor allem, wenn es Ihre erste App-Veröffentlichung ist. Wenn Sie eine App für Auftraggebende erstellen, planen Sie circa ein bis zwei Wochen für die Veröffentlichung in beiden Stores ein. Die Texte, Screenshots und Grafiken sollten gut vorbereitet werden. Prüfen Sie früh genug die Zeichenanzahl der benötigten Texte, die Größe der Screenshots und die Anforderungen an die Datenschutzerklärungen und leiten Sie die Bedingungen gegebenenfalls an die verantwortlichen Teammitglieder weiter, um den Prozess zu beschleunigen. Ihr Android-Build ist nun fertiggestellt – auf in die verführerische Apple-Welt! Klappen Sie Ihr MacBook auf und schnallen Sie sich an.
Kapitel 23
Der iOS-Build IN DIESEM KAPITEL Erfahren Sie, wie Sie einen Apple-Developer-Account anlegen und einen iOS-Build erstellen Lernen Sie, was ein Distribution-Zertifikat ist Bekommen Sie einige Tipps, was Sie prüfen können, wenn Fehler auftreten
Willkommen in der Apple-Welt! Wir haben schon jeher die Flutter-Entwickelnden beneidet, die in einem früheren Leben iOS-App-Entwickelnde waren. Sie bringen so viele Skills mit, wenn es um Fehlerbehebung beim Builden und Veröffentlichen von Apps geht. Falls Sie dazugehören: Herzlichen Glückwunsch! Falls nicht: Buckle up! Nach diesem Kapitel haben Sie hoffentlich einen guten Überblick, wie Sie erfolgreich eine Flutter-iOSApp in den Store bringen. Und wenn es hakt, müssen leider Google und Stack Overflow wieder unterstützen, denn Probleme können sehr individuell auftauchen.
Voraussetzungen Um einen iOS-Build zu generieren – egal, ob Sie die App auf einem Simulator debuggen möchten oder einen Release Build erstellen möchten –, brauchen Sie die Entwicklungsumgebung Xcode. Da Xcode ausschließlich auf dem macOS-Betriebssystem läuft, brauchen Sie einen iMac oder ein MacBook dafür.
Vorbereitung eines iOS-Builds In Kapitel 22, »Der Android-Build«, sind wir schon auf generelle Vorbereitungen für einen Build eingegangen. Sie haben gelernt, wie Sie ein Launcher-Icon anlegen, wie Sie versionieren, und wir haben Ihnen Crashlytics und Analytics vorgestellt, die Sie für eine professionelle Fehlermeldung in Ihre App einbauen können. Um eine iOS-App zu distribuieren, brauchen Sie außerdem einen Apple-Developer-Account.
Apple-Developer-Account Um einen Apple-Developer-Account anzulegen, brauchen Sie eine Apple ID. Falls Sie schon ein Apple-Gerät nutzen, werden Sie schon eine Apple ID haben – ansonsten können Sie eine erstellen unter https://appleid.apple.com.
Melden Sie sich unter https://developer.apple.com mit Ihrer Apple ID an. Sie können wählen, ob Sie einen privaten oder einen Firmenaccount anlegen wollen. Bei AccountEröffnung fallen 99 Dollar Gebühr an, jährlich. Sobald Sie einen Apple-Developer-Account haben, können Sie im App Store Connect Ihre Test-Apps und Ihre App-Veröffentlichungen managen (siehe Abbildung 23.1). Sie erreichen den App Store Connect sowie den Verwaltungsbereich der Zertifikate, Profile und Geräteregistrierung über Ihre Account-Übersicht unter https://developer.apple.com/account.
Abbildung 23.1: Der Apple-Developer-Account in der Übersicht
Registrieren Sie Ihre App Bevor Sie Ihren ersten iOS-Build mit Xcode erstellen, müssen Sie noch einige Anpassungen in Ihrem Apple-Developer-Account vornehmen.
Bundle ID Gehen Sie in den Bereich CERTIFICATES, IDENTIFIERS & PROFILES in das Tab IDENTIFIERS. Hier können Sie eine App ID für Ihre neue App erstellen und eine »explicit Bundle ID« registrieren. Die Bundle ID ist Ihr Package-Name – in unserem Fall
de.losfluttern.pummelTheFish. Wenn Ihre App Zugriff auf bestimmte Features
benötigt, können Sie hier unter dem Reiter CAPABILITIES zum Beispiel Push Notifications für Ihre App erlauben. Diese Features können auch nachträglich hinzugefügt werden.
App im App Store Connect anlegen Navigieren Sie zum App Store Connect und fügen Sie eine neue App hinzu. Geben Sie die benötigten Informationen ein und verknüpfen Sie die eben erstellte Bundle ID.
Erstellen Sie einen Build mit Xcode Jetzt ist es so weit, los gehts! Öffnen Sie den iOS-Ordner Ihres Flutter-Projekts mit Xcode bzw. navigieren Sie in den iOS-Ordner und führen Sie einen Doppelklick auf die Runner.xcworkspace-Datei aus. Jetzt sollte sich Xcode öffnen und Sie können mit Ihrem ersten iOS-Build starten.
Xcode-Einstellungen prüfen Als Erstes überprüfen Sie die allgemeinen Einstellungen: Im GENERAL-Tab (siehe Abbildung 23.2) sollte die Bundle ID mit der im App Store Connect registrierten übereinstimmen. Hier können Sie mögliche Ausrichtungen des Endgerätes auswählen, die die App unterstützen soll. Unter SIGNING & CAPABILITIES ist AUTOMATICALLY MANAGE SIGNING aktiviert und im Dropdown wählen Sie Ihr Team aus. Unter BUILD SETTINGS in der DEPLOYMENT-Sektion können Sie das iOS Deployment Target setzen. Flutter unterstützt aktuell 11.0 und höher. Das kann sich allerdings jederzeit ändern. Außerdem können manche Plug-ins oder Packages verlangen, dass das Deployment Target höher gesetzt wird. Meistens begegnen Ihnen solche Fehler bereits beim Builden der App in der Konsole.
Wenn Sie Ihre App auch als Tablet-Version veröffentlichen möchten, seien Sie sich darüber bewusst, dass Sie diese Einstellung nicht mehr rückgängig machen können. Einmal Tablet-Version, immer Tablet-Version. Nicht, dass schon Leute darüber gestolpert wären, da es vielleicht nirgendwo erwähnt wurde.
Abbildung 23.2: Xcode-Einstellungen
Ein erster Build Wählen Sie im Xcode-Menü PRODUCT | DESTINATION | ANY IOS DEVICE (siehe Abbildung 23.3).
Abbildung 23.3: Xcode Build
Wählen Sie im selben Menü PRODUCT | BUILD, um einen Build zu erstellen. Wenn es tatsächlich sofort funktioniert – Hut ab! Wahrscheinlicher ist, dass Sie sich jetzt auf eine längere Fehlersuche begeben müssen. Wir wünschen Ihnen viel Erfolg und eine hohe Frustrationstoleranz dafür. Sie können auch über VSCode einen iOS-Build erstellen, übrigens, mit dem Befehl flutter build ios – aber unserer Erfahrung nach erwischen Sie damit oft nicht alle Fehler oder bekommen keine detaillierte Fehlerbeschreibung. Darum würden wir Ihnen empfehlen, das lieber mit Xcode zu versuchen. Ihr Build funktioniert also, Ihre Einstellungen sind gemacht: Dann können Sie nun Ihre App archivieren mit PRODUCT | ARCHIVE. Das kann einige Minuten dauern. Wenn alles glatt läuft, haben Sie nun ein Archiv erzeugt und die Möglichkeit, zwischen VALIDATE und DISTRIBUTE zu entscheiden. Wenn Sie checken wollen, ob Ihre App schon bereit ist, um sie in den App Store Connect zu laden, ohne sie tatsächlich schon einzureichen, wählen Sie Ersteres. Wenn Sie Ihre App an Testpersonen ausrollen oder sie im Store veröffentlichen wollen, wählen Sie DISTRIBUTE.
Apps an Testpersonen verteilen
Sie können die App über Firebase App Distribution oder über Testflight an Ihre Testenden ausrollen.
Firebase App Distribution Wenn Sie Ihre App über Firebase Testenden zur Verfügung stellen möchten, wählen Sie AD HOC als Distributionsmethode (siehe Abbildung 23.4).
Abbildung 23.4: Xcode Build
Ihre .ipa-Datei wird nun erstellt. Wenn schon ein Firebase-Projekt besteht, kann die .ipa-Datei nach der Erstellung einfach mit Drag-and-drop in das FIREBASE-APPDISTRIBUTION-Dashboard bewegt werden. Sie können Testende per E-Mail einladen und auch Gruppen von Testenden definieren. Wenn Sie einen neuen Testenden hinzufügen wollen, muss dieser die UDID seines Gerätes in seinen Einstellungen ermitteln, und Sie müssen dann bei CERTIFICATES, IDENTIFIERS & PROFILES ein neues Gerät registrieren und Ihr Provisioning-Profil um dieses Gerät erweitern. Für ein Testen mittels Firebase App Distribution spricht, dass der Build sofort an die Testenden distribuiert wird. Nachteil ist jedoch, dass für jede Testperson die UDID-Nummer des Gerätes bekannt sein muss, das macht alles etwas umständlicher. Außerdem können Testende nicht nachträglich zu einem Build hinzugefügt werden, stattdessen muss ein neuer Build hochgeladen
werden.
Testflight Wenn Sie Ihre App über Testflight an Testende ausrollen möchten, wählen Sie APP STORE CONNECT als Distributionsmethode. Folgen Sie den vorgegebenen Schritten, bis die App auf App Store Connect hochgeladen wird. Sie sollte unter dem TESTFLIGHTReiter erscheinen (siehe Abbildung 23.5).
Abbildung 23.5: Testflight-Upload
Der Vorteil für Sie, die App über Testflight zu Ihren Testenden zu distribuieren, liegt im einfachen Hinzufügen der Testpersonen. Sie brauchen nur eine E-Mail anzugeben, keine UDID ist nötig wie bei Firebase. Sie können interne Testpersonen einladen, denen Sie auch Zugang zu anderen Apps auf Ihrem Developer Account geben können. Wenn Sie externe Testende einladen möchten, kommt Ihre App in die Beta-App-Review. Die Review kann ein bis zwei Tage dauern und es kann sein, dass Ihre App abgelehnt wird und Sie die App anpassen müssen. Dieses Feedback wird aber später das Hochladen und Veröffentlichen Ihres Release-Builds beschleunigen.
Wenn Ihr iOS-Build nicht erfolgreich gebaut wird, kann das mit folgenden Punkten zusammenhängen: In der pubspec.yaml passen verschieden Plug-ins nicht zusammen. Fehler in der Info.plist Datei. Immer auch die Fehlermeldung in Xcode checken, ist manchmal besser als die von Flutter oder gibt weitere Anhaltspunkte. Eventuell die Podfiles mit dem Terminal löschen oder/und updaten. Das Launcher Icon hat einen Alpha-Channel. Der iOS-Build kann häufig ein ganzes Stück zickiger sein als der Android-Build Ihrer Flutter-App. Wie immer gilt: Ruhe bewahren! Wenn Sie iOS-Entwickelnde kennen, setzen Sie sich vielleicht für den ersten Build in deren Nähe.
Fertig! Release Build erstellen und veröffentlichen Der Release ist bei Apple vergleichsweise einfach, wenn Sie Ihre App vorher schon über Testflight an Testpersonen distribuiert haben. Wenn Sie eine App mit Xcode archivieren und über den App Store Connect distribuieren, wird die App automatisch zu Testflight hinzugefügt. Um die App jetzt zu veröffentlichen, navigieren Sie zum Reiter APP STORE und wählen unter IOS APP 1.0 im Build-Bereich den gewünschten Build von Testflight aus (siehe Abbildung 23.6). Jetzt tragen Sie alle gewünschten Informationen ein und laden benötigte Fotos hoch: Texte, Preise, Screenshots der App. Dies können Sie übrigens auch schon vor dem Upload des Builds tun. Wenn alles bereit ist, klicken Sie auf ZUR PRÜFUNG HINZUFÜGEN, und anschließend werden Sie per E-Mail über den Fortschritt der App benachrichtigt. Mit Flutter können Sie auch Builds für Web und für Desktop erstellen. Mit dem Terminalbefehl flutter build web erstellen Sie einen Web-Build, der im Projektordner unter build/web zu finden ist. Sie können das Projekt nun zum Beispiel mit Firebase-Hosting online stellen und öffentlich zugänglich machen. Oder natürlich über Ihren eigenen Server oder Hosting-Provider – wie jede herkömmliche Webseite. Um Ihre App für Desktop zu bauen, verwenden Sie folgenden Terminalbefehl: flutter build macos oder entsprechend flutter build linux oder flutter build windows. Die genauen Schritte über das Signieren bis zum
Upload in die Stores können Sie nachschlagen unter https://docs.flutter.dev.
Abbildung 23.6: App-Store-Veröffentlichung
Recap: Testen, builden und veröffentlichen In diesem Teil haben Sie gelernt, wie Sie verschiedene Tests in ihre App integrieren und so Ihren Code automatisiert testen können, um eine gute Quality-Assurance zu bewerkstelligen. Sie haben Crashlytics und Analytics kennengelernt und eine Androidund iOS-Build gebaut. Außerdem haben Sie gelernt, was es alles für einen Release in den beiden Stores bedarf.
Bye bye Puh, das war ein Ritt! Wir haben nicht geglaubt, dass wir es tatsächlich schaffen, alle wichtigen Punkte zu Flutter und auch allgemein zur App-Entwicklung in ein einziges Buch zu bekommen. Wir hoffen jedoch, dass es geklappt hat und Sie von hier aus mindestens wissen, wie Sie selbstständig weiterkommen können! Natürlich kann man bei jedem Thema noch beliebig in die Tiefe gehen. Aber was wir Ihnen mitgeben wollten, war
ein genereller Überblick, damit Sie einen Kompass für Ihren Flutter-Lernpfad haben. Wir hoffen, es hat Ihnen Spaß gemacht, und wir wünschen Ihnen viel Erfolg auf Ihrem Weg mit Flutter! Zum Schluss geben wir Ihnen im Top-Ten-Teil noch ein paar Tipps und Tricks mit und verraten Ihnen unsere Lieblings-Widgets.
Teil VII
Top-Ten-Teil
Weitere … für Dummies-Bücher finden Sie unter www.fuer-
dummies.de
IN DIESEM TEIL … Sie haben in den vorangegangenen Teilen schon viele schöne Flutter-Widgets kennengelernt. Aber das ist erst die Spitze des Widget-Eisberges! Um Ihnen Lust auf mehr zu machen, verraten wir Ihnen unsere Lieblings-Widgets. Außerdem geben wir Ihnen noch ein paar praktische Tipps und Tricks an die Hand.
Kapitel 24
Unsere 10 Lieblings-Widgets IN DIESEM KAPITEL Lernen Sie Widgets kennen, von denen Sie nicht erwartet haben, dass es sie gibt
So viele Widgets, und es kommen immer mehr hinzu! Im Folgenden wollen wir mit Ihnen unsere Favoriten teilen. Flutter veröffentlicht wöchentlich auf YouTube das »Widget of the Week«. Das sind kurze, schnell verständliche Video-Zusammenfassungen von den Funktionen und der Verwendungsweise eines wechselnden Widgets – und super, um Ihre Flutter-Skills auszubauen!
Widget 1: Chip Chips sind kleine kompakte Elemente mit Text und/oder Icon, die in dem UI gern verwendet werden, um verschiedene Funktionen wie das Anzeigen, Filtern oder Auswählen von Inhalten zu ermöglichen. Neben dem Standard-Chip-Widget gibt es auch spezielle, für bestimmte Anwendungsfälle vorgesehene Widgets wie das ActionChip-, ChoiceChip- und FilterChip-Widget.
Widget 2: Wrap Mit dem Wrap-Widget können Sie Ihre Widgets, die in einer Row oder Column angesiedelt sind, über mehrere Zeilen und Spalten verteilen, wenn der Platz ausgeht. Hervorragend dafür geeignet sind zum Beispiel die gerade erwähnten Chip-Widgets, deren Anzahl und Größe oft variiert.
Widget 3: CupertinoDatePicker und showDatePicker iOS und Android haben jeweils eigene schöne Datepicker. Auch wenn Sie im Rest der App aus Einfachheit dasselbe Design für beide Plattformen benutzen sollten – hier lohnt
es sich auf jeden Fall, die nativen Varianten einzubauen und diese nur farblich mithilfe des Themes anzupassen. Sowohl das CupertinoDatePicker-Widget als auch der showDatePicker-Flow sind elegant zu benutzen, einfach einzubauen und für die nutzende Person ein bekanntes UI-Element.
Widget 4: PageView Mithilfe des PageView-Widgets lassen sich ganze Screens swipebar machen. Damit können Sie zum Beispiel ein mehrseitiges Intro oder Tutorial für Ihre App zaubern, das die nutzende Person durchswipen kann, um eine Einführung in die App zu erhalten. Die einzelnen Screens innerhalb eines PageView-Widgets können Sie komplett frei nach Ihren Wünschen gestalten.
Widget 5: Table Wenn Sie in Flutter eine Tabelle bauen möchten, können Sie dafür entweder Rows und Columns kompliziert ineinander verschachteln oder einfach auf das Table-Widget zurückgreifen.
Widget 6: Hero In diesem Buch konnten wir leider nicht auf Animationen in Flutter eingehen. Das Gute daran? Das müssen wir gar nicht, denn es gibt einige Widgets, die Ihnen unkompliziert hübsche Animationen ermöglichen. So zum Beispiel das Hero-Widget, das eine einfache und schicke Transitionsanimation von einem Screen zu einem anderen erlaubt. Stellen Sie sich eine Liste mit mehreren Bildelementen vor. Beim Klick auf ein Element öffnet sich ein neuer Screen mit dem gewählten Bild als Header und weiteren Detail-Informationen. Bei der Verwendung des Hero-Widgets wird das im Fokus stehende UI-Element – also das Bild – elegant vom Listen-Screen in den Detail-Screen überwechseln.
Widget 7: AnimatedContainer Ebenfalls aus der Reihe »Einfache Animationen in Flutter«: das AnimatedContainerWidget. Es gibt Ihnen die Möglichkeit, die Attribute eines Containers zu ändern und durch eine anpassbare Verlaufsanimation sichtbar zu machen. Wenn Ihr Container zum Beispiel seine Größe von 10 Pixel auf 30 Pixel verändern soll, wird er mithilfe des AnimatedContainer-Widgets animiert in gewünschter Schnelligkeit wachsen. Neben der Größe eines Containers kann zum Beispiel auch die Farbe geändert werden. Auch das würde nicht ruckartig, sondern schön geschmeidig in gewünschter Geschwindigkeit stattfinden. So können Sie schnell etwas erschaffen, das sehr besonders aussieht.
Widget 8: Semantics Mit einem Semantics-Widget, das Sie um nahezu jedes beliebige Widget wrappen können, können Sie die Accessibility Ihrer App erweitern. Hierzu gibt es verschiedene Möglichkeiten, zum Beispiel ein Label für Screen-Reader zu setzen, das dann für blinde Menschen vorgelesen wird. Sie können auch definieren, ob es sich zum Beispiel um einen Text oder einen Button handelt. Somit ist es eine Erweiterung, die Sie kaum Zeit kostet, aber vielen eingeschränkten Menschen die Nutzung Ihrer App ermöglicht.
Widget 9: SliverAppBar Die SliverAppBar ist eine tolle Alternative zur herkömmlichen Top-Navigationsbar, denn sie ermöglicht Ihnen ein schickes Custom-Design. Sie kann beispielsweise aus einer großen Grafik bestehen, die anfangs die Hälfte des App-Screens einnimmt, bis gescrollt wird und sie elegant zu einer praktischeren Größe zusammenschrumpft.
Widget 10: CustomPaint Sie haben tatsächlich ein Design, das Sie mit keinem der zur Verfügung gestellten Widgets abbilden können? Kein Problem, nutzen Sie einfach das CustomPaint-Widget! Damit haben Sie die Möglichkeit, auf den Screen zu malen und somit ein absolutes Custom-Widget zu gestalten. Sie werden diese Liste bestimmt bald mit Ihren eigenen Lieblings-Widgets ergänzen können. Unsere Empfehlung: Probieren Sie immer mal wieder etwas Neues aus und lassen Sie sich von den »Widget of the Week«-Beiträgen inspirieren, um Ihr Portfolio an Widgets, die Sie komfortabel aus dem Ärmel schütteln können, zu erweitern.
Kapitel 25
Unsere 10 Flutter-Tipps und -Tricks IN DIESEM KAPITEL Tipps und Tricks, um mit Fehlern umzugehen Tipps und Tricks, um sauberen Flutter-Code zu schreiben, mit dem Sie auch anderen gegenüber glänzen können Tipps und Tricks, wie Sie mit Dependencies umgehen können
Zu guter Letzt wollen wir Ihnen noch ein paar praktische Empfehlungen mit auf den Weg geben.
Tipp 1: Wenn Sie einen komischen Fehler haben, den Sie nicht lösen können Bevor Sie verrückt werden, probieren Sie einmal, Ihr Projekt mit den berühmtberüchtigten Kommandos flutter clean und einem anschließenden flutter pub get zu säubern. Dadurch wird der Build-Cache Ihrer Flutter-App einmal gelöscht und neu erstellt. Oft hilft es auch, die Entwicklungsumgebung zusätzlich zu schließen und erneut zu öffnen oder – und jetzt kommt der Geheimtipp schlechthin: den Computer neu zu starten. Wenn Sie wüssten, wie viele Probleme das schon gelöst hat. Auch nach jahrelanger Entwicklungserfahrung ist das immer noch ein Top-Tipp.
Tipp 2: Wenn ein iOS-Build nicht hinhaut Leider verschluckt sich der iOS-Build ab und zu und Ihnen werden diverse Fehlermeldungen entgegengeflogen kommen. Wenn auch ein flutter clean nicht mehr hilft, können Sie versuchen, die Podfile.lock-Datei zu löschen und diese erneut zu generieren, indem Sie die folgenden Befehle nacheinander in das Terminal eingeben. >> cd ios >> pod deintegrate >> cd .. >> flutter clean >> flutter build ios
Listing 25.1: Einmal Frühjahrsputz, bitte! Vor allem nach dem Updaten von Abhängigkeiten hilft auch oft der Befehl pod install --repo-update, der ebenfalls innerhalb des ios-Ordners ausgeführt werden muss.
Tipp 3: Konsistente Benennung von Dateien und Klassen Das, was in einer Datei drinsteht, sollte immer dem Namen der Datei entsprechen. Eine Datei sollte – bis auf ein paar Ausnahmen – auch nicht mehrere Klassen enthalten. Dateinamen werden klein geschrieben und anstatt Leerzeichen werden verschiedene Worte durch Unterstrich getrennt. Klassennamen werden in CamelCase geschrieben, sie beginnen mit einem Großbuchstaben und jedes neue Wort ebenfalls. KameleSindToll. Die splash_screen.dart-Datei enthält also die Klasse SplashScreen.
Tipp 4: Arbeiten Sie mit einem Linter Von Haus aus ist das flutter_lints-Package in jedem Flutter-Projekt bereits integriert. Dieses bietet eine Grundsammlung an verschiedenen Regeln, die dafür sorgen sollen, sauberen Code zu schreiben. Die Betonung liegt hier auf »Grundsammlung«. Wenn Sie etwas mehr Wert auf sauberen Code legen, empfehlen wir Ihnen die Verwendung eines Packages, das mehr Regeln zur Verfügung stellt, wie zum Beispiel das lint-Package (https://pub.dev/packages/lint) oder das very_good_analysis-Package (https://pub.dev/packages/very_good_analysis). Auch hier können einzelne Regeln aktiviert und deaktiviert werden.
Tipp 5: Formatieren Sie Ihren Code mit einem Formatter Es gibt wenig, was mehr abschreckt als unformatierter Code. Flutter hat seinen ganz eigenen Mechanismus entwickelt, den Sie mit Sicherheit in diesem Buch rauslesen konnten. Immer wenn ein Komma auf eine Klammer folgt, wird die Zeile umgebrochen und somit eine schöne Einrückung gebaut, bei der Sie genau wissen, welcher Teil zu welchem Widget gehört und wo er endet. Haben Sie mehrere Klammern ohne Komma in einer Zeile, wirft das diese Einrückung komplett über den Haufen, und Ihr Code ist deutlich weniger lesbar. Am einfachsten ist es, hierfür das Auto-Formatting beim Speichern zu aktivieren, falls das bei Ihnen nicht schon automatisch eingestellt ist, indem Sie die folgenden Zeilen in Ihre
settings.json in VSCode eintragen. "[dart]": { "editor.formatOnSave": true, "editor.formatOnType": true, … }
Listing 25.2: Automatisiertes Code-Formatting beim Tippen und Speichern Die Lint Packages aus Tipp 4 inkludieren außerdem eine Regel, die besagt, dass hinter einer Klammer ein Komma stehen muss. Er wird Sie also auf fehlende Kommas und somit fehlende Formatierung hinweisen.
Tipp 6: Verwenden Sie den automatischen Fix-Command von Dart Diverse Probleme, die in Ihrem PROBLEMS-Tab in VSCode auftauchen, können mit einem Autofix-Command von Dart gelöst werden. Speziell wenn Sie Tipp 4 und 5 angewendet haben, lohnt es sich, den Befehl dart fix all –apply einmal laufen zu lassen, und Sie werden sehen, dass viele Probleme automatisch behoben werden können.
Tipp 7: Updaten Sie Flutter und Ihre Dependencies regelmäßig Das gesamte Ökosystem rund um Flutter entwickelt sich rasant schnell. Das ist super auf der einen Seite und oft auch etwas anstrengend auf der anderen Seite. Zwar gibt es ständig Verbesserungen und neue Features – oder gar Plattformen, die unterstützt werden –, das bedeutet aber auch oft Breaking Changes und die Notwendigkeit, Ihren Code zu updaten. Schauen Sie daher am besten, dass Sie nicht zu lange mit einem Update der FlutterVersion warten und auch bei Ihren verwendeten Packages nicht ins Hintertreffen geraten. Flutter können Sie mit dem Befehl flutter upgrade auf den aktuellen Stand bringen und Ihre Packages lassen sich durch flutter pub upgrade aktualisieren. Es lohnt sich aber immer ein Blick in die Changelogs der jeweiligen Packages.
Tipp 8: Wann für oder gegen ein Package oder Plug-in entscheiden Mittlerweile gibt es für nahezu jeden Anwendungsfall mindestens ein Package, das Sie in
Ihre App einbinden können. Überlegen Sie sich dennoch gut, ob das nötig ist und Sie das Package wirklich benötigen. Ein zusätzliches Package bedeutet auch immer eine zusätzliche Abhängigkeit, die in der Wartung berücksichtig werden muss. Halten Sie auch Ausschau nach Packages mit der »Flutter Favorite«-Kennzeichnung oder mindestens nach validierten Autoren. Diese erkennen Sie an einem kleinen verifizierten Häkchen auf pub.dev, wenn Sie nach Ihrem Package suchen.
Tipp 9: Separieren Sie einzelne Widgets in eigene Klassen und separate Dateien Versuchen Sie Ihre Widgets möglich klein zu halten, damit zum einen alles übersichtlich bleibt und Sie Code leichter wiederverwenden können. Es lohnt sich, kleinere TeilWidgets in eigene Widget-Klassen in einer separaten Datei auszulagern. Damit beschleunigen Sie nicht nur die App-Performance, sondern bringen auch Ordnung in ein Chaos, das sich möglicherweise schnell ausbreitet. Denn eins haben wir in den letzten Jahren gelernt: die Widget-Bäume wachsen wie verrückt!
Tipp 10: Schauen Sie bei einem FlutterMeetup vorbei Mitunter das Tollste an Flutter ist die Community, die sich mittlerweile um dieses Framework tummelt. Es gibt – vor allem deutschlandweit – mittlerweile sehr viele Meetups, die Sie besuchen können, um sich mit Gleichgesinnten auszutauschen und Neues zu lernen. Schauen Sie einfach mal im Flutter-Meetup-Network (https://www.meetup.com/pro/flutter) vorbei. Bestimmt gibt es auch ein Meetup in Ihrer Nähe! Falls Sie im schönen Norden Deutschlands leben – Mira leitet eine Google Developer Group (https://www.meetup.com/gdg-hannover) und ein Women Techmakers Meetup in Hannover (https://www.meetup.com/women-techmakers-hannover), die beide öfters Flutter-Inhalte behandeln. Verena leitet die Flutter-DACH-Meetup-Gruppe in Süddeutschland (https://www.meetup.com/flutter-dach) und produziert außerdem den deutschsprachigen Flutter-DACH-Podcast (https://anchor.fm/flutter-dach). Vielleicht laufen wir uns ja einmal über den Weg, und Sie können uns persönlich sagen, wie supergut Ihnen dieses Buch gefallen hat!
Abbildungsverzeichnis Abbildung 3.1: Das frisch generierte Flutter-Projekt Abbildung 3.2: Geräteauswahl Abbildung 3.3: Die App beim ersten Starten Abbildung 3.4: Pummel The Fish Abbildung 3.5: SplashScreen Abbildung 3.6: HomeScreen Abbildung 3.7: DetailPetScreen Abbildung 3.8: CreatePetScreen Abbildung 4.1: Null Safety hilft bei der Fehlererkennung. Abbildung 9.1: Run and Debug Abbildung 9.2: Die App im Debug-Modus Abbildung 9.3: Breakpoints Abbildung 9.4: Die Debug-Leiste Abbildung 9.5: Objekte inspizieren Abbildung 9.6: Variablenwerte beobachten Abbildung 9.7: Den Widget Inspector öffnen Abbildung 9.8: Der Widget Inspector Abbildung 9.9: Element auswählen im Widget Inspector Abbildung 10.1: Die Flutter-Beispiel-App Abbildung 10.2: Das Padding-Widget ausprobieren Abbildung 10.3: Ein Widget-Baum Abbildung 10.4: Die Ebenen des Flutter-Frameworks Abbildung 10.5: Flutters drei Bäume Abbildung 10.6: Die Element-Klasse im Flutter-Framework Abbildung 11.1: Rename Symbol Abbildung 11.2: Quick-Fix-Import Abbildung 11.3: Der noch leere SplashScreen Abbildung 11.4: So soll der SplashScreen aussehen. Abbildung 11.5: Bildschirmaussparung
Abbildung 11.6: Ein roter Container Abbildung 11.7: Der rote Container nimmt die Größe seines child-Widgets an. Abbildung 11.8: Der rote Container mit Text zentriert Abbildung 11.9: Pummel im Container Abbildung 11.10: Pummel im Container mit fit-Parameter Abbildung 11.11: Ordnerstruktur mit Logo im images-Ordner Abbildung 11.12: Der SplashScreen Abbildung 11.13: Der SplashScreen mit Padding Abbildung 11.14: Das Column-Widget Abbildung 11.15: Das Row-Widget Abbildung 11.16: Das Stack-Widget Abbildung 11.17: Der HomeScreen mit ListView Abbildung 11.18: HomeScreen mit Column und Rows Abbildung 11.19: HomeScreen mit FloatingActionButton Abbildung 11.20: Der HomeScreen mit ListView und ListTiles Abbildung 11.21: Der DetailPetScreen Abbildung 11.22: Der DetailPetScreen ist halb fertig. Abbildung 11.23: Der DetailPetScreen ist fast fertig. Abbildung 11.24: Der CreatePetScreen Abbildung 11.25: CreatePetScreen mit TextFormFields Abbildung 11.26: CreatePetScreen mit Dropdown Abbildung 11.27: CreatePetScreen mit CheckboxListTile Abbildung 11.28: CreatePetScreen mit »Speichern«-Button Abbildung 11.29: Validierung der Eingabe beim Speichern Abbildung 12.1: Ein Widget extrahieren Abbildung 12.2: Der CustomButton im CreatePetScreen Abbildung 13.1: Der neue DetailPetScreen Abbildung 13.2: Der CreatePetScreen auf dem Android-Emulator Abbildung 13.3: Der CreatePetScreen auf einem iOS-Simulator Abbildung 13.4: Der Speichern-Button auf dem Smartphone Abbildung 13.5: Der Speichern-Button auf der Desktop-App
Abbildung 13.6: Der CreatePetScreen mit Padding-Anpassung Abbildung 14.1: Der Weg einer Nutzerin Abbildung 14.2: Der Rückweg der Nutzerin Abbildung 14.3: Der Rückweg der Nutzerin ohne SplashScreen Abbildung 14.4: Der Rückweg der Nutzerin wie im Web üblich Abbildung 15.1: Widget-Baum mit ThemeData-Widget Abbildung 15.2: ColorScheme mit einzeln definierten Farben Abbildung 15.3: Die App mit Custom-Farben – aber die Schrift hat sich versteckt! Abbildung 15.4: Die Parameter des TextTheme-Widgets Abbildung 15.5: Default TextTheme aus der offiziellen Flutter Dokumentation: https://api.flutter.dev/flutter/material/TextTheme-class.html
Abbildung 15.6: ColorScheme mit einzeln definierten Farben Abbildung 16.1: Automatisch Overrides erstellen Abbildung 16.2: await ist rot unterkringelt Abbildung 16.3: Exception, weil ein Future zurückgegeben wird Abbildung 16.4: Einen Code-Abschnitt in eine separate Methode extrahieren Abbildung 16.5: Dokumentation von pushNamed anzeigen Abbildung 17.1: Eine relationale Datenbank Abbildung 17.2: Eine nicht relationale Datenbank Abbildung 17.3: Cloud Firestore im Testmodus Abbildung 17.4: Die Cloud Firestore Console mit der pets-Collection und dem ersten Eintrag Abbildung 18.1: Der Datenfluss in einer App Abbildung 18.2: Die Ordnerstruktur der »Pummel The Fish«-App Abbildung 18.3: Der Screen-orientierte Ansatz Abbildung 18.4: Der Layer-First-Ansatz Abbildung 18.5: Der Feature-First-Ansatz Abbildung 19.1: Die AppBar im HomeScreen mit AdoptionBag-Widget Abbildung 20.1: Blocs erhalten Events und geben States zurück. Abbildung 20.2: Cubits empfangen Funktionen und geben States zurück. Abbildung 20.3: Einen neuen Cubit erstellen Abbildung 20.4: Context-Menü für praktische Bloc-Shortcuts
Abbildung 20.5: Snackbar im BlocBuilder? Das funktioniert nicht. Abbildung 20.6: Cases automatisch auflisten Abbildung 21.1: Neue Testdatei automatisch generieren lassen Abbildung 21.2: Methoden gruppieren Abbildung 21.3: Null is not a subtype of type Future Abbildung 21.4: Expected und Actual unterscheiden sich – aber warum? Abbildung 21.5: blocTest-Testfälle erstellen Abbildung 21.6: Der generierte Golden-Test-Screenshot Abbildung 21.7: Die Test-Coverage in der ManagePetsCubit-Klasse Abbildung 21.8: Die Flutter-Coverage aufgeschlüsselt Abbildung 22.1: Crashlytics aktivieren Abbildung 22.2: Analytics aktivieren Abbildung 22.3: Das Analytics-Dashboard Abbildung 22.4: App Distribution aktivieren Abbildung 22.5: .apk-Datei hochladen Abbildung 22.6: .aab- und .apk-Dateien Abbildung 22.7: Die Google Play Console Abbildung 22.8: Eine neue App in der Google Play Console Abbildung 22.9: Einen internen Test-Release erstellen Abbildung 22.10: Testversion zur Production-Version hochstufen Abbildung 23.1: Der Apple-Developer-Account in der Übersicht Abbildung 23.2: Xcode-Einstellungen Abbildung 23.3: Xcode Build Abbildung 23.4: Xcode Build Abbildung 23.5: Testflight-Upload Abbildung 23.6: App-Store-Veröffentlichung
Stichwortverzeichnis Symbols .aab-Datei 385 signieren 386 .apk-Datei 385
A Abstrakte Klasse 100 Align-Widget 166 Analytics 381 Android-Build 377 Android-Emulator 42, 50 AnimatedContainer-Widget 404 AOT-Kompilierung 57 App-Architektur 301 AppBar-Widget 154 App-Bundle siehe .aab-Datei Apple-Developer-Account 394 App-Screen 120, 131 App Store Connect 395 App veröffentlichen 399 Testflight 398 Asset 145 Asynchrone Programmierung 93
B Backstack 212
bloc-Package 319 Bloc 321, 341 BlocBuilder 329 BlocConsumer 333 BlocListener 332 BlocProvider 327 BlocSelector 334 Cubit 322, 341 Breakpoint 107 BuildContext 125, 127 Builder-Widget 316 Bundle ID 395
C Card-Widget 163 Center-Widget 140 CheckboxListTile-Widget 175 Chip-Widget 403 Cloud Firestore 276 installieren 284 Security Rules 289 Color-Klasse 219 ColorScheme.fromSeed-Konstruktor 221 Column-Widget 148, 155 Compile-Time 63 const-Keyword 63 Container-Widget 138 copyWith-Methode 287, 337 Crashlytics 380 CreatePetScreen 53, 167, 132, 200, 225, 268, 287 CupertinoDatePicker-Widget 403
Custom Farben 222 CustomPaint-Widget 405 Custom Widget 191
D Dart 57 Datenschutz 280 Debug Console 109 Debugging 105 DetailPetScreen 132, 160, 196, 226 DetailScreen 53 DevTools 109 dispose-Methode 154 do-while-Schleife 87 DropdownButtonFormField-Widget 173 DropdownMenuItem-Widget 174
E ElevatedButton-Widget 178 Entwicklungsumgebung 33, 41, 45
F Feature-First-Ansatz 305 final-Keyword 62, 63 Firebase 275 installieren 281 Firebase App Distribution 383, 397 FloatingActionButton-Widget 156 Flutter-Channel 40 flutter clean 407 Flutter-Ebenen 125
Flutter-Installation 39 Flutter-SDK 40 Font 227 Google Fonts 230 importieren 230 for-in-Schleife 87 Formatter 408 Form-Validierung 183 Form-Widget 168 for-Schleife 86 Framework 31–32, 34, 36–37 Funktion 64, 66, 70 Lambda-Funktion 66 Methode 64, 66, 97 private Methoden 65 statische Funktion 68, 75 Future 93 FutureBuilder-Widget 262, 266
G GestureDetector-Widget 192 GlobalKey 185 Google Play Store 385 App veröffentlichen 390 Testversion ausrollen 388 GridView-Widget 153
H HashCode 362 Hero-Widget 404 HomeScreen 52, 132, 148, 196, 209, 225, 259, 295, 311
Hot-Reload 50, 57 Hot-Restart 51, 57
I Icon-Widget 158 if-else-Anweisung 83, 195 Image-Widget 142 initState-Methode 153 Interface 99–100 iOS-Build 393, 407 iOS-Simulator 43, 50
J JIT-Kompilierung 57 JSON 250
K Klasse abstrakte 100
L late-Keyword 62 Launcher-Icon 378 Launch Screen 208 Layer 302 Layer-First-Ansatz 304 Linter 408 List 89 ListTile-Widget 158 ListView.builder-Widget 152, 158
M Map 90 MaterialApp-Widget 115, 130, 207, 218 MaterialColor-Widget 219 Material Design 3 218 MediaQuery 202 Mixin 101
N Named Routing 210 Native App-Entwicklung 33 Natives Design 199 Navigator-Funktion 208, 272 NoSQL 277, 297 Null Safety 76–77 Sound Null Safety 81 Unsound Null Safety 81
O Objective-C 32 Operator ternärer 195 Ordnerstruktur 48, 302
P Package 49, 69–70 Padding-Widget 147 PageView-Widget 404
Parameter 70 Optional Named Parameter 71 Positional Parameter 71 Required Named Parameter 72 Pfad 134 Plug-in 49 Positioned-Widget 166 Programmierung asynchrone 93 Progressive Web-App 33, 35 Pummel The Fish 52 PWA siehe Progressive Web-App
Q QUICK-FIX 133
R Race Conditions 342 Repository 66, 245 RepositoryProvider 334 Responsiveness 201
RESTful API 239 DELETE 258 GET 247 HTTP-Request 240 POST 255 PUT 258 Request Body 242 Request-Endpunkt 241 Request Header 242 Request-Methode 241 Response Body 244 Status-Code 243 Row-Widget 148, 155 Rücknavigation 210, 212 Run-Time 63
S SafeArea-Widget 137 Scaffold-Widget 130, 137 Schnittstelle siehe RESTful API Screen-First-Ansatz 303 Semantics-Widget 405 Set 90 setState. siehe StatefulWidget StatefulWidget 310 showDatePicker 403 SingleChildScrollView-Widget 173 SliverAppBar-Widget 405 SnackBar-Widget 269, 271 SplashScreen 52, 131, 135, 208, 224 SQL 276
Stack-Widget 148, 165 State-Management 309, 319 Stream 95 StreamBuilder-Widget 294 String-Interpolation 69 Swift 32 switch-Anweisung 84
T Table-Widget 404 ternärer Operator 195 Testen 347 automatisiert 350 Bloc-Test 363 Golden Test 369 Integration-Test 370 manuell 349 Test-Coverage 372 Test Driven Development 348 Unit-Test 350 Widget-Test 366 TextFormField-Widget 169 TextTheme-Widget 228, 231 Theme 217, 223 dark Theme 221 ThemeData-Widget 218, 220 Timer-Funktion 208 Typableitung siehe Type Inference Type Inference 61
U
Umgebungsvariable 40
V Variablen-Typ 60 var-Keyword 62 Vererbung 97 Versionierung 378 Visual Studio Code siehe VSCode VSCode 45, 106
W Wahrheitswert 195 while-Schleife 87 Widget 117 extrahieren 188 InheritedWidget 312 privates Widget 188 StatefulWidget 120, 122, 123, 132 StatelessWidget 119, 123, 132 Widget-Baum 124–125, 218, 315 Child Widget 124 Element-Baum 126 Parent Widget 124 Render-Baum 126 Widget Inspector 110 WillPopScope-Widget 215 Wrap-Widget 403
X Xcode 395
WILEY END USER LICENSE AGREEMENT Besuchen Sie www.wiley.com/go/eula, um Wiley's E-Book-EULA einzusehen.