Flutter App Development - Teil 4.2 - Main und RSS-Feed-Logik
Aufgrund der Länge des Beitrags habe ich diesen Abschnitt des Tutorials noch einmal aufgeteilt. Die erste Hälfte (Main Logik) findet ihr hier.
Nachdem wir nun die App initial laden können, geht es weiter mit der Liste der RSS Feeds. Dieser Bereich besteht bei mir aus fünf Dateien. Im feed_list Package open_in_new befinden sich die feed_list.dart open_in_new, feed_list_barrel.dart open_in_new, feed_list_bloc.dart open_in_new, feed_list_change.dart open_in_new und feed_list_events_states.dart open_in_new. Der Aufbau ist sehr ähnlich unserem Main Konstrukt, mit einer weiteren Datei, welche das Hinzufügen, Bearbeiten und Löschen über die UI ermöglicht. Die Event / State Datei (lib/feed_list/feed_list_events_states.dart open_in_new) und die Barrel Datei (lib/feed_list/feed_list_barrel.dart open_in_new) wird im Folgenden nicht weiter erläutert, da sie trivial sind.
Beginnen werden wir nun mit dem FeedListBloc. Dieser könnte als Herzstück der App bezeichnet werden, da er sowohl die RSS Feeds verwaltet, wie auch Updates aller Feeds durchführen kann. Die gesamte App arbeitet dabei mit nur einem FeedListBloc, welcher bereits in der lib/main.dart open_in_new erstellt und mittels eines Providers darunterliegenden Strukturen zur Verfügung gestellt wird. Damit kann sichergestellt werden, dass keine unnötigen Objekte erzeugt werden oder aber falsche States aktualisiert werden.
lib/feed_list/feed_list_bloc.dart
(Code auf GitHub open_in_new)
import 'package:bloc/bloc.dart';
import 'package:http/http.dart' as http;
import '../constants/strings_user_visible.dart';
import '../database/database_wrapper.dart';
import '../feed_list/feed_list_events_states.dart';
import '../types/rss_entry.dart';
import '../types/rss_feed.dart';
import '../utils/rss.dart';
class FeedListBloc extends Bloc<FeedListEvent, FeedListState> with RssReader {
final databaseWrapper = DatabaseWrapper();
@override
FeedListState get initialState => LoadingFeedList();
@override
Stream<FeedListState> mapEventToState(FeedListEvent event) async* {
if (event is RequestFeedList) {
yield* loadFeedList();
} else if (event is UpdateFeedList) {
yield* updateFeedList();
} else if (event is SetFeed) {
yield* setFeed(event);
} else if (event is DeleteFeed) {
yield* deleteFeed(event.feed);
}
}
Stream<FeedListState> loadFeedList() async* {
try {
final feedList = await databaseWrapper.getAllFeedsAsync();
yield FeedListLoaded(feedList: feedList);
} catch (exception) {
yield FeedListFailed(error: exception.toString());
}
}
Stream<FeedListState> setFeed(SetFeed event) async* {
final newFeed = event.newFeed;
final oldFeed = event.oldFeed;
if (oldFeed != null && newFeed.id != oldFeed.id) {
await databaseWrapper.deleteAsync(oldFeed, RssFeed);
} else if (oldFeed != null && newFeed.id == oldFeed.id) {
newFeed.lastUpdate = oldFeed.lastUpdate;
}
await databaseWrapper.setAsync(newFeed, RssFeed);
yield* loadFeedList();
}
Stream<FeedListState> deleteFeed(RssFeed feed) async* {
await databaseWrapper.deleteAsync(feed, RssFeed);
await databaseWrapper.clearStoreAsync(RssEntry, feed.id);
yield* loadFeedList();
}
Stream<FeedListState> updateFeedList() async* {
final feedList = await databaseWrapper.getAllFeedsAsync();
final failedFeedNameList = <String>[];
await Future.wait(feedList.map((feed) async {
try {
final response = await http.get(feed.url);
if (response.statusCode == 200) {
final entries = await getRssEntryList(response.bodyBytes);
await databaseWrapper.setMultipleAsync(entries, RssEntry, feed.id);
await databaseWrapper.setAsync(feed.withUpdatedTime(), RssFeed);
} else {
throw Exception(kErrorFeedLoading);
}
} catch (exception) {
failedFeedNameList.add(feed.name);
}
}));
yield FeedListUpdated(feedList: feedList, failedFeedNameList: failedFeedNameList);
}
}
Der generelle Flow des FeedListBloc lässt sich wie folgt beschreiben:
- Via
RequestFeedList()
wird die Liste aller Feeds aus der Datenbank geladen. DieloadFeedList()
Methode kümmert sich um diese Aufgabe und greift über den Datenbankwrapper auf die Inhalte zu. Sollte das Laden erfolgreich sein werden die Daten zurückgegeben oder aber ein Fehler State angezeigt. - Via
UpdateFeedList()
und die dadurch aufgerufeneupdateFeedList()
Methode können alle Feed-Inhalte aktualisiert werden. Dafür werden alle RSS Feeds aus der Datenbank geladen und im Folgenden parallel abgearbeitet. Dies ist möglich durch Future.wait() open_in_new, denn auf diesem Wege wird zwar von folgenden Aufrufen auf die internen Berechnungen gewartet (ähnlich Future.forEach() open_in_new), allerdings werden die internen Aufrufe parallel und nicht sequentiell gestartet. Für jeden Feed wird ein HTTP Request durchgeführt und die Daten anschließend von JSON in Dart Typen konvertiert. Im Erfolgsfall werden die Daten in der Datenbank gespeichert, ansonsten wird der problematische Feed für die spätere Rückgabe an den Nutzer notiert. Abschließend erfolgt die Anpassungen des States. - Via
SetFeed()
und dersetFeed()
Methode werden RSS Feeds hinzugefügt oder bearbeitet. Zwischen den Fällen wird unterschieden indem geprüft wird ob einoldFeed
Objekt gegeben ist oder nicht. Sollte sich beim Bearbeiten die URL eines Feeds ändern, wird die alte Referenz in der Datenbank entfernt, da die URL (als Hashwert) die eindeutige Datenbank-Id darstellt. Falls sich nur der Name geändert hat, wird dieser lediglich angepasst und zusätzlich das letzte Aktualisierungsdatum desoldFeed
Objekts übernommen. Abschließend wird dasnewFeed
Objekt, welches entsprechend den verschiedenen Fällen angepasst wurde, gespeichert und die UI durch eine State Anpassung informiert. Bei dieser Methode könnte man mit weiteren Events / States sicherlich noch etwas mehr Klarheit schaffen. - Via
DeleteFeed()
und derdeleteFeed()
Methode kann ein Feed entfernt werden. Es werden sowohl der RSS Feed Eintrag aus der Datenbank, wie auch alle geladenen Feed Einträge entfernt. Abschließend wird der State angepasst.
lib/feed_list/feed_list.dart
(Code auf GitHub open_in_new)
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:fss/types/rss_feed.dart';
import '../constants/strings_user_visible.dart';
import '../entry_list/entry_list.dart';
import '../feed_list/feed_list_barrel.dart';
import '../widgets/state_info.dart';
import 'feed_list_change.dart';
class FeedList extends StatefulWidget {
@override
_FeedListState createState() => _FeedListState();
}
class _FeedListState extends State<FeedList> {
FeedListBloc _bloc;
Completer<void> _refreshCompleter;
@override
void initState() {
super.initState();
_bloc = BlocProvider.of<FeedListBloc>(context);
_bloc.add(RequestFeedList());
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(kFeedListScreenTitle),
actions: <Widget>[
IconButton(
icon: Icon(Icons.add),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute<String>(
builder: (context) {
return BlocProvider.value(
value: _bloc,
child: FeedListChange(),
);
},
),
);
},
)
],
),
body: BlocConsumer<FeedListBloc, FeedListState>(
listener: (BuildContext context, state) {
if (_refreshCompleter != null && !_refreshCompleter.isCompleted) {
_refreshCompleter.complete();
}
if (state is FeedListUpdated && state.failedFeedNameList.isNotEmpty) {
final snackBar = SnackBar(content: Text('$kErrorFeedUpdate: ${state.failedFeedNameList.join(', ')}'));
Scaffold.of(context).showSnackBar(snackBar);
}
},
builder: (BuildContext context, state) {
if (state is FeedListLoaded || state is FeedListUpdated) {
final feedList = state is FeedListLoaded ? state.feedList : state is FeedListUpdated ? state.feedList : null;
return RefreshIndicator(
onRefresh: () {
_refreshCompleter = Completer();
_bloc.add(UpdateFeedList());
return _refreshCompleter.future;
},
child: ListView.builder(
itemCount: feedList.length,
itemBuilder: (context, index) {
final feed = feedList[index];
return FeedListItem(feed: feed);
},
),
);
} else if (state is FeedListFailed) {
return ErrorState(error: state.error);
} else {
return LoadingState();
}
},
),
);
}
}
class FeedListItem extends StatelessWidget {
const FeedListItem({Key key, @required RssFeed feed})
: _feed = feed,
super(key: key);
final RssFeed _feed;
@override
Widget build(BuildContext context) {
final bloc = BlocProvider.of<FeedListBloc>(context);
return ListTile(
title: Text(_feed.name),
subtitle: Text('$kFeedListLastUpdate: ${_feed.formattedLastUpdate}'),
onTap: () async {
final result = await Navigator.push(
context,
MaterialPageRoute<String>(
builder: (context) {
return BlocProvider.value(
value: bloc,
child: EntryList(feedId: _feed.id),
);
},
),
);
if (result != null) {
Scaffold.of(context)
..removeCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(result)));
}
},
);
}
}
Der gezeigte Code beherbergt zwei verschiedene Widgets, zum einen die FeedList, zum anderen aber auch das FeedListItem. Mehrere Widgets zu erstellen hilft der Übersichtlichkeit und im Dart / Flutter Kontext ist es kein Problem mehrere Widgets in einer Datei zu definieren.
Die eigentliche FeedList ist ein StatefulWidget open_in_new. In der init()
Methode, welche beim initialen erstellen aufgerufen wird, wird über das RequestFeedList()
Event die aktuelle Liste alle RSS Feeds vom BloC angefragt. Der BloC wird dabei aus dem aktuellen Context geholt. Via BlocProvider.of() open_in_new wird nach selbigem gesucht und ein Fehler ausgegeben, falls auf dem gegebenem Context kein BloC dieser Art gefunden wurde.
In der build()
Methode zeichnet es sowohl die AppBar open_in_new, wie auch den Content. Die AppBar stellt einen Button zum Hinzufügen von RSS Feeds zur Verfügung. Ein Tap auf diesen öffnet einen neuen Screen (lib/feed_list/feed_list_change.dart open_in_new), welcher in einem kommenden Teil der Reihe besprochen wird und für das Hinzufügen und Bearbeiten von RSS Feeds verantwortlich ist. Der Navigator open_in_new und die MaterialPageRoute open_in_new sind der normale Weg, um Flutter und einer MaterialApp mitzuteilen dass ein neuer Screen geöffnet werden soll. Außerdem sieht man hier den BlocProvider.value open_in_new in Aktion, welcher unseren bereits vorhandenen BloC auch auf neuen Screens verfügbar macht.
Des Weiteren nutze ich einen BlocConsumer open_in_new, welcher im Prinzip nur Syntactic-Sugar für die Kombination aus BlocListener open_in_new und BlocBuilder open_in_new ist. Letzterer wurde bereits besprochen, doch was tut ein BlocListener? Ein BlocListener reagiert einmalig auf State Änderungen - anders als die builder()
Methode des BlocBuilders - und wird von mir aus diesem Grund genutzt, um auf Ergebnismeldungen nach Updates oder andere Anpassungen zu reagieren.
Der BlocBuilder gibt im genannten Kontext, basierend auf dem aktuellen State, die UI zurück, welche relativ trivial ist.
Eine Besonderheit ist allerdings der RefreshIndicator open_in_new, welcher zusammen mit dem _refreshCompleter
(Completer open_in_new) für meine Pull-To-Refresh Logik verantwortlich ist. Der Indikator zeigt die UI an, während der _refreshCompleter
als Callback genutzt wird. Der Flow sieht dabei wie folgt aus:
- Der Nutzer führt einen Pull-To-Refresh aus und der FeedListBloc startet das Update
- Die UI erwartet ein Future als Rückgabewert, sodass der Indikator nach Abschluss der Berechnung ausgeblendet werden kann. Im BloC Kontext wissen wir aber nicht implizit wann die Logik ihre Aufgabe erfüllt hat, wir brauchen einen Callback. Führt man übrigens lediglich eine asynchrone Berechnung durch, bzw. nutzt man hier keine Trennung von UI und Logik, kann hier auch direkt ein Future zurückgegeben werden
- Der BlocListener hört auf den Stand des Aktualisierungsvorgang und sobald selbiger beendet wurde, teilt er dies dem Completer mit (
_refreshCompleter.complete();
), was für besagten Callback sorgt - Die UI kann entsprechend den Indikator ausblenden.
Neben dem ein- und ausblenden des Indikator kümmern wir uns im BlocListener um Snackbars, die den Nutzer über fehlerhafte Vorgänge beim Refresh der RSS Feeds informieren. Hier prüfen wir ob nach einem Update die state.failedFeedNameList
gefüllt ist und zeigen eine entsprechende Meldung.
Sofern alles erfolgreich geladen ist, wird ein ListView.builder open_in_new genutzt, welcher entsprechend der Anzahl an Einträgen FeedListItems erzeugt. Ein FeedListItem ist relativ simpel aufgebaut. Es ist ein StatelessWidget, welches die Informationen (Name, Zeitpunkt der letzten Aktualisierung) zum jeweiligen RSS Feed anzeigt.
Mit einem Tap auf ein Item wird die EntryList (lib/entry_list/entry_list.dart open_in_new) geöffnet, welche über einen BlocProvider mit dem bekannten BloC versorgt wird, sodass dasselbe Objekt wiederverwendet werden kann. Das FeedListItem erwartet einen Rückgabe Wert, welcher beim spätere schließen der EntryList definiert wird. Auf diesem Wege kann ich das Widget informieren, dass z.B. ein Eintrag gelöscht wurde und daraufhin eine Snackbar anzeigen.
Das soll es für den Moment gewesen sein und im nächsten Beitrag folgt die UI zum Hinzufügen und Bearbeiten von RSS Feeds. Hier wird abermals mit BloCs gearbeitet und das Formular erstellt, sowie erklärt wie der Formular-Screen Informationen an interessierte Dritte weitergeben kann.