Boehrsi.de - Blog

Flutter App Development - Teil 3 - Typen und die Datenbank

Erstellt am event Uhr von account_circle Boehrsi in label Development
Flutter App Development - Teil 3 - Typen und die Datenbank Bild

Nach dem der letzte Teil ein sehr langes Intro war, erstellen wir nun die Typen und die Datenbank, inklusive Wrapper-Objekten, welche uns die einfache Nutzung der Datenbankinhalte innerhalb der BloCs erlauben sollen. Neben einer sehr kleinen Basisklasse (lib/types/database_entry.dart open_in_new) für die Typen, gibt es zwei Haupttypen. Einmal den RssFeed (lib/types/rss_feed.dart open_in_new) und den RssEntry (lib/types/rss_entry.dart open_in_new). Diese beiden bilden die initiale Datenbasis, mit welcher ich arbeiten möchte. Im folgenden findet ihr Code-Teile der genannten Klassen und einige Erklärungen dazu.

lib/types/database_entry.dart (Code auf GitHub open_in_new)

abstract class DatabaseEntry {
  Map<String, dynamic> toMap();

  int get id;
}

Diese kleine Basisklasse zwingt unsere Typen dazu, sowohl ein Mapping Funktion zur Verfügung zu stellen, wie auch einen Getter für die Id. Die Mapping Funktion ist relevant, da unsere Datenbank JSON open_in_new als internes Format nutzt und entsprechend gerne mit Key-Value-Maps arbeitet. Ich hätte hier gerne noch Wrapper-Funktionen für die Erstellung der jeweiligen Objekte hinzugefügt, aber dafür wäre Generic-Black-Magic nötig gewesen, welche den Umfang dieses Tutorials sprengen würde.

lib/types/rss_feed.dart (Code auf GitHub open_in_new)

import 'package:flutter/material.dart';

import '../constants/database.dart';
import '../constants/strings_user_visible.dart';
import '../utils/date.dart';
import 'database_entry.dart';

class RssFeed extends DatabaseEntry {
  @override
  int id;
  String url;
  String name;
  int lastUpdate;

  String get formattedLastUpdate => lastUpdate != null ? lastUpdate.formattedDate() : kEntryListUpdatedNever;

  RssFeed({@required this.id, @required this.url, @required this.name, this.lastUpdate});

  RssFeed.fromMap(Map<String, dynamic> map) {
    id = map[kColumnId];
    url = map[kColumnUrl];
    name = map[kColumnName];
    lastUpdate = map[kColumnLastUpdate];
  }

  @override
  Map<String, dynamic> toMap() {
    return {
      kColumnId: id,
      kColumnUrl: url,
      kColumnName: name,
      kColumnLastUpdate: lastUpdate,
    };
  }

  RssFeed withUpdatedTime() {
    lastUpdate = DateTime.now().millisecondsSinceEpoch;
    return this;
  }
}

Die RssFeed Klasse und die sehr ähnliche RssEntry Klasse open_in_new bestehen hauptsächlich aus einem Konstruktor mit Named-Parametern open_in_new und zwei Mapping Funktionen. Dies erlaubt eine einfache Umwandlung von und zu Datenbankobjekten bzw. JSON. Hierfür könnte man übrigens auch die json_serializable open_in_new Library nehmen. Zusätzlich nutze ich die withUpdatedTime() Methode, um einen RSS Feed mit aktulisiertem Timestamp zu erhalten. Dies ist immer dann wichtig wenn die Inhalte des Feeds einem Update unterzogen worden. Alle Variablen die mit die mit einem k beginnen, z.B. kColumnId sind globale Konstanten und befinden sich im constants Package (https://github.com/Boehrsi/fss/tree/master/lib/constants open_in_new).
Abschließend gibt es hier noch den formattedLastUpdate Getter, welcher uns das Datum (ein Integer Timestamp) in einer lesbaren Form anzeigt. Diese Datumsformatierung habe ich in eine Utility Datei (lib/utils/date.dart open_in_new) ausgelagert und in Form einer Extension Method open_in_new auf der int Klasse umgesetzt. Method Extensions sind meiner Meinung nach ein extrem schöner Weg, um Utilities und diverse andere Dinge umzusetzen. Ich persönlich kann sie jedem Dart / Flutter Entwickler nur empfehlen.

lib/utils/date.dart (Code auf GitHub open_in_new)

import 'package:intl/intl.dart';

DateFormat _dateFormat = DateFormat('yyyy.MM.dd - HH:mm');

extension DateFormatting on int {
  String formattedDate() {
    final dateTime = DateTime.fromMillisecondsSinceEpoch(this);
    return _dateFormat.format(dateTime);
  }
}

Da die rss_entry.dart open_in_new Klasse sehr ähnlich aussieht werde ich sie an dieser Stelle überspringen. Mit diesen Objekten werden wir innerhalb der App arbeiten, gespeichert werden die Daten allerdings via sembast open_in_new, in Form von JSON. Um diese Inhalte verwalten zu können habe ich einen kleinen Datenbank-Wrapper geschrieben, welchen ihr im folgenden seht:

lib/database/database_wrapper.dart (Code auf GitHub open_in_new)

import 'dart:io';

import 'package:path_provider/path_provider.dart';
import 'package:sembast/sembast.dart';
import 'package:sembast/sembast_io.dart';

import '../constants/database.dart';
import '../constants/strings_user_visible.dart';
import '../types/database_entry.dart';
import '../types/rss_entry.dart';
import '../types/rss_feed.dart';

class DatabaseWrapper {
  static DatabaseWrapper _instance;

  final _feedStore = intMapStoreFactory.store(kStoreRssFeedList);

  Database _database;

  Database get database => _database ?? Exception(kErrorDatabaseNotInitialized);

  factory DatabaseWrapper() => _instance ??= DatabaseWrapper._internal();

  DatabaseWrapper._internal();

  Future<void> initAsync() async {
    if (_database != null) {
      return;
    }
    final databasePath = Platform.isIOS ? await getLibraryDirectory() : await getApplicationSupportDirectory();
    final dbFactory = databaseFactoryIo;
    _database = await dbFactory.openDatabase('${databasePath.path}$kDatabaseName');
  }

  Future<void> setAsync(DatabaseEntry entry, Type type, [int storeId]) async {
    final store = _getStore(type, storeId);
    await store.record(entry.id).put(database, entry.toMap());
  }

  StoreRef _getStore(Type type, int storeId) {
    StoreRef store;
    if (type == RssFeed) {
      store = _feedStore;
    } else {
      store = intMapStoreFactory.store('${kStoreRssEntryList}_$storeId');
    }
    return store;
  }

  Future<void> setMultipleAsync(List<DatabaseEntry> entryList, Type type, [int storeId]) async {
    final store = _getStore(type, storeId);
    await database.transaction((transaction) async {
      await Future.forEach(entryList, (entry) async {
        await store.record(entry.id).put(transaction, entry.toMap());
      });
    });
  }

  Future<void> deleteAsync(DatabaseEntry entry, Type type, [int storeId]) async {
    final store = _getStore(type, storeId);
    await store.record(entry.id).delete(database);
  }

  Future<void> clearStoreAsync(Type type, [int storeId]) async {
    final store = _getStore(type, storeId);
    await store.delete(database);
  }

  Future<RssFeed> getFeedAsync(int id) async {
    final snapshot = await _getAsync(_feedStore, id);
    return RssFeed.fromMap(snapshot);
  }

  Future<List<RssFeed>> getAllFeedsAsync() async {
    final snapshotList = await _getAllAsync(_feedStore, kColumnName);
    return snapshotList.map((feedMap) => RssFeed.fromMap(feedMap.value)).toList();
  }

  Future<List<RssEntry>> getAllEntriesAsync(int storeId) async {
    final store = _getStore(RssEntry, storeId);
    final snapshotList = await _getAllAsync(store, kColumnDate);
    return snapshotList.map((entryMap) => RssEntry.fromMap(entryMap.value)).toList();
  }

  Future<Map<String, dynamic>> _getAsync(StoreRef store, int id) async {
    return await store.record(id).get(database);
  }

  Future<List<RecordSnapshot<int, Map<String, dynamic>>>> _getAllAsync(StoreRef store, String sortBy) async {
    final finder = Finder(sortOrders: [SortOrder(sortBy)]);
    return await store.find(database, finder: finder);
  }
}

Dieser Wrapper bedient sich eines Factory Konstruktors open_in_new, was hier den gleichen Effekt erzeugt wie ein Singelton z.B. in Java. Es wird also ein Objekt erstellt, sofern nicht bereits eins erstellt wurde. Abschließend wird das Objekt zurückgegeben. Ich möchte dies, denn Datenbankzugriffe sollten meiner Meinung nach zentralisiert stattfinden. Für Ausnahmen und direkte Zugriffe steht zusätzlich ein Getter für das Datenbank Objekt selbst zur Verfügung.

Für den Zugriff werden mehrere Stores open_in_new genutzt, welcher einen Integer Wert als Key nutzen und ein Objekt damit verbinden. Dabei wird ein Store für alle RssFeeds open_in_new erstellt (_feedStore), welcher als Field direkt bei der Erstellung der Klasse kreiert wird. Weiterhin gibt es pro RssFeed einen Store für RssEntries open_in_new, also alle Beiträge eines RssFeeds. Unsere _getStore() Methode kümmert sich darum, je nach Typ, den richtigen Store zurückzugeben. Für die RssEntries / Beiträge wird dabei ein Suffix in Form der RssFeed ID an den Store-Namen angehängt, sodass eine eindeutige Zuordnung stattfinden kann.

Extrem wichtig ist in dieser Datei das Flutter Async Handling open_in_new. Denn I/O Zugriffe auf das Dateisystem und vieles andere auch, sollten nicht synchron durchgeführt werden, da dies die UI blockieren könnte. Flutters Async Handling ist durchaus gut gemacht, bedarf aber definitiv der einen oder anderen Leseeinheit, um alles zu durdringen. Solltet ihr mit Flutter aktiv starten wollen, ist dies einer der wichtigsten Bereiche wie ich finde.

Zugriffe auf die Datenbank (sembast open_in_new) sind nur nach erfolgreicher Initialisierung durch die initAsync() Methode erlaubt. Diese Methode erstellt die Datenbank an einem geeigneten Ort (geliefert durch die path_provider open_in_new) Library), falls noch nicht vorhanden und öffnet sie. Mehrfache Calls gegen diese Methode sind nicht problematisch, da initial geprüft wird ob die Datenbank bereits geladen wurde.
Zusätzlich gibt es noch die Methoden setAsync() (einzelnen Eintrag speichern), setMultipleAsync() (mehrere Einträge speichern), deleteAsync() (einzelnen Eintrag löschen) und clearStoreAsync() (alle Einträge eines Stores entfernen). Diese erlauben schreibenden Zugriff auf die Daten. Sie sind so implementiert, dass sie für verschiedene Typen gleichermaßen genutzt werden können. Über Parameter lässt sich der Typ und eventuelle StoreIds angeben. Diese werden dann in der zuvor bereits erwähnten _getStore() Methode genutzt, um die korrekten Daten zu erhalten. Dadurch ist weniger Filterung und Sortierung nötig, im Vergleich zu z.B. einem globalen Store für die RssEntries.
Die Methoden selbst bestehen meist daraus den geeigneten Store zu holen und folgend einen einzelnen Befehl auf der Datenbank auszuführen. Der einzige Ausreißer ist hier setMultipleAsync(), denn hier wird eine Transaction open_in_new erstellt. Dies bedeutet wird sammeln eine Reihe von Datenbankbefehlen und senden diese auf einmal ab. Würden wir dies nicht tun, würden wesentlich mehr einzelne schreibende Operationen auf der Datenbank ausgeführt werden. Mittels await Future.forEach() können wir übrigens Flutters asynchrone Logik auch innerhalb einer For-Schleife anwenden.

Abschließend wollen wir natürlich auch lesenden Zugriff haben. Dieser wird durch die Methoden getFeedAsync(), getAllFeedsAsync() und getAllEntriesAsync() realisiert. Die Methoden pro Typ dienen der Vereinfachung der Nutzung und intern erfolgt ein Mapping auf die privaten Methoden (getAsync() und _getAllAsync()), um die Duplizierung von Logik zu vermeiden.
Ebenso wie bei den schreibenden Methoden sind auch die lesenden sehr simple aufgebaut. Je nach Typ wird über die internen Methoden ein snapshot oder eine snapshotList direkt aus der Datenbank geholt. Bei den Listen (_getAllAsync()) ist es dabei noch möglich ein Feld anzugeben (String sortBy), nach welchem sortiert werden soll. Hat man nun die Rohdaten abgefragt, erfolgt ein Mapping von JSON auf den gewünschten Typ, z.B. via RssFeed.fromMap(feedMap.value)).toList();.
Mit dieser Basis werden wir im nächsten Teil die erste Logik zum Hinzufügen und Anzeigen von RssFeeds via BloC umsetzen. Auch der eigentliche Start der App und die damit verbundene Initialisierung wird dann über einen BloC gelöst.

Related Links
Kommentare  
Kommentar erstellen
Mit dem Abschicken des Kommentars erklären sie sich mit der in der Datenschutzerklärung dargelegten Datenerhebung für Kommentare einverstanden. Spam, unangebrachte Werbung und andere unerwünschte Inhalte werden entfernt. Das Abonnieren via E-Mail ist nur für E-Mail Adressen erlaubt die Sie rechtmäßig administrieren. Widerrechtliche Abonnements werden entfernt.