kotlinx.serializationとProto DataStoreを一緒に使う

2022-09-18

はじめに

DataStoreというものがあります。DataStore は、SharedPreferences の進化系と言うと分かりやすいかもしれません。そんな DataStore にはさらに、Preferences DataStoreProto DataStoreがあります。公式の説明を読んだ感じでは、なんとなく Proto DataStore の方がタイプセーフであるため、Preferences の上位互換なのかな?と思いました。(まだ使い分けまでは理解できていません…。とりあえず Proto を使っとけばよさそうなのかな…とは思っています。)

そんな便利な Proto DataStore には、デメリットもあると思っていて、使用するまでの準備がかなり大変です(自分が実際に手を動かしてそう感じました…)。protobuf 言語でスキーマファイルを書いたり、Gradle の設定を追加したりなどなど… もっと簡単に実装する方法はないか色々調べたところ、kotlinx.serializationdata classを一緒に使うことで、protobuf 言語を使用せずに実装できることが分かりました。この記事は色々試行錯誤した時のメモです。ついでに勉強のためにHiltや、Coroutinesを使ったりもしています。

今回サンプルに使ったコードは、このリポジトリで公開しています。簡単なものとして画面に、書込みボタンと読込みボタンの 2 つがあり、書込みボタンをタップすると DataStore に 0~100 のランダムな Int を保存し、読込みボタンをタップすると DataStore から数字を読取り、画面上の TextView に表示するアプリを作ります。

実装

セットアップ

以下のライブラリ、プラグインを追加します。GitHub にあるプロジェクトではHiltktxなどのライブラリも追加していますが、ここでは分かりやすくするために、DataStore に必要なライブラリのみを抜き出して載せています。

app/build.gradle
plugins {
    id 'org.jetbrains.kotlin.plugin.serialization'
}

dependencies {
    // by datastoreをするために必要
    implementation 'androidx.datastore:datastore:1.0.0'
    implementation 'androidx.datastore:datastore-core:1.0.0'
    implementation 'org.jetbrains.kotlinx:kotlinx-serialization-protobuf:1.4.0'
}

プロジェクト直下のbuild.gradleには以下のコードを追加します。kotlin-serializationのライブラリバージョンはプロジェクトで使用している Kotlin のバージョンと同じである必要があるみたいです。 最近の Android Studio でプロジェクトを作るとプロジェクト直下のbuild.gradleに、buildscriptの記述がないかと思います。ない場合はトップレベルに追加してあげるといいみたいです。(Gradle も勉強せねば…)

build.gradle
buildscript {

    dependencies {
        classpath 'org.jetbrains.kotlin:kotlin-serialization:1.6.10'
    }
}

Proto DataStore に使うスキーマを定義

通常であれば、protobuf 言語を使ってスキーマファイルを作成しますが、kotlinx.serialization を使うことで、data classを定義するだけで使えるようになります。今回は、簡単なdata classを作成します。

ExamplePreferences.kt
@Serializable
data class ExamplePreferences (
    val result: Int
    )

Serializer を作成

Serializer<T>を継承したクラスを作成します。このクラスは、データ型の読取りや書込みの方法を指示するために作成します。この記事を書いている段階では、ProtoBufが experimental(実験的)な機能のため Waring がでると思います。が、今回は無視します。 Serializer は Singleton でいいと思うので、@Singletonを付与し、object にします。

ExampleSerializer.kt
@Singleton
object ExampleSerializer: Serializer<ExamplePreferences> {

    override val defaultValue: ExamplePreferences
        get() = ExamplePreferences(result = 0)

    override suspend fun readFrom(input: InputStream): ExamplePreferences {
        try {
            return ProtoBuf.decodeFromByteArray(input.readBytes())
        } catch (exception: SerializationException) {
            throw CorruptionException("Cannot read AppStatusPreferences proto", exception)
        }
    }

    override suspend fun writeTo(t: ExamplePreferences, output: OutputStream) = output.write(ProtoBuf.encodeToByteArray(t))

}

Repository を作成

作成した Serializer を扱うための Repository を作成します。DataStore を使うだけならなくても大丈夫ですが、Google が Repository の作成を推奨しているのでそれにならって作成します。この Repository を後ほど ViewModel に DI します。

ドキュメントでは、Context に拡張関数を生やしており、Serializer に書いていました。これだとどこからでも呼び出せて、処理の債務の切り分けがうやむやになってくるかなと思ったので、private にして、この Repository 内でしか呼び出せないようにします。

読み書きの結果は、Flow でデータを返すようにしました。Coroutines 勉強中でこの場合は、Flow が最適なのかはまだ分かっていません…(Flow は Rx でいう Single とほぼ同じだと思ってるので、まぁ全く違うとかではないかなと思っています…多分…) readメソッド内でデータの読込で例外が出た場合は、特別何かしたいこともないので、例外を握りつぶして、ExamplePreferences(result = 0)を返すようにします。

ExampleRepository.kt
interface ExampleRepository {
    suspend fun read(): Flow<ExamplePreferences>
    suspend fun write(result: Int)
}

@Singleton
class ExampleRepositoryImpl @Inject constructor(
    @ApplicationContext private val context: Context
): ExampleRepository {

    private val Context.dataStore: DataStore<ExamplePreferences> by dataStore(
        // 保存するファイル名(自分はDB名みたいなものと認識しました)
        fileName = "EXAMPLE_DATA_STORE",
        // Serializeするために作成したクラスを指定
        serializer = ExampleSerializer
    )

    override suspend fun read(): Flow<ExamplePreferences> {
        try {
            return flowOf(context.dataStore.data.first())
        } catch (e: Exception) {
            return flowOf(ExamplePreferences(result = 0))
        }
    }

    override suspend fun write(result: Int) {
        context.dataStore.updateData { ExamplePreferences(result) }
    }
}

後ほど、ViewModel に Repository を DI します。そのために、Hilt にこの Repository を DI してねと伝える Module を作成します。

RepositoryModule.kt
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {

    @Binds
    @Singleton
    abstract fun bindExampleRepository(repository: ExampleRepositoryImpl): ExampleRepository
}

ViewModel を作成

作成した Repository を ViewModel に DI して使います。UiState は、Google 推奨の UI レイヤの定義方法なのでやっています。 ViewModel 内は、MutableStateFlowにし、ViewModel 外には、StateFlowにすることで、データの流れを単方向にしています。また、こうすることで外部からの変更に強くなるのかなと思いました。

UiState は各ボタンの enabled(有効かどうか)と、画面に表示する DataStore に保存したデータを定義しています。

ExampleViewModel.kt
@HiltViewModel
class ExampleViewModel @Inject constructor(
    private val repository: ExampleRepository
): ViewModel() {

    data class UiState(
        val result: Int,
        val isEnabledReadButton: Boolean = true,
        val isEnabledWriteButton: Boolean = true
    )

    // ViewModel内はMutableで使い、privateにして外部アクセスを拒否します
    private val _uiState = MutableStateFlow(UiState(0))
    // ViewModel外にはImmutableで公開します
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()

    fun write(result: Int) {
        viewModelScope.launch {
            _uiState.update { it.copy(isEnabledWriteButton = false) }
            repository.write(result)
            _uiState.update { it.copy(isEnabledWriteButton = true) }
        }
    }

    fun read() {
        viewModelScope.launch {
            _uiState.update { it.copy(isEnabledReadButton = false) }
            val data = repository.read()
            _uiState.update { it.copy(result = data.first().result, isEnabledReadButton = true) }
        }
    }
}

Activity を作成

Activity も特別なことはやっていません。Data Binding は使わずに、findViewByIdを使っています。

MainActivity.kt
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    private val viewModel: ExampleViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val writeButton: Button = findViewById(R.id.button1)
        val readButton: Button = findViewById(R.id.button2)
        val textView: TextView = findViewById(R.id.textView)

        lifecycleScope.launchWhenStarted {
            viewModel.uiState.collectLatest { uiState ->
                textView.text = uiState.result.toString()
                writeButton.isEnabled = uiState.isEnabledWriteButton
                readButton.isEnabled = uiState.isEnabledReadButton
            }
        }

        writeButton.setOnClickListener {
            val randomInt = (0..100).random()
            viewModel.write(randomInt)
        }

        readButton.setOnClickListener {
            viewModel.read()
        }
    }
}

最後に

Proto DataStore と、kotlinx.serialization を組み合わせて使う方法について紹介しました。ついでに一緒に、HiltCoroutinesを使いました。 kotlinx.serialization を使うことで、protobuf 言語を使ったスキーマファイルを作成せずに Proto DataStore が使えることが分かりました。

自分の中では kotlinx.serialization=Json のパースに使うものくらいの認識でしたが予想以上に便利であることが分かりました。 DataStore は Google 公式でも、SharedPreferences から Data Store に置き換えることを推奨しているので、今後も DataStore を使っていこうと思います。

もしかしたら、Proto DataStore に必要な設定が抜けていたりするかもしれません…そのときは、GitHub の方のコードを参照して下さい。

参考サイト

Tatsumi0000

Written by Tatsumi0000 モバイル開発が好きなエンジニアのブログです. GitHub

Copyright © 2023, Tatsumi0000 All Rights Reserved.