Proto DataStoreを使ってみる

# Proto DataStoreとは
ProtoDataStoreは、SharedPreferencesに変わるデータストレージであるDataStoreの一種であり、**Protocol Buffersを用いてプリミティブ型以外も保存できる**という特徴があります。

# SharedPreferences, PreferencesDataStore, Roomとの比較
[DataStoreに関する公式ドキュメント](https://developer.android.com/topic/libraries/architecture/datastore?hl=ja)では、
>現在 SharedPreferences を使用してデータを保存している場合は、DataStore に移行することを検討してください。
>
と説明されており、DataStoreは、**小規模な永続的なデータを保存する際** に用いられていたSharedPreferencesの代わりとして使うことが推奨されています。

## SharedPreferencesの代わりにDataStoreを使用すべき理由
なぜSharedPreferencesの代わりとして用いられることが推奨されているのでしょうか？
[公式ブログ](https://android-developers.googleblog.com/2020/09/prefer-storing-data-with-jetpack.html?utm_source=developer.android.com&utm_medium=referral)を見てみると、SharedPreferencesにはいくつかの弱点があることがわかります。
![Screen Shot 2020-08-31 at 11.25.43 AM.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2243505/3ae51415-bfda-f337-7466-3f5244f3ec36.png)

#### Safe to call on UI thread
「UIスレッドで呼んでも安全かどうか」という意味です。
SharedPreferencesの書き込みを反映させる`apply`を用いる際、UIスレッドをブロックして処理を行うため、アプリがフリーズしているかのように見えるという問題が発生する場合があります。

この問題についてはこちらの記事で詳しく書いたので、気になる方はこちらを見てください

https://qiita.com/takasshii/items/8f7e1cb224acfacee528#%E5%90%8C%E6%9C%9F%E5%87%A6%E7%90%86

一方で、ProtoDataStoreの処理はCoroutineを用いており、IOスレッドで完全非同期で行われるため、アプリがフリーズしているかのように見える問題は発生しません。

#### Can signal erros, Safe from runtime exception
「エラーが正しく伝播するか」、「RuntimeExceptionから保護されるか」という意味です。
SharedPreferencesでは、エラーが起こったときに`RuntimeException`の`ParseException`がスローされるのですが、この型は**非検査例外**と呼ばれるものであり、コンパイラが例外であると理解することができません。
それゆえ、正しくエラーが伝搬せず、**データが正しく保存できたかどうか分からない**という問題が生じていました。

一方で、DataStoreではエラー時に`IOException`のような検査例外が投げられるため、エラーが正しく伝播するようになっています。

#### Flowが返ってくる
また、DataStoreはFlowを介してデータの取得ができるため、mapやfilterといった様々なオペレーターを用いることができます。
```kotlin:内部実装
override val data: Flow<T> = flow {

        val currentDownStreamFlowState = downstreamFlow.value

        if (currentDownStreamFlowState !is Data) {
            // We need to send a read request because we don't have data yet.
            actor.offer(Message.Read(currentDownStreamFlowState))
        }

        emitAll(
            downstreamFlow.dropWhile {
                if (currentDownStreamFlowState is Data<T> ||
                    currentDownStreamFlowState is Final<T>
                ) {
                    // We don't need to drop any Data or Final values.
                    false
                } else {
                    // we need to drop the last seen state since it was either an exception or
                    // wasn't yet initialized. Since we sent a message to actor, we *will* see a
                    // new value.
                    it === currentDownStreamFlowState
                }
            }.map {
                when (it) {
                    is ReadException<T> -> throw it.readException
                    is Final<T> -> throw it.finalException
                    is Data<T> -> it.value
                    is UnInitialized -> error(
                        "This is a bug in DataStore. Please file a bug at: " +
                            "https://issuetracker.google.com/issues/new?" +
                            "component=907884&template=1466542"
                    )
                }
            }
        )
    }
```

## PreferencesDataStoreとProtoDataStoreとの使い分け
続いて、PreferencesDataStoreとProtoDataStoreとの使い分けについて解説を行います。
この2つの間には**プリミティブ型以外も保存できる**かどうかという違いがあります。

ProtoDataStoreは、**Protocol Buffersを用いることでプリミティブ型以外も保存できる**ようになっていますが、Protocol Buffersを用いる分だけコード量が増えるため、プリミティブ型を保存するかどうかでこの2つを使い分けるようにしましょう。

## Roomとの使い分け
大規模なデータはRoomに、小規模なデータはDataStoreに保存するように使い分けましょう。
[Codelab](https://developer.android.com/codelabs/android-proto-datastore?hl=ja&utm_source=developer.android.com&utm_medium=referral#3)にも以下のように書かれています。
>部分更新、参照整合性、大規模なデータセットや複雑なデータセットが必要な場合は、DataStore の代わりに Room を使用することを検討してください。DataStore は、小規模で単純なデータセットに適しており、部分更新や参照整合性をサポートしていません。
>


# 実装
それでは、実際にProtoDataStoreを用いて、プリミティブ型以外の型を保存していきましょう。
今回は、以下の`QRStringResult`というカスタムクラスを保存していきます。
```kotlin: QRStringResult
sealed interface QRCodeStringResult {
    data class Url(
        val url: List<String>
    ) : QRCodeStringResult

    data class Text(
        val text: String
    ) : QRCodeStringResult
}
```
### ライブラリの追加
まずライブラリを追加します（各自で最新versionを確認してください）。
```Groovy:app/build.gradle
dependencies {
    // DataStore
    implementation "androidx.datastore:datastore:1.1.0-alpha01"

    // protobuf
    implementation  "com.google.protobuf:protobuf-javalite:3.21.9"

    // lifecycleScope
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1'
}

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.21.9"
    }

    // Generates the java Protobuf-lite code for the Protobufs in this project. See
    // https://github.com/google/protobuf-gradle-plugin#customizing-protobuf-compilation
    // for more information.
    generateProtoTasks {
        all().each { task ->
            task.builtins {
                java {
                    option 'lite'
                }
            }
        }
    }
}
```
### Protoファイルの追加
続いてProtoファイルを作成します。

Proto datastoreではProtocol Buffersを用いてデータを保存するため、proto言語を用いた`.proto`のファイルを`app/main/proto`内に作成する必要があります。

:::note warn
注意
main直下でないと自動生成されるファイルが作成されない可能性があるため、作成場所に注意しましょう。
:::


それでは、上に示した`QRStringResult`というカスタムクラスをProto言語に置き換えていきましょう。

Proto言語の詳細はこれらの記事にまとまっているため、省かせていただきます。

https://developers.google.com/protocol-buffers/docs/overview?hl=ja&utm_source=developer.android.com&utm_medium=referral

https://star-zero.medium.com/proto-datastore%E3%81%A7%E6%A7%98%E3%80%85%E3%81%AA%E3%83%87%E3%83%BC%E3%82%BF%E3%82%92%E6%89%B1%E3%81%86-ba7ef63710f3

今回の`QRStringResult`というカスタムクラスには以下の2つが存在しているため、これらをProtoファイルに置き換えます。
- `val url: List<String>`
- `val text: String`

list型はrepeatedを用いて置き換えることができるため、置き換えた結果は以下のようになります。

```protobuf:app/main/proto/Result.proto
syntax = "proto3";

option java_package = "com.example.qrcodereader";
option java_multiple_files = true;

message QRCodeStringResultPreference {
  repeated string url = 1;
  string text = 2;
}
```

ここまでできたら一度ビルドを行い、ファイルを自動生成しましょう。

### Serializer
続いて、Serializerを作成します。
Serializerによって、データの書き込み、読み込みを行えるようになります。
`writeTo`を用いて書き込み、`readTo`を用いて読み込みができるようになっているため、これらの関数に先ほど自動生成されたカスタムクラスを教えてあげましょう。
```kotlin:Serializer
class ResultSerializer @Inject constructor() : Serializer<QRCodeStringResultPreference> {
    override val defaultValue: QRCodeStringResultPreference =
        QRCodeStringResultPreference.getDefaultInstance()

    override suspend fun readFrom(input: InputStream): QRCodeStringResultPreference {
        try {
            return QRCodeStringResultPreference.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto.", exception)
        }
    }

    override suspend fun writeTo(
        t: QRCodeStringResultPreference,
        output: OutputStream
    ) = t.writeTo(output)
}

val Context.resultDataStore: DataStore<QRCodeStringResultPreference> by dataStore(
    fileName = "result.pb",
    serializer = ResultSerializer()
)
```

### Repositoryの作成
続いて、data層にリポジトリを作成し、data層から引数の書き込み、呼び出しができるようにします。
```kotlin: Repository
interface QRResultRepository {
    suspend fun writeQRCodeResult(qrCodeStringResult: QRCodeStringResult)

    suspend fun getQRCodeResult(): Result<QRCodeStringResultPreference>
}

// 結果を返すクラス
sealed class Result<out T> {
    // 成功した場合
    data class Success<out T>(val data: T) : Result<T>()

    // 失敗した場合
    data class Error(val exception: Exception) : Result<Nothing>()
}
```
```kotlin: RepositoryImpl
@Singleton
class QRResultRepositoryImpl @Inject constructor(
    private val qrResultDataStore: DataStore<QRCodeStringResultPreference>
    @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
) : QRResultRepository {
    override suspend fun writeQRCodeResult(qrCodeStringResult: QRCodeStringResult) {
        withContext(ioDispatcher) {
            try {
                qrResultDataStore.updateData { currentResult ->
                    when (qrCodeStringResult) {
                        is QRCodeStringResult.Url -> {
                            currentResult.toBuilder().clear().addAllUrl(qrCodeStringResult.url)
                                .build()
                        }
                        is QRCodeStringResult.Text -> {
                            currentResult.toBuilder().clear().setText(qrCodeStringResult.text)
                                .build()
                        }
                    }
                }
            } catch (exception: Exception) {
                Log.e("QRCodeResult", "Failed to update user preferences")
            }
        }
    }

    override suspend fun getQRCodeResult(): Result<QRCodeStringResultPreference> =
        withContext(Dispatchers.IO) {
            try {
                Result.Success(data = qrResultDataStore.data.first())
            } catch (exception: Exception) {
                Result.Error(exception = exception)
            }
        }
}
```

### DI(Optional)
Data層から呼び出すため、DIを行います。
```kotlin: Module
@Module
@InstallIn(SingletonComponent::class)
object QRResultModule {
    @Provides
    @Singleton
    fun providesUserPreferencesDataStore(
        @ApplicationContext context: Context,
        resultSerializer: ResultSerializer
    ): DataStore<QRCodeStringResultPreference> =
        DataStoreFactory.create(
            serializer = resultSerializer,
        ) {
            context.dataStoreFile("result.pb")
        }
}

@Module
@InstallIn(SingletonComponent::class)
abstract class QRResultRepositoryModule {
    @Singleton
    @Binds
    abstract fun bindQRResultRepository(
        impl: QRResultRepositoryImpl
    ): QRResultRepository
}
```

### データの書き込み
ここまで来たら、あとは呼び出すだけです。
データを書き込む関数をViewModelに定義します。
```kotlin:ViewModel
@HiltViewModel
class QRCodeViewModel @Inject constructor(
    private val qrResultRepository: QRResultRepository
) : ViewModel() { 
    
    ...

    fun pushResult(result: QRCodeStringResult) {
        viewModelScope.launch {
            qrResultRepository.writeQRCodeResult(result)
        }
    }
}
```

その後、書き込みを行う箇所で定義した関数を呼び出すことで、データの書き込みが完了します。
```kotlin:Ui
viewModel.pushResult(event.stringResult)
```

### データの読み取り
続いて、データを読み取ります。
データを読み取る関数をViewModelに定義します。
```kotlin: ViewModel
@HiltViewModel
class ResultViewModel @Inject constructor(
    private val qrResultRepository: QRResultRepository
) : ViewModel() {
  
    ...

    private fun fetchResult() {
        // ローディング開始
        _state.value = _state.value.copy(proceeding = true)

        viewModelScope.launch {
            val result = qrResultRepository.getQRCodeResult()
            // レスポンスに応じて値を格納
            _state.value = when (result) {
                is Result.Success -> {
                    //　値をセット
                    if (!result.data.urlList.isNullOrEmpty()) {
                        _state.value.copy(
                            result = QRCodeStringResult.Url(result.data.urlList)
                        )
                    } else {
                        _state.value.copy(
                            result = QRCodeStringResult.Text(result.data.text)
                        )
                    }
                }
                // エラーが生じていた場合 
                is Result.Error -> {
                    ...
                }
            }
            // ローディングを終了
            _state.value = _state.value.copy(proceeding = false)
        }
    }
}
```

その後、関数を呼び出すことで、データの読み込みが完了します。
```kotlin: UI
fetchResult()
```

## 速度
書き込み、読み込みにかかる大まかな速度を計測してみます。

以下のような書き込み、読み込みを連続して行うQRコードアプリを制作し、書き込み、読み込みにかかる時間を測定してみます。
```mermaid
%%{init:{"theme":"neutral"}}%%
sequenceDiagram
    Note over Dispatchers.Main: QRコードの結果を受け取る
    participant Dispatchers.Main
    participant Dispatchers.IO
    Dispatchers.Main->>+Dispatchers.IO: 書き込みを依頼
    Note over Dispatchers.IO: 書き込み処理
    loop 
        Dispatchers.Main->>Dispatchers.Main: "書き込み中"と表示
    end
    Dispatchers.IO-->>- Dispatchers.Main: 終了を知らせる
    Note over Dispatchers.Main: 画面遷移
    Dispatchers.Main->>+Dispatchers.IO: 読み込みを依頼
    Note over Dispatchers.IO: 読み込み処理
    loop 
        Dispatchers.Main->>Dispatchers.Main: "読み込み中"と表示
    end
    Dispatchers.IO-->>- Dispatchers.Main: 結果を返す
    Note over Dispatchers.Main: 結果を表示
```
### 結果
書き込みの際に表示される「書き込み中」や、読み込みの際に表示される「読み込み中」という表示を見ることなく、即座に値の読み書きが行われていることがわかります。

あまりファイルサイズが大きくなく、定量的な評価ではありませんが、かなり高速に読み書きができていることがわかります。

https://twitter.com/takashiho_2/status/1602262873065283584?ref_src=twsrc%5Etfw%7Ctwcamp%5Etweetembed%7Ctwterm%5E1602262873065283584%7Ctwgr%5E6ef56d64ea5d5e0ae18f2f8c46ce4bea4a1b6ea7%7Ctwcon%5Es1_c10&ref_url=https%3A%2F%2Fpublish.twitter.com%2F%3Fquery%3Dhttps3A2F2Ftwitter.com2Ftakashiho_22Fstatus2F1602262873065283584widget%3DTweet

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">ProtoDataStoreの速度検証してみた<br>（結果受け取り→書き込み→読み取り→表示） <a href="https://t.co/7n8bWqoND0">pic.twitter.com/7n8bWqoND0</a></p>&mdash; たかっしー（開発垢） (@takashiho_2) <a href="https://twitter.com/takashiho_2/status/1602265158398922752?ref_src=twsrc%5Etfw">December 12, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

計測に用いたQRコードアプリのリポジトリはこちらです。

https://github.com/takasshii/QRCodeReader/tree/develop/add_web_view


## 最後に

最後まで読んでいただきありがとうございました。よければtwitterのフォローよろしくお願いします。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">⚡️ &quot;たかっしーの開発日記&quot;<a href="https://t.co/aGE0WGERzU">https://t.co/aGE0WGERzU</a></p>&mdash; たかっしー（開発垢）@東北放浪中 (@takashiho_2) <a href="https://twitter.com/takashiho_2/status/1592537525981450240?ref_src=twsrc%5Etfw">November 15, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>