N予備校 Android チームでテックリードをしている鎌田です。
N予備校
Android チームでは、SharedPreferences から DataStore(Preferences DataStore) に移行しました。
Preferences DataStore とは、SharedPreferences の後継となるデータストレージで、SharedPreferences からの移行に対応しています。
DataStore には 二種類あって Proto DataStore もありますが、こちらは使用しなかったため説明を省略します。
Kotlin Coroutines、Flow をベースに実装されており、非同期処理であるため UI スレッドをブロックすることなくデータを安全に扱うことができるのと、ANR が発生するリスクを抑えることができるため移行しました。
本記事では、どのように SharedPreferences から DataStore に移行したのかについてまとめます。
導入手順
既存の開発を止めずに DataStore を導入するために、以下の手順で開発を進めていきました。
0. DataStore の学習をする
Google Codelab の教材を使って DataStore の学習をしました。
ミーディング後など、全員で集まれる時間を定期的に作って学習しました。
1. DataStore のライブラリを追加する
Preferences DataStore のライブラリを追加します。
dependencies {
...
implementation "androidx.datastore:datastore-preferences:1.0.0"
...
}
バージョンは 2023 年 8 月時点で 1.0.0 となっております。
2. DataStoreWrapper を定義する
DataStore をグローバルに参照できてしまうのはよろしくないため、DataStore を扱うための DataStoreWrapperContract
および DataStoreWrapper
を定義します。
interface DataStoreWrapperContract {
/**
* DataStore に値を書き込む
* @return 書き込みに成功したら true, 失敗(例外がスローされた)したら false
*/
suspend fun <T> writeValue(key: Preferences.Key<T>, value: T): Boolean
fun <T> readValue(key: Preferences.Key<T>, defaultValue: T): Flow<T>
/**
* DataStore から値を削除する
* @return 削除に成功したら true, 失敗(例外がスローされた)したら false
*/
suspend fun <T> removeValue(key: Preferences.Key<T>): Boolean
}
class DataStoreWrapper(private val context: Context, fileName: String) : DataStoreWrapperContract {
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = fileName,
produceMigrations = { context ->
listOf(
SharedPreferencesMigration(
context = context,
sharedPreferencesName = fileName,
keysToMigrate = setOf(
...
)
)
)
}
)
override suspend fun <T> writeValue(key: Preferences.Key<T>, value: T) =
runCatching {
context.dataStore.edit { preferences ->
preferences[key] = value
}
}.isSuccess
override fun <T> readValue(key: Preferences.Key<T>, defaultValue: T): Flow<T> {
return context.dataStore.data.map { preferences ->
preferences[key] ?: defaultValue
}
}
override suspend fun <T> removeValue(key: Preferences.Key<T>): Boolean =
runCatching {
context.dataStore.edit { preferences ->
preferences.remove(key)
}
}.isSuccess
}
SharedPreferences との違いとして以下が挙げられます。
- key の型が String から Preferences.Key<データ型> になっていること
-
writeValue
とremoveValue
が suspend 関数になっている - 書き込み時および削除時に
context.dataStore.edit
を呼び出すことになるが、失敗した場合例外がスローされる- 本アプリでは書き込み(削除)に成功したかどうかが分かればよかったので、
runCatching.isSuccess
を使って例外がスローされたら失敗扱いにしました
- 本アプリでは書き込み(削除)に成功したかどうかが分かればよかったので、
3. DataStore 用の処理を定義する
一気に DataStore に置き換えるのはリスクが大きいため、まずは DataStore 用の処理を定義します。
まずは Preferences.Key を記載します。
sealed class SamplePreferencesKeys<T>(val key: Preferences.Key<T>) {
object Token : SamplePreferencesKeys<String>(stringPreferencesKey("token"))
}
ポイントとしては、一気に全てのキーを移行しようとせず、1 つずつ移行していくことです。
テストでキーの重複をチェックできるよう、sealed class である SamplePreferencesKeys
にまとめます。
テストは以下のように作成します。
class SamplePreferencesKeysTest {
@Test
fun `キーが重複していないこと`() {
val subClasses = SamplePreferencesKeys::class.sealedSubclasses
val nonDuplicatedKeys = subClasses.map { it.objectInstance?.key?.name }.toSet()
assertEquals(nonDuplicatedKeys.count(), subClasses.count())
}
}
次に、XXXRepository クラスに DataStore 用の処理を定義します。
以下は TokenRepository
の例です。
/**
* トークンを永続化するデータストアのインターフェース。
*/
interface TokenRepositoryContract {
/**
* トークンを読み込む。
*/
@Deprecated("loadTokenDataStore に置き換えます。置き換え後、loadTokenDataStore を loadToken にリネームします。")
fun loadToken(): String
suspend fun loadTokenDataStore(): String
/**
* トークンを破棄する。
*/
@Deprecated("resetTokenDataStore に置き換えます。置き換え後、resetTokenDataStore を resetToken にリネームします。")
fun resetToken(): Boolean
suspend fun resetTokenDataStore(): Boolean
/**
* トークンを登録する。
*/
@Deprecated("registerTokenDataStore に置き換えます。置き換え後、registerTokenDataStore を registerToken にリネームします。")
fun registerToken(token: String): Boolean
suspend fun registerTokenDataStore(token: String): Boolean
}
class TokenRepository(
private val preferences: PreferencesWrapperContract,
private val dataStore: DataStoreWrapperContract,
) : TokenRepositoryContract {
override fun loadToken(): String {
return preferences.readValue(KEY_TOKEN, DEFAULT_VALUE).first()
}
override suspend fun loadTokenDataStore(): String {
return dataStore.readValue(SamplePreferencesKeys.Token.key, DEFAULT_VALUE).first()
}
override fun resetToken(): Boolean {
return preferences.removeValue(KEY_TOKEN)
}
override suspend fun resetTokenDataStore(): Boolean {
return dataStore.removeValue(SamplePreferencesKeys.Token.key)
}
override fun registerToken(token: String): Boolean {
return preferences.writeValue(KEY_TOKEN, token)
}
override suspend fun registerTokenDataStore(token: String): Boolean {
return dataStore.writeValue(SamplePreferencesKeys.Token.key, token)
}
companion object {
private const val KEY_TOKEN = "token"
private const val DEFAULT_VALUE = ""
}
}
loadTokenDataStore
にて first()
を呼び、Flow から値を抽出します。
理由としては、DataStore の値が更新されると、オブジェクト全体に対して更新通知(Flow)が通知され、collect しないと 2 回目以降値を取得できなくなるためです。
公式リファレンス にて、一貫性を補償するために first() を使用して単一のスナップショットにアクセスする旨が記載されています。
また、呼び出し元で都度 first()
を呼ぶのも手間であるので、Repository 側で吸収します。
4. DataStore に置き換える、マイグレーションを行う
3 で用意した DataStore の処理に置き換えます。
実装ポイントとしては以下が挙げられます。
- suspend 関数となっているため、呼び出し元もそれに応じて処理を変更する
- 呼び出し元の関数が suspend 関数に変更不可で、かつ coroutineScope を渡すのも現実的でない場合は
runBlocking
を使用する(処理が完了するまでスレッドをブロックするため、あくまで最終手段)
- 呼び出し元の関数が suspend 関数に変更不可で、かつ coroutineScope を渡すのも現実的でない場合は
置き換え後、マイグレーションのため DataStoreWrapper
の keysToMigrate
に移行する SharedPreferences の Key を記載します。
class DataStoreWrapper(private val context: Context, fileName: String) : DataStoreWrapperContract {
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
...
produceMigrations = { context ->
listOf(
SharedPreferencesMigration(
...
keysToMigrate = setOf(
SamplePreferencesKeys.Token.key.name
)
)
)
}
)
...
}
そして、以下について動作確認を行います。
- DataStore への読み書きが正常に行われること(値を永続化できること)
- SharedPreferences に保存された値が引き継がれていること
- 修正前のアプリをあらかじめインストールして該当動作を行い、修正後のアプリで上書きインストールして再度該当動作を行うことで確認できます
5. 置き換えにより不要になった処理を削除する
置き換えにより不要になった処理を削除します。
具体的には以下について対応します。
- 参照されなくなった処理の削除
- メソッド名から DataStore を取り除く
- 例:
resetTokenDataStore
→resetToken
- 例:
DataStore から取得した値を直接 Composable で使用する場合
基本的には上記の方法でおおむね置き換えることができましたが、DataStore から取得した値を直接 Composable で使用したいケースもありました。
sealed class SamplePreferencesKeys<T>(val key: Preferences.Key<T>) {
...
object SampleFlag : SamplePreferencesKeys<Boolean>(booleanPreferencesKey("sampleFlag"))
}
class SampleRepository(private val dataStore: DataStoreWrapperContract) {
fun readSampleFlag(preference: SamplePreferencesKeys<Boolean>) = dataStore.readValue(preference.key, DEFAULT_VALUE)
companion object {
private const val DEFAULT_VALUE = false
}
}
class SampleViewModel(private val sampleRepository: SampleRepository) : ViewModel() {
val sampleFlag: Flow<Boolean> = sampleRepository.readSampleFlag(SamplePreferencesKeys.SampleFlag)
}
val sampleFlag by viewModel.sampleFlag.collectAsState(initial = false)
ですが、上記のコードでは値が更新されても最新値が取得できず、collectAsState
に指定されている初期値が取得されてしまいました。
解決策として、stateIn
を使って Flow を StateFlow に変換することで値が更新されるたび、 Composable に最新の値が通知されるようになります。
class SampleViewModel(private val sampleRepository: SampleRepository) : ViewModel() {
val sampleFlag: StateFlow<Boolean> = sampleRepository.readSampleFlag(SamplePreferencesKeys.SampleFlag).stateIn(scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = false)
}
val sampleFlag by viewModel.sampleFlag.collectAsState()
SharingStarted.WhileSubscribed(5_000)
の意味は以下になります。
- 画面回転により Fragment とひいては ViewModel が破棄された場合、5 秒以内に Fragment とひいては ViewModel が再生成されるので、Flow はキャンセルされない
- Fragment から離脱した場合、5 秒後に Flow がキャンセルされるので、バッテリーや他のリソースの節約になる
Now in Android や DroidKaigi 2023 公式アプリ、公式動画で積極的に使用しているため採用しました。
また、collectAsState
の initial
が不要になります。
まとめ
本記事では、N予備校
Android チームでどのように SharedPreferences から DataStore(Preferences DataStore)に移行したのかについてまとめました。
DataStore に置き換えたことで、UI スレッドをブロックすることなくデータを安全に扱うことができるようになり、ANR が発生するリスクを抑えることができました。