Tutorial · 18 min · intermediate
Storage, all four kinds
SharedPreferences, the Keychain, Hive boxes, and SQLite — four drop-in stores over FFI, no MethodChannel, and the production rules for picking between them.
What you'll build — running on device.
Every app stores things, and DartNative ships four stores with the APIs
you already know from Flutter — shared_preferences,
flutter_secure_storage, Hive, and a sqflite-style SQLite — all running
over direct FFI. Two of the four pick themselves: secrets go in the
platform keychain, and relational rows go in SQLite. The other two —
SharedPreferences and Hive — overlap: both are key-value stores, and
real apps use both. The working rule from production DartNative apps:
- SharedPreferences for small flags and settings — and for anything
background code must read. It writes to the platform’s own store
(
NSUserDefaults/ Android’s preferences), which native services and background tasks can reach while your app isn’t in the foreground. - Hive for typed Dart objects and caches — a box of models loaded into memory at startup, fast to read in bulk. Boxes belong to the running app: keep anything a background task needs out of them.
You’ll build a four-tab screen with one panel per store: each panel is a column of action buttons over an output log, so every write and read prints what just happened. It’s the same screen as the DartNative playground’s Storage demo, carried over byte-identical.
What you need
- A project from Your first DartNative app
- The storage plugins in your
pubspec.yaml:
dependencies:
dartnative_shared_preferences: ^1.0.0
dartnative_secure_storage: ^1.0.0
dartnative_hive: ^2.0.0
dartnative_sqlite: ^1.0.0
dartnative_path_provider: ^1.0.0
After dn pub get, the generated registrant picks up the new plugins —
DartNativePluginRegistrant.registerAll() in main() loads their FFI
symbols (forget it and plugin calls fail at startup; the registrant is
regenerated for you, so there’s nothing to hand-wire). Hive, SQLite, and
path_provider are pure Dart over the core symbols — only preferences and
secure storage have symbols of their own to load.
Step 1 — Settings: SharedPreferences
Small flags and counters — “has the user seen the intro?”, “how many
times has this launched?” — belong in the platform’s preferences store,
where they survive relaunches without you managing a file. Get the
singleton once, then it’s the drop-in package:shared_preferences API:
_prefs = await SharedPreferences.getInstance();
The Save values button writes three types in a row:
final count = (prefs.getInt('tap_count') ?? 0) + 1;
await prefs.setInt('tap_count', count);
await prefs.setBool('seen_demo', true);
await prefs.setString('last_name', 'Alice');
Reads are synchronous — no await — because the store is mirrored in
memory; that’s the whole appeal for settings you check on every build.
The Read back button shows the raw nullable values on purpose:
// Show the raw nullable values: a missing key reads back as `(unset)`,
// not a defaulted 0/false/empty — so `Clear all` is visibly effective.
final count = prefs.getInt('tap_count');
final seen = prefs.getBool('seen_demo');
final name = prefs.getString('last_name');
A missing key is null, not 0 — so after await _prefs?.clear() you
can see that the platform store is empty, rather than staring at
defaults that look the same either way. Under the hood this is
NSUserDefaults on iOS and SharedPreferences on Android, wired
through Dart FFI.
Step 2 — Secrets: SecureStorage
The rule of thumb: anything you’d be uncomfortable seeing in a device
backup — tokens, keys, credentials — never goes in preferences. It goes
in the platform’s secret store: the Keychain on iOS (hardware-backed
encrypted storage that survives even app reinstalls) and
EncryptedSharedPreferences on Android. The API is drop-in
flutter_secure_storage:
final FlutterSecureStorage _secure = const FlutterSecureStorage();
Save an auth token, read it, delete it:
await _secure.write(key: 'auth_token', value: token);
final token = await _secure.read(key: 'auth_token'); // null if absent
await _secure.delete(key: 'auth_token');
Every call is async here — secret stores aren’t memory-mirrored, by
design. The demo’s Delete token button also demonstrates a habit
worth stealing: after deleting, it reads the key back and only reports
success if the read returns null. And await _secure.deleteAll()
wipes every key this app wrote — your “log out everywhere” button.
Step 3 — Objects: Hive
The other key-value store. Where SharedPreferences holds scalar settings
in the platform’s store, Hive holds typed Dart objects in a box — a
named file of key-value pairs that loads fully into memory when opened,
which makes bulk reads essentially free. It’s the natural home for
structured state that isn’t worth a schema: a profile, a draft, a model
cache (production apps open their boxes once at startup and read them all
session). Remember the split from the intro: boxes live inside the running
app — data a background task must see belongs in SharedPreferences
instead. One line differs from
Flutter Hive: Hive.initDartNative(path) replaces Hive.initFlutter(),
with the path from dartnative_path_provider:
Hive.initDartNative(getApplicationDocumentsDirectory(), subDir: 'hive');
_box = await Hive.openBox<dynamic>('demo');
After openBox, the trade is: reads are instant (in-memory), writes are
awaited (persisted to disk). The Tap to count button does both:
final n = ((box.get('tap_count') as int?) ?? 0) + 1;
await box.put('tap_count', n);
Relaunch the app and the panel greets you with the count from the previous session — the box reloaded it from disk on open. And values aren’t just scalars — Save a Map stores a whole structure and reads it straight back:
final profile = <String, Object>{
'name': 'Alice',
'score': 99,
'active': true,
};
await box.put('profile', profile);
final back = box.get('profile');
Step 4 — Rows: SQLite
When your data has relationships, or you’ll query it in more than one
way, you want a real database. dartnative_sqlite is the sqflite shape —
open with version/onCreate migrations, insert/query/delete
maps, transactions — over direct FFI to libsqlite3.
The demo opens the database the way you’d want to in production, and each choice has a reason:
// A production-ready pattern:
// 1. Put the db in a `database/` subdir of documents (keeps WAL + SHM
// side files grouped, easy to back up / wipe).
// 2. On iOS, create that subdir with `NSFileProtectionNone` so the
// DB can be opened from a background isolate while the device is
// locked (push-notification handler, background fetch).
// 3. On Android, plain recursive mkdir.
// 4. PRAGMA foreign_keys = ON in onConfigure (cascade deletes work).
// 5. singleInstance: true (default in `Sqlite.open`) so re-opening
// from the same isolate returns the cached connection.
final docsDir = getApplicationDocumentsDirectory();
final dbDir = '$docsDir/database';
if (Platform.isIOS) {
final ok = await createUnprotectedFolder(
parent: docsDir,
name: 'database',
);
// (the demo logs a warning when `ok` is false — protection stays default)
} else {
final dir = Directory(dbDir);
if (!await dir.exists()) {
await dir.create(recursive: true);
}
}
final dbPath = '$dbDir/storage_demo.db';
_db = await Sqlite.open(
dbPath,
version: 1,
onConfigure: (db) async {
// Enable cascade deletes — must run before onCreate / onUpgrade.
await db.execute('PRAGMA foreign_keys = ON');
},
onCreate: (db, v) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS scores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
value INTEGER NOT NULL,
ts INTEGER NOT NULL
)
''');
},
);
Unpacking the unfamiliar parts: WAL and SHM are side files SQLite keeps
next to the database in its default journaling mode — a dedicated
subdirectory keeps the trio together. NSFileProtectionNone opts that
folder out of iOS’s lock-screen file encryption, which is what lets a
background isolate open the database while the phone is locked (the
default protection class would refuse). And a PRAGMA is a SQLite
configuration statement — foreign_keys = ON is off by default and must
be set per-connection, which is exactly what onConfigure is for.
From there it’s maps in, maps out:
final id = await db.insert('scores', {
'name': name,
'value': value,
'ts': DateTime.now().millisecondsSinceEpoch,
});
final rows = await db.query('scores', orderBy: 'ts DESC', limit: 5);
The Insert 2 in a transaction button shows atomicity — both rows commit together, or neither does:
await db.transaction((txn) async {
await txn.insert('scores', {'name': 'TxA', 'value': 10, 'ts': now});
await txn.insert('scores', {'name': 'TxB', 'value': 20, 'ts': now + 1});
});
Note the callback writes through txn, not db — that’s what scopes
the inserts to the transaction. If anything inside throws, everything
rolls back.
Why this is native
Flutter’s storage plugins bounce every call across a MethodChannel to platform code. These four talk to the platform stores over direct FFI — no channel, no codec, callable from any isolate (a background push handler can read the same SQLite database). The APIs stayed drop-in on purpose: your Flutter storage code ports by changing imports.
The finished code
In the public repo — dn create ., dn run. The screen is a
byte-identical copy of the playground’s Storage demo: when the
playground screen improves, this tutorial inherits it by re-copying
the file.