Start Debugging

Migre o armazenamento local de um app Flutter do Hive para o Drift (hive_ce 2.22 para drift 2.35)

Uma importação única e segura contra falhas dos boxes existentes do Hive para um banco SQLite drift no dispositivo do usuário: design do schema, a função de importação, idempotência, exclusão dos boxes e as duas armadilhas que corrompem dados silenciosamente (precisão de DateTime e colisão de nomes de classe). Testado no Flutter 3.44.8 com hive_ce 2.22.0 e drift 2.35.2.

Mover um app Flutter já publicado do Hive para o drift não é uma refatoração de código, é uma migração de dados que roda no celular de cada usuário. Os dados que seus usuários já têm ficam em arquivos .hive no diretório de documentos do app, e a nova versão precisa copiá-los para um banco SQLite exatamente uma vez, sem perder nada se o app for encerrado no meio do processo. A abordagem que funciona: manter o Hive como dependência somente leitura por algumas versões, executar na inicialização uma função de importação que lê cada box dentro de uma única transação do drift, marcar a conclusão com uma linha de flag na mesma transação e excluir os arquivos dos boxes apenas depois do commit. Para um app típico com 2 a 5 boxes, isso dá meio dia de trabalho mais testes. As duas coisas que quebram silenciosamente são o armazenamento padrão de DateTime do drift (segundos inteiros, hora local) e a classe de linha gerada colidindo com o nome do seu modelo Hive. Tudo abaixo foi testado no Flutter 3.44.8 (Dart 3.12.2) com hive_ce 2.22.0, drift 2.35.2, drift_flutter 0.3.1 e drift_dev 2.35.1.

Por que sair do Hive

O que muda no app

ÁreaMudançaSeveridade
Classes de modeloAs classes @HiveType são substituídas por tabelas drift mais classes de linha geradasalta
Colunas DateTimeO drift armazena segundos inteiros e devolve hora local, a menos que você opte pelo armazenamento em textoalta
ChavesAs chaves auto-incrementais do Hive (box.add) são int, as chaves personalizadas são String; o drift quer uma chave primária tipadamédia
Listas e mapasNão há coluna de lista nativa; é preciso um TypeConverter ou uma tabela filhamédia
InicializaçãoHive.initFlutter() + openBox são substituídos por uma única instância de AppDatabasemédia
UI reativaValueListenableBuilder(box.listenable()) vira StreamBuilder(query.watch())baixa
Local de armazenamentoAmbos usam getApplicationDocumentsDirectory(); o drift grava <name>.sqlite ao lado dos arquivos .hivebaixa

Checklist pré-migração

Passos da migração

  1. Adicione o drift ao lado do Hive, não no lugar dele. O pacote antigo permanece porque a importação precisa ler os arquivos antigos. Note que o sqlite3_flutter_libs não é mais necessário: o drift_flutter 0.3.x depende do sqlite3 3.x, que embute o SQLite por meio de build hooks, e o sqlite3_flutter_libs 0.6.0 é uma versão vazia de “fim de vida”.

    # 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.1

    Verifique com flutter pub get e confirme que nenhuma linha sqlite3_flutter_libs 0.5.x restou no pubspec.lock.

  2. Mude o drift para armazenamento de DateTime como texto antes de gravar qualquer dado. Por padrão, o drift armazena um DateTime como um timestamp Unix inteiro em segundos e sempre o lê de volta como hora local. No meu teste, um valor do Hive 2026-10-15T09:30:12.345Z voltou do drift como 2026-10-15T12:30:12.000 com isUtc=false (a máquina roda em +03:00): os milissegundos e a flag UTC se perderam, e a coluna bruta continha 1792056612. O Hive preserva ambos. Adicione um build.yaml ao lado do pubspec.yaml:

    # build.yaml - drift_dev 2.35.1
    targets:
      $default:
        builders:
          drift_dev:
            options:
              store_date_time_values_as_text: true

    Com essa opção, o mesmo valor é armazenado como 2026-10-15T09:30:12.345Z e é lido de volta com isUtc=true. Fazer isso agora não custa nada; trocar depois exige uma migração de schema sobre todas as colunas de data.

  3. Modele as tabelas e mantenha a chave antiga do Hive. Uma coluna legacyKey com restrição de unicidade é o que torna a importação idempotente. Renomeie a classe de linha gerada com @DataClassName, porque o drift deriva Note de uma tabela chamada Notes, e isso colide com sua classe Hive Note existente assim que um mesmo arquivo importa as duas (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;
    }

    A tabela Settings substitui um Box<dynamic> não tipado de preferências por uma simples tabela chave-valor com valores em JSON. Execute dart run build_runner build e verifique que o database.g.dart é gerado sem avisos.

  4. Escreva a importação como uma única transação com uma flag de conclusão. Tudo o que importa acontece nesta função: ela abre os boxes antigos, insere cada entrada em um único batch dentro de uma única transaction, grava a linha hive_import_done na mesma transação e exclui os arquivos dos boxes apenas depois do commit.

    // 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,
            );
      }
    }

    Verifique com o teste da seção Verificação abaixo antes de integrá-la ao app.

  5. Chame-a uma vez na inicialização, antes de a primeira tela ler dados. Inicialize o Hive da mesma forma que a versão antiga, para que os boxes sejam encontrados no mesmo diretório, depois execute a importação e só então entregue o banco de dados ao restante do app.

    // 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));
    }

    Se a versão antiga chamava Hive.initFlutter('some_subdir'), passe o mesmo valor; caso contrário, boxExists retorna false, a importação marca uma “instalação nova” e os dados do usuário são ignorados silenciosamente. Verifique instalando a versão antiga, criando dados e depois instalando o novo build por cima.

  6. Substitua leituras e gravações tela por tela. box.values.where(...) vira uma consulta drift, box.put vira um insert ou update, e box.listenable() vira 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();

    Verifique com flutter analyze: quando nenhum arquivo fora de legacy/ e hive_migration.dart importar package:hive_ce, a lógica do app estará totalmente no drift.

  7. Remova o Hive depois da janela de importação. Quando as métricas mostrarem que quase todas as instalações ativas já executaram a importação (ou quando a versão mais antiga suportada sair de circulação), exclua hive_migration.dart, os adapters em lib/legacy/ e as duas dependências do Hive. Mantenha a linha hive_import_done; ela não custa nada e documenta o que aconteceu.

Verificação

O drift roda na Dart VM em flutter test com NativeDatabase.memory(), e o Hive roda em qualquer diretório com Hive.init(path), então a importação inteira pode ser testada sem um dispositivo:

// 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();
  });
}

O que minha execução imprimiu: duas linhas com as chaves legadas 0 e custom-key, createdAt igual ao valor UTC original, o diretório dos boxes passando de [notes.hive, settings.hive] para [] e uma contagem de linhas inalterada após a segunda chamada.

Para provar a segurança contra falhas, adicionei um trigger SQLite que aborta o insert da linha hive_import_done, o que simula o app morrendo no último momento possível. A importação lançou SqliteException(1811): ... simulated crash, a tabela notes ficou com 0 linhas e os dois arquivos .hive continuaram no disco. Na próxima inicialização, a importação simplesmente roda de novo. Esse é todo o objetivo de colocar a flag dentro da transação e as exclusões fora dela.

Depois, em um dispositivo real: instale o build anterior da loja, crie dados, instale o novo build por cima e confirme que os dados aparecem e que app.sqlite existe no diretório de documentos enquanto os arquivos .hive sumiram.

Reverter a migração

Antes de a janela de importação se fechar, a migração é reversível apenas em uma direção do tempo: uma versão mais antiga do app não consegue ler o app.sqlite, e depois de uma importação bem-sucedida os arquivos .hive são excluídos. Se você precisa de um caminho seguro de rollback, adie a exclusão por uma versão (mantenha o passo 4, mas pule as chamadas deleteFromDisk), para que um usuário que faça downgrade ainda encontre seus boxes antigos. Os dados gravados depois da importação não apareceriam no app com downgrade, então deixe isso bem claro para o QA. Depois que os boxes forem excluídos, a migração é de mão única.

Armadilhas de uma migração real

jsonEncode lança exceção com valores que o Hive armazenava nativamente

Um Box<dynamic> pode conter um DateTime, um Uint8List ou um tipo de adapter personalizado. O jsonEncode só lida com mapas, listas, strings, números, booleanos e null, então um box de preferências com uma data lastSync lança JsonUnsupportedObjectError dentro da transação e a importação inteira sofre rollback a cada inicialização. Mapeie essas chaves explicitamente, ou passe toEncodable: (o) => o is DateTime ? o.toUtc().toIso8601String() : throw ....

Boxes criptografados precisam do seu cipher

Se a versão antiga abria um box com encryptionCipher: HiveAesCipher(key), a importação deve passar o mesmo cipher, normalmente lido do flutter_secure_storage. Sem ele, as entradas não são decodificadas. Decida se o banco drift também precisa de criptografia; o suporte do drift a SQLCipher é uma configuração separada e o drift_flutter não o ativa por você.

Boxes grandes e memória

O Box carrega todos os valores na memória ao ser aberto, o que o app antigo já fazia, então um box normal não é problema. Se o código antigo usava LazyBox porque os dados são grandes, leia com await lazy.get(key) e insira em blocos de alguns milhares de linhas por batch. Mantenha tudo em uma única transação de qualquer forma, para que a flag de conclusão continue atômica.

Chaves inteiras

box.add atribui chaves int auto-incrementais e box.put aceita int ou String. Armazenar key.toString() em legacyKey cobre ambos os casos, mas se outros boxes referenciam entradas por chave (um box de “favoritos” guardando chaves de notas), resolva essas referências depois do insert principal com uma busca em legacyKey, em vez de assumir que o id do drift é igual à chave do Hive.

Executar a importação fora da thread de UI

Para dezenas de milhares de entradas, decodificar os boxes é trabalho de CPU no isolate principal durante a inicialização. O driftDatabase() já executa o SQLite em um isolate em segundo plano (ele abre o arquivo por meio de NativeDatabase.createBackgroundConnection), mas a decodificação do Hive ainda acontece onde openBox é chamado. Se o tempo de inicialização sofrer, mostre uma tela de migração em vez de uma splash congelada, ou mova a decodificação para um isolate separado e devolva dados simples.

Relacionados

Fontes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Voltar