Michael Evans

A bunch of technobabble.

Modernizing Your Android App's Data Storage: SharedPreferences to DataStore

| Comments

SharedPreferences has long been a staple for storing small pieces of data and user preferences in Android apps. However, it has notable limitations, such as a lack of type safety, no support for safe schema evolution, and potential performance issues on the main thread. Google introduced Proto DataStore as a modern and robust alternative, offering:

  • Strong typing with Protocol Buffers
  • Safe schema evolution
  • Built-in migration support
  • Flow API for reactive programming
  • Coroutines support for main-thread safety

In this post, we’ll walk through the process of migrating your existing SharedPreferences data to Proto DataStore without data loss, including best practices and common pitfalls to avoid.

Step 1: Add Dependencies

First, add the necessary dependencies to your app’s build.gradle file:

1
2
3
4
5
6
7
8
9
dependencies {
    def datastore_version = "1.0.0"
    
    // Proto DataStore
    implementation "androidx.datastore:datastore:$datastore_version"
    
    // Protocol Buffers
    implementation "com.google.protobuf:protobuf-javalite:3.18.0"
}

Step 2: Define Your Proto DataStore Schema

Create a new .proto file in app/src/main/proto/my_data.proto to define your data schema:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
syntax = "proto3";

option java_package = "com.example.app";
option java_multiple_files = true;

message UserPreferences {
    // Define your fields with unique numbers
    string user_name = 1;
    bool notifications_enabled = 2;
    string theme = 3;
    
    // Optional: Add a version field for future schema evolution
    int32 schema_version = 999;
}

Note the schema_version field – this helps manage schema evolution as your app grows.

Step 3: Create a Proto DataStore Serializer

The serializer handles reading and writing your protocol buffer messages:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
object UserPreferencesSerializer : Serializer<UserPreferences> {
    override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance()
    
    override suspend fun readFrom(input: InputStream): UserPreferences {
        try {
            return UserPreferences.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto.", exception)
        }
    }
    
    override suspend fun writeTo(t: UserPreferences, output: OutputStream) {
        t.writeTo(output)
    }
}

Step 4: Create a Repository Class

Following best practices, wrap the DataStore in a repository:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
class UserPreferencesRepository(private val context: Context) {
    private val dataStore: DataStore<UserPreferences> = context.createDataStore(
        fileName = "user_prefs.pb",
        serializer = UserPreferencesSerializer
    )
    
    val userPreferencesFlow: Flow<UserPreferences> = dataStore.data
        .catch { exception ->
            if (exception is IOException) {
                emit(UserPreferences.getDefaultInstance())
            } else {
                throw exception
            }
        }
    
    suspend fun migrateFromSharedPreferences() = withContext(Dispatchers.IO) {
        try {
            val sharedPrefs = context.getSharedPreferences("user_prefs", Context.MODE_PRIVATE)
            
            // Read existing preferences
            val userName = sharedPrefs.getString("user_name", "") ?: ""
            val notificationsEnabled = sharedPrefs.getBoolean("notifications_enabled", true)
            val theme = sharedPrefs.getString("theme", "system") ?: "system"
            
            // Migrate to DataStore
            dataStore.updateData { preferences ->
                preferences.toBuilder()
                    .setUserName(userName)
                    .setNotificationsEnabled(notificationsEnabled)
                    .setTheme(theme)
                    .setSchemaVersion(1)
                    .build()
            }
            
            // Verify migration
            val migratedData = dataStore.data.first()
            if (migratedData.userName == userName &&
                migratedData.notificationsEnabled == notificationsEnabled &&
                migratedData.theme == theme) {
                // Clear SharedPreferences after successful migration
                sharedPrefs.edit().clear().apply()
                Result.success(Unit)
            } else {
                Result.failure(Exception("Migration verification failed"))
            }
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
    
    suspend fun updateUserName(name: String) {
        dataStore.updateData { preferences ->
            preferences.toBuilder()
                .setUserName(name)
                .build()
        }
    }
    
    suspend fun updateNotificationsEnabled(enabled: Boolean) {
        dataStore.updateData { preferences ->
            preferences.toBuilder()
                .setNotificationsEnabled(enabled)
                .build()
        }
    }
    
    suspend fun updateTheme(theme: String) {
        dataStore.updateData { preferences ->
            preferences.toBuilder()
                .setTheme(theme)
                .build()
        }
    }
}

Step 5: Use in Your App

Here’s how to use the repository in your app:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class MainActivity : AppCompatActivity() {
    private lateinit var repository: UserPreferencesRepository
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        repository = UserPreferencesRepository(applicationContext)
        
        // Migrate data on app first launch
        lifecycleScope.launch {
            repository.migrateFromSharedPreferences()
        }
        
        // Observe preferences changes
        lifecycleScope.launch {
            repository.userPreferencesFlow.collect { preferences ->
                // Update UI based on preferences
                updateUI(preferences)
            }
        }
    }
    
    private fun updateUI(preferences: UserPreferences) {
        // Update your UI components based on preferences
    }
}

Testing Your Migration

Here’s an example of how to test your migration:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Test
fun testMigrationFromSharedPreferences() = runTest {
    val context = ApplicationProvider.getApplicationContext<Context>()
    
    // Set up test SharedPreferences
    val sharedPrefs = context.getSharedPreferences("user_prefs", Context.MODE_PRIVATE)
    sharedPrefs.edit()
        .putString("user_name", "Test User")
        .putBoolean("notifications_enabled", true)
        .putString("theme", "dark")
        .apply()
    
    // Perform migration
    val repository = UserPreferencesRepository(context)
    val result = repository.migrateFromSharedPreferences()
    
    // Verify migration
    assert(result.isSuccess)
    
    val migratedData = repository.userPreferencesFlow.first()
    assertEquals("Test User", migratedData.userName)
    assertTrue(migratedData.notificationsEnabled)
    assertEquals("dark", migratedData.theme)
    
    // Verify SharedPreferences was cleared
    assertTrue(sharedPrefs.all.isEmpty())
}

Best Practices and Common Pitfalls

  1. Schema Evolution: When adding new fields to your proto schema, always:

    • Use new field numbers
    • Provide default values
    • Increment the schema version
  2. Error Handling: Always handle potential exceptions when reading/writing data:

    • IOException for file system issues
    • CorruptionException for invalid data
    • InvalidProtocolBufferException for proto parsing errors
  3. Performance:

    • Perform migrations in the background using Coroutines
    • Use Flow’s collectLatest when frequent updates aren’t necessary
    • Consider caching frequently accessed values
  4. Testing:

    • Write unit tests for your repository
    • Test migration with various SharedPreferences states
    • Include error cases in your tests
    • Test schema evolution scenarios

Conclusion

Migrating from SharedPreferences to Proto DataStore requires some initial setup, but the benefits of type safety, schema evolution, and reactive programming make it worthwhile. The resulting code will be more maintainable, type-safe, and resistant to runtime errors. Happy coding!

Comments