Flutter App Development - Teil 4.1 - Main und RSS-Feed-Logik
Nun geht es in die vollen, denn heute bauen wir unsere erste richtige Logik. Am Ende des vierten Teils dieser Tutorialreihe soll die initiale Logik zum Start der App und die RSS Feed Logik implementiert sein.
Zur Umsetzung erstellen wir den MainBloc (lib/main/main_bloc.dart open_in_new), der die Datenbank lädt und später z.B. das Laden der Konfiguration übernehmen kann. Sobald dieser Vorgang abgeschlossen ist, sollen alle RssFeed Objekte mit Hilfe des FeedListBloc (lib/feed_list/feed_list_bloc.dart open_in_new) aus der Datenbank geladen werden.
Damit die Datenbank auch mit Inhalt gefüllt ist, soll es über die UI möglich sein einen neuen Feed anzulegen. Sowohl für das Laden der RssFeeds, wie auch für das Hinzufügen wird der FeedListBloc genutzt. Außerdem erstellen wir geeignete Widgets zur Darstellung der Liste (lib/feed_list/feed_list.dart open_in_new) und des Formulars (lib/feed_list/feed_list_change.dart open_in_new), wobei letzteres im nächsten Teil der Reihe umgesetzt wird.
In diesem Zuge habe ich auch eine kleine Umbenennung vorgenommen, denn unser RssList Widget (nun FeedList Widget) und alle damit verwandten Klassen, befinden sich nun im FeedList Kontext open_in_new (Namen und Pakete). Dieser Name gefiel mir einfach besser und ist gleichzeitig kürzer und deutlicher. Die Bezeichnung für den Typ bleibt allerdings unverändert bei RssFeed.
Damit das Verstehen der folgenden Schritte leichter fällt, beginne ich mit einem schnellen Einblick in das BloC Konzept. Ich empfehle euch allerdings die offizielle Dokumentation open_in_new zum Thema anzuschauen, wenn ihr das Ganze aktiv nutzen wollt. Sie ist übersichtlich, gut gemacht und bietet außerdem diverse gute Beispiele.
BloC oder auch Business Logic Components ist mein aktuell favorisierter Weg, um Logik von UI und wiederum von Daten zu trennen. Dafür wird sehr intensiv auf das Konzept von Streams open_in_new gesetzt. Ein BloC interagiert immer mit zwei Streams, einem für den Input (Events) und einen für den Output (States), dabei sollte jedes Event zu mindestens einem passenden State führen. Im konkreten Library Kontext wird dafür die mapEventToState() open_in_new Methode genutzt. Sofern ihr einen BloC erstellt und Bloc open_in_new erweitert, müsst ihr sowohl die akzeptierten Events dieses BloC, wie auch die dazugehörigen States angeben. Diese definiert man meist in einer gesonderten Datei, wodurch ein übersichtlicher klarer Rahmen von erlaubten Eingaben und Ausgaben definiert wird.
Events können von der UI, z.B. durch einen Tap auf einen Button, kommen oder aber von anderen BloCs, bzw. tieferliegenden Komponenten. States werden vom BloC erzeugt und meist von der UI verarbeitet und führen zu automatischen Anpassungen von selbiger. Allerdings können auch andere BloCs auf State Änderungen hören und entsprechend Aktionen durchführen. Es ist außerdem erlaubt im Laufe der Verarbeitung mehrere States zu liefern. So macht es bei längeren Aktionen zum Beispiel Sinn, am Anfang einen Loading State zu übergeben und nach Abschluss einen State der die finalen Daten zurückgibt.
Entsprechend gibt es verschiedene Quellen für Events und verschiedene Ziele für States, auch mehrere gleichzeitig sind möglich. Streams bieten hier viele Freiheiten und Flexibilität, es ist allerdings extrem wichtig alles ordentlich zu dokumentieren und klare Flows zu etablieren. Denn mit großer Flexibilität kommt sonst großes Chaos.
Mit dieser Einleitung beginnen wir nun mit dem MainBloc.
lib/main/main_bloc.dart
(Code auf GitHub open_in_new)
import 'package:bloc/bloc.dart';
import '../database/database_wrapper.dart';
import 'main_events_states.dart';
class MainBloc extends Bloc<MainEvent, MainState> {
@override
MainState get initialState => LoadingApp();
MainBloc() {
add(PrepareApp());
}
@override
Stream<MainState> mapEventToState(MainEvent event) async* {
if (event is PrepareApp) {
yield* _prepareApp();
}
}
Stream<MainState> _prepareApp() async* {
try {
final databaseWrapper = DatabaseWrapper();
await databaseWrapper.initAsync();
yield AppLoaded();
} catch (exception) {
yield AppFailed(error: exception.toString());
}
}
}
Die dazugehörigen Events und States sehen wie folgt aus:
lib/main/main_events_states.dart
(Code auf GitHub open_in_new)
import 'package:flutter/widgets.dart';
abstract class MainEvent {}
class PrepareApp extends MainEvent {}
abstract class MainState {}
class LoadingApp extends MainState {}
class AppLoaded extends MainState {}
class AppFailed extends MainState {
final String error;
AppFailed({@required this.error});
}
Dieser BloC wird im LoadingApp()
State initialisiert und wartet dann auf weitere Anweisungen. Sobald das PrepareApp()
Event gesendet wird beginnt das Setup, in der _prepareApp()
Methode. Wir erstellen hier ein Datenbank-Wrapper Objekt und initialisieren es. Zu dieser Klasse findet ihr im dritten Teil der Reihe mehr Informationen. Sofern der Ladevorgang erfolgreich ist wird der State auf AppLoaded()
gesetzt und ansonsten auf AppFailed()
. Letzteres kann z.B. eintreten, falls man die Datenbankdatei nicht erstellen bzw. nicht laden konnte.
Zum generellen Flow kann hier folgendes gesagt werden:
- Die App UI zeigt im initialen
LoadingApp()
State einen Indikator an, um zu zeigen das Daten geladen werden. Nun wartet die UI auf weitere Updates - Die Erstellung des MainBloc sorgt automatisch dafür, dass das
PrepareApp()
Event gesendet wird - Die
_prepareApp()
Methode ist durch ihre Kombination ausyield*
(erwartet eine Generatorfunktion) beim Aufruf undasync*
(generiert eine Reihe von Ausgaben, in unserem Fall vom Typ MainState) bei der Deklaration in der Lage selber States zu setzen - Sobald sich der State ändert (
AppLoaded()
oderAppFailed()
) erfährt die UI dies und passt sich entsprechend an
Die eigentliche main.dart Datei sieht dann wie folgt aus:
lib/main.dart
(Code auf GitHub open_in_new)
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'constants/strings_user_visible.dart';
import 'feed_list/feed_list.dart';
import 'feed_list/feed_list_barrel.dart';
import 'main/main_barrel.dart';
import 'widgets/state_info.dart';
void main() {
runApp(FssApp());
}
class FssApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: kAppTitle,
theme: ThemeData(
primarySwatch: Colors.teal,
accentColor: Colors.redAccent,
visualDensity: VisualDensity.adaptivePlatformDensity,
buttonTheme: ButtonThemeData(
textTheme: ButtonTextTheme.primary,
),
),
home: BlocBuilder(
bloc: MainBloc(),
builder: (BuildContext context, state) {
if (state is AppLoaded) {
return BlocProvider(
create: (BuildContext context) => FeedListBloc(),
child: FeedList(),
);
} else if (state is AppFailed) {
return ErrorState(error: state.error);
} else {
return LoadingState();
}
},
),
);
}
}
Diese Datei erstellt ein FssApp
Objekt, welches ein StatelessWidget open_in_new ist und mittels der main() open_in_new und runApp() open_in_new Methode gestartet wird. Innerhalb der Build Methode wird die Basis-UI der App erstellt, welche auf einer MaterialApp open_in_new basiert und mittels eines BlocProviders open_in_new unser zentraler FeedListBloc open_in_new an alle darunterliegenden UI Ebenen weitergegeben. Hier seht ihr außerdem dass mittels eines BlocBuilders open_in_new auf die verschiedenen States des MainBloc gehört wird und sich entsprechend die UI aktualisiert. Dieses dreierlei (UI, BloC, Events / States) ist die Basis eines jeden BloC Konstrukts. Ihr habt also meist eine UI, diese reagiert auf State Änderungen, welche durch die im BloC empfangenen und verarbeiteten Events ausgelöst werden. Ein Event kann von der UI, der Logik, einem BloC oder anderen Strukturen, welche Zugriff auf den BloC haben, ausgeführt werden.
Events und States halte ich explizit in einer Datei, obwohl hier häufig zwei getrennte genutzt werden. Ich tue dies, wohl wissend das größere Dateien entstehen können, denn zumindest ich persönlich kann dadurch beim Debugging wesentlich besser nachvollziehen welches Event, welchen State bedient und wie die eigentliche Kommunikation aktuell aussieht. Wer Events und States lieber auf zwei Dateien aufteilen will kann dies gerne tun, an dieser Stelle würde ich sagen: Geschmackssache.
Damit man egal ob zwei, drei oder mehr Dateien nur eine Datei importieren muss, ist es gern gesehen Barrel Dateien zu verwenden. Dadurch können wir mit nur einem Import in anderen Dateien, den gesamten Kontext eines BloCs verfügbar machen. Die Barrel Datei für den MainBloc ist dabei recht kurz.
lib/main/main_barrel.dart
(Code auf GitHub open_in_new)
export 'main_bloc.dart';
export 'main_events_states.dart';
Aufgrund der Länge des Beitrags teile ich ihn spontan in zwei Teile, welche allerdings parallel veröffentlicht werden. Die Logik zum Laden und Bearbeiten der RSS Feeds findet ihr hier.