Android provides multiple distinct storage paradigms depending on data volume, query complexity, and structure:
For over a decade, Android relied on SharedPreferences. However, it suffers from critical architectural flaws:
commit() executes synchronous disk I/O on the Main thread, inducing Application Not Responding (ANR) crashes. While apply() queues writes asynchronously, it still blocks the main thread during Activity teardowns."user_id" and reading it as a String throws a fatal crash at runtime.Jetpack DataStore completely replaces SharedPreferences by providing asynchronous, transactional, and type-safe storage built natively on Kotlin Coroutines and Flow.
| Architectural Attribute | Legacy SharedPreferences | Jetpack Preferences DataStore |
|---|---|---|
| Threading Model | Synchronous disk writes blocking Main UI thread | 100% Asynchronous execution on Dispatchers.IO |
| Observable Streams | Fragile string-keyed listeners (prone to memory leaks) | Native reactive Flow<Preferences> |
| Error Handling | Silently drops write errors; throws ClassCastException | Encapsulated in standard Kotlin IOException handlers |
| Transactional Safety | Non-atomic; file corruption risk on crash | ACID transactional updates via atomic temporary files |
package com.devascent.storage.datastore
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.*
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import java.io.IOException
// Top-level delegate: creates singleton DataStore instance
private val Context.userPreferencesDataStore: DataStore<Preferences> by preferencesDataStore(name = "user_settings")
class SettingsRepository(private val context: Context) {
// Strongly-typed preference keys
private object PreferencesKeys {
val DARK_MODE_ENABLED = booleanPreferencesKey("dark_mode_enabled")
val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
val STUDENT_HANDLE = stringPreferencesKey("student_handle")
}
// 1. Reactive Flow Reading with Error Encapsulation
val isDarkModeFlow: Flow<Boolean> = context.userPreferencesDataStore.data
.catch { exception ->
if (exception is IOException) {
emit(emptyPreferences()) // Graceful fallback on disk read error
} else {
throw exception
}
}
.map { preferences ->
preferences[PreferencesKeys.DARK_MODE_ENABLED] ?: false
}
// 2. Transactional Asynchronous Mutation
suspend fun setDarkMode(enabled: Boolean) {
context.userPreferencesDataStore.edit { preferences ->
preferences[PreferencesKeys.DARK_MODE_ENABLED] = enabled
}
}
}
Raw SQLite programming using SQLiteOpenHelper required writing raw string queries, manually iterating over Cursor indices, and converting columns into Java objects. Syntax errors inside SQL strings caused runtime app crashes!
The Room Persistence Library provides an abstraction layer over SQLite, verifying all SQL syntax at compile-time and eliminating boilerplate cursor mapping through three fundamental pillars:
@Entity (The Table Schema): Standard Kotlin data classes annotated to represent database tables. Declares column names, types, primary keys, and indices.@Dao (Data Access Object): The interface defining methods for querying, inserting, updating, and deleting data. Validates SQL queries during compilation.@Database (The Main Connection Hub): An abstract class extending RoomDatabase. Manages SQLite database instantiation, versioning, migrations, and DAO exposure.The most powerful capability of Room is its native integration with Kotlin Coroutine Flow:
Flow<List<CourseEntity>>, Room automatically registers internal SQLite triggers via its InvalidationTracker.@Insert, @Update, or @Delete transaction modifies the underlying table, Room re-executes the query on a background thread and emits the fresh list to the Flow.package com.devascent.storage.room
import android.content.Context
import androidx.room.*
import kotlinx.coroutines.flow.Flow
// ==================== 1. ENTITY DEFINITION ====================
@Entity(tableName = "courses")
data class CourseEntity(
@PrimaryKey
val id: String,
@ColumnInfo(name = "course_title")
val title: String,
@ColumnInfo(name = "module_count")
val moduleCount: Int,
@ColumnInfo(name = "is_enrolled")
val isEnrolled: Boolean = false
)
// ==================== 2. DATA ACCESS OBJECT (DAO) ====================
@Dao
interface CourseDao {
// Reactive Continuous Query: Emits automatically on table writes!
@Query("SELECT * FROM courses ORDER BY course_title ASC")
fun getAllCourses(): Flow<List<CourseEntity>>
@Query("SELECT * FROM courses WHERE id = :courseId LIMIT 1")
suspend fun getCourseById(courseId: String): CourseEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertCourse(course: CourseEntity)
@Delete
suspend fun deleteCourse(course: CourseEntity)
@Query("UPDATE courses SET is_enrolled = :enrolled WHERE id = :courseId")
suspend fun updateEnrollmentStatus(courseId: String, enrolled: Boolean)
}
// ==================== 3. DATABASE ENGINE ====================
@Database(entities = [CourseEntity::class], version = 1, exportSchema = false)
abstract class DevAscentDatabase : RoomDatabase() {
abstract fun courseDao(): CourseDao
companion object {
@Volatile
private var INSTANCE: DevAscentDatabase? = null
fun getDatabase(context: Context): DevAscentDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
DevAscentDatabase::class.java,
"devascent_academy.db"
)
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
instance
}
}
}
}
@RelationIn relational databases, real-world data is normalized across multiple tables. For example, a single Course has multiple child Lessons:
foreignKeys = [ForeignKey(entity = CourseEntity::class, parentColumns = ["id"], childColumns = ["course_id"], onDelete = ForeignKey.CASCADE)]. If a course is deleted, all its associated lessons are automatically purged.@Embedded & @Relation: Room avoids complex SQL JOIN boilerplate by allowing developers to model composite query results into Kotlin relational containers.@Entity(
tableName = "lessons",
foreignKeys = [
ForeignKey(
entity = CourseEntity::class,
parentColumns = ["id"],
childColumns = ["course_id"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("course_id")]
)
data class LessonEntity(
@PrimaryKey val lessonId: String,
@ColumnInfo(name = "course_id") val courseId: String,
val lessonTitle: String
)
// Relational Query Container: Combines parent Course with all child Lessons
data class CourseWithLessons(
@Embedded val course: CourseEntity,
@Relation(
parentColumn = "id",
entityColumn = "course_id"
)
val lessons: List<LessonEntity>
)
The complete implementation below showcases the fusion of Preferences DataStore (for instant dark mode preference persistence) and Room Database (for continuous, reactive course enrollment tracking with Flow).
package com.devascent.storage.app
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.devascent.storage.datastore.SettingsRepository
import com.devascent.storage.room.CourseDao
import com.devascent.storage.room.CourseEntity
import com.devascent.storage.room.DevAscentDatabase
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
// ViewModel Managing both Room & DataStore
class StorageSuiteViewModel(
private val courseDao: CourseDao,
private val settingsRepo: SettingsRepository
) : ViewModel() {
// Continuous Reactive Flow from Room DB
val courses: StateFlow<List<CourseEntity>> = courseDao.getAllCourses()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
// Continuous Flow from DataStore
val isDarkMode: StateFlow<Boolean> = settingsRepo.isDarkModeFlow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
fun toggleTheme(dark: Boolean) {
viewModelScope.launch {
settingsRepo.setDarkMode(dark)
Log.i("StorageApp", "DataStore: Persisted dark mode preference = $dark")
}
}
fun addSampleCourse(id: String, title: String, modules: Int) {
viewModelScope.launch {
courseDao.upsertCourse(CourseEntity(id, title, modules, true))
Log.i("StorageApp", "Room: Inserted course $title")
}
}
fun deleteCourse(course: CourseEntity) {
viewModelScope.launch {
courseDao.deleteCourse(course)
Log.w("StorageApp", "Room: Deleted course ${course.title}")
}
}
}
class StorageActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val db = DevAscentDatabase.getDatabase(applicationContext)
val settings = SettingsRepository(applicationContext)
setContent {
val vm: StorageSuiteViewModel = viewModel(
factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
StorageSuiteViewModel(db.courseDao(), settings) as T
}
)
val isDark by vm.isDarkMode.collectAsStateWithLifecycle()
val courseList by vm.courses.collectAsStateWithLifecycle()
MaterialTheme(
colorScheme = if (isDark) darkColorScheme() else lightColorScheme()
) {
StorageAppScreen(
isDark = isDark,
courses = courseList,
onToggleTheme = { vm.toggleTheme(it) },
onAdd = { vm.addSampleCourse("unit10", "Unit 10: Data Storage", 15) },
onDelete = { vm.deleteCourse(it) }
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StorageAppScreen(
isDark: Boolean,
courses: List<CourseEntity>,
onToggleTheme: (Boolean) -> Unit,
onAdd: () -> Unit,
onDelete: (CourseEntity) -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("DevAscent Storage Lab") },
actions = {
Text("Dark Mode", modifier = Modifier.align(Alignment.CenterVertically))
Switch(checked = isDark, onCheckedChange = onToggleTheme)
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = onAdd) {
Icon(Icons.Default.Add, contentDescription = "Add Course")
}
}
) { padding ->
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(courses, key = { it.id }) { course ->
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(text = course.title, style = MaterialTheme.typography.titleMedium)
Text(text = "${course.moduleCount} Modules", style = MaterialTheme.typography.bodySmall)
}
IconButton(onClick = { onDelete(course) }) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
}
}
}
}
}
}
SharedPreferences blocks the Main UI thread during commit() disk I/O, which frequently triggers Application Not Responding (ANR) dialogs. Furthermore, it lacks type safety (throwing runtime ClassCastException), lacks transactional consistency (corrupting files if killed mid-write), and does not expose a native reactive stream. Preferences DataStore runs 100% asynchronously on Dispatchers.IO via Kotlin Coroutines, guarantees ACID transactional writes via atomic temporary file renaming, and exposes continuous reactive Flow<Preferences> streams.
@Entity: Kotlin data class representing a table in the SQLite database. Configures primary keys, table names, and column annotations.@Dao (Data Access Object): Clean interface providing SQL query contracts (@Insert, @Query, @Update, @Delete) verified at compile time.@Database: Abstract class extending RoomDatabase serving as the central connection access point, defining table schemas and versioning.@Query("...") annotations against the declared @Entity schema tables and column definitions. If you misspell a column name, query a nonexistent table, or return an incompatible data type, compilation fails immediately with an explicit error message rather than crashing at runtime on a user's device.
Flow<T>, Room attaches an internal InvalidationTracker equipped with SQLite database triggers on the queried tables. When any write transaction (@Insert, @Update, @Delete) commits to those tables, the tracker detects the modification, re-executes the query asynchronously on a background dispatcher, and pushes the updated dataset down the Flow to active collectors automatically.
RoomDatabase is an extremely resource-intensive operation that creates heavy SQLite file locks, connection pools, and in-memory query caches. Creating multiple database instances simultaneously can induce severe file lock contention, race conditions, memory leaks, and out-of-sync caching. A singleton pattern (using @Volatile and double-checked locking) guarantees only one connection pool exists throughout the process lifespan.
OnConflictStrategy.REPLACE and ABORT in Room?REPLACE: Deletes the existing conflicting row and inserts the new row (acting as an Upsert), updating all fields atomically.ABORT (Default): Cancels and rolls back the current SQLite transaction, throwing an SQLiteConstraintException.ForeignKey.CASCADE in Room?onDelete = ForeignKey.CASCADE guarantees that if a parent record is deleted from the courses table, SQLite automatically deletes all associated child records in the lessons table, preventing orphaned records from polluting database storage.
version = 2 without supplying a corresponding Migration object, Room throws an IllegalStateException: A migration from 1 to 2 was required but not found upon startup, crashing the application. If fallbackToDestructiveMigration() is enabled, Room will drop all preexisting tables and recreate them empty, causing permanent user data loss. In enterprise apps, explicit Migration(1, 2) { database.execSQL("ALTER TABLE ...") } rules are mandatory.