Flutter: Lokale Datenspeicherung mit SharedPreferences
9 Min. Lesezeit

Der Nutzer wählt das dunkle Theme, schließt die App, öffnet sie wieder, und alles ist wieder hell. Das ist das typische Zeichen dafür, dass eine Einstellung nur im Arbeitsspeicher lebt. Für kleine Werte, die einen Neustart der App überstehen sollen, ist das vom Flutter-Team veröffentlichte Paket shared_preferences das erste Werkzeug. In diesem Beitrag geht es um die zwei APIs, die das Paket heute empfiehlt (SharedPreferencesAsync und SharedPreferencesWithCache), den alten Weg über SharedPreferences.getInstance(), ein vollständiges Beispiel, das die Theme-Wahl dauerhaft speichert, und um etwas ebenso Wichtiges: was Sie dort nicht speichern sollten.
Was ist SharedPreferences?
Das Paket kapselt den einfachen Key-Value-Speicher jeder Plattform hinter einer einzigen Dart-Schnittstelle: NSUserDefaults unter iOS und macOS, unter Android mit den neuen APIs standardmäßig DataStore Preferences (mit der alten API Android SharedPreferences) und im Web den localStorage des Browsers. Die speicherbaren Typen sind begrenzt: int, double, bool, String und List<String>.
Die Paketdokumentation nennt eine Warnung ganz direkt: Schreibvorgänge werden unter Umständen asynchron auf die Festplatte übertragen, und es gibt keine Garantie, dass die Daten nach der Rückkehr der Methode tatsächlich geschrieben sind; daher darf das Plugin nicht für kritische Daten verwendet werden. Hier gehören also Einstellungen wie Theme, Sprache oder „Onboarding schon gesehen?“ hin, bei denen ein verlorener Wert kein Drama ist.
Installation
flutter pub add shared_preferencesLaut der Support-Tabelle auf pub.dev braucht Android mindestens SDK 24 und iOS mindestens 13.0. Starten Sie die App nach dem Hinzufügen mit einem vollständigen Build statt per Hot Reload; neu hinzugekommener nativer Code greift nur so.
Drei APIs: Welche nehmen?
Seit Version 2.3.0 bietet das Paket drei getrennte APIs. Laut Dokumentation wird das alte SharedPreferences künftig als veraltet (deprecated) markiert, für neuen Code werden die beiden anderen empfohlen:
| API | Lesen | Cache | Wann? |
|---|---|---|---|
SharedPreferencesAsync |
Jeder Lesezugriff braucht await |
Keiner, liest jedes Mal von der Plattform | Wenn Daten aus einem anderen Isolate oder nativem Code geändert werden können; der sicherste Standard |
SharedPreferencesWithCache |
Einmal beim Start geladen, danach synchron | Ja, begrenzt durch allowList |
Wenn Sie Werte in build synchron lesen möchten |
SharedPreferences (alt) |
Synchron nach getInstance() |
Ja, alle Schlüssel | Bestehende Projekte; in neuem Code vermeiden |
Die APIs mit Cache haben eine Schwachstelle: Schreibt ein Hintergrund-Isolate (etwa eine separate Engine, die ein Benachrichtigungs-Plugin startet) oder nativer Code in denselben Speicher, veraltet Ihr Cache. SharedPreferencesAsync liest in diesem Fall immer den aktuellen Wert; mit SharedPreferencesWithCache müssen Sie vor dem Lesen reloadCache() aufrufen.
Wenn Sie unsicher sind, funktioniert diese Regel in den meisten Projekten: Müssen Sie den Wert in der Oberfläche synchron lesen (Theme, Sprache, Schriftgröße), nehmen Sie SharedPreferencesWithCache; lesen und schreiben Sie ihn nur zu bestimmten Zeitpunkten, etwa einmal beim Start oder beim Tippen auf einen Button, nehmen Sie SharedPreferencesAsync. Beide nutzen denselben Plattformspeicher und können daher im selben Projekt nebeneinander existieren.
Grundlagen mit SharedPreferencesAsync
import 'package:shared_preferences/shared_preferences.dart';
Future<void> example() async {
final prefs = SharedPreferencesAsync();
// Schreiben
await prefs.setBool('onboarding_done', true);
await prefs.setInt('launch_count', 3);
await prefs.setString('last_tab', 'profile');
await prefs.setStringList('recent_searches', ['flutter', 'dart']);
// Lesen: liefert null, wenn der Schlüssel fehlt
final bool onboardingDone = await prefs.getBool('onboarding_done') ?? false;
final int launchCount = await prefs.getInt('launch_count') ?? 0;
final String? lastTab = await prefs.getString('last_tab');
// Löschen
await prefs.remove('last_tab');
await prefs.clear(allowList: {'onboarding_done', 'launch_count'});
}Jeder Getter kann null liefern, denn beim allerersten Start existiert noch kein Schlüssel. Einen sinnvollen Standardwert mit ?? anzugeben, sollte zur Gewohnheit werden. Auch die allowList bei clear() ist wichtig: clear() ohne Parameter kann auch Werte löschen, die andere Pakete oder nativer Code in denselben Speicher geschrieben haben; deshalb empfiehlt die Paketdokumentation dringend, die Liste anzugeben.
SharedPreferencesWithCache: synchron lesen
Einen Wert wie das Theme in jedem build mit await zu lesen, ist nicht praktikabel. SharedPreferencesWithCache lädt die erlaubten Schlüssel beim Start einmal in den Speicher; danach sind Lesezugriffe synchron, Schreibzugriffe gehen in den Cache und auf die Festplatte:
final prefs = await SharedPreferencesWithCache.create(
cacheOptions: const SharedPreferencesWithCacheOptions(
allowList: {'onboarding_done', 'launch_count'},
),
);
final count = prefs.getInt('launch_count') ?? 0; // kein await
await prefs.setInt('launch_count', count + 1);Die allowList wirkt wie ein Sicherheitsgurt: Versuchen Sie, einen Schlüssel zu lesen oder zu schreiben, der nicht auf der Liste steht, erhalten Sie einen ArgumentError. Eine neue Einstellung nicht in die Liste aufzunehmen, ist der Fehler, dem Sie bei dieser API am häufigsten begegnen. Lassen Sie allowList ganz weg, werden alle Schlüssel gecacht, die Dokumentation rät aber davon ab.
Die alte API: SharedPreferences.getInstance()
Die meisten Beispiele im Netz nutzen noch diese API, deshalb sollten Sie sie kennen:
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('launch_count', 1);
final count = prefs.getInt('launch_count') ?? 0;Funktioniert sie in einem bestehenden Projekt, müssen Sie nichts überstürzen. Beim Umstieg auf die neue API ist aber Vorsicht geboten: Unter Android nutzen die neuen APIs standardmäßig DataStore, also einen anderen Speicher als die Datei, in die die alte API geschrieben hat. Ändern Sie nur den Klassennamen, wirken die gespeicherten Einstellungen der Nutzer nach dem Update wie „verschwunden“. Genau dafür bringt das Paket eine Migrationsfunktion mit:
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences/util/legacy_to_async_migration_util.dart';
Future<void> migratePreferences() async {
final legacy = await SharedPreferences.getInstance();
await migrateLegacySharedPreferencesToSharedPreferencesAsyncIfNecessary(
legacySharedPreferencesInstance: legacy,
sharedPreferencesAsyncOptions: const SharedPreferencesOptions(),
migrationCompletedKey: 'prefs_migration_done',
);
}Rufen Sie die Funktion beim Start auf, bevor Sie die neue API verwenden. Sie markiert die abgeschlossene Migration mit dem übergebenen Schlüssel; solange migrationCompletedKey gleich bleibt, verliert ein Aufruf bei jedem Start keine Daten.
Onboarding nur beim ersten Start zeigen
Der häufigste Anwendungsfall ist die Frage „Hat der Nutzer die Onboarding-Screens schon gesehen?“. Der Wert wird beim Start einmal gelesen und entscheidet über den ersten Screen:
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
const onboardingDoneKey = 'onboarding_done';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final done =
await SharedPreferencesAsync().getBool(onboardingDoneKey) ?? false;
runApp(MaterialApp(home: done ? const HomePage() : const OnboardingPage()));
}
// In der State-Klasse der OnboardingPage, der „Los geht's“-Button auf der letzten Seite:
Future<void> _finish() async {
await SharedPreferencesAsync().setBool(onboardingDoneKey, true);
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const HomePage()),
);
}pushReplacement entfernt die Onboarding-Seite vom Stack, sodass der Zurück-Button nicht dorthin führt; Seitenwechsel behandelt der Navigator-Beitrag ausführlich. Die mounted-Prüfung nach dem await verhindert, dass ein veralteter context verwendet wird, falls die Seite während des Schreibens geschlossen wurde.
Mini-Szenario: die Theme-Wahl dauerhaft speichern
Im Theme-Beispiel aus dem Provider-Beitrag lebte die Einstellung nur im Speicher. (Wie Sie helles und dunkles Theme selbst mit ThemeData aufbauen, zeigt der Beitrag zu Theme und ThemeData; hier speichern wir nur die Auswahl.) Jetzt machen wir dieselbe Idee dauerhaft. Es gibt drei Teile: eine kleine Klasse, die mit dem Speicher spricht, einen ChangeNotifier, der das Theme hält, und die App selbst.
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class SettingsStore {
SettingsStore._(this._prefs);
static const _themeModeKey = 'theme_mode';
final SharedPreferencesWithCache _prefs;
static Future<SettingsStore> create() async {
final prefs = await SharedPreferencesWithCache.create(
cacheOptions: const SharedPreferencesWithCacheOptions(
allowList: {_themeModeKey},
),
);
return SettingsStore._(prefs);
}
ThemeMode get themeMode {
final saved = _prefs.getString(_themeModeKey);
return ThemeMode.values.asNameMap()[saved] ?? ThemeMode.system;
}
Future<void> saveThemeMode(ThemeMode mode) =>
_prefs.setString(_themeModeKey, mode.name);
}
class ThemeController extends ChangeNotifier {
ThemeController(this._store) : _mode = _store.themeMode;
final SettingsStore _store;
ThemeMode _mode;
ThemeMode get mode => _mode;
Future<void> setMode(ThemeMode mode) async {
if (mode == _mode) return;
_mode = mode;
notifyListeners(); // Zuerst die Oberfläche aktualisieren
await _store.saveThemeMode(mode); // Dann dauerhaft speichern
}
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final store = await SettingsStore.create();
runApp(MyApp(themeController: ThemeController(store)));
}
class MyApp extends StatelessWidget {
const MyApp({super.key, required this.themeController});
final ThemeController themeController;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: themeController,
builder: (context, child) => MaterialApp(
theme: ThemeData(colorSchemeSeed: Colors.indigo),
darkTheme: ThemeData(
colorSchemeSeed: Colors.indigo,
brightness: Brightness.dark,
),
themeMode: themeController.mode,
home: SettingsPage(themeController: themeController),
),
);
}
}
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key, required this.themeController});
final ThemeController themeController;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Einstellungen')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Theme'),
const SizedBox(height: 8),
SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(value: ThemeMode.system, label: Text('System')),
ButtonSegment(value: ThemeMode.light, label: Text('Hell')),
ButtonSegment(value: ThemeMode.dark, label: Text('Dunkel')),
],
selected: {themeController.mode},
onSelectionChanged: (selection) =>
themeController.setMode(selection.first),
),
],
),
),
);
}
}In diesem Code stecken vier Entscheidungen:
- Der Wert wird vor
runAppgelesen. Das Theme stimmt schon im ersten Frame; es gibt kein kurzes helles Aufblitzen, bevor eine halbe Sekunde später auf Dunkel gewechselt wird. Um vorrunAppauf ein Plugin zuzugreifen, istWidgetsFlutterBinding.ensureInitialized()Pflicht. - Das Enum wird über seinen Namen gespeichert.
mode.nameliefert Text wie"dark",asNameMap()wandelt ihn zurück. Wer stattdessen die Position (index) speichert, bekommt das falsche Theme, sobald später ein neuer Wert ins Enum eingefügt wird. Mehr zum Arbeiten mit Enums lesen Sie im Dart-Enum-Beitrag. - Fehlt der gespeicherte Wert oder ist er ungültig, greift
?? ThemeMode.system; die App stürzt nie ab. - Erst die Oberfläche, dann die Festplatte.
notifyListeners()wird vor dem Schreiben aufgerufen, der Nutzer spürt also keine Verzögerung.
Ich habe hier ListenableBuilder verwendet, damit das Beispiel ohne zusätzliche Pakete läuft. Nutzt Ihr Projekt Provider, geben Sie denselben ThemeController mit ChangeNotifierProvider in den Baum und lesen ihn mit context.watch; mit Riverpod stellen Sie SettingsStore über einen Provider bereit. Der Schritt „dauerhaft speichern“ im Einstellungs-Screen mit Switch, SegmentedButton und Slider entspricht genau diesem Aufbau.
Ein kleines Objekt speichern
Objekte gehören nicht zu den unterstützten Typen, aber Sie können ein kleines Objekt in JSON-Text umwandeln und als String speichern:
import 'dart:convert';
class SearchFilter {
const SearchFilter({required this.query, required this.onlyFree});
final String query;
final bool onlyFree;
Map<String, dynamic> toJson() => {'query': query, 'onlyFree': onlyFree};
factory SearchFilter.fromJson(Map<String, dynamic> json) => SearchFilter(
query: json['query'] as String,
onlyFree: json['onlyFree'] as bool,
);
}
Future<void> saveFilter(SharedPreferencesAsync prefs, SearchFilter filter) =>
prefs.setString('last_filter', jsonEncode(filter.toJson()));
Future<SearchFilter?> loadFilter(SharedPreferencesAsync prefs) async {
final raw = await prefs.getString('last_filter');
if (raw == null) return null;
return SearchFilter.fromJson(jsonDecode(raw) as Map<String, dynamic>);
}Das Muster aus fromJson und toJson ist dasselbe wie bei API-Daten; ausführlich erkläre ich es im Beitrag über HTTP-Anfragen. Dieser Ansatz passt für ein einzelnes kleines Objekt wie den zuletzt genutzten Filter. Speichern Sie bereits eine Liste mit Hunderten Einträgen als einen JSON-String, benutzen Sie das falsche Werkzeug: Um einen Eintrag zu ändern, müssen Sie die ganze Liste lesen und neu schreiben, Filtern und Sortieren erledigen Sie von Hand, und mit SharedPreferencesWithCache liegt der gesamte String dauerhaft im Speicher. Eine Datenbank aktualisiert eine einzelne Zeile und führt die Abfrage für Sie aus.
Was gehört wohin?
| Daten | Der richtige Ort |
|---|---|
| Theme, Sprache, Benachrichtigungswunsch, „Onboarding gesehen“-Flag | shared_preferences |
| Sitzungstoken, Passwort, API-Schlüssel | flutter_secure_storage |
| Viele strukturierte Datensätze wie Notizen, Bestellungen, Nachrichten | Eine Datenbank wie sqflite oder drift |
| Bilder, PDFs, große Dateien | Das Dateisystem (App-Ordner über path_provider) |
Warum das Token hier nicht hingehört, ist wichtig: shared_preferences verschlüsselt nichts. Unter Android liegen die Daten unverschlüsselt in einer Datei im Datenordner der App, im Web im localStorage, den man über die Entwicklertools des Browsers lesen kann. flutter_secure_storage nutzt dagegen unter iOS die Keychain und unter Android einen verschlüsselten Speicher, und die Verwendung ist fast gleich: await storage.write(key: 'token', value: token) und await storage.read(key: 'token').
Häufige Fehler
1. ensureInitialized vor runApp vergessen
Symptom: Die App scheitert direkt beim Start mit Binding has not yet been initialized. Greifen Sie in main vor runApp auf ein Plugin zu, muss die erste Zeile WidgetsFlutterBinding.ensureInitialized(); lauten.
2. Schlüssel als Strings im ganzen Code verstreuen
Schreiben Sie an einer Stelle 'themeMode' und an einer anderen 'theme_mode', wird der Wert nie gelesen, und Sie erhalten nicht einmal einen Fehler, sondern nur den Standardwert. Halten Sie die Schlüssel als Konstanten in einer Klasse; mit SharedPreferencesWithCache verwenden Sie dieselben Konstanten auch für die allowList. Einen Schlüssel in einer veröffentlichten App umzubenennen, hat denselben Effekt: Die Werte bestehender Nutzer bleiben unter dem alten Namen liegen und werden nie wieder gelesen. Legen Sie den Namen einmal fest, und wenn eine Umbenennung nötig ist, schreiben Sie eine kleine Migration, die den alten Namen liest und den Wert unter den neuen schreibt.
3. Von der alten auf die neue API wechseln und Daten „verlieren“
Symptom: Nach einem Update scheinen die Einstellungen der Nutzer zurückgesetzt. Unter Android schreiben alte und neue API in verschiedene Speicher. Führen Sie den Wechsel mit migrateLegacySharedPreferencesToSharedPreferencesAsyncIfNecessary wie oben durch.
4. Sensible oder kritische Daten hier ablegen
Tokens und Passwörter werden unverschlüsselt gespeichert, und bei Daten, die nicht verloren gehen dürfen, etwa einem Zahlungsdatensatz, ist nicht garantiert, dass der Schreibvorgang die Festplatte erreicht. Das Erste gehört in einen sicheren Speicher, das Zweite in eine Datenbank und idealerweise zusätzlich auf den Server.
5. Den Wert auf jedem Screen per FutureBuilder neu lesen
Richtet jeder Screen beim Öffnen einen FutureBuilder für await prefs.getBool(...) ein, bekommen Sie doppelten Code und jedes Mal einen kurzen Ladezustand. Laden Sie app-weite Einstellungen einmal beim Start, halten Sie sie in einem Controller wie oben, und lassen Sie die Screens von dort lesen.
Häufig gestellte Fragen
Aktualisiert sich der Screen automatisch, wenn sich ein Wert ändert?
Nein. shared_preferences bietet weder einen Änderungs-Stream noch einen Listener; es schreibt und liest nur. Soll die Oberfläche reagieren, halten Sie den Wert in einem ChangeNotifier (oder Provider, Riverpod), benachrichtigen bei einer Änderung die Listener und schreiben zugleich in den Speicher. Der ThemeController oben macht genau das.
Sind die Daten nach Deinstallation und Neuinstallation weg?
Meistens ja, aber achten Sie auf zwei Ausnahmen. Unter Android können App-Daten bei aktiviertem Auto Backup (standardmäßig an) im Google-Konto gesichert und bei einer Neuinstallation wiederhergestellt werden; das „Onboarding gesehen“-Flag kann also auch bei einer frischen Installation true sein. Unter iOS können die Keychain-Einträge, die flutter_secure_storage nutzt, nach dem Löschen der App erhalten bleiben. Bauen Sie keine Logik, die sich auf dieses Verhalten verlässt oder es ignoriert; legen Sie ausdrücklich fest, was bei einer Neuinstallation passieren soll.
Wie nutze ich SharedPreferences in Widget-Tests?
In Tests gibt es keinen echten Plattformspeicher. Für die alte API richtet SharedPreferences.setMockInitialValues({}) einen Speicher im Arbeitsspeicher ein. Für die neuen APIs fügen Sie das Paket shared_preferences_platform_interface als Dev-Abhängigkeit hinzu und führen zu Beginn des Tests SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); aus; danach verwenden SharedPreferencesAsync und SharedPreferencesWithCache diesen Speicher.
Verwandte Artikel
Flutter: HTTP-Anfragen und REST-APIs
GET, POST, PUT und DELETE mit dem http-Paket in Flutter, JSON in Modellklassen, Statuscodes, Timeouts sowie Lade- und Fehlerzustände mit FutureBuilder.
Flutter: Echtzeit-Datenströme mit StreamBuilder
StreamBuilder in Flutter: connectionState bei Streams, initialData, Broadcast vs. Single-Subscription, StreamController schließen, listen() im Vergleich.
Flutter: Asynchrone Listen mit FutureBuilder
FutureBuilder in Flutter: Snapshot-Zustände richtig lesen, der Fehler „Future in build erzeugen“, die Lösung mit initState und Aktualisieren per setState.