SharedPreferences から DataStore Preferences への移行を実施した。ユーザーの設定データを失わないように、慎重に移行する必要があったので、その記録を残しておく。
目次
DataStoreMigration
を使えば自動化できる公式のドキュメントでも現在は SharedPreferences から DataStore への移行が推奨されている。 実際に下記のようなメリットもある。
一番シンプルなケースはこんな感じ。
// SharedPreferences(移行前)
class UserPreferences(context: Context) {
private val prefs = context.getSharedPreferences("user_prefs", Context.MODE_PRIVATE)
fun getUserId(): String? = prefs.getString("user_id", null)
fun setUserId(id: String) = prefs.edit().putString("user_id", id).apply()
}
// DataStore(移行後)
class UserDataStore(context: Context) {
private val Context.dataStore by preferencesDataStore(
name = "user_preferences",
produceMigrations = { context ->
listOf(
SharedPreferencesMigration(
context,
"user_prefs" // 既存の SharedPreferences 名
)
)
}
)
private val dataStore = context.dataStore
companion object {
val USER_ID = stringPreferencesKey("user_id")
}
val userId: Flow<String?> = dataStore.data
.map { preferences -> preferences[USER_ID] }
suspend fun setUserId(id: String) {
dataStore.edit { preferences ->
preferences[USER_ID] = id
}
}
}
SharedPreferencesMigration
を使えば、既存のデータが自動的に移行される。便利!
以前の記事「SharedPreferences に末尾が改行になる文字列を保存するとよくわからんスペースが4つ追加される件」で書いたように、SharedPreferences には改行文字周りのバグがあるため、文字列を "
で囲んで保存する workaround を使ってた。
この workaround を使った既存データを DataStore に移行する場合、unwrap 処理が必要。
// 移行時の処理
private val Context.dataStore by preferencesDataStore(
name = "app_preferences",
produceMigrations = { context ->
listOf(
SharedPreferencesMigration(
context = context,
sharedPreferencesName = "app_prefs",
keysToMigrate = setOf("config")
) { prefs ->
// 移行時に unwrap する(前後のダブルクォートを除去)
val config = prefs.getString("config", null)
?.removeSurrounding("\"")
mutablePreferencesOf(
CONFIG_KEY to config
)
}
)
}
)
Preferences DataStore は SharedPreferences に似た API を提供しているが Protocol Buffers 形式で保存されるため、SharedPreferences の XML パースに起因する改行文字バグは発生しない。移行後は wrapping 不要。
SharedPreferences は型がゆるいので、間違った型で保存されていることがある。
// Int として保存したつもりが String で保存されてた…
class MigrationFactory {
fun create(context: Context): DataStoreMigration<Preferences> {
return object : DataStoreMigration<Preferences> {
override suspend fun migrate(currentData: Preferences): Preferences {
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
return currentData.toMutablePreferences().apply {
// 型変換しながら移行
val countStr = prefs.getString("view_count", "0")
this[VIEW_COUNT] = countStr?.toIntOrNull() ?: 0
}.toPreferences()
}
override suspend fun shouldMigrate(currentData: Preferences): Boolean {
// まだ移行してない場合のみ
return !currentData.contains(MIGRATION_COMPLETED)
}
override suspend fun cleanUp() {
// 移行完了後、古い SharedPreferences を削除
context.getSharedPreferences("settings", Context.MODE_PRIVATE)
.edit().clear().commit()
}
}
}
}
DataStore は破損に強いけど、念のため CorruptionHandler を設定する。
private val Context.dataStore by preferencesDataStore(
name = "user_preferences",
corruptionHandler = ReplaceFileCorruptionHandler { exception ->
Log.e("DataStore", "Corruption detected", exception)
// デフォルト値で初期化
PreferencesFactory.createDefault()
},
produceMigrations = { context ->
listOf(SharedPreferencesMigration(context, "user_prefs"))
}
)
移行ロジックをテストできるように、ファクトリパターンを使う。まあファクトリパターンである必要は全く無いけど、とにかくテスタブルな形にしておく。
// 移行ファクトリー(テスト可能)
class PreferencesMigrationFactory @Inject constructor() {
fun createMigration(
context: Context,
oldPrefsName: String
): DataStoreMigration<Preferences> {
return SharedPreferencesMigration(
context = context,
sharedPreferencesName = oldPrefsName,
migrate = { sharedPrefs ->
migratePreferences(sharedPrefs)
}
)
}
// テスト可能な純粋関数
internal fun migratePreferences(
sharedPrefs: SharedPreferencesView
): Preferences {
return mutablePreferencesOf().apply {
// ユーザー設定
sharedPrefs.getString("user_id", null)?.let {
this[USER_ID] = it
}
// テーマ設定(文字列 → enum)
val themeStr = sharedPrefs.getString("theme", "system")
this[THEME] = when (themeStr) {
"light" -> Theme.LIGHT.name
"dark" -> Theme.DARK.name
else -> Theme.SYSTEM.name
}
// 通知設定
this[NOTIFICATIONS_ENABLED] = sharedPrefs.getBoolean("notifications", true)
}.toPreferences()
}
}
// テスト
@Test
fun `test theme migration`() {
val mockPrefs = MockSharedPreferencesView().apply {
putString("theme", "dark")
}
val migrated = factory.migratePreferences(mockPrefs)
assertThat(migrated[THEME]).isEqualTo(Theme.DARK.name)
}
全部一気に移行するんじゃなくて、機能ごとに段階的にやることも可能。しかし SharedPreferences からの移行をシュッとやってくれる SharedPreferencesMigration
があるので、DataStore より上のレイヤーで移行実装が間に合わない、とかない限りはあんまりやらないほうがいいと思う。
// Phase 1: 新機能は DataStore で実装
class NewFeatureSettings(context: Context) {
private val dataStore = context.newFeatureDataStore
// DataStore のみ使用
}
// Phase 2: 読み取りを DataStore に移行
class UserSettings(context: Context) {
private val dataStore = context.userDataStore
private val legacyPrefs = context.getSharedPreferences("user", Context.MODE_PRIVATE)
// 読み取りは DataStore から
val userId: Flow<String?> = dataStore.data.map { it[USER_ID] }
// 書き込みは両方に(移行期間中)
suspend fun setUserId(id: String) {
dataStore.edit { it[USER_ID] = id }
legacyPrefs.edit().putString("user_id", id).apply()
}
}
// Phase 3: 完全移行
class UserSettings(context: Context) {
private val dataStore = context.userDataStore
// DataStore のみ使用
}
DataStore の最大のメリットは Flow での監視。Kotlin Coroutines にネイティブで対応してるのは大変ありがたい。
// ViewModel での使用例
class SettingsViewModel @Inject constructor(
private val settingsDataStore: SettingsDataStore
) : ViewModel() {
// 設定の変更を自動的に UI に反映
val uiState: StateFlow<SettingsUiState> = combine(
settingsDataStore.theme,
settingsDataStore.notificationsEnabled,
settingsDataStore.fontSize
) { theme, notifications, fontSize ->
SettingsUiState(
theme = theme,
notificationsEnabled = notifications,
fontSize = fontSize
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = SettingsUiState()
)
}
さらに型安全にしたい場合は Proto DataStore も選択肢。Preferences DataStore は .preferences_pb
ファイルだけど、Proto DataStore は .pb
ファイルで、より構造化されたデータを扱える:
// user_preferences.proto
syntax = "proto3";
option java_package = "com.example.app.data";
message UserPreferences {
string user_id = 1;
Theme theme = 2;
bool notifications_enabled = 3;
enum Theme {
SYSTEM = 0;
LIGHT = 1;
DARK = 2;
}
}
// 使用例
private val Context.userDataStore by dataStore(
fileName = "user_preferences.pb",
serializer = UserPreferencesSerializer
)
// 型安全な読み書き
val theme: Flow<Theme> = context.userDataStore.data
.map { it.theme }
SharedPreferences から DataStore への移行、最初は面倒に感じたけど、移行ツールが充実してて意外とスムーズだった。特に下記のメリットはだいぶうれしい。
ただし、改行文字バグの workaround とか型の不一致とか、レガシーコードの罠には注意。移行前にしっかりテストを書いておくのが大事。
次は Proto DataStore への移行も検討中…でもまあ、Preferences DataStore で充分な気もしている。