Why SharedPreferences Must Go
Preferences DataStore: Drop-In Replacement
// 1. Create the DataStore instance (top-level, one per file)
val Context.settingsDataStore by preferencesDataStore(
name = "user_settings"
)
// 2. Define typed preference keys
object SettingsKeys {
val DARK_MODE = booleanPreferencesKey("dark_mode")
val FONT_SIZE = intPreferencesKey("font_size")
val USERNAME = stringPreferencesKey("username")
val ONBOARDING_COMPLETE = booleanPreferencesKey("onboarding_complete")
val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
val LAST_SYNC_TIMESTAMP = longPreferencesKey("last_sync_timestamp")
}
// 3. Read reactively with Flow
class SettingsRepository(private val context: Context) {
val darkModeEnabled: Flow<Boolean> = context.settingsDataStore.data
.map { prefs -> prefs[SettingsKeys.DARK_MODE] ?: false }
val fontSize: Flow<Int> = context.settingsDataStore.data
.map { prefs -> prefs[SettingsKeys.FONT_SIZE] ?: 16 }
// Combine multiple preferences into a domain object
val userSettings: Flow<UserSettings> = context.settingsDataStore.data
.map { prefs ->
UserSettings(
darkMode = prefs[SettingsKeys.DARK_MODE] ?: false,
fontSize = prefs[SettingsKeys.FONT_SIZE] ?: 16,
username = prefs[SettingsKeys.USERNAME] ?: "Guest",
notificationsEnabled = prefs[SettingsKeys.NOTIFICATIONS_ENABLED] ?: true
)
}
// 4. Write with suspend transactions
suspend fun setDarkMode(enabled: Boolean) {
context.settingsDataStore.edit { prefs ->
prefs[SettingsKeys.DARK_MODE] = enabled
}
}
suspend fun updateFontSize(size: Int) {
context.settingsDataStore.edit { prefs ->
prefs[SettingsKeys.FONT_SIZE] = size.coerceIn(12, 32)
}
}
suspend fun completeOnboarding(username: String) {
context.settingsDataStore.edit { prefs ->
prefs[SettingsKeys.ONBOARDING_COMPLETE] = true
prefs[SettingsKeys.USERNAME] = username
}
}
}Proto DataStore: Type-Safe Structured Data
// user_preferences.proto
syntax = "proto3";
option java_package = "com.myapp.datastore";
option java_multiple_files = true;
message UserPreferences {
bool dark_mode = 1;
int32 font_size = 2;
string username = 3;
bool onboarding_complete = 4;
bool notifications_enabled = 5;
int64 last_sync_timestamp = 6;
enum Theme {
SYSTEM = 0;
LIGHT = 1;
DARK = 2;
}
Theme theme = 7;
}
// Serializer (required by DataStore)
object UserPreferencesSerializer : Serializer<UserPreferences> {
override val defaultValue: UserPreferences =
UserPreferences.getDefaultInstance()
override suspend fun readFrom(input: InputStream): UserPreferences {
try {
return UserPreferences.parseFrom(input)
} catch (e: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", e)
}
}
override suspend fun writeTo(t: UserPreferences, output: OutputStream) {
t.writeTo(output)
}
}
// Create the DataStore
val Context.userPrefsStore: DataStore<UserPreferences> by dataStore(
fileName = "user_preferences.pb",
serializer = UserPreferencesSerializer
)
// Repository with type-safe access
class UserPrefsRepository(private val context: Context) {
val preferences: Flow<UserPreferences> = context.userPrefsStore.data
suspend fun setTheme(theme: UserPreferences.Theme) {
context.userPrefsStore.updateData { current ->
current.toBuilder()
.setTheme(theme)
.build()
}
}
suspend fun updateProfile(username: String, fontSize: Int) {
context.userPrefsStore.updateData { current ->
current.toBuilder()
.setUsername(username)
.setFontSize(fontSize)
.build()
}
}
}Migrating from SharedPreferences
// Preferences DataStore with migration
val Context.settingsDataStore by preferencesDataStore(
name = "user_settings",
produceMigrations = { context ->
listOf(
SharedPreferencesMigration(
context = context,
sharedPreferencesName = "legacy_settings",
keysToMigrate = setOf(
"dark_mode", // Will map to SettingsKeys.DARK_MODE
"font_size", // Will map to SettingsKeys.FONT_SIZE
"username" // Will map to SettingsKeys.USERNAME
)
)
)
}
)
// Proto DataStore with custom migration logic
val Context.userPrefsStore: DataStore<UserPreferences> by dataStore(
fileName = "user_preferences.pb",
serializer = UserPreferencesSerializer,
produceMigrations = { context ->
listOf(
SharedPreferencesMigration(
context = context,
sharedPreferencesName = "legacy_settings"
) { sharedPrefs, currentData ->
// Custom mapping from SharedPreferences to Proto
currentData.toBuilder().apply {
if (sharedPrefs.contains("dark_mode")) {
darkMode = sharedPrefs.getBoolean("dark_mode", false)
}
if (sharedPrefs.contains("font_size")) {
fontSize = sharedPrefs.getInt("font_size", 16)
}
if (sharedPrefs.contains("username")) {
username = sharedPrefs.getString("username", "") ?: ""
}
// Migrate computed values
if (sharedPrefs.contains("dark_mode")) {
theme = if (sharedPrefs.getBoolean("dark_mode", false)) {
UserPreferences.Theme.DARK
} else {
UserPreferences.Theme.LIGHT
}
}
}.build()
}
)
}
)Integrating DataStore with Jetpack Compose
class SettingsViewModel(
private val settingsRepo: SettingsRepository
) : ViewModel() {
val uiState: StateFlow<SettingsUiState> = settingsRepo.userSettings
.map { prefs ->
SettingsUiState(
darkMode = prefs.darkMode,
fontSize = prefs.fontSize,
username = prefs.username,
notificationsEnabled = prefs.notificationsEnabled
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = SettingsUiState()
)
fun toggleDarkMode() {
viewModelScope.launch {
val current = uiState.value.darkMode
settingsRepo.setDarkMode(!current)
// No need to manually update uiState --
// the Flow from DataStore will emit automatically
}
}
fun updateFontSize(newSize: Int) {
viewModelScope.launch {
settingsRepo.updateFontSize(newSize)
}
}
}
@Composable
fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Column(modifier = Modifier.padding(16.dp)) {
SwitchRow(
label = "Dark Mode",
checked = state.darkMode,
onCheckedChange = { viewModel.toggleDarkMode() }
)
SliderRow(
label = "Font Size: ${state.fontSize}sp",
value = state.fontSize.toFloat(),
valueRange = 12f..32f,
onValueChange = { viewModel.updateFontSize(it.toInt()) }
)
Text(
text = "Logged in as ${state.username}",
fontSize = state.fontSize.sp
)
}
}Key Takeaways
- 1SharedPreferences blocks the UI thread, loses data on concurrent writes, and provides no type safety -- DataStore fixes all three.
- 2Use Preferences DataStore for simple key-value pairs and Proto DataStore for structured objects with schemas.
- 3DataStore reads return Flow (reactive, non-blocking) and writes use suspend functions (coroutine-safe).
- 4Built-in SharedPreferencesMigration preserves existing user data during the migration with zero user impact.
- 5Proto DataStore schemas are versioned through Protocol Buffers, making migrations explicit and testable.
- 6The DataStore -> Flow -> StateFlow -> Compose pipeline creates a fully reactive settings architecture with no manual observation.
Frequently Asked
Should I use Preferences or Proto DataStore?
Preferences for simple key-value storage. Proto for complex, structured data. Proto provides type safety and is worth the setup for production apps.
How do I handle migration?
Use DataStore's built-in migration helper. Map SharedPreferences keys to DataStore keys. Transform data types if needed. Test migration with old app data.
Ready to architect your next Android app?
ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.