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

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.

Open the finished code →