Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

Android DataStore の段階導入

こんにちは。Android アプリ開発担当の nagayama(@nagayan_dev)です。

この記事は Gunosy Advent Calendar 2023 の 1 日目の記事です。昨年最後の記事は koid さんの 障害発生時の社内コミュニケーションをスムーズにするために でした。 今年もよろしくお願いします。

今回は私が担当している「auサービスToday」で DataStore の導入と段階的移行の基本実装を行ったため、その内容についてお伝えします。




DataStore とは

DataStore とは、プロトコルバッファを使用して Key-Value ペアや型付きオブジェクトを格納できるデータストレージソリューションです。同じような機能として Android 1.5 から SharedPreferences があり、「auサービスToday」でも長い間使用してきました。公式のアナウンスからも SharedPreferences から DataStore への移行が推奨されているため、今回を機に対応したいと思います。

DataStore には 2 種類あります。SharedPreferences と同様にキーを使用してデータを保存する Preferences DataStore と、データ型のインスタンスとして保存をする Proto DataStore です。今回は Preferences DataStore で進めたいと思います。

developer.android.com

現状と対応方針

「auサービスToday」は MVVM のアーキテクチャパターンを採用しています。Model 層に PreferencesRepository が存在し、SharedPreferences で使用するキーの定義やデータごとの取得・保存のメソッドがあります。

PreferencesRepository の例

@Singleton
class PreferencesRepository @Inject constructor(
        private val context: Context
) {
  companion object {
    private const val PREF_NAME = "name"
    // Preference のキー
    private const val PREF_KEY_AGREE_FOR_TERMS = "agree_for_terms"
    〜省略〜
  }
  private val preference: SharedPreferences = context.getSharedPreferences(NAME, Context.MODE_PRIVATE)
  
  fun setAgreedForTerms(isAgree: Boolean) {
    preference.edit().putBoolean(PREF_KEY_AGREE_FOR_TERMS, isAgree).apply()
  }
  
  fun isAgreedForTerms(): Boolean {
    return preference.getBoolean(PREF_KEY_AGREE_FOR_TERMS, false)
  }
}

今回 DataStore に移行するにあたり、下記の方針としました。

方針 1. Repository のインタフェースとなっている「データごとの取得・保存メソッド」はそのまま使用する

アプリのファーストリリースからの設計方針であったため、多くの UseCase から参照されています。そのため、例えばインタフェースを getString のようにシンプルにするといった変更をすると、アプリケーションに多大な影響が出てしまいます。今回は影響を最小限にするよう、インタフェース部分はそのまま使用します。

方針 2. Repository の戻り値は Flow型 にする

インタフェースはそのままと言いましたが、取得メソッドの戻り値は Flow 型に変更します。

DataStore は Flow 型で返されることが特徴です。一方、ViewModel や UseCase では SharedPreferences から取得された値を LiveData や Flow に変換して使用するシーンが多かったです。不要な変換処理を減らす観点から、DataStore で取得された Flow 型をそのまま返して使用するようにします。

方針 3. SharedPreferences と DataStore が共存できるようにする

PreferencesRepository が多くの UseCase から参照されている関係で、一度に全ての処理を DataStore へ移行することが難しいです。段階を分けて対応をしていくため、一定期間までは SharedPreferences と DataStore が共存する状態になります。

移行実装

それでは移行実装を行なっていきます。

依存関係を追加

まずは build.gradle ファイルに Preference DataStore の依存関係を追加します。

dependencies {
  implementation "androidx.datastore:datastore-preferences:$DATA_STORE_VERSION"
}

下記から最新の DataStore のバージョンを確認できます。 developer.android.com

enum クラスの DataStoreKeys を作成

一定期間 SharedPreferences と DataStore が共存する状態となるため、PreferencesRepository で定義されたキーの文字列がどこまで DataStore へ移行ができているかの判別がつきにくくなっていました。「DataStore へ移行したキーの一覧」が分かるように、 enum クラスを作成しました。

enum class DataStoreKeys {
  agree_for_terms
}

DataStoreKeys.agree_for_terms.names のように参照を簡略化するため、キーの文字列をそのまま enum に定義しました。

DataStore へ保存・取得するメソッドを作成

DataStore へ保存・取得等を行うメソッドを作成します。

DataStore インスタンスは、preferencesDataStore を用いて作成します。引数の name には SharedPreferences と同様に設定名を指定します。後にこの name が内部でフォルダ名になるとのことです。

今回は Boolean の値の保存と取得を例にしているため、 updateBoolean と getBoolean を作成します。getBoolean は戻り値を Flow 型にしています。

private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
    name = PREF_NAME
)
    
suspend fun updateBoolean(key: DataStoreKeys, value: Boolean) {
  val preferenceKey = booleanPreferencesKey(key.name)
  context.dataStore.edit { preferences ->
    preferences[preferenceKey] = value
  }
}
  
fun getBoolean(key: DataStoreKeys, defaultValue: Boolean?): Flow<Boolean?> {
  val preferenceKey = booleanPreferencesKey(key.name)  
  return context.dataStore.data.map { preferences ->
    preferences[preferenceKey] ?: defaultValue
  }
}

SharedPreferences から Migration をする

SharedPreferences から DataStore へ移行するため、元々保存していた値を持ってくる必要があります。preferencesDataStore の引数に produceMigrations があり、移行する対象を DataMigration のリストを指定します。DataMigration を継承している SharedPreferencesMigration のインスタンスを作成し、その引数の keysToMigrate に移行対象のキーを入れて Migration を行います。

また、この Migration をすることで SharedPreferences 側が削除されて DataStore に移行される ため、保存データが増えてしまう心配も無用です。

companion object {
  private val KEY_TO_MIGRATE = DataStoreKeys.values().map { it.name }.toSet()
}
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
    name = PREF_NAME,
    produceMigrations = { context ->
      listOf(
        SharedPreferencesMigration(
          context = context,
          sharedPreferencesName = PREF_NAME,
          keysToMigrate = KEY_TO_MIGRATE
        )
      )
    }
)
〜省略〜

DataStore の処理に繋ぎこむ

DataStore のメソッドに繋ぎ込みをします。保存メソッドは suspend 関数にし、取得メソッドの戻り値は Flow 型に変更します。

@Singleton
class PreferencesRepository @Inject constructor(〜省略〜) {
  〜省略〜  
  suspend fun setAgreedForTerms(isAgree: Boolean) {
    updateBoolean(DataStoreKeys.agree_for_terms, isAgree)
  }
  
  fun isAgreedForTerms(): Flow<Boolean?> {
    return getBoolean(DataStoreKeys.agree_for_terms, false)
  }
}

Repository を使用する ViewModel / UseCase 側の変更をする

Repository の変更に伴い、使用している ViewModel / UseCase 側も変更を行います。

呼び元は Coroutines で実行する必要があります。

fun onAgree() {
  viewModelScope.launch {
    useCase.setAgreedForTerms(isChecked)
  }
}

またちょっとしたところで使用できるよう、 Coroutines で実行しなくても良いように getBlockedFirstOrNull の Extension を作成しました。

fun <T> Flow<T>.getBlockedFirstOrNull(): T? {
  return runBlocking(Dispatchers.Default) {
    this@getBlockedFirstOrNull.firstOrNull()
  }
}

使用例は下記のようになります。

fun onCheck() {
  if (preferenceRepository.isAgreedForTerms().getBlockedFirstOrNull() == true) {
    showPopup()
  }
}

以上で DataStore の段階移行が完了となります。

今回は Boolean の方法を記載しましたが、 String や Int 等の場合でも同様な実装になります。

まとめ

DataStore の導入と段階的な移行をするための基本実装を行いました。今後のスムーズな移行実装を実現する設計にできたと思います。また一括で対応をするのではなく徐々に置き換えを進めていくことで、エラーの検知や QA の負荷を抑えつつ進められそうです。

今年も Gunosy Advent Calendar 2023 がスタートしました。このあと 24 日間もぜひお付き合いください。明日は morita さんが「LLM 論文の探し方」です!お楽しみに!