SharedPreferences Problems
SharedPreferences is synchronous, not type-safe, and can cause ANR errors when accessed on the main thread with commit(). It also has no built-in mechanism for observing changes.
// SharedPreferences problems
val prefs = getSharedPreferences("settings", Context.MODE_PRIVATE)
// Problem 1: Synchronous IO on main thread
prefs.edit().putString("theme", "dark").commit() // blocks main thread!
// Problem 2: apply() is async but not awaitable — data may not be written
prefs.edit().putString("theme", "dark").apply() // fire-and-forget; crashes on process death?
// Problem 3: No type safety
val theme = prefs.getString("theme", null) // returns String? — can be null unexpectedly
prefs.putInt("theme", 42) // wrong type — no compile error!
// Problem 4: No reactive observation (requires registering a listener manually)
Preferences DataStore
DataStore uses coroutines and Flow — all IO happens off the main thread, and changes are observable as a Flow<Preferences>.
// build.gradle.kts
dependencies {
implementation("androidx.datastore:datastore-preferences:1.0.0")
}
Defining Typed Keys
object SettingsKeys {
val THEME = stringPreferencesKey("theme")
val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
val FONT_SIZE = intPreferencesKey("font_size")
val LAST_SYNC = longPreferencesKey("last_sync_timestamp")
}
Creating the DataStore
// Create once per application — use a singleton or DI
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
Repository
class SettingsRepository @Inject constructor(
@ApplicationContext private val context: Context
) {
private val dataStore = context.dataStore
// Read: returns Flow — reacts to every write
val themeFlow: Flow<String> = dataStore.data
.catch { exception ->
// Handle IOException (file corruption)
if (exception is IOException) emit(emptyPreferences())
else throw exception
}
.map { preferences ->
preferences[SettingsKeys.THEME] ?: "system"
}
val settingsFlow: Flow<AppSettings> = dataStore.data
.catch { if (it is IOException) emit(emptyPreferences()) else throw it }
.map { preferences ->
AppSettings(
theme = preferences[SettingsKeys.THEME] ?: "system",
notificationsEnabled = preferences[SettingsKeys.NOTIFICATIONS_ENABLED] ?: true,
fontSize = preferences[SettingsKeys.FONT_SIZE] ?: 16
)
}
// Write: suspend function — safe to call from any coroutine
suspend fun setTheme(theme: String) {
dataStore.edit { preferences ->
preferences[SettingsKeys.THEME] = theme
}
}
suspend fun setNotificationsEnabled(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[SettingsKeys.NOTIFICATIONS_ENABLED] = enabled
}
}
// Atomic update: read current value and write new value in one transaction
suspend fun toggleNotifications() {
dataStore.edit { preferences ->
val current = preferences[SettingsKeys.NOTIFICATIONS_ENABLED] ?: true
preferences[SettingsKeys.NOTIFICATIONS_ENABLED] = !current
}
}
// Clear all preferences
suspend fun clearAll() {
dataStore.edit { it.clear() }
}
}
Proto DataStore
Proto DataStore uses Protocol Buffers to define a strongly-typed schema. No keys at all — just access typed fields. Best for complex nested settings objects.
1. Add Dependencies
// build.gradle.kts
plugins {
id("com.google.protobuf") version "0.9.4"
}
dependencies {
implementation("androidx.datastore:datastore:1.0.0")
implementation("com.google.protobuf:protobuf-javalite:3.21.7")
}
protobuf {
protoc { artifact = "com.google.protobuf:protoc:3.21.7" }
generateProtoTasks {
all().forEach { task ->
task.builtins { create("java") { option("lite") } }
}
}
}
2. Define the .proto Schema
// src/main/proto/user_settings.proto
syntax = "proto3";
option java_package = "com.example.proto";
option java_multiple_files = true;
enum Theme {
SYSTEM = 0;
LIGHT = 1;
DARK = 2;
}
message UserSettings {
Theme theme = 1;
bool notifications_enabled = 2;
int32 font_size = 3;
string language_code = 4;
repeated string blocked_user_ids = 5;
}
3. Implement the Serializer
object UserSettingsSerializer : Serializer<UserSettings> {
override val defaultValue: UserSettings = UserSettings.getDefaultInstance()
override suspend fun readFrom(input: InputStream): UserSettings {
return try {
UserSettings.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto", exception)
}
}
override suspend fun writeTo(t: UserSettings, output: OutputStream) {
t.writeTo(output)
}
}
4. Create and Use the DataStore
// Singleton DataStore instance
private val Context.userSettingsDataStore: DataStore<UserSettings> by dataStore(
fileName = "user_settings.pb",
serializer = UserSettingsSerializer
)
class UserSettingsRepository @Inject constructor(
@ApplicationContext private val context: Context
) {
private val dataStore = context.userSettingsDataStore
val settingsFlow: Flow<UserSettings> = dataStore.data
.catch { exception ->
if (exception is IOException) emit(UserSettings.getDefaultInstance())
else throw exception
}
suspend fun setTheme(theme: Theme) {
dataStore.updateData { current ->
current.toBuilder()
.setTheme(theme)
.build()
}
}
suspend fun setNotificationsEnabled(enabled: Boolean) {
dataStore.updateData { current ->
current.toBuilder()
.setNotificationsEnabled(enabled)
.build()
}
}
suspend fun addBlockedUser(userId: String) {
dataStore.updateData { current ->
current.toBuilder()
.addBlockedUserIds(userId)
.build()
}
}
}
Migration from SharedPreferences
// Migrate existing SharedPreferences to DataStore on first access
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = "settings",
produceMigrations = { context ->
listOf(
SharedPreferencesMigration(
context = context,
sharedPreferencesName = "old_prefs",
keysToMigrate = setOf("theme", "notifications_enabled")
)
)
}
)
Hilt DI Setup
@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {
@Provides
@Singleton
fun providePreferencesDataStore(
@ApplicationContext context: Context
): DataStore<Preferences> = PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile("settings") }
)
@Provides
@Singleton
fun provideUserSettingsDataStore(
@ApplicationContext context: Context
): DataStore<UserSettings> = DataStoreFactory.create(
serializer = UserSettingsSerializer,
produceFile = { context.dataStoreFile("user_settings.pb") }
)
}
Testing
class SettingsRepositoryTest {
private lateinit var testDataStore: DataStore<Preferences>
private lateinit var repository: SettingsRepository
@Before
fun setup() {
val testDir = Files.createTempDirectory("test_datastore").toFile()
testDataStore = PreferenceDataStoreFactory.create(
produceFile = { File(testDir, "test_settings.preferences_pb") }
)
repository = SettingsRepository(testDataStore)
}
@Test
fun `setTheme persists correctly`() = runTest {
repository.setTheme("dark")
val theme = repository.themeFlow.first()
assertEquals("dark", theme)
}
@Test
fun `default theme is system`() = runTest {
val theme = repository.themeFlow.first()
assertEquals("system", theme)
}
}
Preferences vs Proto DataStore
| Preferences DataStore | Proto DataStore | |
|---|---|---|
| Schema | Untyped key-value pairs | Strongly typed .proto schema |
| Type safety | Compile-time per-key | Full schema enforced |
| Nested objects | Manual (JSON serialization) | Native (proto messages) |
| Collections | Not directly supported | repeated field support |
| Setup complexity | Low | Medium (protobuf plugin) |
| Versioning | Ad-hoc | Proto field numbers |
| Best for | Simple flags and settings | Complex structured settings |
Key Takeaways
| Concept | Summary |
|---|---|
| SharedPreferences problems | Synchronous IO, no type safety, no reactive observation |
| Preferences DataStore | Typed keys, suspend edit(), Flow<Preferences> |
| Proto DataStore | Schema-first, strongly typed, supports nested/repeated fields |
dataStore.data | Returns Flow — collect in ViewModel with stateIn() |
| Error handling | Catch IOException; emit emptyPreferences() / default instance |
| Migration | SharedPreferencesMigration handles one-time migration on first read |
| Testing | PreferenceDataStoreFactory.create() with temp file directory |