$30 off During Our Annual Pro Sale. View Details »

プロダクトで安全にDataStore移行する

Go Takahana
October 06, 2022

 プロダクトで安全にDataStore移行する

Go Takahana

October 06, 2022
Tweet

More Decks by Go Takahana

Other Decks in Technology

Transcript

  1. プロダクトで安全に
    DataStore移行する
    Go Takahana
    @go_takahana
    1

    View Slide

  2. 2
    Go Takahana
    株式会社サイバーエージェント
    株式会社AbemaTV
    21新卒入社。
    ABEMAのAndroidアプリ開発。

    View Slide

  3. Agenda
    DataStoreの概要
    Preferences DataStore移行のモチベーション
    Preferences DataStoreへの移行計画
    リリース前のテスト、リリース後の懸念
    まとめ
    01
    02
    03
    04
    05
    3

    View Slide

  4. Agenda
    DataStoreの概要
    Preferences DataStore移行のモチベーション
    Preferences DataStoreへの移行計画
    リリース前のテスト、リリース後の懸念
    まとめ
    01
    02
    03
    04
    05
    4

    View Slide

  5. DataStore
    ● ローカルのデータストレージのライブラリ。 (Jetpackの一部)
    ● 小規模で単純なデータ保存に適している。
    ○ アプリのユーザ設定など
    5

    View Slide

  6. DataStoreの特徴
    ● Preferences DataStore と Proto DataStore の2種類がある。
    ● Kotlin Coroutines (suspend / Flow) をベースに実装されている。
    6

    View Slide

  7. Preferences DataStore と Proto DataStore
    特徴的な違いは「データ格納方法」と「タイプセーフかどうか」の2点。
    7
    Preferences DataStore Proto DataStore
    データ格納方法 Key-Valueでデータ格納 Protocol Buffersを利用して型付きオ
    ブジェクトを格納
    タイプセーフかどうか タイプセーフではない タイプセーフ
    参考:Preferences vs Proto DataStore - Introduction to Jetpack DataStore
    https://medium.com/androiddevelopers/introduction-to-jetpack-datastore-3dc8d74139e7

    View Slide

  8. DataStore と Kotlin Coroutines
    DataStoreはKotlin Coroutines (suspned / Flow) をベースに実装されている。
    8
    public interface DataStore {

    public val data: Flow

    public suspend fun updateData(transform: suspend (t: T) -> T): T
    }
    データの取得はFlow
    データの更新はsuspend関数

    View Slide

  9. Preferences DataStore はSharedPreferencesからの
    移行が可能。
    9
    private val USER_PREFERENCES_NAME = "user_preferences"
    private val sharedPreferences
    : SharedPreferences =
    context.applicationContext.getSharedPreferences(
    USER_PREFERENCES_NAME
    ,
    Context.MODE_PRIVATE
    )
    SharedPreferencesのインスタンス取得
    private const val USER_PREFERENCES_NAME = "user_preferences"
    private val Context.dataStore by preferencesDataStore(
    name = USER_PREFERENCES_NAME,
    produceMigrations = { context ->
    listOf(SharedPreferencesMigration(context, USER_PREFERENCES_NAME))
    }
    )
    DataStoreのインスタンス取得
    SharedPreferencesMigrationに同じnameを
    渡すだけでマイグレーションが可能。

    View Slide

  10. Agenda
    DataStoreの概要
    Preferences DataStore移行のモチベーション
    Preferences DataStoreへの移行計画
    リリース前のテスト、リリース後の懸念
    まとめ
    01
    02
    03
    04
    05
    10

    View Slide

  11. Agenda
    DataStoreの概要
    Preferences DataStore移行のモチベーション
    Preferences DataStoreへの移行計画
    リリース前のテスト、リリース後の懸念
    まとめ
    01
    02
    03
    04
    05
    11

    View Slide

  12. 移行していなくてもあまり問題にならない
    ● プロダクトとしては機能(価値)を提供できているので、問題にはならない。
    →開発者目線で移行するモチベーションを見つけることが必要。
    12

    View Slide

  13. 移行するモチベーションはチームごとに違う
    ● まずはSharedPreferencesとPreferences DataStoreの比較をして、
    DataStoreの良いところを知るのが第1歩。
    13

    View Slide

  14. DataStoreとの機能比較
    14
    Shared
    Preferences
    Preferences
    DataStore
    Proto
    DataStore
    非同期API
    同期処理
    エラーハンドリング
    タイプセーフ
    データの整合性
    マイグレーション
    のサポート



















    ※UIスレッドを
    ブロッキングする
    参考:Introduction to Jetpack DataStore
    https://medium.com/androiddevelopers/introduc
    tion-to-jetpack-datastore-3dc8d74139e7

    View Slide

  15. DataStoreとの機能比較
    15
    Shared
    Preferences
    Preferences
    DataStore
    Proto
    DataStore
    非同期API
    同期処理
    エラーハンドリング
    タイプセーフ
    データの整合性
    マイグレーション
    のサポート



















    ※UIスレッドを
    ブロッキングする
    参考:Introduction to Jetpack DataStore
    https://medium.com/androiddevelopers/introduc
    tion-to-jetpack-datastore-3dc8d74139e7
    Editor#apply(),
    OnSharedPreference
    ChangeListener※
    Kotlin coroutines
    (suspend / Flow)
    非同期でデータを読み書きする APIがあるかどうか。

    View Slide

  16. DataStoreとの機能比較
    16
    Shared
    Preferences
    Preferences
    DataStore
    Proto
    DataStore
    非同期API
    同期処理
    エラーハンドリング
    タイプセーフ
    データの整合性
    マイグレーション
    のサポート



















    ※UIスレッドを
    ブロッキングする
    参考:Introduction to Jetpack DataStore
    https://medium.com/androiddevelopers/introduc
    tion-to-jetpack-datastore-3dc8d74139e7
    Editor#commit()
    UIスレッドで呼び出すと ANRやUIジャンクの原因になる。
    →だからDataStoreでは対応していない。
    スケジュールされた書き込み処理が完了するまで待ち合
    わせる処理があるかどうか。

    View Slide

  17. DataStoreとの機能比較
    17
    Shared
    Preferences
    Preferences
    DataStore
    Proto
    DataStore
    非同期API
    同期処理
    エラーハンドリング
    タイプセーフ
    データの整合性
    マイグレーション
    のサポート



















    ※UIスレッドを
    ブロッキングする
    参考:Introduction to Jetpack DataStore
    https://medium.com/androiddevelopers/introduc
    tion-to-jetpack-datastore-3dc8d74139e7
    RuntimeExceptionをス
    ローすることがある
    Flowのエラーハンドリングで
    例外をキャッチできる

    View Slide

  18. OnSharedPreferenceChangeListenerの懸念点
    18
    class UserPreferencesRepository private constructor
    (context: Context) {
    private val sharedPreferences
    : SharedPreferences = …
    private val mutableUserPreferencesStateFlow = MutableStateFlow(
    null)
    val userPreferencesStateFlow
    : StateFlow get() =
    mutableUserPreferencesStateFlow
    .asStateFlow()
    init {
    sharedPreferences
    .registerOnSharedPreferenceChangeListener { sharedPreferences
    , key ->
    when (key) {
    Key.
    AGE -> {
    mutableUserPreferencesStateFlow
    .value =
    UserPreferences(
    age = sharedPreferences.getInt(Key.
    AGE, -1)
    )
    }
    }
    }
    }
    }
    callbackはmainスレッドで実行される。
    RuntimeExceptionが起こる可能性がある。
    https://cs.android.com/android/platform/superpr
    oject/+/master:frameworks/base/core/java/andr
    oid/content/SharedPreferences.java;l=301

    View Slide

  19. 移行モチベーションの例
    ● メインスレッドでEditor#commit()を呼び出しているところがあり、ANRやUIジャン
    クが起きる可能性があって、それを回避したい。
    ● 非同期 (Flow) でPreferencesの変更を受け取りやすくしたい。
    19

    View Slide

  20. Agenda
    DataStoreの概要
    Preferences DataStore移行のモチベーション
    Preferences DataStoreへの移行計画
    リリース前のテスト、リリース後の懸念
    まとめ
    01
    02
    03
    04
    05
    20

    View Slide

  21. Preferences DataStoreへの移行計画
    1. 新規でキーを追加する場合は、Preferences DataStoreに追加する
    2. SharedPreferencesで保存していたデータをFlowで流したいケースが出てきた
    ら、DataStoreに移行する。
    21

    View Slide

  22. DataStoreのインスタンスの管理
    22
    注意点
    ● ファイル(xxx.preferences_pb)に対して、DataStoreのインスタンスは1つである
    必要がある。(シングルトン)

    View Slide

  23. DataStoreのインスタンスの管理
    23
    private const val USER_PREFERENCES = "user_preferences"
    @InstallIn(SingletonComponent::
    class)
    @Module
    object DataStoreModule {
    @Singleton
    @Provides
    fun providePreferencesDataStore
    (@ApplicationContext appContext: Context): DataStore {
    return PreferenceDataStoreFactory.create(
    scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
    produceFile = { appContext.preferencesDataStoreFile(USER_PREFERENCES) }
    )
    }
    }
    class UserPreferencesRepository @Inject constructor(
    private val dataStore: DataStore
    ) {

    }
    Repositoryのテストは書きやすい ...?🤔
    Inject

    View Slide

  24. DataStoreのインスタンスの管理
    24
    テストを書きやすいようにFakeを差し込めるように実装するのも良い。
    class UserPreferencesRepository @Inject constructor(
    private val dataStore: UserPreferencesDataStore
    ) {

    }
    interface UserPreferencesDataStore {
    fun getUserPreferencesFlow
    ():
    Flow
    }
    @Singleton
    class UserPreferencesDataStoreImpl @Inject constructor(
    private val context: Context
    ) : UserPreferencesDataStore {
    private val Context.dataStore by preferencesDataStore(
    name = USER_PREFERENCES
    ,
    )
    override fun getUserPreferencesFlow
    (): Flow {
    return context.dataStore.data.map { … }
    }
    実装クラスをシングルトンにする。

    View Slide

  25. DataStoreのキーの管理
    25
    テストでキーの重複をチェックできるようにsealed classなどにまとめておく。
    @VisibleForTesting
    sealed class UserPreferencesKeys<
    T>(val key: Preferences.Key<
    T>) {
    object Age : UserPreferencesKeys(
    booleanPreferencesKey("age"))
    }
    @Test
    fun checkDuplicatedKeys
    () {
    val subClasses = Key::
    class.sealedSubclasses
    val nonDuplicatedKeys = subClasses.
    map { it.objectInstance
    ?.key?.name }.toSet()
    assertThat(nonDuplicatedKeys.
    count()).isEqualTo(subClasses.
    count())
    }

    View Slide

  26. 26
    マイグレーションの方法
    private const val USER_PREFERENCES_NAME = "user_preferences"
    private val Context.dataStore by preferencesDataStore(
    name = USER_PREFERENCES_NAME,
    produceMigrations = { context ->
    listOf(SharedPreferencesMigration(context, USER_PREFERENCES_NAME))
    }
    )
    public fun SharedPreferencesMigration
    (
    context: Context
    ,
    sharedPreferencesName: String
    ,
    keysToMigrate: Set = MIGRATE_ALL_KEYS,
    ): SharedPreferencesMigration = …
    デフォルト実装だと一度に全てのキーをマイ
    グレーションする。
    便利ではあるが、影響範囲が大きすぎる可
    能性がある😅

    View Slide

  27. 27
    1度に全てのキーを移行する場合に起こること
    class UserPreferencesSharedPreferences(
    private val context: Context
    ) {
    private val sharedPreferences
    : SharedPreferences = …
    fun getAge(): Int {
    return sharedPreferences
    .getInt(Key.Age, -1)
    }
    fun getHeight(): Int {
    return sharedPreferences
    .getInt(Key.Height, -1)
    }
    fun setAge(age: Int) {
    sharedPreferences
    .edit { putInt(Key.Age, age) }
    }
    fun setHeight(height: Int) {
    sharedPreferences
    .edit { putInt(Key.Height, height) }
    }

    View Slide

  28. 28
    1度に全てのキーを移行する場合に起こること
    class UserPreferencesSharedPreferences(
    private val context: Context
    ) {
    private val sharedPreferences
    : SharedPreferences = …
    fun getAge(): Int {
    return sharedPreferences
    .getInt(Key.Age, -1)
    }
    fun getHeight(): Int {
    return sharedPreferences
    .getInt(Key.Height, -1)
    }
    fun setAge(age: Int) {
    sharedPreferences
    .edit { putInt(Key.Age, age) }
    }
    fun setHeight(height: Int) {
    sharedPreferences
    .edit { putInt(Key.Height, height) }
    }

    class UserPreferencesDataStoreImpl(
    private val context: Context
    ) {
    private val Context.dataStore by preferencesDataStore(

    )
    fun getAge(): Flow {
    return context.dataStore.data
    .map { preferences -> preferences[DataStoreKey.
    Age] ?: -1 }
    }

    suspend fun setAge(age: Int) {
    context.dataStore.edit {
    preferences -> preferences[DataStoreKey.
    Age] = age
    }
    }

    }
    全てKotlin Courutines (suspend / Flow)
    に対応させる必要がある。

    View Slide

  29. キーは1つずつ移行しよう
    29
    ● Kotlin Coroutines (Flow / suspend) の対応を一度にやらなくていい。
    ● マイグレーションするとSharedPreferencesからはデータが消えてしまうので、正
    しくマイグレーションできたのか確認しやすいように1つずつ移行した方がいい。

    View Slide

  30. キーを1つずつマイグレーションする方法
    30
    private val Context.dataStore by preferencesDataStore(
    name = USER_PREFERENCES_NAME,
    produceMigrations = { context ->
    listOf(
    SharedPreferencesMigration(
    context = context,
    sharedPreferencesName = USER_PREFERENCES_NAME,
    keysToMigrate = setOf(Key.Age)
    )
    )
    }
    )
    マイグレーションしたいキーを指定する。

    View Slide

  31. Kotlin Coroutines の対応で悩むこと
    ● データの利用側が Flow, suspend 関数を扱う実装になっていない。
    ● Javaからの呼び出しをどうするか。
    31

    View Slide

  32. データの利用側がFlowを扱う実装になっていない
    対応方法
    ● 戻り値をFlowにする。
    ● suspend関数にして、Flow#first()でデータを取得する。
    32
    class GetUserAgeUseCase(
    val sharedPreferences
    :
    UserPreferencesSharedPreferences
    ,
    ) {
    operator fun invoke(): Int {
    return sharedPreferences
    .getAge()
    }
    }
    class GetUserAgeUseCase(
    val dataStore: UserPreferencesDataStore
    ,
    ) {
    operator fun invoke(): Int {
    // 戻り値がFlowなのでビルドエラー
    return dataStore.getAge()
    }
    }
    SharedPreferencesを使っていた時 DataStoreに置き換えた時

    View Slide

  33. データ取得時にrunBlockingを使っていいのか
    Flowにするのもsuspned関数にするのも大変なケースがある。
    (修正範囲が広すぎる場合など)
    33
    class GetUserAgeUseCase(
    val dataStore: UserPreferencesDataStore
    ,
    ) {
    operator fun invoke(): Int {
    return runBlocking {
    dataStore.getAge().first()
    }
    }
    }
    DataStoreを使う時(runBlockingを使う時)
    推奨はされないが毎回ファイル
    I/Oが生じるわけではない
    ので、許容して使うこともできる。
    (※UIスレッドをブロックすると
    UIジャンクやANRの可能性はある)
    参考:同期コードで DataStore を使用する
    https://developer.android.com/topic/libraries/architecture/datasto
    re#synchronous

    View Slide

  34. データのプリロード
    ● はじめてデータの取得をするときにファイルI/Oは生じる。
    ● 次回以降はファイルI/Oをスキップしてメモリキャッシュしているデータを取得できる
    のがほとんど。
    → データ取得時にrunBlockingを使うなら、プリロードをしておくのも良い。
    34
    override fun onCreate(savedInstanceState: Bundle?
    , persistentState: PersistableBundle?) {
    super.onCreate(savedInstanceState
    , persistentState)
    lifecycleScope.launchWhenCreated {
    try {
    dataStore.data.first()
    } catch (e: IOException) {
    // handle IOException
    }
    }
    }
    参考:同期コードで DataStore を使用する
    https://developer.android.com/topic/librari
    es/architecture/datastore#synchronous

    View Slide

  35. データの書き込み時はrunBlockingしない方が良いかも
    書き込みする時は必ずファイルI/Oが生じるので、runBlockingでメインスレッドをブロッ
    キングするのは避けた方が良い。
    35
    class SetUserAgeUseCase(
    val dataStore: UserPreferencesDataStore
    ,
    val applicationScope
    : CoroutineScope
    ,
    ) {
    operator fun invoke(age: Int) {
    applicationScope
    .launch {
    dataStore.setAge(age)
    }
    }
    }
    例)アプリケーションスコープの CoroutineScopeを使
    い、コルーチンを起動して書き込む。

    View Slide

  36. Javaからの呼び出しをどうするか
    JavaからKotlin Coroutinesの機能を使えないので、Flowでデータを公開したり、書き
    込む関数をsuspendで公開しても使えない。
    →別の関数を用意するアプローチをする
    36

    View Slide

  37. Javaから呼べるメソッドを実装する(取得時)
    37
    import kotlinx.coroutines.rx3.asObservable
    class UserPreferencesDataStoreImpl(
    private val context: Context,
    private val applicationScope: CoroutineScope
    ,
    ) : UserPreferencesDataStore {

    override fun getAgeAsObservable
    (): Observable {
    return context.dataStore.data
    .map { preferences -> preferences[DataStoreKey.
    Age] ?: -1 }
    .asObservable()
    }
    override fun getAgeSync(): Int {
    return runBlocking {
    context.dataStore.data.map { preferences -> preferences[DataStoreKey.
    Age] ?: -1 }.first()
    }
    }
    }
    Flow → RxのObservableの変換
    を利用する

    View Slide

  38. Javaから呼べるメソッドを実装する(更新時)
    38
    import kotlinx.coroutines.rx3.rxCompletable
    class UserPreferencesDataStoreImpl(
    private val context: Context,
    private val applicationScope: CoroutineScope
    ,
    ) : UserPreferencesDataStore {

    override fun setAgeAsCompletable
    (age: Int): Completable {
    return rxCompletable {
    context.dataStore.edit { preferences -> preferences[DataStoreKey.
    Age] = age }
    }
    }
    override fun setAgeAsync(age: Int) {
    applicationScope
    .launch {
    context.dataStore.edit { preferences -> preferences[DataStoreKey.
    Age] = age }
    }
    }
    }

    View Slide

  39. Agenda
    DataStoreの概要
    Preferences DataStore移行のモチベーション
    Preferences DataStoreへの移行計画
    リリース前のテスト、リリース後の懸念
    まとめ
    01
    02
    03
    04
    05
    39

    View Slide

  40. リリース前のテスト
    ● ユーザがアプリをアップデートするシナリオでテストする。
    ○ 前に保存した状態が残っているか
    ○ 機能にデグレが生じないか
    40

    View Slide

  41. リリース後の懸念
    DataStoreにマイグレーションしたコミットをrevertすると、SharedPreferencesの値が
    DataStoreに反映されない場合がある。
    41
    v1.0.0
    Shared
    Preferences
    Preferences
    DataStore
    age = 24
    v1.1.0
    age = 24
    マイグレーション実
    装追加
    v1.2.0
    revertして
    コミットはv1.0.0と同じ
    age = 24
    age = 25
    v1.3.0
    マイグレーション実
    装追加
    age = 24
    マイグレーション済みのキーがあ
    ると上書きされずに消える。

    View Slide

  42. リリースタイミングに気を付ける
    ● 重要な機能がリリースされるバージョンで一緒にリリースするのは避ける
    ○ revertしたり、前のバージョンに切り戻す可能性があるから
    42

    View Slide

  43. Agenda
    DataStoreの概要
    Preferences DataStore移行のモチベーション
    Preferences DataStoreへの移行計画
    リリース前のテスト、リリース後の懸念
    まとめ
    01
    02
    03
    04
    05
    43

    View Slide

  44. まとめ
    プロダクトで安全にPreferences DataStore移行するためには
    ● キーを1つずつ移行する。
    ● ユーザがアプリをアップデートするシナリオでテストする。
    ● マイグレーションのrevertが起きないようにリリースを調整する。
    44

    View Slide