3538 Words
⚠️

この記事はDataStore 1.0.0-alpha05,Kotlin Serialization 1.0.1について書かれています。
(とくにDataStoreの方はアルファ版なので)アップデートにより最新バージョンでは記事内容が合わなくなる可能性があります。


追記 (2021-01-08)

モデルに後から変更を加える際の注意

データストアで管理するデータクラスの内容を後から変更する場合の注意事項を追記した。


DataStore

SharedPreferencesに代わり良い感じにデータ永続化するやつとしてDataStoreが開発されている。

DataStore | Android デベロッパー | Android Developers

SharedPreferencesといえば、データの出し入れの際の型の扱いがふわっとしていたり、キーがただの文字列だったり、非同期のこととかはとくに考えられていなかったりで、がっちり使い込むには自分でラッパを書いたりする必要があった。

DataStoreの仕組みで実装されているPreferences DataStoreを使うと、SharedPreferences相当のことがとりあえず少しは良い感じになる。

val dataStore: DataStore<Preferences> = context.createDataStore(
    name = "settings"
)
val fooKey = preferencesKey<Int>("foo")
val fooFlow: Flow<Int> = dataStore.data
    .map { prefs ->
        prefs[fooKey] ?: 0
    }
val fooKey = preferencesKey<Int>("foo")
coroutineScope.launch {
    dataStore.edit { prefs ->
        prefs[fooKey] = 334
    }
}

ただこれはこれでキーの扱いが面倒臭いとか、プリミティブ型じゃないオブジェクトを簡単に扱えないとか、色々問題がある。

リンク先の解説ではProtocol Buffersを使ってProto DataStoreもできるよとか書いてあるが、Kotlin書く上で楽するためにDataStore使うのにprotobufでデータ定義を別に記述してどうのこうのとか正直やりたくない。

そこで、Kotlin SerializationでオブジェクトをJsonにシリアライズして保存して、入出力インタフェースとしてDataStoreを使用する方法を使う。

Kotlin Serialization + DataStore

Proto DataStoreはどうもバイト列を入出力できれば必ずしもProtocol Buffersを必要としていないようなので、シンプルにJsonシリアライズした文字列をバイト列化して保存するようにするDataStoreシリアライザを用意する。

参考

ここではざっくりと結果だけ書くので、詳しいことは参照先を読んだ方がいいです。

/**
 * 雑なサンプル用設定データクラス
 *
 * すべてのプロパティにデフォルト値を記述しておくと良さげ
 * `data class`で作っておくと更新処理で`copy`メソッドが使えて楽
 */
@Serializable
data class Hoge(
    val foo : Int = 0,
    val bar : String = "default value",

    /** ユーザークラスには別途シリアライザを用意する必要がある */
    @Serializable(with = LocalTimeSerializer::class)
    val baz : LocalTime = LocalTime.MIN
)

Kotlin Serialization用のシリアライザは、外部のライブラリなどから持ってきているデータなら上記のようにプロパティで指定すれば良いし、自分で用意したクラスであるならクラス宣言に書いてしまっても良い。
他にJsonインスタンスのserializersModuleにまとめてシリアライザを渡してしまって、プロパティには@Contextualアノテーションをつける方法もあるが、あんま良い感じはしない。

次コードがユーザークラス用のJsonシリアライザ例。

/**
 * `LocalTime`を`Long`値に変換するシリアライザ
 */
class LocalTimeSerializer : KSerializer<LocalTime> {
    override val descriptor: SerialDescriptor
        get() = PrimitiveSerialDescriptor(
            LocalTimeSerializer::class.qualifiedName!!,
            PrimitiveKind.LONG
        )

    override fun serialize(encoder: Encoder, value: LocalTime) {
        encoder.encodeLong(value.toSecondOfDay().toLong())
    }

    override fun deserialize(decoder: Decoder): LocalTime {
        return LocalTime.ofSecondOfDay(decoder.decodeLong())
    }
}

PrimitiveSerialDescriptorに渡す名前はユニークであればなんでも良いっぽいので、適当にシリアライザ自身のクラス名を渡している。

次コードはDataStoreがデータをファイルに入出力する際に使用するシリアライザ例。
ここでKotlin Serializationを使って文字列化、それをさらにバイト列化して読み書きする。

/**
 * `Hoge`クラスのインスタンスを`DataStore`がファイルに入出力するためのシリアライザ
 */
@OptIn(ExperimentalSerializationApi::class)
class HogeSerializer(
    private val stringFormat: StringFormat = Json
) : Serializer<Hoge> {

    // すべてのプロパティがデフォルト値なインスタンスを生成する
    override val defaultValue : Hoge
        get() = Hoge()

    override fun writeTo(value: Hoge, output: OutputStream) {
        val string = stringFormat.encodeToString(value)
        val bytes = string.encodeToByteArray()
        output.write(bytes)
    }

    override fun readFrom(input: InputStream) : Hoge {
        try {
            val bytes = input.readBytes()
            val string = bytes.decodeToString()
            return stringFormat.decodeFromString(string)
        }
        catch (e: SesrializationException) {
            throw CorruptionException("failed to read stored data", e)
        }
    }
}

保存するファイル内容を暗号化などしたいなら、ここで色々すれば良いと思う。ちょうどバイト列にしてるし。

プログラムでの使用例

val dataStore = context.createDataStore(
    fileName = "hoge.json",
    serializer = HogeSerializer()
)

ファイル名はPreferences DataStoreのときと違い拡張子は勝手に追加されないので書く必要があるが、別に拡張子なくても良いといえば良い。

class HogeViewModel(
    private val dataStore: DataStore<Hoge>
) : ViewModel() {
    val fooLiveData = MutableLiveData<Int>()
    val barLiveData = MutableLiveData<String>()
    val bazLiveData = MutableLiveData<LocalTime>()

    /** `DataStore`から編集用の`LiveData`にデータを読み込み */
    init {
        dataStore.data
            .onEach {
                fooLiveData.postValue(it.foo)
                barLiveData.postValue(it.bar)
                bazLiveData.postValue(it.baz)
            }
            .flowOn(Dispatchers.Default)
            .launchIn(viewModelScope)
    }

    /** 編集内容の保存 */
    fun saveHoge() = viewModelScope.launch {
        dataStore.updateData { hoge ->
            hoge.copy(
                foo = fooLiveData.value!!,
                bar = barLiveData.value!!,
                baz = bazLiveData.value!!
            )
        }
    }
}

コンストラクタ引数付きのViewModelについては この記事 とか。
今回別に関係ないのでDIでもなんでもとにかく良い感じに渡してやればいいと思う。

DataStoreApplication継承したクラスとかに持たせればいいと思う。

MutableLiveDataobserveしてアレすれば値編集のたびに結果を保存することもできなくはないが、頻繁に値が更新されるようになっている場合その都度シリアライズ→ディスク書き込み→Flowへの反映が発生して非常にアレな感じなので、保存ボタン押したときとかActivity終了前とか一定時間無操作とかで適当にsaveHoge()呼ぶような作りにしておけばいいと思う。

ここまでとくに触れてなかったが、DataStoreのデータの読み取りには Kotlin Flow が使用されている。
これはコルーチンの仕組みを使ってRxやろうといったもので、正直なところアプリ設定の読み取りとか簡単な編集画面くらいの用途ではそれほど要り様でもないような気もする。

上の例で使用しているonEach {...}では値が更新されるたびにその内容が流れてきてLiveDataに反映するようになっている(この実装では基本的には初期化時と保存時くらいしか流れてこないと思われるが)。flowOn(CoroutineContext)は上流の処理を指定したCoroutineContextで行う指示、launchIn(CoroutineScope)は指定したCoroutineScopeでそれらを実行せよという指示だ。

編集画面での設定値の読み書きの場合はFlowを使ってこんな感じでいいが、実際にその設定値を使って処理をアレコレしたい場合には「Flowでデータが流れてきたら~」とか非同期にではなく、同期的に値を取得したくなると思うので、そういう時はコルーチン内で次のようにすれば良い。

coroutineScope.launch {
    try {
        val hoge: Hoge = dataStore.data.first()
        ...
    }
    catch (e: IOException) {
        ...
    }
}

公式ドキュメントによると「IOExceptionsが発生する恐れがあるのでハンドルせよ」とのことだ。try~catchなりrunCatchingなり。


モデルに後から変更を加える際の注意

以上の方法で扱うデータクラスに対して変更を無暗に加えるとデシリアライズに失敗する可能性があるので注意が必要である。
破壊的変更を施さないのを基本としつつ、とはいえアプリ設定などにデータストアを使用する場合、後から変更を加えるなというのも酷な話なのでとりあえず以下の点に注意しつつ場合によっては何かしらの対処を行う。

  • 新しいプロパティの追加は問題ない

  • 既存のプロパティの型(やさらにその中身)の変更は問題がある

  • 既存のプロパティの削除は問題がある

問題がある2点については、既に出力されているjsonに該当のフィールドが含まれる場合(該当プロパティをデフォルト値から変更した状態でデータストアの内容を更新した場合)デシリアライズ失敗する。

「プロパティの型変更」については、旧版のプロパティは@Deprecatedにしつつ残しておいて初回デシリアライズ後に新しいプロパティに値を変換して格納するようにするとか、変換用のKSerializer<T>を新たに作成するとかで対処できるような気がする。
「さらにその中身」というのは、たとえば

enum class Hoge { FOO, BAR, BAZ }

なる列挙型のプロパティを元々使用していたがアップデートでBARだけ削除した、といったシナリオなどのことを指している。この場合、アップデート前にHoge.BARが既に保存されているとアップデート後のデータストア読み込みで失敗する。これも非推奨状態でしばらく残しておくなどすればまぁなんとかなるような気がする。

「プロパティの削除」については、stringFormat生成時に

Json { ignoreUnknownKeys = true }

として、データクラスに存在しないフィールドを無視するようにすれば良い。

See Also