Best Practice #

Memilih pustaka penyimpanan lokal yang tepat — baik itu SharedPreferences, Hive, ObjectBox, maupun Drift — barulah langkah awal dalam merancang arsitektur aplikasi Flutter yang tangguh. Bagaimana kita mengintegrasikan pustaka tersebut ke dalam kode aplikasi, mengelola keamanan data sensitif, menangani pembersihan memori, melakukan migrasi skema yang aman, hingga menulis pengujian unit (unit testing) adalah faktor penentu apakah lapisan penyimpanan lokal kita siap digunakan untuk skala produksi (production-ready).

Di dalam artikel ini, kita akan membahas secara komprehensif praktik-praktik terbaik (best practices) lintas pustaka yang wajib kita terapkan. Kita akan mempelajari pola abstraksi data source, manajemen keamanan multi-tier, strategi caching berbasis masa berlaku (TTL), optimasi multi-threading menggunakan Isolate Dart, penanganan pembersihan data saat logout, serta daftar anti-pattern yang harus kita hindari.

1. Abstraksi Penyimpanan di Balik Antarmuka (Interface) #

Salah satu kesalahan arsitektur yang paling sering terjadi adalah mengakses instansiasi database atau Box penyimpanan secara langsung di dalam kode antarmuka (Widget) atau pengelola state (Notifier/Cubit). Pendekatan ini membuat kode kita memiliki ketergantungan yang sangat ketat (tight coupling). Jika di kemudian hari kita ingin mengganti Hive dengan ObjectBox, kita harus mengubah puluhan berkas UI aplikasi kita.

Langkah terbaik adalah menerapkan Repository Pattern dengan mendefinisikan sebuah kelas antarmuka abstrak (abstract class / interface) sebagai kontrak. Kode UI atau pengolah state hanya akan berinteraksi dengan interface ini, sedangkan implementasi konkretnya disembunyikan di lapisan data source.

Berikut adalah implementasi abstraksi data source produk yang bersih:

// lib/features/shop/data/datasources/product_local_data_source.dart

import '../../domain/entities/product.dart';

// Interface abstrak sebagai kontrak
abstract class ProductLocalDataSource {
  Future<List<Product>> getCachedProducts();
  Future<void> cacheProducts(List<Product> products);
  Future<void> clearCache();
}

// Implementasi konkret menggunakan Hive CE
class HiveProductLocalDataSource implements ProductLocalDataSource {
  final Box<Product> _productBox;

  HiveProductLocalDataSource(this._productBox);

  @override
  Future<List<Product>> getCachedProducts() async {
    // Membaca data dari Box secara sinkron
    return _productBox.values.toList();
  }

  @override
  Future<void> cacheProducts(List<Product> products) async {
    // Menghapus data lama dan menulis data baru secara massal (bulk write)
    await _productBox.clear();
    final Map<String, Product> productMap = {
      for (final p in products) p.id: p
    };
    await _productBox.putAll(productMap);
  }

  @override
  Future<void> clearCache() async {
    await _productBox.clear();
  }
}

Saat melakukan pengujian unit (unit testing), kita tidak perlu memicu inisialisasi Hive yang lambat. Kita cukup membuat implementasi tiruan (Mock) berbasis memori RAM:

// test/mocks/mock_product_local_data_source.dart

import 'package:flutter_app/features/shop/data/datasources/product_local_data_source.dart';
import 'package:flutter_app/features/shop/domain/entities/product.dart';

class MockProductLocalDataSource implements ProductLocalDataSource {
  // Penyimpanan sementara di memori RAM untuk simulasi database
  final List<Product> _tempStorage = [];

  @override
  Future<List<Product>> getCachedProducts() async {
    return List.from(_tempStorage);
  }

  @override
  Future<void> cacheProducts(List<Product> products) async {
    _tempStorage.clear();
    _tempStorage.addAll(products);
  }

  @override
  Future<void> clearCache() async {
    _tempStorage.clear();
  }
}

Dengan cara ini, pengujian unit kita dapat berjalan dalam hitungan milidetik karena tidak ada interaksi disk I/O sesungguhnya.


2. Keamanan Multi-Tier & Enkripsi Data #

Menyimpan semua jenis data ke dalam satu wadah penyimpanan yang sama tanpa mempertimbangkan tingkat sensitivitas data adalah kesalahan fatal. Kita harus membagi klasifikasi data aplikasi kita menjadi tiga tingkatan (Multi-Tier Data Classification):

  1. Tier 1: Data Sangat Sensitif (High-Risk): Token akses (JWT), PIN, kata sandi, dan kunci enkripsi database master. Data ini wajib disimpan di penyimpanan aman tingkat sistem operasi terenkripsi hardware menggunakan flutter_secure_storage.
  2. Tier 2: Data Sensitif Kompleks (Medium-Risk): Riwayat transaksi, alamat email pengguna, data pribadi profil, dan pesan obrolan. Data ini dapat disimpan di Hive atau SQLite/Drift, namun wajib menggunakan enkripsi database (seperti HiveAesCipher atau SQLCipher untuk SQLite) dengan kunci master yang diambil dari Tier 1.
  3. Tier 3: Data Tidak Sensitif (Low-Risk): Pengaturan tema (gelap/terang), pilihan bahasa, status tutorial onboarding, dan cache gambar dari server. Data ini aman disimpan di SharedPreferences biasa atau Box tanpa enkripsi.

Diagram Alur Enkripsi Multi-Tier #

Berikut adalah diagram visual yang menggambarkan bagaimana aplikasi Flutter mengambil kunci enkripsi master dari perangkat keras sebelum mengakses database lokal terenkripsi:

graph TD
    App["Aplikasi Flutter (Dart)"] -->|1. Request Kunci Master| Secure["Flutter Secure Storage (Keychain/Keystore)"]
    Secure -->|2. Kembalikan Kunci Master| App
    
        App -->|3. Kirim Objek & Kunci| Cipher["Database Encrypted (HiveAES / SQLCipher)"]
        Cipher -->|4. Tulis Data Terenkripsi| Storage["Penyimpanan Fisik (Encrypted Disk)"]

Dengan mematuhi arsitektur enkripsi multi-tier di atas, data berharga milik pengguna kita akan terlindungi secara maksimal bahkan ketika perangkat fisik mereka hilang atau dibobol.


3. Strategi Cache Modern & Masa Berlaku (TTL) #

Menyimpan data cache dari API server lokal tanpa menyertakan batas masa berlaku (Time To Live / TTL) adalah bom waktu yang akan memicu bug inkonsistensi data. Pengguna akan terus melihat data usang yang tersimpan di perangkat mereka meskipun data di server backend telah diperbarui.

Setiap kali kita menyimpan cache data, kita wajib menyimpan metadata berupa stempel waktu (timestamp) kapan data tersebut ditulis. Saat aplikasi mencoba membaca data cache tersebut, kita bandingkan selisih waktu saat ini dengan stempel waktu tersebut. Jika selisihnya melampaui batas TTL, data cache harus dianggap kedaluwarsa, dihapus (invalidated), dan aplikasi harus melakukan pemanggilan API server untuk memperbarui data.

Berikut adalah alur kueri data dengan integrasi TTL yang ideal:

// lib/core/cache/cache_policy.dart

class CachePolicy<T> {
  final Duration maxAge;
  
  const CachePolicy({required this.maxAge});

  // Memeriksa apakah cache sudah kedaluwarsa berdasarkan timestamp tersimpan
  bool isExpired(int cachedTimestamp) {
    final DateTime cachedTime = DateTime.fromMillisecondsSinceEpoch(cachedTimestamp);
    return DateTime.now().difference(cachedTime) > maxAge;
  }
}

Berikut adalah contoh skenario penanganan cache di lapisan Repositori:

// lib/features/shop/data/repositories/product_repository_impl.dart

import '../../domain/entities/product.dart';
import '../datasources/product_local_data_source.dart';
import '../datasources/product_remote_data_source.dart';

class ProductRepositoryImpl {
  final ProductRemoteDataSource remoteDataSource;
  final ProductLocalDataSource localDataSource;
  final Box metadataBox; // Box khusus untuk stempel waktu cache
  
  static const _cacheKey = 'products_cache_timestamp';
  static const _policy = CachePolicy(maxAge: Duration(minutes: 30));

  ProductRepositoryImpl({
    required this.remoteDataSource,
    required this.localDataSource,
    required this.metadataBox,
  });

  Future<List<Product>> getProducts({bool forceRefresh = false}) async {
    final int? cachedTime = metadataBox.get(_cacheKey) as int?;
    
    // Cek kondisi cache: jika ada, tidak kedaluwarsa, dan tidak dipaksa refresh
    if (!forceRefresh && cachedTime != null && !_policy.isExpired(cachedTime)) {
      try {
        final localData = await localDataSource.getCachedProducts();
        if (localData.isNotEmpty) {
          return localData;
        }
      } catch (e) {
        // Jika pembacaan cache gagal, toleransi error dan lanjutkan ke remote API
      }
    }

    // Ambil data segar dari server
    final remoteData = await remoteDataSource.fetchProductsFromServer();
    
    // Simpan ke cache lokal untuk penggunaan berikutnya
    await localDataSource.cacheProducts(remoteData);
    await metadataBox.put(_keyCacheTime(), DateTime.now().millisecondsSinceEpoch);

    return remoteData;
  }

  String _keyCacheTime() => _cacheKey;
}

Pola ini menjamin pengguna mendapatkan respon UI yang instan saat membuka aplikasi, sekaligus menjamin data yang ditampilkan tetap aktual.


4. Inisialisasi Terpadu Saat Startup #

Banyak pengembang melakukan inisialisasi database secara malas (lazy initialization) saat database tersebut pertama kali dipanggil di widget. Pendekatan ini dapat memicu kondisi balapan (race condition) di mana beberapa komponen UI mencoba mengakses database yang statusnya masih dalam proses pembukaan.

Praktik terbaik adalah menyelesaikan seluruh inisialisasi database secara berurutan (sequential) di dalam fungsi main() sebelum memanggil fungsi runApp(). Kita harus memastikan bahwa WidgetsFlutterBinding.ensureInitialized() dipanggil pertama kali agar jembatan native Flutter siap digunakan untuk operasi asinkron.

Berikut adalah konfigurasi startup yang ideal:

// lib/main.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'core/database/app_database.dart'; // Drift Database
import 'core/storage/preferences_service.dart';

void main() async {
  // 1. Wajib dipanggil untuk mengamankan binding native
  WidgetsFlutterBinding.ensureInitialized();

  // 2. Lakukan inisialisasi secara paralel atau berurutan yang terkendali
  // Pastikan untuk menangani kemungkinan error agar aplikasi tidak stuck di splash screen
  try {
    // Inisialisasi SharedPreferences
    final SharedPreferences sharedPrefs = await SharedPreferences.getInstance();
    final PreferencesService preferencesService = PreferencesService(sharedPrefs);

    // Inisialisasi Hive CE
    await Hive.initFlutter();
    // Registrasi adapter-adapter model
    Hive.registerAdapter(UserAdapter());
    await Hive.openBox('metadata_cache');

    // Inisialisasi Drift SQLite
    final AppDatabase driftDb = AppDatabase();

    runApp(
      ProviderScope(
        overrides: [
          // Suntikkan instance yang sudah siap pakai ke provider masing-masing
          preferencesServiceProvider.overrideWithValue(preferencesService),
          driftDatabaseProvider.overrideWithValue(driftDb),
        ],
        child: const MyApp(),
      ),
    );
  } catch (e) {
    // Tangani crash inisialisasi kritis, misalnya dengan merender widget error khusus
    runApp(MaterialApp(
      home: Scaffold(
        body: Center(child: Text('Gagal memuat database lokal: $e')),
      ),
    ));
  }
}

5. Sanitasi Data Spesifik Pengguna Saat Logout #

Kebocoran data (data leakage) sering terjadi ketika pengguna melakukan logout dari akun mereka, namun data transaksi dan profil lama mereka tetap tersimpan di database perangkat lokal. Saat ada pengguna lain yang masuk di perangkat yang sama, data lama tersebut berpotensi muncul kembali.

Saat memicu fungsi logout, kita harus melakukan sanitasi data secara menyeluruh. Namun, kita harus berhati-hati: jangan menghapus seluruh database. Kita harus membedakan antara data spesifik pengguna (user-specific data) dan data spesifik perangkat (device-specific data).

  • Data yang WAJIB dihapus: Token akses, profil pengguna, riwayat keranjang belanja, cache transaksi, dan log aktivitas pribadi.
  • Data yang TIDAK BOLEH dihapus: Pengaturan tema aplikasi (gelap/terang), status tutorial onboarding (agar pengguna lama tidak perlu melihat tutorial kembali), dan pilihan bahasa antarmuka.

Berikut adalah implementasi fungsi sanitasi logout yang aman:

// lib/features/auth/data/services/logout_service.dart

import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:hive_ce/hive.dart';
import '../../../../core/database/app_database.dart';
import '../../../../core/storage/preferences_service.dart';

class LogoutService {
  final AppDatabase _db;
  final PreferencesService _prefs;
  final Box<dynamic> _cacheBox;

  LogoutService({
    required AppDatabase db,
    required PreferencesService prefs,
    required Box<dynamic> cacheBox,
  });

  Future<void> executeLogout() async {
    // 1. Bersihkan Tier 1 (Secure Storage) - Hapus token akses & token refresh
    const secureStorage = FlutterSecureStorage();
    await secureStorage.delete(key: 'auth_access_token');
    await secureStorage.delete(key: 'auth_refresh_token');

    // 2. Bersihkan Tier 2 (Database Bisnis Relasional / NoSQL)
    // Hapus seluruh baris di tabel transaksional Drift
    await _db.transaction(() async {
      await _db.delete(_db.orderItems).go();
      await _db.delete(_db.orders).go();
      await _db.delete(_db.products).go();
    });

    // Bersihkan cache biner Hive
    await _cacheBox.clear();

    // 3. Bersihkan Tier 3 (Preferensi) secara selektif
    // Kita menghapus status login, namun tetap menjaga preferensi tema & bahasa perangkat
    await _prefs.setIsUserLoggedIn(false);
    await _prefs.removeUserPersonalData(); // Helper untuk menghapus data nama & email saja
  }
}

6. Pengelolaan Migrasi Skema yang Tangguh #

Evolusi skema adalah bagian yang tidak terhindarkan dari siklus hidup pengembangan perangkat lunak. Ketika kita merilis skema database baru ke pengguna produksi, satu kesalahan kecil pada skrip migrasi dapat mengakibatkan database gagal dibuka dan aplikasi langsung crash saat pertama kali dijalankan (Crash on Startup).

Beberapa aturan penting untuk memastikan migrasi berjalan dengan tangguh meliputi:

  1. Jangan Pernah Melakukan Downgrade schemaVersion: SQLite dan ObjectBox melarang penurunan nomor versi database.
  2. Selalu Gunakan Transaksi untuk Migrasi: Jalankan langkah-langkah migrasi di dalam blok transaksi SQLite. Jika salah satu perintah SQL gagal (misalnya karena kesalahan penulisan kolom), seluruh transaksi akan dibatalkan (rollback) dan database kembali ke versi stabil sebelumnya, bukan rusak di tengah jalan.
  3. Tulis Pengujian Migrasi secara Otomatis: Drift menyediakan alat bantu drift_dev yang dapat menguji berkas skema sebelum dan sesudah migrasi secara otomatis. Manfaatkan fitur ini untuk memverifikasi integritas data pengguna.

7. Optimasi Threading & Menghindari Jank dengan Isolate #

Dart adalah bahasa pemrograman yang berjalan di atas satu thread utama (Single-Threaded). Meskipun operasi asinkron (async/await) membantu kita menghindari pemblokiran eksekusi program, operasi tersebut tetap dijalankan secara bergantian di thread UI yang sama.

Jika kita melakukan operasi tulis yang sangat besar (misalnya mengimpor file backup database berukuran 50 MB atau menulis 10.000 entitas baru ke ObjectBox), CPU thread utama akan sibuk memproses encoding data. Hal ini akan menyebabkan layar aplikasi kita membeku sementara (jank / frame drop) dan merusak pengalaman pengguna.

Untuk skenario data besar, kita wajib melimpahkan pekerjaan berat tersebut ke thread terpisah di latar belakang menggunakan Isolate Dart.

Sejak Flutter 3.7+, kita dapat menggunakan metode Isolate.run() yang sangat sederhana untuk menjalankan kode di Isolate lain secara aman:

// lib/features/backup/data/services/database_import_service.dart

import 'package:flutter/foundation.dart';
import 'package:hive_ce/hive.dart';
import '../../domain/entities/heavy_record.dart';

class DatabaseImportService {
  final Box<HeavyRecord> _heavyBox;

  DatabaseImportService(this._heavyBox);

  // Mengimpor data besar di thread utama (Dapat memicu jank)
  Future<void> importDataLegacy(List<HeavyRecord> records) async {
    await _heavyBox.clear();
    final map = {for (final r in records) r.id: r};
    await _heavyBox.putAll(map); // CPU sibuk melakukan serialization biner di thread UI!
  }

  // Mengimpor data menggunakan Isolate latar belakang (Aplikasi tetap mulus)
  Future<void> importDataWithIsolate(List<HeavyRecord> records) async {
    // Dapatkan path folder database dari thread utama terlebih dahulu
    final String databasePath = Hive.box('metadata_cache').get('db_path_directory') as String;

    // Jalankan komputasi berat di Isolate latar belakang
    await Isolate.run(() async {
      // Di dalam Isolate baru, kita harus melakukan inisialisasi Hive secara mandiri
      Hive.init(databasePath);
      
      // Buka Box secara lokal di thread ini
      final Box<HeavyRecord> localBox = await Hive.openBox<HeavyRecord>('heavy_records_box');
      
      await localBox.clear();
      final Map<String, HeavyRecord> map = {
        for (final r in records) r.id: r
      };
      
      // Operasi tulis massal dilakukan di Isolate latar belakang
      await localBox.putAll(map);
      
      // Tutup koneksi box di isolate ini setelah selesai
      await localBox.close();
    });
    
    // Memicu sinkronisasi data ulang di thread utama setelah operasi Isolate selesai
  }
}

Dengan menggeser proses serialisasi massal ke Isolate, antarmuka aplikasi kita (seperti animasi memutar loading indicator) akan tetap berjalan dengan sangat mulus pada kecepatan 60 atau 120 FPS tanpa terganggu.


8. Skenario Pengujian Unit & Mocking #

Menulis tes untuk lapisan penyimpanan lokal memastikan bahwa logika bisnis kita (seperti penghitungan harga keranjang belanja atau pemfilteran produk) tetap berjalan dengan benar ketika kode dasar diperbarui.

Berikut adalah pola penulisan pengujian unit untuk database Drift menggunakan koneksi memori RAM (NativeDatabase.memory()) yang sangat efisien untuk pengujian otomatis:

// test/features/inventory/data/daos/product_dao_test.dart

import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_app/core/database/app_database.dart';
import 'package:flutter_app/core/database/tables.dart';

void main() {
  late AppDatabase database;

  // Dijalankan setiap sebelum unit test dimulai
  setUp(() {
    // Gunakan in-memory database agar data tidak tertulis ke disk fisik komputer
    database = AppDatabase.connect(
      DatabaseConnection(
        NativeDatabase.memory(logStatements: false),
      ),
    );
  });

  // Dijalankan setiap setelah unit test selesai
  tearDown(() async {
    await database.close();
  });

  group('Pengujian DAO Produk Drift', () {
    test('Harus berhasil menyimpan produk dan membacanya kembali secara reaktif', () async {
      // 1. Buat data dummy kategori umum terlebih dahulu (Foreign Key Constraint)
      final int catId = await database.into(database.categories).insert(
        CategoriesCompanion.insert(name: 'Kategori Uji'),
      );

      // 2. Buat query watch reaktif untuk memantau perubahan
      final Stream<List<Product>> streamProduk = (database.select(database.products)
            ..where((p) => p.categoryId.equals(catId)))
          .watch();

      // 3. Pasang ekspektasi perubahan data pada Stream
      expectLater(
        streamProduk,
        emitsInOrder([
          isEmpty, // Nilai awal stream kosong
          hasLength(1), // Setelah insert, stream memancarkan list dengan panjang 1
        ]),
      );

      // 4. Lakukan operasi insert data
      await database.into(database.products).insert(
        ProductsCompanion.insert(
          name: 'Buku Pemrograman Flutter',
          price: 120000.0,
          categoryId: catId,
        ),
      );
    });
  });
}

9. Daftar Anti-Pattern Teratas di Flutter #

Untuk menjaga kualitas kodebase aplikasi kita tetap prima, perhatikan dan hindari daftar kesalahan fatal (anti-patterns) berikut saat bekerja dengan local storage di Flutter:

Anti-Pattern 1: Membuka dan Menutup Box Hive Secara Berulang-ulang #

// KESALAHAN FATAL: Membuka box setiap kali ingin menulis satu nilai
Future<void> saveUserToken(String token) async {
  final Box box = await Hive.openBox('token_box'); // Buka box (Disk I/O)
  await box.put('auth_token', token);
  await box.close(); // Tutup box
}
  • Dampak: Performa aplikasi menurun drastis karena sistem operasi dipaksa melakukan operasi buka-tutup berkas fisik secara terus menerus.
  • Solusi: Buka semua Box yang dibutuhkan satu kali saja saat startup di main(), simpan instansinya di memori RAM, lalu akses secara sinkron tanpa await menggunakan Hive.box('token_box').

Anti-Pattern 2: Menggunakan SharedPreferences untuk Menyimpan File Gambar atau JSON Raksasa #

// KESALAHAN FATAL: Menyimpan string JSON berukuran megabyte
final List<Map<String, dynamic>> rawData = dapatkanDataSangatBesar();
await prefs.setString('heavy_cache_data', jsonEncode(rawData));
  • Dampak: Startup aplikasi menjadi sangat lambat karena SharedPreferences memuat seluruh berkas XML/plist ke dalam RAM cache secara sinkron.
  • Solusi: Gunakan LazyBox pada Hive CE atau SQLite/Drift yang mendukung pemuatan data per halaman (pagination).

Anti-Pattern 3: Menulis ke Storage di Setiap Ketukan Keyboard (Keystroke) #

// KESALAHAN FATAL: Menulis draf teks langsung ke disk di setiap huruf yang diketik
TextField(
  onChanged: (String text) async {
    await prefs.setString('draft_message', text); // Menulis ke disk setiap huruf!
  },
)
  • Dampak: Disk I/O menjadi sangat sibuk, berpotensi memperpendek umur pakai memori flash perangkat dan memicu jank di UI.
  • Solusi: Terapkan teknik Debounce (tunggu jeda beberapa ratus milidetik setelah pengguna berhenti mengetik) atau cukup simpan data ke disk saat pengguna menekan tombol simpan atau menutup halaman (onDispose).

Ringkasan #

  • Abstraksi Terpusat: Selalu bungkus pustaka penyimpanan lokal di balik kelas antarmuka abstrak (interface). Hal ini membuat kode kita independen, mudah dipelihara, dan dapat diuji tanpa menyentuh database fisik.
  • Klasifikasi Keamanan: Terapkan enkripsi multi-tier. Simpan kunci master di flutter_secure_storage dan gunakan kunci tersebut untuk membuka database bisnis terenkripsi (AES/SQLCipher). Jangan simpan data sensitif di SharedPreferences.
  • Kontrol Masa Berlaku: Cache yang baik wajib memiliki stempel waktu (timestamp) dan masa berlaku (TTL) yang terkelola dengan baik agar tidak menyajikan data usang.
  • Isolate untuk Data Besar: Hindari memproses data berukuran megabyte di thread utama. Manfaatkan Isolate.run() untuk memindahkan beban kerja I/O biner ke thread latar belakang demi menjaga animasi UI tetap mulus.
  • Sanitasi Logout Terarah: Hapus hanya data personal spesifik pengguna saat logout. Biarkan preferensi perangkat (tema gelap/terang, pilihan bahasa) tetap utuh untuk menjaga kenyamanan pengguna.
  • Pengujian Efisien: Manfaatkan in-memory database (NativeDatabase.memory()) untuk menguji logika DAO Drift secara cepat pada lingkungan pengujian lokal.

← Sebelumnya: Drift   Berikutnya: Testing Overview →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact