Effiziente Datenverwaltung mit Flutter Isar: Tipps & Tricks

Inspiriert durch den Futter DACH Podcast und den Gast Simon Choi, habe ich mich mal näher mit dem Thema “Offline First” und lokale Datenbanken beschäftigt. Da ich sowieso gerade meine Wetterapp  überarbeite und ich sowieso ein Offline Feature mit hinzufügen wollte, kam mir da Flutter Isar genau richtig.

Dabei war der Gedanke, dass Flutter Isar nicht nur als Offline Speicher dient, sondern auch generell als Datenverwaltung für Leute, die sich nicht registrieren wollen oder für Apps, die keinerlei Anbindung zu einer API haben. 

Also werfen wir mal einen Blick auf Flutter Isar.

Warum ist Datenverwaltung so wichtig?

In den meisten Apps kommt es vor, dass Daten lokal gespeichert werden müssen, besonders für Apps die keine Anbindung zur API und erst recht nicht zum Internet haben. 

Ein Beispiel hierfür könnte eine To-Do-App sein, die alle To-Dos lokal speichern muss. Auch wenn eine App verschiedene Einstellungen bietet, sollten diese ebenfalls gespeichert werden.

Bisher habe ich Flutter Secure Storage verwendet, um kleinere Datengrößen zu speichern, insbesondere den Bearer Token. Mit dem letzten App-Update habe ich jedoch die Gelegenheit genutzt, Flutter Isar auszuprobieren. Für den Token werde ich jedoch weiterhin Flutter Secure Storage verwenden.

Flutter Isar im Überblick

Schauen wir uns mal die offizielle Webseite von Isar an.

  • Open Source und extra für Flutter gemacht
  • Multiplattform (Android, iOS und Web natürlich)
  • Volltextsuche in der Datenbank
  • Parallele Abfragen und Multi-Isoliert
  • ACID Semantik (das schauen wir uns auch noch an)
  • Schnell (das ist doch alles was wir wollen)

Grundsätzlich kann man Flutter Isar als Entwicklung zu Flutter Hive betrachten, jedoch mit einigen Verbesserungen und neuen Features. Später werde ich auf die genauen Features nochmal eingehen. Schauen wir uns mal an wie wir das ganze implementieren.

Installation

Flutter Packages kann man auf zwei Arten installieren. Füge entweder das Package in deine pubspec.yaml und führt den Befehl flutter pub get aus.

				
					dependencies:
    flutter_riverpod: ^0.14.0+3
				
			
				
					flutter pub get
				
			

oder du installierst das Package direkt über die Kommandozeile.

				
					flutter pub add isar
				
			

Datenmodel erstellen

In herkömmlichen relationalen Datenbanken wie MySQL oder MariaDB werden Tabellen erstellt und mit Attributen gefüllt. So benötigen wir in einer Todo App eine Datenbanken Tabelle, die alle Todos speichert. Diese könnte in etwa so aussehen:

iduser_idtododone
11Küche aufräumentrue
21Flutter lernenfalse
32Blog Artikel schreibentrue

Und so ähnlich müssen wir uns die Datenbank in Isar vorstellen, nur dass die Tabellen hier Collections heißen. Schauen wir uns das mal in der Beispiel-App an.

Die Beispiel-App ist ebenfalls eine Todo-App, die jedoch ihre Daten in der lokalen Datenbank speichert.

Hier ist mein Datenmodel dazu:

				
					import 'package:isar/isar.dart';

part 'todo.g.dart';

@collection
class Todo {
  Id? id;
  String? todo;
  bool? done;
  DateTime? createdAt;

  Todo({
    this.id,
    required this.todo,
    this.done = false,
    this.createdAt,
  });

  @override
  String toString() {
    return 'id: $id, todo: $todo';
  }
}

				
			

Nichts komplexes. Todo ist unser Todo als String, Done ´gibt an, ob das Todo abgeschlossen wurde und createAt ein Timestamp, der angibt, wann das Todo erstellt wurde, einfach nur Just For Fun.

Die Annotation @collection definiert dieses Model und erstellt eine lokale Collection daraus.

Jetzt müssen wir nur einmal den build Befehl aufrufen, um unsere Hilfsklasse zu generieren.

				
					dart run build_runner build  
				
			

Genau dieser Befehl generiert die Datei todo.g.dart. Diese Datei enthält nützliche Methoden, die wir verwenden können, um beispielsweise eine Datenbankabfrage zu erstellen. Die funktionieren ähnlich wie eine SQL-Abfragen.

Datenbank initialisieren

Für die Initiallisierung hab ich ein IsarService implementiert. Der wie folgt aussieht:

				
					import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
import 'package:wetterfest/models/clothes.dart';
import 'package:wetterfest/models/setting.dart';
import 'package:wetterfest/models/user.dart';

class IsarService {
  IsarService() {
    instance = openDB();
  }

  late Future<Isar> instance;

  Future<Isar> openDB() async {
    if (Isar.instanceNames.isEmpty) {
      final dir = await getApplicationDocumentsDirectory();
      return Isar.open(
        [
          TodoSchema,
        ],
        directory: dir.path,
      );
    }
    return Future.value(Isar.getInstance());
  }
}

				
			

Die IsarService Klasse fungiert hier als Singleton, sodass wir immer die selbe Instanz verwenden können. Die Funktion Isar.open() wird zur  Initialisierung der Datenbank verwendet. Dabei werden die Collection Schematas übegeben, die mit der todo.g.dart Datei generiert wurden sind. 

Außerdem wird als zweiter Parameter ein Pfad angegeben, indem die Datenbank gespeichert wird.

In der main.dart Datei kann optionial openDB() aufgerufen werden. Das ist jedoch nicht zwingend notwendig, da unser IsarService sowieso eine Instanz erstellt, sobald wir Flutter Isar nutzen. Für die Entwicklung kann es aber von Vorteil sein, da der Isar Inspector sofort gestartet wird. Auf den Isar Inspector gehen wir im späteren Abschnitt noch näher drauf ein.

				
					
  // initial database (optional)
  await IsarService().openDB();
				
			

Lese- und Schreibtransaktionen

Im wesentlichen wird zwischen Lese und Schreibtransaktion in Flutter Isarunterschieden. 

Lesetransaktionen können sehr unterschiedlich sein. Die folgenden Beispiele zeigen nur einige der vielen get, filter und where Abfragen. 

				
					final isar = await IsarService().instance;
final collection = isar.collection<Todo>();
    
// gibt ein bestimmes todo zurück
Todo? todo = await collection.get(1);

// gibt eine Liste von todos zurück
List<Todo?> todos = await collection.getAll([1,2,3]);

// gibt alle todos zurück
List<Todo> todos = await collection.where().findAll()
				
			

Bei Schreibtransaktionen ist nur eines zu beachten. Alle Transaktionen, die ein Eintrag in der Datenbank ändern, müssen innerhalb des writeTxn() Callbacks ausgeführt werden. 

Dies stellt die Atomarität (Abgeschlossenheit) sicher. Das bedeutet, wenn in irgendeinem Teil der Transaktion ein Fehler auftritt, wird die gesamte Transaktion nicht durchgeführt. Dadurch wird verhindert, dass im Falle eines Fehlers Datenbankeinträge fehlerhaft eingetragen werden.

				
					final isar = await IsarService().instance;

// Transaktion wird gestartet
await isar.writeTxn(() async {
    // todo wird abgerufen
    final todo = await isar.todos.get(id);
    // prüfe ob todo vorhanden ist 
    if (todo != null) {
        // todo ändern
        todo.done = done;
        // todo in der Datenbank updaten
        isar.todos.put(todo);
    }
});
// Transaktion wurde beendet und komplett durchgeführt
				
			

Zurück zur Beispiel-App. Aus dieser haben sich dann vier Methoden für die Todos herauskristalisiert. 

Man könnte diese jetzt noch in einer extra Datei auslagern oder mit dem Bloc-Pattern verknüpfen, aber ich hab die jetzt der Einfachheit halber direkt in das Widget mit implementiert.

				
					 Future<List<Todo>> getTodo() async {
    Isar isar = await IsarService().instance;
    return Future.value(isar.todos.where().findAll());
  }

  Future<void> addTodo(String todo) async {
    Isar isar = await IsarService().instance;
    await isar.writeTxn(() async {
      isar.todos.put(Todo(todo: todo));
    });
  }

  Future<void> setTodo(int id, bool done) async {
    Isar isar = await IsarService().instance;
    await isar.writeTxn(() async {
      final todo = await isar.todos.get(id);

      if (todo != null) {
        todo.done = done;
        isar.todos.put(todo);
      }
    });
  }

  Future<void> removeTodo(int id) async {
    Isar isar = await IsarService().instance;
    await isar.writeTxn(() async {
      isar.todos.delete(id);
    });
  }
				
			

Alles zusammen verknüpfen

So wir haben alles zusammen. Jetzt nur noch alles zusammenpacken und schon haben wir unsere lokale Todo-App. Den ganzen Code findet ihr in meiner Github-Repo.

Flutter Isar Todo App
Simulator-Screenshot-iPhone-14-Pro-2023-09-11-at-11.08.47
				
					import 'package:flutter/material.dart';
import 'package:isar/isar.dart';
import 'package:youtube/models/todo.dart';
import 'package:youtube/utils/isar_service.dart';

class HomeIsarScreen extends StatefulWidget {
  static const String routeName = 'home-isar';

  const HomeIsarScreen({super.key});

  @override
  State<HomeIsarScreen> createState() => _HomeIsarScreenState();
}

class _HomeIsarScreenState extends State<HomeIsarScreen> {
  String todo = "";

  Future<List<Todo>> getTodo() async {
    Isar isar = await IsarService().instance;
    return Future.value(isar.todos.where().findAll());
  }

  Future<void> addTodo(String todo) async {
    Isar isar = await IsarService().instance;
    await isar.writeTxn(() async {
      isar.todos.put(Todo(todo: todo));
    });
  }

  Future<void> setTodo(int id, bool done) async {
    Isar isar = await IsarService().instance;
    await isar.writeTxn(() async {
      final todo = await isar.todos.get(id);

      if (todo != null) {
        todo.done = done;
        isar.todos.put(todo);
      }
    });
  }

  Future<void> removeTodo(int id) async {
    Isar isar = await IsarService().instance;
    await isar.writeTxn(() async {
      isar.todos.delete(id);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Isar'),
      ),
      body: Center(
        child: FutureBuilder(
          future: getTodo(),
          builder: (context, snapshot) {
            if (snapshot.connectionState == ConnectionState.waiting) {
              return const Text('Loading...');
            }
            if (snapshot.data!.isNotEmpty) {
              return Column(
                children: List.generate(
                  snapshot.data!.length,
                  (index) {
                    Todo todo = snapshot.data![index];
                    return ListTile(
                      leading: Checkbox(
                        onChanged: (value) {
                          setTodo(todo.id!, value ?? false);
                          setState(() {});
                        },
                        value: todo.done ?? false,
                      ),
                      title: Text(todo.todo ?? ''),
                      trailing: IconButton(
                        icon: const Icon(Icons.delete),
                        onPressed: () async {
                          await removeTodo(todo.id!);
                          setState(() {});
                        },
                      ),
                    );
                  },
                ),
              );
            } else {
              return const Text('No data');
            }
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.add),
        onPressed: () {
          showModalBottomSheet(
            context: context,
            builder: (context) {
              return Container(
                margin: const EdgeInsets.all(10),
                child: Column(
                  children: [
                    TextField(
                      decoration: const InputDecoration(
                        labelText: 'Was möchtest du tun?',
                        border: OutlineInputBorder(),
                      ),
                      onChanged: (value) {
                        todo = value;
                      },
                    ),
                    const SizedBox(height: 10),
                    Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: [
                        ElevatedButton(
                          onPressed: () {
                            Navigator.of(context).pop();
                          },
                          child: const Text('zurück'),
                        ),
                        ElevatedButton(
                          onPressed: () async {
                            await addTodo(todo);
                            setState(() {});
                            Navigator.of(context).pop();
                          },
                          child: const Text('hinzufügen'),
                        ),
                      ],
                    ),
                  ],
                ),
              );
            },
          );
        },
      ),
    );
  }
}

				
			

Der Isar Inspector

Jetzt möchte ich noch auf die Isar Inspector Oberfläche eingehen. Sobald du den Debug-Mode startest, wird in der Kommandozeile der Link zum Isar Inspector angezeigt:

				
					
flutter: ╔══════════════════════════════════════════════════════╗
flutter: ║                 ISAR CONNECT STARTED                 ║
flutter: ╟──────────────────────────────────────────────────────╢
flutter: ║         Open the link to connect to the Isar         ║
flutter: ║        Inspector while this build is running.        ║
flutter: ╟──────────────────────────────────────────────────────╢
flutter: ║ https://inspect.isar.dev/3.1.0+1/#/55350/l-jf1hQcKDU ║
flutter: ╚══════════════════════════════════════════════════════╝
				
			

Dieser Link bringt dich zur Isar Enspector Oberfläche mit der kompletten Übersicht deiner lokalen Datenbank.

Und WOW, ich bin sowas von begeistert davon. Das ist etwas was mit bei Hive immer gefehlt hat. Eine gute Oberfläche, die mir bei meiner Entwicklung hilft. Es ist nicht nur möglich, Objekte hinzuzufügen oder auch die Datenbank zu filtern, sondern auch einen Datensatz mit einer JSON Datei hinzuzufügen. Sogar an White und Dark Mode wurde gedacht. Für mich als UI Fan ist das eine enorme Bereicherung.

Zusammenfassung

Flutter Isar hat mich wirklich beeindruckt und dazu gebracht mehr über Offline First nachzudenken. Bisher hab ich nur an der Oberfläche der ganzen Funktionen gekratzt. Ich bin gespannt wie es sich auch auf größeren Projekten auswirkt. 

Also wer darüber nachdenkt seine Aktuelle App ein kleines Update zu geben und über Offline Speicher nachdenkt oder wer auf der Suche nach einer Hive Alternative ist, der sollte sich Flutter Isar mal anschauen. Zumal Simon Choi ebenfalls an Hive mitbeteiligt war. Dadurch kennt er die Schwächen von Hive und konnte diese in Flutter Isar ausbessern.

Posted in Allgemein, Flutter/DartTags:
Write a comment