Menyimpan data lokal dengan tepat: perbandingan SharedPreferences/ DataStore, SQLite/Room, Realm, dan options lain, konsep offline-first dengan migration & query reactive — sambil membangun penyimpanan offline-capable untuk Fitku

Setelah episode 10, aplikasi Fitku bisa berbicara dengan server. Tapi ada masalah: di kereta bawah tanah atau daerah tanpa sinyal, server tidak terjangkau. Di episode 11 ini kita belajar local storage & database — menyimpan data di device sehingga aplikasi tetap berguna tanpa internet. Ini landasan untuk offline-first yang akan kita dalami di episode 23.
Mengapa ini penting? Pengguna menghabiskan banyak waktu di jaringan yang buruk. Aplikasi yang bisa bekerja offline dan menyinkronkan saat online kembali mendapat loyalitas jauh lebih tinggi. Selain itu, baca dari disk selalu lebih cepat daripada jaringan — UX langsung meningkat.
| Kebutuhan | Alat yang Tepat |
|---|---|
| Setting kecil (token, pref) | DataStore / SharedPreferences |
| Data terstruktur ringan | SQLite / Room / SwiftData |
| Data real-time & reactive | Realm / Room + Flow |
| File (gambar, video) | Filesystem (cache + document dir) |
Untuk pasangan key-value kecil, Android modern memakai DataStore (pengganti SharedPreferences):
val Context.dataStore by preferencesDataStore(name = "fitku_prefs")
val USER_ID = stringPreferencesKey("user_id")
val GOAL_STEPS = intPreferencesKey("goal_steps")
// Baca (Flow → reactive)
val userId: Flow<String?> = context.dataStore.data
.map { prefs -> prefs[USER_ID] }
// Tulis
suspend fun setGoal(steps: Int) {
context.dataStore.edit { prefs -> prefs[GOAL_STEPS] = steps }
}DataStore berbasis Flow → UI otomatis update saat data berubah, tanpa mekanisme manual.
Untuk data terstruktur (workout history), Room di Android adalah lapisan di atas SQLite dengan compile-time query check:
@Entity(tableName = "workouts")
data class WorkoutEntity(
@PrimaryKey val id: String,
val name: String,
val startedAt: Long,
val durationMinutes: Int,
val synced: Boolean = false,
)
@Dao
interface WorkoutDao {
@Query("SELECT * FROM workouts ORDER BY startedAt DESC")
fun observeAll(): Flow<List<WorkoutEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(items: List<WorkoutEntity>)
}observeAll() mengembalikan Flow — setiap perubahan database otomatis di-push ke UI. Inilah pola reactive persistence.
Flutter punya banyak pilihan; untuk 2026, drift (lapisan SQLite type-safe) dan Isar (database NoSQL cepat) populer. Contoh memakai sqflite untuk kebutuhan sederhana:
import 'package:sqflite/sqflite.dart';
Future<void> insertWorkout(Workout w) async {
final db = await openDatabase('fitku.db');
await db.insert('workouts', w.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace);
}Untuk RN, pilihan umum: AsyncStorage (sederhana) atau SQLite via expo-sqlite untuk data terstruktur.
Realm (kini bagian dari MongoDB) adalah database mobile object-oriented: objek langsung persist tanpa ORM, dan query-nya live/reactive. Kelebihan: sangat cepat untuk data ber-relasi kompleks; kekurangan: ukuran binary lebih besar dan vendor lock-in.
DataStore → setting kecil, key-value, ringan
Room → data terstruktur, SQL, reactive (Flow), mature
Realm → objek langsung, reactive, cepat, tapi berat
SwiftData → pilihan iOS modern (episode ini fokus Android/Flutter/RN)Aplikasi berevolusi → skema database pun berubah. Room menangani ini lewat Migration:
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE workouts ADD COLUMN intensity TEXT DEFAULT 'medium'")
}
}
val database = Room.databaseBuilder(context, AppDatabase::class.java, "fitku.db")
.addMigrations(MIGRATION_1_2)
.build()Warning
Migration adalah kewajiban, bukan pilihan. Jika kalian mengubah skema tanpa migration, pengguna dengan versi lama akan crash saat update. Biasakan: setiap perubahan skema = satu migration baru, dan uji dengan exportSchema = true untuk deteksi otomatis.
Prinsip offline-first: tulis lokal dulu, sinkronkan kemudian. Data lokal adalah sumber kebenaran sementara; server menjadi final setelah sync (detail di episode 23).
class WorkoutRepositoryImpl(
private val api: FitkuApi,
private val dao: WorkoutDao,
) : WorkoutRepository {
override suspend fun saveWorkout(workout: Workout) {
dao.insert(workout.copy(synced = false)) // 1. simpan lokal seketika
syncPending() // 2. coba sync di background
}
private suspend fun syncPending() {
dao.pending().forEach { local ->
try {
api.uploadWorkout(local.toDto())
dao.markSynced(local.id)
} catch (_: RetryableException) {
return // gagal → tinggal di antrean, dicoba lagi nanti
}
}
}
}Pola "outbox": tabel data lokal diberi flag synced. Saat online, item yang belum synced dikirim; saat offline, antrean menunggu. Ini fondasi aplikasi yang bisa dipakai di mana pun.
Tip
Untuk praktik cepat, expo-sqlite di Expo dan sqflite di Flutter sama-sama mudah dimulai. Mulailah dari model sederhana (Workout), lalu perluas. Kuncinya konsisten: simpan lokal dulu, reactive ke UI, sinkronkan dengan tenang.
LRU/batas ukuran.Flow) mengirim banyak event; gunakan distinctUntilChanged.Pada episode 11 ini, kalian telah menyimpan data dengan benar:
Di episode 12 selanjutnya kita membangun jalur pengguna: Navigation & Deep Linking — stack, tab, drawer, dan deep linking untuk membuka layar spesifik dari notifikasi atau link. Sampai jumpa!