ObjectBox #

Ketika aplikasi Flutter yang kita kembangkan membutuhkan penyimpanan lokal dengan volume data yang sangat besar (mencapai puluhan ribu hingga jutaan baris data) dan relasi objek yang kompleks, SharedPreferences atau Hive mulai menunjukkan keterbatasannya. SQLite dengan pustaka pembungkus seperti Drift memang handal, namun kita harus menulis lapisan pemetaan objek (Object-Relational Mapping / ORM) dan query SQL yang sering kali memakan waktu pengembangan. Sebagai alternatif modern, ObjectBox hadir menawarkan solusi basis data NoSQL berorientasi objek (object-oriented) dengan kinerja ekstrem yang dirancang khusus untuk perangkat mobile, desktop, dan embedded.

Di dalam panduan mendalam ini, kita akan mengupas tuntas ObjectBox dari dasar hingga tingkat lanjut. Kita akan mempelajari arsitektur native di balik kecepatannya, konfigurasi entity, cara mengelola siklus hidup database (Store), teknik kueri tingkat lanjut menggunakan Query Builder, pemodelan relasi satu-ke-banyak (ToOne) dan banyak-ke-banyak (ToMany), transaksi ACID untuk integritas data, hingga integrasi reaktif dengan state management.

Pendahuluan & Arsitektur Berorientasi Objek #

ObjectBox berbeda dengan kebanyakan basis data mobile lainnya. Alih-alih membungkus pustaka SQLite berbasis SQL atau menggunakan parser JSON teks polos, ObjectBox ditulis menggunakan bahasa pemrograman C++ berkinerja tinggi sebagai mesin dasarnya (core engine). Pustaka C++ ini berinteraksi langsung dengan sistem berkas perangkat menggunakan teknologi Memory-Mapped Files yang mirip dengan arsitektur basis data LMDB (Lightning Memory-Mapped Database).

Dalam arsitektur berorientasi objek ObjectBox, kita tidak perlu memikirkan baris, kolom, atau tabel. Kita mendefinisikan model data kita dalam bentuk kelas Dart biasa, dan ObjectBox akan langsung menyimpan objek tersebut ke dalam penyimpanan fisik perangkat sebagai struktur biner yang terkompresi. Proses ini meniadakan kebutuhan untuk melakukan konversi dari objek Dart ke JSON, lalu dari JSON ke format basis data, yang biasanya memakan banyak waktu CPU (proses serialisasi).

Beberapa keunggulan utama dari arsitektur ObjectBox meliputi:

  • Kecepatan Ekstrem: Operasi baca dan tulis di ObjectBox jauh melampaui SQLite/Drift karena data diakses langsung melalui pointer memori native C tanpa overhead interpretasi string SQL.
  • Transaksi ACID Penuh: ObjectBox menjamin integritas data kita melalui transaksi yang memenuhi prinsip ACID (Atomicity, Consistency, Isolation, Durability). Jika aplikasi tiba-tiba crash atau perangkat mati di tengah-tengah operasi tulis, database dijamin tidak akan rusak (corrupt).
  • Penggunaan Sumber Daya yang Rendah: Penggunaan memori RAM dan siklus CPU yang sangat efisien membuat aplikasi kita lebih ramah baterai.
  • Relasi Native: Hubungan antar objek dimodelkan secara langsung sebagai referensi objek, bukan sebagai kunci asing (foreign keys) yang harus di-join secara manual di dalam kueri.

Instalasi & Keterbatasan Platform Web #

Untuk mengintegrasikan ObjectBox ke dalam proyek Flutter, kita perlu menambahkan dependensi utama dan pustaka biner native yang sesuai dengan sistem operasi target.

Tambahkan konfigurasi berikut pada berkas pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  objectbox: ^4.0.3
  objectbox_flutter_libs: any   # Pustaka biner native untuk Android dan iOS
  path: ^1.9.0
  path_provider: ^2.1.5

dev_dependencies:
  build_runner: ^2.4.13
  objectbox_generator: any      # Generator kode binding ObjectBox

Setelah itu, jalankan perintah flutter pub get di terminal proyek kita.

[!WARNING] Keterbatasan Kritis Platform Web: Aspek paling krusial yang wajib kita pertimbangkan sebelum memilih ObjectBox adalah ketiadaan dukungan native untuk platform Web. Karena mesin inti ObjectBox ditulis dalam C++ dan dikompilasi secara native untuk masing-masing arsitektur CPU (ARM, x86), ia tidak dapat berjalan di lingkungan peramban (browser) standar secara langsung. Jika aplikasi Flutter kita dirancang untuk berjalan sebagai aplikasi multi-platform yang mencakup Android, iOS, Desktop, dan Web, kita harus menggunakan pustaka alternatif seperti Hive atau Drift (dengan WASM driver) untuk bagian web, atau menggunakan teknik conditional imports untuk memisahkan implementasi penyimpanan lokal per platform.


Entity: Mendefinisikan Model Data #

Di dalam ObjectBox, kelas model Dart yang ingin kita simpan ke dalam basis data disebut sebagai Entity. Kita mendefinisikan Entity dengan menambahkan anotasi @Entity() di atas definisi kelas kita. Setiap Entity wajib memiliki sebuah properti identifikasi unik (ID) bertipe data int yang ditandai dengan anotasi @Id().

Berikut adalah contoh model data Product yang dikonfigurasi sebagai Entity ObjectBox:

// lib/features/inventory/data/models/product.dart

import 'package:objectbox/objectbox.dart';

@Entity()
class Product {
  // ID wajib bertipe int. Nilai 0 menandakan objek baru yang belum disimpan.
  // ObjectBox akan memberikan ID auto-increment saat kita menyimpannya ke Box.
  @Id()
  int id;

  // Indeks digunakan untuk mempercepat proses pencarian berdasarkan field ini
  @Index()
  String code;

  String name;
  double price;
  bool isAvailable;

  // Mengubah representasi DateTime menjadi format tanggal/waktu milidetik di database
  @Property(type: PropertyType.date)
  DateTime createdAt;

  // Properti dengan anotasi @Transient tidak akan disimpan ke dalam database fisik.
  // Sangat berguna untuk menampung state UI sementara.
  @Transient()
  bool isCheckedInUi;

  Product({
    this.id = 0,
    required this.code,
    required this.name,
    required this.price,
    required this.isAvailable,
    required this.createdAt,
    this.isCheckedInUi = false,
  });
}

Setelah mendefinisikan Entity, kita harus menjalankan generator kode untuk membuat berkas binding objectbox.g.dart yang berisi logika internal pemetaan properti:

flutter pub run build_runner build --delete-conflicting-outputs

Penjelasan Anotasi Penting: #

  • @Id(): Menandai kunci utama (primary key). Jika kita ingin menetapkan ID secara manual dari aplikasi (bukan auto-increment), gunakan @Id(assignable: true).
  • @Index(): Membuat indeks pencarian pada properti tersebut. Sangat direkomendasikan untuk properti yang sering kita gunakan di dalam kondisi penyaringan kueri (where clause) untuk menghindari pemindaian seluruh basis data (full database scan).
  • @Unique(): Menjamin bahwa nilai properti tersebut tidak boleh sama dengan data lain di dalam database. Jika terjadi duplikasi, ObjectBox akan melempar error saat penulisan.
  • @Property(type: ...): Digunakan untuk memberikan konfigurasi tambahan seperti tipe penyimpanan khusus (misalnya menyimpan gambar sebagai biner byte).

Store & Manajemen Siklus Hidup Database #

Store adalah objek utama yang bertindak sebagai pintu masuk (entry point) untuk berinteraksi dengan database ObjectBox kita. Store mewakili berkas database fisik di disk dan mengelola koneksi ke mesin database native. Karena inisialisasi Store memakan sumber daya CPU yang cukup besar, kita harus membuat satu instance Store saja (Singleton) dan menggunakannya di sepanjang siklus hidup aplikasi kita.

Berikut adalah cara merancang kelas helper manajemen Store yang bersih dan aman:

// lib/core/storage/objectbox_manager.dart

import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:objectbox/objectbox.dart';
import '../../features/inventory/data/models/product.dart';
import 'objectbox.g.dart'; // File generated hasil build_runner

class ObjectBoxManager {
  // Instance tunggal Store ObjectBox
  late final Store _store;

  // Konstruktor privat
  ObjectBoxManager._(this._store);

  // Metode inisialisasi asinkron
  static Future<ObjectBoxManager> create() async {
    // 1. Dapatkan folder dokumen aplikasi yang aman di perangkat
    final directory = await getApplicationDocumentsDirectory();
    
    // 2. Tentukan jalur folder penyimpanan khusus ObjectBox
    final String databasePath = p.join(directory.path, 'objectbox_db');

    // 3. Buka Store. Fungsi openStore() didefinisikan di dalam objectbox.g.dart
    final Store store = await openStore(directory: databasePath);

    return ObjectBoxManager._(store);
  }

  // Getter untuk mengakses Store dari luar
  Store get store => _store;

  // Getter untuk mempermudah akses ke Box Entity tertentu
  Box<Product> get productBox => Box<Product>(_store);

  // Menutup Store secara aman saat aplikasi ditutup
  // Sangat penting untuk melepaskan file lock di sistem operasi
  void dispose() {
    if (!_store.isClosed()) {
      _store.close();
    }
  }
}

Di dalam main.dart, kita melakukan inisialisasi global:

// lib/main.dart

import 'package:flutter/material.dart';
import 'core/storage/objectbox_manager.dart';

// Variabel global agar mudah diakses di seluruh aplikasi (atau disuntikkan via Dependency Injection)
late final ObjectBoxManager localDatabase;

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Inisialisasi database sebelum runApp
  localDatabase = await ObjectBoxManager.create();

  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: Center(child: Text('ObjectBox Siap Digunakan')),
      ),
    );
  }
}

Operasi CRUD Sinkron & Asinkron #

Interaksi penulisan dan pembacaan data di ObjectBox dilakukan melalui kelas Box<T>. Berbeda dengan basis data lainnya yang mewajibkan penggunaan kata kunci await di setiap operasi (seperti Drift atau SQFlite), ObjectBox menyediakan metode sinkron secara default. Hal ini karena akses datanya yang sangat cepat lewat RAM mapping membuat operasi tersebut tidak memblokir thread UI untuk data berukuran kecil.

Namun, untuk penulisan data dalam jumlah besar, ObjectBox juga menyediakan metode asinkron yang berjalan di background thread agar aplikasi kita tetap responsif.

Berikut adalah contoh lengkap operasi CRUD pada Box:

import 'features/inventory/data/models/product.dart';
import 'main.dart'; // Mengakses variabel global localDatabase

void demonstrasiCrudObjectBox() {
  final Box<Product> box = localDatabase.productBox;

  // ==========================================
  // 1. CREATE / INSERT (Operasi Tambah Data)
  // ==========================================
  
  final product1 = Product(
    code: 'PROD-A',
    name: 'Mouse Gaming X',
    price: 350000.0,
    isAvailable: true,
    createdAt: DateTime.now(),
  );

  // Menyimpan satu objek secara sinkron. Mengembalikan ID baru.
  final int newId = box.put(product1);
  debugPrint('Produk berhasil disimpan dengan ID: $newId');

  // Menyimpan banyak objek sekaligus (Bulk Write) - Jauh lebih cepat dibanding loop put()
  final listProduk = [
    Product(code: 'PROD-B', name: 'Keyboard Mechanical', price: 750000.0, isAvailable: true, createdAt: DateTime.now()),
    Product(code: 'PROD-C', name: 'Monitor LED 24 Inch', price: 1800000.0, isAvailable: false, createdAt: DateTime.now()),
  ];
  final List<int> listIds = box.putMany(listProduk);
  debugPrint('Daftar ID produk baru: $listIds');

  // ==========================================
  // 2. READ (Operasi Baca Data)
  // ==========================================

  // Membaca satu data berdasarkan ID (Mengembalikan null jika tidak ditemukan)
  final Product? product = box.get(newId);
  if (product != null) {
    debugPrint('Ditemukan produk: ${product.name}');
  }

  // Membaca banyak data berdasarkan daftar ID
  final List<Product?> listHasil = box.getMany(listIds);
  debugPrint('Jumlah data ditemukan: ${listHasil.length}');

  // Membaca seluruh data yang ada di dalam database
  final List<Product> semuaProduk = box.getAll();
  debugPrint('Total semua produk: ${semuaProduk.length}');

  // ==========================================
  // 3. UPDATE (Operasi Ubah Data)
  // ==========================================

  if (product != null) {
    // Cukup ubah nilainya, lalu panggil put() kembali menggunakan objek dengan ID yang sama
    product.price = 320000.0; // Diskon
    box.put(product); // Karena product.id tidak bernilai 0, ObjectBox akan melakukan update data
  }

  // ==========================================
  // 4. DELETE (Operasi Hapus Data)
  // ==========================================

  // Menghapus data berdasarkan ID
  final bool statusHapus = box.remove(newId);
  debugPrint('Status penghapusan: $statusHapus');

  // Menghapus banyak data sekaligus
  box.removeMany(listIds);

  // Menghapus seluruh isi database (Gunakan dengan sangat hati-hati!)
  // box.clear();

  // ==========================================
  // 5. ASYNC OPERATIONS (Operasi Asinkron)
  // ==========================================
  
  // Sangat penting untuk menulis data dalam jumlah ribuan baris di latar belakang
  box.putAsync(product1).then((idAsync) {
    debugPrint('Put asinkron selesai dengan ID: $idAsync');
  });
}

Query Builder: Pencarian & Penyaringan Data #

Untuk mencari data dengan kriteria spesifik, kita menggunakan Query Builder yang disediakan secara tipe data aman (type-safe) oleh ObjectBox. File objectbox.g.dart yang dihasilkan sebelumnya berisi properti metadata (seperti Product_) yang memetakan kolom database, sehingga kita terhindar dari pengetikan kueri manual dalam bentuk teks biasa.

[!IMPORTANT] Penting: Pengelolaan Memori Kueri: Setiap kali kita selesai membuat kueri dengan query.build(), kita wajib memanggil metode query.close() setelah selesai membaca hasilnya. Hal ini sangat krusial karena objek kueri mengalokasikan memori pointer pada sisi C++ native. Jika kita lupa menutup kueri, memori tersebut tidak akan dibebaskan oleh Garbage Collector Dart, yang lambat laun akan menyebabkan kebocoran memori (memory leak).

Berikut adalah contoh variasi kueri menggunakan Query Builder:

import 'core/storage/objectbox.g.dart'; // Wajib diimpor untuk mendeteksi Product_
import 'features/inventory/data/models/product.dart';
import 'main.dart';

void demonstrasiQueryObjectBox() {
  final Box<Product> box = localDatabase.productBox;

  // 1. Kueri dengan satu kondisi sederhana (Mencari produk yang tersedia)
  final queryTersedia = box.query(Product_.isAvailable.equals(true)).build();
  final List<Product> hasilTersedia = queryTersedia.find();
  queryTersedia.close(); // Wajib ditutup!

  // 2. Kueri dengan kondisi ganda (AND)
  final queryFilter = box.query(
    Product_.isAvailable.equals(true)
    .and(Product_.price.greaterThan(500000.0))
  ).build();
  final List<Product> hasilFilter = queryFilter.find();
  queryFilter.close();

  // 3. Kueri rentang nilai (Pencarian Harga Antara 100.000 hingga 1.000.000)
  final queryRange = box.query(Product_.price.between(100000.0, 1000000.0)).build();
  final List<Product> hasilRange = queryRange.find();
  queryRange.close();

  // 4. Kueri String dengan kecocokan teks parsial (Case Insensitive)
  final queryNama = box.query(
    Product_.name.contains('gaming', caseSensitive: false)
  ).build();
  final List<Product> hasilNama = queryNama.find();
  queryNama.close();

  // 5. Kueri dengan Pengurutan (Sorting) dan Halaman (Pagination/Limit-Offset)
  // Menampilkan 10 produk termahal yang tersedia
  final querySortPage = box.query(Product_.isAvailable.equals(true))
    .order(Product_.price, flags: Order.descending) // Urutkan menurun
    .build()
    ..limit = 10
    ..offset = 0; // Halaman pertama
    
  final List<Product> listTermahal = querySortPage.find();
  querySortPage.close();

  // 6. Menghitung jumlah data tanpa memuat objek ke RAM
  final queryHitung = box.query(Product_.price.lessThan(200000.0)).build();
  final int jumlahMurah = queryHitung.count();
  queryHitung.close();
  debugPrint('Jumlah produk murah: $jumlahMurah');
}

Pemodelan Relasi: ToOne dan ToMany #

ObjectBox mendukung relasi database secara penuh dan menanganinya secara efisien menggunakan konsep pemuatan malas (lazy loading). Data relasi hanya akan dimuat dari disk ke memori RAM ketika properti relasi tersebut diakses secara aktif di dalam kode.

Kita membagi relasi menjadi dua tipe utama:

  1. ToOne<Target>: Menghubungkan satu objek ke tepat satu objek target lainnya (Relasi Satu-ke-Satu atau Banyak-ke-Satu).
  2. ToMany<Target>: Menghubungkan satu objek ke banyak objek target lainnya (Relasi Satu-ke-Banyak atau Banyak-ke-Banyak).

Diagram Skema Relasi #

Perhatikan diagram relasi di bawah ini untuk melihat bagaimana ToOne dan ToMany dimodelkan serta bagaimana tautan balik (backlink) digunakan untuk mengakses relasi dari arah yang berlawanan.

graph TD
    Pelanggan["Pelanggan (Entity)"] -. "Backlink (ToMany)" .-> Pesanan["Pesanan (Entity)"]
    Pesanan -->|ToOne| Pelanggan
    
    Kategori["Kategori (Entity)"] -->|ToMany| Produk["Produk (Entity)"]
    Produk -. "Backlink (ToMany)" .-> Kategori

1. Implementasi ToOne (Satu Pesanan memiliki Satu Pelanggan) #

Pertama, mari kita definisikan Entity Customer dan Order:

// lib/features/orders/data/models/customer.dart
import 'package:objectbox/objectbox.dart';
import 'order.dart';

@Entity()
class Customer {
  @Id()
  int id;
  String name;
  String email;

  // Backlink: Akses otomatis dari Pelanggan untuk melihat semua pesanannya
  // Kita menunjuk properti 'customer' yang ada di kelas Order
  @Backlink('customer')
  final orders = ToMany<Order>();

  Customer({this.id = 0, required this.name, required this.email});
}
// lib/features/orders/data/models/order.dart
import 'package:objectbox/objectbox.dart';
import 'customer.dart';

@Entity()
class Order {
  @Id()
  int id;
  String orderNumber;
  double grandTotal;

  // Relasi ToOne ke Customer
  final customer = ToOne<Customer>();

  Order({this.id = 0, required this.orderNumber, required this.grandTotal});
}

Berikut adalah contoh cara menulis dan membaca data relasi ToOne:

void demoToOneRelasi() {
  final Box<Customer> customerBox = localDatabase.store.box<Customer>();
  final Box<Order> orderBox = localDatabase.store.box<Order>();

  // 1. Buat data Customer
  final budi = Customer(name: 'Budi Hartono', email: '[email protected]');
  customerBox.put(budi); // ID budi akan di-assign otomatis

  // 2. Buat data Order
  final orderBaru = Order(orderNumber: 'ORD-2026-001', grandTotal: 450000.0);
  
  // Hubungkan pesanan ke pelanggan Budi
  orderBaru.customer.target = budi;

  // Simpan Order. ObjectBox akan otomatis mencatat relasi ID budi.
  orderBox.put(orderBaru);

  // 3. Membaca relasi (Lazy Loading)
  final Order? pesananTersimpan = orderBox.get(orderBaru.id);
  if (pesananTersimpan != null) {
    // Objek customer di-load secara asinkron di latar belakang saat properti .target dipanggil
    final Customer? pelangganPesanan = pesananTersimpan.customer.target;
    debugPrint('Pesanan milik pelanggan: ${pelangganPesanan?.name}'); // Output: Budi Hartono
  }
}

2. Implementasi ToMany (Satu Kategori memiliki Banyak Produk) #

Mari kita gunakan model Category yang memiliki relasi ToMany ke Product:

// lib/features/inventory/data/models/category.dart
import 'package:objectbox/objectbox.dart';
import 'product.dart';

@Entity()
class Category {
  @Id()
  int id;
  String name;

  // Relasi ToMany ke Product
  final products = ToMany<Product>();

  Category({this.id = 0, required this.name});
}

Berikut adalah cara menambahkan produk ke dalam kategori tertentu:

void demoToManyRelasi() {
  final Box<Category> categoryBox = localDatabase.store.box<Category>();
  final Box<Product> productBox = localDatabase.store.box<Product>();

  // 1. Buat Kategori Baru
  final elektronik = Category(name: 'Elektronik Rumah Tangga');

  // 2. Buat Produk Baru
  final kipas = Product(code: 'KIPAS-01', name: 'Kipas Angin Berdiri', price: 250000.0, isAvailable: true, createdAt: DateTime.now());
  final blender = Product(code: 'BLEN-02', name: 'Blender Juicer', price: 400000.0, isAvailable: true, createdAt: DateTime.now());

  // 3. Masukkan produk ke dalam list produk milik Kategori
  elektronik.products.addAll([kipas, blender]);

  // 4. Simpan Kategori.
  // PENTING: ObjectBox secara otomatis akan menyimpan objek Produk baru (kipas & blender)
  // yang ada di dalam list products tersebut ke dalam Box produk secara berantai (Cascading Put).
  categoryBox.put(elektronik);

  // Membaca isi kategori
  final Category? cat = categoryBox.get(elektronik.id);
  debugPrint('Kategori ${cat?.name} memiliki ${cat?.products.length} produk.');
}

Transaksi ACID & Optimasi Performa #

Setiap kali kita memanggil box.put(...), ObjectBox secara implisit akan membuka dan menutup sebuah transaksi tulis. Jika kita memanggil put() di dalam perulangan sebanyak 1.000 kali, maka ObjectBox akan melakukan operasi pembukaan dan penulisan transaksi fisik sebanyak 1.000 kali. Hal ini merupakan penyusutan performa yang sangat besar karena pembatasan I/O disk.

Untuk mengatasinya, kita harus menyatukan seluruh operasi penulisan tersebut ke dalam satu transaksi tunggal menggunakan metode runInTransaction. Dengan cara ini, commit transaksi hanya dilakukan satu kali di akhir proses, meningkatkan kecepatan penulisan hingga puluhan kali lipat.

Berikut adalah teknik implementasi transaksi:

void demoTransaksiObjectBox() {
  final List<Product> listBaru = List.generate(500, (index) {
    return Product(
      code: 'PROD-INDEX-$index',
      name: 'Item Ke-$index',
      price: 10000.0 * index,
      isAvailable: true,
      createdAt: DateTime.now(),
    );
  });

  // OPTIMASI: Jalankan transaksi tulis massal secara sinkron
  // Operasi 500 put ini akan disatukan menjadi satu commit transaksi fisik saja
  localDatabase.store.runInTransaction(TxMode.write, () {
    final Box<Product> box = localDatabase.productBox;
    for (final product in listBaru) {
      box.put(product);
    }
  });

  // JIKA terjadi error atau kegagalan logika di dalam blok transaksi:
  try {
    localDatabase.store.runInTransaction(TxMode.write, () {
      final Box<Product> box = localDatabase.productBox;
      
      box.put(Product(code: 'A', name: 'Produk A', price: 100.0, isAvailable: true, createdAt: DateTime.now()));
      
      // Simulasi error yang terjadi di tengah jalan
      throw Exception('Simulasi kegagalan sistem transaksi');
      
      box.put(Product(code: 'B', name: 'Produk B', price: 200.0, isAvailable: true, createdAt: DateTime.now()));
    });
  } catch (e) {
    debugPrint('Transaksi gagal: Seluruh operasi tulis di dalam blok di-rollback secara otomatis.');
    // Produk A dijamin tidak akan tersimpan di database fisik
  }
}

Reactive Streams: Sinkronisasi UI Otomatis #

ObjectBox mendukung konsep pemrograman reaktif secara bawaan. Kita dapat mendengarkan perubahan data pada suatu Box atau kueri spesifik, kemudian memperbarui antarmuka pengguna secara real-time. Hal ini dimungkinkan karena ObjectBox menyediakan integrasi Stream Dart.

Membuat Watch Query untuk UI #

Berikut adalah contoh implementasi Watch Query yang mendengarkan daftar produk dan menyalurkannya dalam bentuk Stream untuk dikonsumsi oleh StreamBuilder di UI widget:

// lib/features/inventory/presentation/widgets/product_list_stream.dart

import 'package:flutter/material.dart';
import '../../data/models/product.dart';
import '../../../../main.dart'; // Mengakses database global
import '../../../../core/storage/objectbox.g.dart';

class ProductListStream extends StatefulWidget {
  const ProductListStream({super.key});

  @override
  State<ProductListStream> createState() => _ProductListStreamState();
}

class _ProductListStreamState extends State<ProductListStream> {
  late final Query<Product> _productQuery;
  late final Stream<List<Product>> _productStream;

  @override
  void initState() {
    super.initState();

    // 1. Buat Query pembacaan produk yang aktif
    _productQuery = localDatabase.productBox
        .query(Product_.isAvailable.equals(true))
        .order(Product_.name)
        .build();

    // 2. Aktifkan watch() untuk menghasilkan Stream
    // triggerImmediately: true memastikan stream langsung mengirim data awal saat didengar
    _productStream = _productQuery
        .watch(triggerImmediately: true)
        .map((query) => query.find()); // Map hasil query menjadi list produk Dart
  }

  @override
  void dispose() {
    // 3. Wajib close query dan bebaskan pointer memori biner native C++
    _productQuery.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<List<Product>>(
      stream: _productStream,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const Center(child: CircularProgressIndicator());
        }
        if (snapshot.hasError) {
          return Center(child: Text('Error: ${snapshot.error}'));
        }

        final products = snapshot.data ?? [];
        if (products.isEmpty) {
          return const Center(child: Text('Tidak ada produk tersedia'));
        }

        return ListView.builder(
          itemCount: products.length,
          itemBuilder: (context, index) {
            final product = products[index];
            return ListTile(
              title: Text(product.name),
              subtitle: Text('Rp ${product.price}'),
            );
          },
        );
      },
    );
  }
}

Melalui implementasi watch(), kapan pun bagian kode kita yang lain memanggil box.put() atau box.remove(), stream akan memicu pengiriman data baru secara otomatis dan antarmuka widget di atas akan langsung melakukan render ulang secara instan.


Integrasi State Management & ObjectBox Admin #

Untuk menyatukan ObjectBox dengan arsitektur bersih (clean architecture), kita sebaiknya menyembunyikan akses database langsung di bawah lapisan penyedia (state provider) seperti Riverpod.

Integrasi Menggunakan Riverpod #

Berikut adalah cara merancang penyediaan ObjectBox yang bersih menggunakan Riverpod:

// lib/core/providers/database_provider.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:objectbox/objectbox.dart';
import '../storage/objectbox_manager.dart';
import '../../features/inventory/data/models/product.dart';

// Provider utama untuk ObjectBoxManager
// Nilai akan di-override pada file main()
final objectBoxManagerProvider = Provider<ObjectBoxManager>((ref) {
  throw UnimplementedError('ObjectBoxManager belum di-override');
});

// Provider untuk mengakses Box Product secara instan
final productBoxProvider = Provider<Box<Product>>((ref) {
  return ref.watch(objectBoxManagerProvider).productBox;
});

// StreamProvider untuk data produk yang reaktif
final reactiveProductsProvider = StreamProvider<List<Product>>((ref) {
  final box = ref.watch(productBoxProvider);
  
  // Buat query, dengarkan, dan tutup query secara otomatis saat provider dihancurkan (autoDispose)
  final query = box.query().build();
  
  ref.onDispose(() {
    query.close();
  });

  return query.watch(triggerImmediately: true).map((q) => q.find());
});

Kemudian di berkas main.dart, kita melakukan override provider:

// lib/main.dart (Riverpod setup)

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/storage/objectbox_manager.dart';
import 'core/providers/database_provider.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  final ObjectBoxManager obxManager = await ObjectBoxManager.create();

  runApp(
    ProviderScope(
      overrides: [
        objectBoxManagerProvider.overrideWithValue(obxManager),
      ],
      child: const MaterialApp(home: HomeScreen()),
    ),
  );
}

ObjectBox Admin untuk Debugging #

Selama masa pengembangan (development mode), melihat isi database langsung dari perangkat atau emulator sangatlah sulit karena data bertipe biner terenkripsi. ObjectBox menyediakan alat visualisasi GUI web yang sangat canggih bernama ObjectBox Admin.

Untuk menggunakannya:

  1. Tambahkan paket objectbox_admin pada berkas pubspec.yaml kita (disarankan hanya untuk keperluan debug).
  2. Aktifkan admin di fungsi main.dart setelah Store dibuka:
import 'package:flutter/foundation.dart';
import 'package:objectbox/objectbox.dart';
import 'core/storage/objectbox_manager.dart';

void inisialisasiAdmin(Store store) {
  // Aktifkan Admin HANYA pada mode debug agar tidak mengotori aplikasi produksi
  if (kDebugMode) {
    // Jalankan server admin pada port default 8090
    final admin = Admin(store);
    debugPrint('ObjectBox Admin Server berjalan di http://localhost:8090');
    
    // Kita bisa membuka tautan di atas melalui peramban web komputer
    // untuk melihat data, melakukan query visual, dan memantau skema database.
  }
}

ObjectBox Admin sangat mempermudah proses verifikasi apakah data relasional kita terhubung dengan benar dan mempercepat investigasi bug data secara visual selama proses penulisan kode.

Ringkasan #

  • ObjectBox adalah database NoSQL berorientasi objek yang ditulis dalam C++ native. Ia menawarkan kecepatan baca/tulis tercepat di perangkat mobile tanpa perlu menulis pemetaan SQL manual.
  • Entity & ID: Gunakan anotasi @Entity() dan pastikan ada field @Id() int id sebagai kunci utama. Masukkan nilai 0 untuk menyimpan data baru.
  • Store tunggal: Buka Store hanya satu kali saat startup aplikasi, lalu gunakan instance tersebut di sepanjang masa aktif aplikasi untuk mencegah penguncian berkas basis data.
  • Optimasi Transaksi: Gunakan store.runInTransaction untuk menyatukan banyak operasi tulis (put) ke dalam satu commit tunggal demi efisiensi I/O disk.
  • Manajemen Memori Kueri: Selalu panggil query.close() setelah mendapatkan hasil kueri untuk membebaskan alokasi memori native C++ dan menghindari kebocoran memori.
  • Relasi Malas: Hubungan data dimodelkan lewat kelas ToOne<T> dan ToMany<T> yang mendukung proses pemuatan data secara malas (lazy loading).
  • Kueri Reaktif: Manfaatkan query.watch() untuk menghasilkan Stream data yang akan melakukan pembaruan antarmuka secara otomatis ketika basis data berubah.
  • Ketiadaan Fitur Web: ObjectBox tidak mendukung Flutter Web karena ketergantungan native C++. Jika platform Web adalah wajib, gunakan Hive atau Drift.

← Sebelumnya: Hive   Berikutnya: Drift →

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