Sincronizzazione dati offline-first in Flutter con Hive e code di operazioni

Foto di Mick Haupt su Unsplash

GuideAvanzato45 min Flutter 3.x

Sincronizzazione dati offline-first in Flutter con Hive e code di operazioni

Le app moderne devono funzionare anche senza connessione. In questo tutorial adotteremo l'approccio offline-first: l'utente lavora sempre sui dati locali (rapidissimi) e le modifiche vengono propagate al server tramite una coda di sincronizzazione.

Useremo:

  • Hive come database NoSQL locale veloce e senza dipendenze native pesanti;
  • una coda di operazioni pendenti persistita anch'essa su Hive;
  • connectivity_plus per rilevare il ritorno della rete e far partire la sincronizzazione.

Alla fine avrai una TodoApp capace di creare e modificare elementi offline e di riconciliarli con il backend appena possibile.

  1. 1

    Installare le dipendenze e inizializzare Hive

    Aggiungi i pacchetti necessari al pubspec.yaml. hive e hive_flutter per il database locale, connectivity_plus per la rete, e i generatori per gli adapter.

    dependencies:
      hive: ^2.2.3
      hive_flutter: ^1.1.0
      connectivity_plus: ^6.0.0
      uuid: ^4.0.0
    
    dev_dependencies:
      hive_generator: ^2.0.1
      build_runner: ^2.4.0
    

    Inizializza Hive nel main prima di avviare l'app.

    import 'package:flutter/material.dart';
    import 'package:hive_flutter/hive_flutter.dart';
    
    void main() async {
      WidgetsFlutterBinding.ensureInitialized();
      await Hive.initFlutter();
      // Gli adapter verranno registrati nel prossimo passo
      runApp(const MyApp());
    }
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return const MaterialApp(home: Scaffold());
      }
    }

    Risultato atteso

    Il progetto compila e Hive è inizializzato, pronto ad aprire i box locali.

  2. 2

    Definire il modello Todo con supporto alla sincronizzazione

    Creiamo il modello Todo come HiveObject. Aggiungiamo un campo synced per sapere se l'elemento è già stato inviato al server e un updatedAt utile per risolvere i conflitti.

    Dopo aver scritto il file, genera l'adapter con:

    dart run build_runner build --delete-conflicting-outputs
    
    import 'package:hive/hive.dart';
    
    part 'todo.g.dart';
    
    @HiveType(typeId: 0)
    class Todo extends HiveObject {
      @HiveField(0)
      final String id;
    
      @HiveField(1)
      String title;
    
      @HiveField(2)
      bool done;
    
      @HiveField(3)
      bool synced;
    
      @HiveField(4)
      DateTime updatedAt;
    
      Todo({
        required this.id,
        required this.title,
        this.done = false,
        this.synced = false,
        required this.updatedAt,
      });
    
      Map<String, dynamic> toJson() => {
            'id': id,
            'title': title,
            'done': done,
            'updatedAt': updatedAt.toIso8601String(),
          };
    }

    Risultato atteso

    Viene generato `todo.g.dart` con `TodoAdapter`, senza errori di build_runner.

  3. 3

    Definire la coda delle operazioni pendenti

    Ogni modifica offline diventa un'operazione (create, update, delete) salvata in un box dedicato. Quando la rete torna, svuoteremo questa coda inviando le operazioni al server.

    Usiamo un enum e un secondo HiveType. Ricordati di rigenerare gli adapter con build_runner.

    import 'package:hive/hive.dart';
    
    part 'pending_op.g.dart';
    
    @HiveType(typeId: 1)
    enum OpType {
      @HiveField(0)
      create,
      @HiveField(1)
      update,
      @HiveField(2)
      delete,
    }
    
    @HiveType(typeId: 2)
    class PendingOp extends HiveObject {
      @HiveField(0)
      final String todoId;
    
      @HiveField(1)
      final OpType type;
    
      @HiveField(2)
      final Map<String, dynamic> payload;
    
      @HiveField(3)
      final DateTime createdAt;
    
      PendingOp({
        required this.todoId,
        required this.type,
        required this.payload,
        required this.createdAt,
      });
    }

    Risultato atteso

    Vengono generati gli adapter `OpTypeAdapter` e `PendingOpAdapter`.

  4. 4

    Registrare gli adapter e aprire i box

    Torniamo al main per registrare gli adapter e aprire i due box: quello dei Todo e quello delle operazioni pendenti. Facciamo tutto prima del runApp per avere i box pronti all'avvio.

    import 'package:flutter/material.dart';
    import 'package:hive_flutter/hive_flutter.dart';
    import 'todo.dart';
    import 'pending_op.dart';
    
    late Box<Todo> todoBox;
    late Box<PendingOp> opBox;
    
    void main() async {
      WidgetsFlutterBinding.ensureInitialized();
      await Hive.initFlutter();
    
      Hive.registerAdapter(TodoAdapter());
      Hive.registerAdapter(OpTypeAdapter());
      Hive.registerAdapter(PendingOpAdapter());
    
      todoBox = await Hive.openBox<Todo>('todos');
      opBox = await Hive.openBox<PendingOp>('pending_ops');
    
      runApp(const MyApp());
    }

    Risultato atteso

    L'app si avvia con i box `todos` e `pending_ops` aperti e persistenti tra i riavvii.

  5. 5

    Creare il repository offline-first

    Il repository è l'unico punto di accesso ai dati per la UI. Ogni scrittura aggiorna subito Hive (con synced = false) e accoda un'operazione. La UI resta reattiva e non attende mai la rete.

    Questo è il cuore del pattern: scrivo prima in locale, sincronizzo dopo.

    import 'package:uuid/uuid.dart';
    import 'todo.dart';
    import 'pending_op.dart';
    import 'main.dart';
    
    class TodoRepository {
      final _uuid = const Uuid();
    
      List<Todo> getAll() => todoBox.values.toList()
        ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
    
      Future<void> addTodo(String title) async {
        final todo = Todo(
          id: _uuid.v4(),
          title: title,
          updatedAt: DateTime.now(),
        );
        await todoBox.put(todo.id, todo);
        await _enqueue(todo.id, OpType.create, todo.toJson());
      }
    
      Future<void> toggleDone(Todo todo) async {
        todo.done = !todo.done;
        todo.synced = false;
        todo.updatedAt = DateTime.now();
        await todo.save();
        await _enqueue(todo.id, OpType.update, todo.toJson());
      }
    
      Future<void> _enqueue(
          String todoId, OpType type, Map<String, dynamic> payload) async {
        await opBox.add(PendingOp(
          todoId: todoId,
          type: type,
          payload: payload,
          createdAt: DateTime.now(),
        ));
      }
    }

    Risultato atteso

    Aggiungere o modificare un todo aggiorna istantaneamente Hive e crea un record nella coda operazioni.

  6. 6

    Implementare il servizio di sincronizzazione

    Il SyncService ascolta i cambi di connettività con connectivity_plus. Quando la rete torna online, scorre la coda in ordine, invia ogni operazione al server (qui simulata) e la rimuove dopo il successo, marcando il todo come synced = true.

    Usiamo flush() anche manualmente all'avvio, nel caso ci fossero operazioni rimaste in sospeso da una sessione precedente.

    import 'dart:async';
    import 'package:connectivity_plus/connectivity_plus.dart';
    import 'pending_op.dart';
    import 'todo.dart';
    import 'main.dart';
    
    class SyncService {
      StreamSubscription? _sub;
      bool _syncing = false;
    
      void start() {
        _sub = Connectivity().onConnectivityChanged.listen((results) {
          final online = !results.contains(ConnectivityResult.none);
          if (online) flush();
        });
        flush(); // tentativo all'avvio
      }
    
      Future<void> flush() async {
        if (_syncing) return;
        _syncing = true;
        try {
          final ops = opBox.values.toList()
            ..sort((a, b) => a.createdAt.compareTo(b.createdAt));
          for (final op in ops) {
            await _sendToServer(op); // chiamata reale al backend
            final todo = todoBox.get(op.todoId);
            if (todo != null) {
              todo.synced = true;
              await todo.save();
            }
            await op.delete();
          }
        } catch (e) {
          // Errore di rete: le operazioni restano in coda per il prossimo tentativo
        } finally {
          _syncing = false;
        }
      }
    
      Future<void> _sendToServer(PendingOp op) async {
        // Simulazione: sostituisci con http/Dio verso il tuo backend
        await Future.delayed(const Duration(milliseconds: 300));
      }
    
      void dispose() => _sub?.cancel();
    }

    Risultato atteso

    Quando la connessione torna, la coda viene svuotata e i todo passano a `synced = true`.

  7. 7

    Collegare tutto alla UI reattiva

    Usiamo ValueListenableBuilder su todoBox.listenable() per ricostruire la lista automaticamente ad ogni modifica di Hive. Un'icona indica se l'elemento è sincronizzato (cloud) o in attesa (cloud_off).

    Avvia il SyncService nell'initState e ricordati di chiuderlo nel dispose.

    import 'package:flutter/material.dart';
    import 'package:hive_flutter/hive_flutter.dart';
    import 'todo.dart';
    import 'main.dart';
    import 'todo_repository.dart';
    import 'sync_service.dart';
    
    class TodoScreen extends StatefulWidget {
      const TodoScreen({super.key});
      @override
      State<TodoScreen> createState() => _TodoScreenState();
    }
    
    class _TodoScreenState extends State<TodoScreen> {
      final repo = TodoRepository();
      final sync = SyncService();
    
      @override
      void initState() {
        super.initState();
        sync.start();
      }
    
      @override
      void dispose() {
        sync.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('Todo Offline-first')),
          floatingActionButton: FloatingActionButton(
            onPressed: () => repo.addTodo('Task ${DateTime.now().second}'),
            child: const Icon(Icons.add),
          ),
          body: ValueListenableBuilder(
            valueListenable: todoBox.listenable(),
            builder: (context, Box<Todo> box, _) {
              final todos = repo.getAll();
              return ListView.builder(
                itemCount: todos.length,
                itemBuilder: (context, i) {
                  final t = todos[i];
                  return ListTile(
                    leading: Checkbox(
                      value: t.done,
                      onChanged: (_) => repo.toggleDone(t),
                    ),
                    title: Text(t.title),
                    trailing: Icon(
                      t.synced ? Icons.cloud_done : Icons.cloud_off,
                      color: t.synced ? Colors.green : Colors.orange,
                    ),
                  );
                },
              );
            },
          ),
        );
      }
    }

    Risultato atteso

    La lista si aggiorna in tempo reale; disattivando la rete i nuovi todo restano 'cloud_off' e diventano 'cloud_done' al ritorno della connessione.

CondividiXLinkedInFacebookWhatsApp

Commenti (0)

Ancora nessun commento. Inizia tu!