Flutter アプリのローカルストレージを Hive から Drift へ移行する (hive_ce 2.22 から drift 2.35 へ)
既存の Hive box を、ユーザーの端末上の drift SQLite データベースへ一度だけ安全にインポートする方法です。スキーマ設計、インポート関数、冪等性、box の削除に加えて、データを黙って壊す 2 つの落とし穴 (DateTime の精度とクラス名の衝突) を扱います。Flutter 3.44.8、hive_ce 2.22.0、drift 2.35.2 で検証しています。
リリース済みの Flutter アプリを Hive から drift へ移すのは、コードのリファクタリングではなく、すべてのユーザーのスマートフォン上で実行されるデータ移行です。ユーザーが既に持っているデータはアプリのドキュメントディレクトリの .hive ファイルにあり、新バージョンはそれを SQLite データベースへちょうど一度だけコピーしなければなりません。途中でアプリが強制終了されても何も失ってはいけません。有効な方法は次のとおりです。数リリースの間は Hive を読み取り専用の依存として残し、起動時にインポート関数を実行して、単一の drift トランザクション内ですべての box を読み込み、同じトランザクション内でフラグ行を書いて完了を記録し、コミット後にだけ box ファイルを削除します。box が 2 から 5 個程度の一般的なアプリなら、テストを含めて半日の作業です。黙って壊れるのは 2 点で、drift の既定の DateTime 保存形式 (秒単位、ローカル時刻) と、生成される行クラスが Hive モデルの名前と衝突する問題です。以下はすべて Flutter 3.44.8 (Dart 3.12.2)、hive_ce 2.22.0、drift 2.35.2、drift_flutter 0.3.1、drift_dev 2.35.1 で検証しました。
そもそもなぜ Hive から移るのか
- 元のパッケージは更新が止まっています。
hive2.2.3 は 2022 年 6 月 30 日に公開されたもので、今も pub.dev の最新版です。hive_flutter1.1.0 は 2021 年 6 月のものです。コミュニティのフォークであるhive_ceは保守されており (2.22.0 が今週公開されました)、保守だけが目的なら import をhive_ceに切り替えるほうが手軽です。私のテストでは、hive2.2.3 で書き込んだ box をhive_ce2.22.0 で正しく開いて読み取れたため、この入れ替えにデータ移行は不要です。 - 必要なのはルックアップではなくクエリです。 Hive はキーバリューストアです。フィルタ、並べ替え、ページング、結合は、値をメモリに読み込んだあとで Dart 側で行うことになります。drift ならコンパイル時に検査される Dart のクエリとしての SQL、インデックス、そして基になるテーブルが変わったときにだけ更新されるリアクティブな
watch()ストリームが使えます。 - スキーマの進化が明示的になります。 Hive でフィールドを追加すると、
@HiveFieldのインデックスと null 許容の読み取りを延々とやりくりすることになります。drift にはschemaVersion、onUpgradeコールバック、そして型付きでテスト可能な段階的マイグレーションを生成するツール (drift_dev schema steps) があります。 - トランザクション。 Hive の書き込みはキー単位です。drift は任意の数の書き込みを SQLite のトランザクションにまとめられ、これこそが以下のインポートをクラッシュに強くしている仕組みです。
アプリ内で何が変わるか
| 領域 | 変更内容 | 重大度 |
|---|---|---|
| モデルクラス | @HiveType クラスは drift のテーブルと生成された行クラスに置き換わります | 高 |
DateTime 列 | テキスト保存を選ばない限り、drift は秒単位で保存し、ローカル時刻として返します | 高 |
| キー | Hive の自動採番キー (box.add) は int、独自キーは String です。drift では型付きの主キーが必要です | 中 |
| リストとマップ | ネイティブのリスト列はないため、TypeConverter か子テーブルが必要です | 中 |
| 起動処理 | Hive.initFlutter() と openBox が、1 つの AppDatabase インスタンスに置き換わります | 中 |
| リアクティブな UI | ValueListenableBuilder(box.listenable()) が StreamBuilder(query.watch()) になります | 低 |
| 保存場所 | どちらも getApplicationDocumentsDirectory() を使います。drift は .hive ファイルの隣に <name>.sqlite を書き込みます | 低 |
事前チェックリスト
- すべての box を洗い出します。名前、値の型 (
Box<Note>、Box<dynamic>、LazyBox)、キーの型、暗号化の有無、そしてヘビーユーザーのおおよそのエントリ数です。 - すべての
TypeAdapterとそのtypeIdを一覧にします。インポートのコードでそれらを登録しないと、未知の型 ID に遭遇した時点でopenBoxが例外を投げます。 - 実機の
.hiveファイルのコピーを (デバッグビルドから、adb pullか Xcode のコンテナのダウンロードで) 取得し、テスト用のフィクスチャとして使います。 - 移行元として引き続きサポートする最小のアプリバージョンを決めます。そのサポートを終了するまで、Hive は
pubspec.yamlに残します。 - CI で
dart run build_runner buildを実行できることを確認します。drift はコード生成を使うためです。
移行手順
-
Hive の代わりではなく、Hive の隣に drift を追加します。 インポートで古いファイルを読む必要があるため、古いパッケージは残します。なお
sqlite3_flutter_libsはもう不要です。drift_flutter0.3.x はsqlite33.x に依存しており、これはビルドフックで SQLite を同梱します。そしてsqlite3_flutter_libs0.6.0 は中身のない “end of life” リリースです。# pubspec.yaml - Flutter 3.44.8, Dart 3.12.2 dependencies: drift: ^2.35.2 drift_flutter: ^0.3.1 hive_ce: ^2.22.0 # kept read-only until the import window closes hive_ce_flutter: ^2.5.0 path_provider: ^2.1.6 dev_dependencies: build_runner: ^2.15.1 drift_dev: ^2.35.1flutter pub getで確認し、pubspec.lockにsqlite3_flutter_libsの 0.5.x 系が残っていないことを確かめます。 -
データを書き込む前に、drift を
DateTimeのテキスト保存に切り替えます。 既定では、drift はDateTimeを秒単位の整数の Unix タイムスタンプとして保存し、読み戻すときは常にローカル時刻にします。私のテストでは、Hive の値2026-10-15T09:30:12.345Zは drift からisUtc=falseの2026-10-15T12:30:12.000として返ってきました (マシンは +03:00 で動作しています)。ミリ秒と UTC フラグが失われ、生の列の値は1792056612でした。Hive は両方を保持します。pubspec.yamlの隣にbuild.yamlを追加します。# build.yaml - drift_dev 2.35.1 targets: $default: builders: drift_dev: options: store_date_time_values_as_text: trueこのオプションを付けると、同じ値が
2026-10-15T09:30:12.345Zとして保存され、isUtc=trueで読み戻されます。今やれば手間はかかりませんが、あとから切り替えると、すべての日付列に対するスキーママイグレーションが必要になります。 -
テーブルをモデル化し、古い Hive のキーを残します。 一意制約付きの
legacyKey列が、インポートを冪等にします。生成される行クラスの名前は@DataClassNameで変更してください。drift はNotesというテーブルからNoteを導出するため、1 つのファイルが両方を import した時点で、既存の Hive のNoteクラスと衝突するからです (Error: 'Note' is imported from both ...)。// lib/data/database.dart - Flutter 3.44.8, drift 2.35.2, drift_flutter 0.3.1 import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; part 'database.g.dart'; @DataClassName('NoteRow') class Notes extends Table { IntColumn get id => integer().autoIncrement()(); // The old Hive key, kept so the import is idempotent. TextColumn get legacyKey => text().nullable().unique()(); TextColumn get title => text()(); TextColumn get body => text()(); DateTimeColumn get createdAt => dateTime()(); BoolColumn get pinned => boolean().withDefault(const Constant(false))(); TextColumn get tags => text().map(const TagsConverter())(); } class Settings extends Table { TextColumn get key => text()(); TextColumn get value => text()(); @override Set<Column> get primaryKey => {key}; } class TagsConverter extends TypeConverter<List<String>, String> { const TagsConverter(); @override List<String> fromSql(String fromDb) => fromDb.isEmpty ? [] : fromDb.split('\u001f'); @override String toSql(List<String> value) => value.join('\u001f'); } @DriftDatabase(tables: [Notes, Settings]) class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? driftDatabase(name: 'app')); @override int get schemaVersion => 1; }Settingsテーブルは、型のないBox<dynamic>の設定値を、JSON 値を持つ単純なキーバリューテーブルとして置き換えます。dart run build_runner buildを実行し、database.g.dartが警告なしに生成されることを確認します。 -
インポートは、完了フラグ付きの 1 つのトランザクションとして書きます。 重要な処理はすべてこの関数の中にあります。古い box を開き、1 つの
transaction内の 1 つのbatchですべてのエントリを挿入し、同じトランザクション内でhive_import_done行を書き、コミット後にだけ box ファイルを削除します。// lib/data/hive_migration.dart - Flutter 3.44.8, hive_ce 2.22.0, drift 2.35.2 import 'dart:convert'; import 'package:drift/drift.dart'; import 'package:hive_ce/hive.dart'; import '../legacy/note.dart'; import 'database.dart'; const _doneFlag = 'hive_import_done'; Future<void> migrateFromHive(AppDatabase db, {String? hivePath}) async { final done = await (db.select(db.settings)..where((s) => s.key.equals(_doneFlag))).getSingleOrNull(); if (done != null) return; final hasNotes = await Hive.boxExists('notes', path: hivePath); final hasSettings = await Hive.boxExists('settings', path: hivePath); if (hasNotes || hasSettings) { if (!Hive.isAdapterRegistered(0)) Hive.registerAdapter(NoteAdapter()); final notes = hasNotes ? await Hive.openBox<Note>('notes', path: hivePath) : null; final settings = hasSettings ? await Hive.openBox('settings', path: hivePath) : null; await db.transaction(() async { await db.batch((b) { if (notes != null) { for (final key in notes.keys) { final n = notes.get(key)!; b.insert( db.notes, NotesCompanion.insert( legacyKey: Value(key.toString()), title: n.title, body: n.body, createdAt: n.createdAt, pinned: Value(n.pinned), tags: n.tags, ), mode: InsertMode.insertOrIgnore, ); } } if (settings != null) { for (final key in settings.keys) { b.insert( db.settings, SettingsCompanion.insert(key: key.toString(), value: jsonEncode(settings.get(key))), mode: InsertMode.insertOrReplace, ); } } }); await db.into(db.settings).insert( SettingsCompanion.insert(key: _doneFlag, value: DateTime.now().toUtc().toIso8601String()), ); }); // Only after the transaction committed. await notes?.deleteFromDisk(); await settings?.deleteFromDisk(); } else { await db.into(db.settings).insert( SettingsCompanion.insert(key: _doneFlag, value: 'fresh-install'), mode: InsertMode.insertOrReplace, ); } }アプリに組み込む前に、下の “検証” セクションのテストで確認してください。
-
起動時に一度だけ、最初の画面がデータを読む前に呼び出します。 古いバージョンと同じ方法で Hive を初期化して box が同じディレクトリで見つかるようにし、次にインポートを実行して、データベースをアプリの残りの部分へ渡します。
// lib/main.dart - Flutter 3.44.8, hive_ce_flutter 2.5.0 import 'package:flutter/material.dart'; import 'package:hive_ce_flutter/hive_flutter.dart'; import 'data/database.dart'; import 'data/hive_migration.dart'; Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); await Hive.initFlutter(); // same (sub)directory as the old release used final db = AppDatabase(); await migrateFromHive(db); runApp(MyApp(db: db)); }古いリリースが
Hive.initFlutter('some_subdir')を呼んでいた場合は同じ値を渡してください。そうしないとboxExistsがfalseを返し、インポートが “fresh install” と記録して、ユーザーのデータが黙って無視されます。古いリリースをインストールしてデータを作成し、その上から新しいビルドをインストールして確認します。 -
読み取りと書き込みを画面ごとに置き換えます。
box.values.where(...)は drift のクエリに、box.putは insert または update に、box.listenable()はwatch()になります。// Flutter 3.44.8, drift 2.35.2 Stream<List<NoteRow>> watchPinned(AppDatabase db) => (db.select(db.notes) ..where((n) => n.pinned.equals(true)) ..orderBy([(n) => OrderingTerm.desc(n.createdAt)])) .watch();flutter analyzeで確認します。legacy/とhive_migration.dartの外にpackage:hive_ceを import しているファイルがなくなれば、アプリのロジックは完全に drift に移行できています。 -
インポート期間の終了後に Hive を削除します。 アクティブなインストールのほぼすべてでインポートが実行されたことを分析データで確認できたら (または、サポート対象の最も古いバージョンが配布対象から外れたら)、
hive_migration.dart、lib/legacy/のアダプター、そして 2 つの Hive 依存を削除します。hive_import_done行は残してください。コストはかからず、何が起きたかの記録になります。
検証
drift は flutter test の中で NativeDatabase.memory() を使って Dart VM 上で動き、Hive は Hive.init(path) で任意のディレクトリ上で動くため、インポート全体を実機なしでテストできます。
// test/migration_test.dart - Flutter 3.44.8, hive_ce 2.22.0, drift 2.35.2
import 'dart:io';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.dart';
import 'package:myapp/data/database.dart';
import 'package:myapp/data/hive_migration.dart';
import 'package:myapp/legacy/note.dart';
void main() {
test('imports once, deletes boxes, keeps milliseconds', () async {
final dir = await Directory.systemTemp.createTemp('hive2drift');
final created = DateTime.utc(2026, 10, 15, 9, 30, 12, 345);
Hive.init(dir.path);
Hive.registerAdapter(NoteAdapter());
final box = await Hive.openBox<Note>('notes');
await box.add(Note(title: 'first', body: 'hello', createdAt: created, tags: ['a', 'b']));
await box.put('custom-key', Note(title: 'second', body: 'world', createdAt: created, pinned: true));
await Hive.close();
final db = AppDatabase(NativeDatabase.memory());
await migrateFromHive(db);
await migrateFromHive(db); // second run must be a no-op
final rows = await db.select(db.notes).get();
expect(rows, hasLength(2));
expect(rows.first.createdAt, created);
expect(rows.first.tags, ['a', 'b']);
expect(dir.listSync(), isEmpty);
await db.close();
});
}
私の実行結果は次のとおりでした。レガシーキーが 0 と custom-key の 2 行、元の UTC 値に等しい createdAt、box のディレクトリが [notes.hive, settings.hive] から [] になったこと、そして 2 回目の呼び出し後も行数が変わらなかったことです。
クラッシュに強いことを証明するため、hive_import_done 行の挿入を中断させる SQLite のトリガーを追加しました。これは、最後のごく間際にアプリが終了する状況をシミュレートします。インポートは SqliteException(1811): ... simulated crash を投げ、notes テーブルは 0 行のままで、両方の .hive ファイルはディスク上に残っていました。次回の起動時には、インポートが単純にもう一度実行されます。フラグをトランザクションの内側に、削除を外側に置く理由はまさにこれです。
そのうえで実機で確認します。直前のストア版ビルドをインストールしてデータを作成し、その上から新しいビルドをインストールして、データが表示されること、ドキュメントディレクトリに app.sqlite が存在すること、そして .hive ファイルがなくなっていることを確かめます。
ロールバック
インポート期間が終わるまでの間でも、この移行は時間の一方向にしか戻せません。古いバージョンのアプリは app.sqlite を読めず、インポートが成功すると .hive ファイルは削除されます。安全なロールバック経路が必要なら、削除を 1 リリース遅らせてください (手順 4 はそのままにして、deleteFromDisk の呼び出しを省きます)。そうすれば、ダウングレードしたユーザーも古い box を見つけられます。インポート後に書き込まれたデータはダウングレード先のアプリには表示されないため、QA には強く周知してください。box を削除してしまったあとは、移行は後戻りできません。
実際の移行で出会った落とし穴
Hive がネイティブに保存した値で jsonEncode が例外を投げる
Box<dynamic> には DateTime、Uint8List、あるいはカスタムアダプターの型を入れられます。jsonEncode が扱えるのはマップ、リスト、文字列、数値、真偽値、null だけなので、lastSync の日付を含む設定の box があると、トランザクション内で JsonUnsupportedObjectError が発生し、起動のたびにインポート全体がロールバックされます。該当するキーを明示的に変換するか、toEncodable: (o) => o is DateTime ? o.toUtc().toIso8601String() : throw ... を渡してください。
暗号化された box には暗号が必要です
古いバージョンが encryptionCipher: HiveAesCipher(key) で box を開いていた場合、インポートでも同じ暗号を渡す必要があります。通常は flutter_secure_storage から読み取ります。これがないと、エントリのデコードに失敗します。drift のデータベースにも暗号化が必要かどうかは検討してください。drift の SQLCipher サポートは別途のセットアップであり、drift_flutter が自動で有効にすることはありません。
大きな box とメモリ
Box は開いたときにすべての値をメモリに読み込みます。古いアプリも既にそうしていたため、通常の box なら問題ありません。データが大きいために古いコードが LazyBox を使っていた場合は、await lazy.get(key) で読み取り、1 回の batch あたり数千行ずつに分けて挿入してください。完了フラグをアトミックに保つため、それでもすべてを 1 つのトランザクションに入れておきます。
整数のキー
box.add は自動採番の int キーを割り当て、box.put は int か String を受け付けます。key.toString() を legacyKey に保存すれば両方に対応できますが、他の box がキーでエントリを参照している場合 (ノートのキーを保持する “favorites” box など) は、drift の id が Hive のキーと等しいと仮定せず、メインの挿入のあとで legacyKey を検索してそれらの参照を解決してください。
インポートを UI スレッドの外で実行する
数万件のエントリがある場合、box のデコードは起動中にメイン isolate 上で行われる CPU 処理になります。driftDatabase() は既に SQLite をバックグラウンド isolate で実行します (NativeDatabase.createBackgroundConnection を通じてファイルを開きます) が、Hive のデコードは openBox を呼び出した場所で行われます。起動時間が問題になる場合は、固まったスプラッシュ画面ではなく移行用の画面を表示するか、デコードを別の isolate に移して、プレーンなデータだけを返してください。
関連記事
- インポートが遅く、起動がカクつく場合は、how to write a Dart isolate for CPU-bound work と the difference between a Dart isolate and a thread を参照してください。
- インポートを待機してから画面遷移する移行画面には、using BuildContext safely after an await in Flutter のパターンが必要です。
drift_devとbuild_runnerを追加するとロックファイルが変わります。CI が突然ロックファイルの変更を報告した場合は、why pubspec.lock changes after a Flutter upgrade を読んでください。
参考資料
drift_flutterREADME (pub.dev)- Drift: DateTime storage and migrating between modes (drift.simonbinder.eu)
- Drift: migrations and
schemaVersion(drift.simonbinder.eu) sqlite3upgrading to v3 notes (simolus3/sqlite3.dart)hive_cepackage andhivepackage (pub.dev)- Hive CE source (IO-Design-Team/hive_ce)
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.