androidengineers.Book a session

Modern Android Architecture Components

DataStore (Preferences & Proto)

article20 minMedium

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 DataStoreProto DataStore
SchemaUntyped key-value pairsStrongly typed .proto schema
Type safetyCompile-time per-keyFull schema enforced
Nested objectsManual (JSON serialization)Native (proto messages)
CollectionsNot directly supportedrepeated field support
Setup complexityLowMedium (protobuf plugin)
VersioningAd-hocProto field numbers
Best forSimple flags and settingsComplex structured settings

Key Takeaways

ConceptSummary
SharedPreferences problemsSynchronous IO, no type safety, no reactive observation
Preferences DataStoreTyped keys, suspend edit(), Flow<Preferences>
Proto DataStoreSchema-first, strongly typed, supports nested/repeated fields
dataStore.dataReturns Flow — collect in ViewModel with stateIn()
Error handlingCatch IOException; emit emptyPreferences() / default instance
MigrationSharedPreferencesMigration handles one-time migration on first read
TestingPreferenceDataStoreFactory.create() with temp file directory

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
DataStore (Preferences & Proto) | Android System Design | Android Engineers