はじめに
この記事は Android Advent Calendar 2023 8日目の記事です。
新人プログラマがアプリのリニューアルに伴い、SharedPreferencesからPreferences DataStoreへ移行したお話になります。
本記事のスニペットの動作保証はしていません。サンプルとして記載しています。
SharedPreferencesからDataStoreへ移行
公式コードラボに一括移行の実装が載っています。とても便利そうなのですが、実際にはアプリ影響が大きく怖いので一括での移行は行いませんでした。
keysToMigrate
で移行するキーを指定できるため、こちらを利用して下記のようにデータごとに移行していきました。
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = "file_name",
produceMigrations = { context ->
listOf(
SharedPreferencesMigration(
context = context,
sharedPreferencesName = "shared_prefs_name",
keysToMigrate = setOf("example_key")
)
)
}
)
DataStoreの保存パスは以下でした。
/data/data/app.package.name/files/datastore/file_name.preferences_p
DataStoreで読み取り
DataStoreの読み取り、書き込みはFlowを用いて非同期で行われます。
またSharedPreferencesと異なりDataStoreは値の読み取りに失敗した場合、IOExceptionを投げてくれます。
このことは公式コードラボに記載がありますが、読み取り時は下記のように例外処理の実装が推奨されています。
val userPreferencesFlow: Flow<String> = dataStore.data
.catch { exception ->
if (exception is IOException) {
emit(emptyPreferences())
} else {
throw exception
}
}.map { preferences ->
preferences[PreferencesKeys.EXAMPLE_KEY] ?: "defaultValue"
}
As DataStore reads data from a file, IOExceptions are thrown when an error occurs while reading data. We can handle these by using the catch() Flow operator before map() and emitting emptyPreferences() in case the exception thrown was an IOException.
以下翻訳
DataStore がファイルからデータを読み取るとき、データの読み取り中にエラーが発生すると IOExceptions がスローされます。これらは、map() の前に catch() フロー演算子を使用し、スローされた例外が IOException だった場合に emptyPreferences() を発行することで処理できます。
出典:https://developer.android.com/codelabs/android-preferences-datastore#5
同期的処理での注意点
前述した通りDataStoreの読み取り、書き込みはFlowを用いて非同期で行われます。
一方SharedPreferencesは同期的に扱えるため、元々は同期前提での実装になっている方が多いのではないでしょうか?
僕は当初なるべく呼び出し元を変更しないで同期的に扱えるようにrunBlocking()
を用いることで解消していました。
ここが気を付けるべきポイントです。
以下のような感じです。
class SampleRepository(private val sharedPreferences: SharedPreferences) {
fun getExampleKey(): String = sharedPreferences.getString("example_key", null) ?: "defaultValue"
}
class SampleRepository(private val dataStore: DataStore<Preferences>) {
fun getExampleKey(): String = runBlocking {
dataStore.data.map { preferences ->
preferences[EXAMPLE_KEY] ?: "defaultValue"
}.first()
}
}
こうすることでANRが発生するようになってしまいました。。
runBlocking()
はUIスレッドをブロックしますが、当時正しく理解できておらず安易に利用してしまっていました。
同期的な処理については公式ドキュメントに下記の注意書きがありました。
注意: 可能な限り、DataStore のデータ読み取りでスレッドがブロックされないようにしてください。UI スレッドをブロックすると、ANR や UI ジャンクが発生する可能性があります。また、他のスレッドをブロックすると、デッドロックが発生する可能性があります。
出典:https://developer.android.com/topic/libraries/architecture/datastore?hl=ja#synchronous
結局下記のようにsuspend関数に置き換え、呼び出し元でCoroutineScopeを立ち上げるように修正しました。
これが至る箇所にあると結構辛いです。。
class SampleRepository(private val dataStore: DataStore<Preferences>, private val ioDispatcher: CoroutineDispatcher) {
suspend fun getExampleKey(): String = withContext(ioDispatcher) {
dataStore.data.map { preferences ->
preferences[EXAMPLE_KEY] ?: "defaultValue"
}.first()
}
}
// 呼び出し元
scope.launch {
sampleRepository.getExampleKey()
// DataStorから読み取った値で何かする
}
所感
公式ドキュメントではDataStoreへの移行を検討するよう促されているため、今後SharedPreferencesがサポートされなくなる可能性もあるのかと考えると移行してしまえば安心かと思います。
ただ、移行対応をしている途中から思っていたのですが、アプリそのものとしてはSharedPrefrencesでも提供できる機能とは変わりないんですよね。。
移行作業自体も影響が大きいので、通常は正式に非推奨になるなどなにかしらきっかけがないと、移行するモチベーションを持つのは中々難しいのかな、と思ったりしました。
現在 SharedPreferences を使用してデータを保存している場合は、DataStore に移行することを検討してください。
出典:https://developer.android.com/topic/libraries/architecture/datastore?hl=ja
おわりに
アドカレ初参加だったのですが、書くきっかけが出来て素敵なイベントだと思いました。
来年は有用な記事が書けるように頑張りたいです。
ありがとうございました。