Compose NavigationでParcelableを渡す際にキャッシュを使う

## はじめに
この記事は、Jetpack ComposeでSingle Activityのプロジェクトにおいて、Parcelableのような複雑な型を引数として渡す際に、キャッシュを使ってみたらどのようなコードになるかを検証するものとなっています。

## Compose NavigationでParcelableを渡す際の問題点
従来のnavigationでは、「引数をカスタムクラスにまとめてParcelableとして渡す」という方法がよく用いられていました。

一方で[Compose Navigation](https://developer.android.com/jetpack/compose/navigation?hl=ja)では、複雑な引数を渡すことはアンチパターンとして紹介されており、公式のドキュメントでは、**必要最低限の情報のみを渡してデータを取得する**べきであると説明されています。
> 移動時には複雑なデータ オブジェクトを渡すのではなく、ナビゲーション アクションの実行時に引数として必要最低限の情報（一意の識別子やその他の形式の ID など）を渡すことを強くおすすめします。
>
しかし、この必要最低限の情報のみを渡してデータを取得する方法については未だベストプラクティスが定められておらず、以下のようなさまざまな方法が提案されています。

- IDを用いてData層から値を取得
- キャッシュとしてdata層に保存する
- 共通のViewModelを用いる
- Jsonとして保存してパースする

色々と方法が紹介されていますが、個人的には**Data層に保存して取り出す方法**が良いと考えています。

その理由について、マルチモジュールのプロジェクトを取り上げて解説をします。

## マルチモジュールのNavigationで考慮すべき点
プロジェクトがマルチモジュールの場合、Navigationでデータを渡しあうモジュールは互いに通信すべきでないと言われています。[^1]

[^1]: [一般的なモジュールパターン](https://developer.android.com/topic/modularization/patterns?hl=ja#communication)

>モジュールが連携して頻繁に情報を交換する場合でも、結合度を低く抑えることが重要です。2 つのモジュール間の直接通信は、アーキテクチャの制約の場合のように、望ましくない場合があります。また、循環依存関係の場合など、不可能な場合もあります。
>

それゆえ、マルチモジュールでcompose navigationを行う際は、appモジュールにnavigation hostを置き、**拡張関数を用いてappモジュールから呼び出す**ことで、循環参照を避けるという方法が用いられることが多いです。
![2_mediator.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2243505/9e7021a9-536e-c9de-76c2-14476eb3a25c.png)

Now in Androidのコードでも、appモジュールに以下のようなNavHostが置かれており、
```kotlin: appモジュール
@Composable
fun NiaNavHost(
    navController: NavHostController,
    onBackClick: () -> Unit,
    modifier: Modifier = Modifier,
    startDestination: String = forYouNavigationRoute
) {
    NavHost(
        navController = navController,
        startDestination = startDestination,
        modifier = modifier,
    ) {
        forYouScreen()
        bookmarksScreen()
        interestsGraph(
            navigateToTopic = { topicId ->
                navController.navigateToTopic(topicId)
            },
            navigateToAuthor = { authorId ->
                navController.navigateToAuthor(authorId)
            },
            nestedGraphs = {
                topicScreen(onBackClick)
                authorScreen(onBackClick)
            }
        )
    }
}

```

各モジュールで拡張関数を用いてnavigationを行うという方法をとることで、モジュール間の結合が疎になるようにしています。
```kotlin: featureモジュール
fun NavGraphBuilder.interestsGraph(
    navigateToTopic: (String) -> Unit,
    navigateToAuthor: (String) -> Unit,
    nestedGraphs: NavGraphBuilder.() -> Unit
) {
    navigation(
        route = interestsGraphRoutePattern,
        startDestination = interestsRoute
    ) {
        composable(route = interestsRoute) {
            InterestsRoute(
                navigateToTopic = navigateToTopic,
                navigateToAuthor = navigateToAuthor,
            )
        }
        nestedGraphs()
    }
}
```

### 引数を渡す際、モジュール間で互いに通信することを避けるには？
この**モジュール間で互いに通信することを避け**つつ引数を渡す方法として、公式ドキュメントでは、data層を用いて引数を渡すことが推奨されています。

>ナビゲーション引数としてオブジェクトを渡さないようにしてください。代わりに、データレイヤーから目的のリソースにアクセスして読み込むために機能で使用されるシンプルな ID を使用します。これにより、結合度を低く抑えることができ、「信頼できる唯一の情報源」の原則を遵守できます。[^2]
>
[^2]: [一般的なモジュールパターン](https://developer.android.com/topic/modularization/patterns?hl=ja#communication)

![2_shared_data.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2243505/91c2522e-7318-36c7-e8f4-20744bcbff37.png)

この方法をとることで、Single Source of Truthの原則を守ることにもなるため、データ層を用いたnavigationをcompose navigationにおいても適用すべきではないかと考えました。

## Data層に保存する方法
Data層にParcelableの引数を保存する方法はたくさんあり、Roomを用いてSQLiteに保存する方法や、ProtoDataStoreで保存する方法、キャッシュを用いる方法などが考えられます。

しかし、Roomに保存する方法はMigrationが面倒で、ProtoDataStoreはProtoファイルの設定が面倒であり、キャッシュ（変数に保存すること）は信頼できる情報源とは言い難いと言えます。

# 信頼度を上げたキャッシュでの実装
そこでキャッシュを信頼できる情報源にするため、以下の4つのポイントを押さえて実装してみたいと思います。
#### 抑えるべきポイント
- スレッドセーフにする
- シングルトンにする
- 生存期間を長くする
- エラーを定義する

## スレッドセーフにする
まず、スレッドセーフについて解説をします。

`Dispatchers.IO`や`Defalut`などのバッググラウンドのスレッドでは、同時に書き込みがあった場合にどちらかの情報が失われてしまうことがあります。
この状態を**競合**といい、例を挙げると[Mori Atsushiさんのブログ記事では](https://at-sushi.work/blog/44/)以下のような状態が示されています。
```kotlin: 競合が起こっている例
var i = 0
runBlocking {
    repeat(100_000) {
        launch(Dispatchers.Default) {
            i += 1
        }
    }
}
println(i)
```
100000が出力されるように思われますが、100000回よりも少ない数が出力されています。
```
99622
```
このようにバックグラウンドで確実に値を書き込みたい時には、**競合を防ぐ**工夫をする必要があり、競合が防がれた状態のことを**スレッドセーフ**と言います。

#### スレッドセーフにする方法
スレッドセーフにする方法の一つとして、`mutex`があります。
[キャッシュに関する公式ドキュメント](https://developer.android.com/jetpack/guide/data-layer#in-memory-cache)にもこの方法が記載されており、以下のようなサンプルコードが示されています。
```kotlin: Mutexを使ってキャッシュに書き込む例
class NewsRepository(
  private val newsRemoteDataSource: NewsRemoteDataSource
) {
    // Mutex to make writes to cached values thread-safe.
    private val latestNewsMutex = Mutex()

    // Cache of the latest news got from the network.
    private var latestNews: List<ArticleHeadline> = emptyList()

    suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
        if (refresh || latestNews.isEmpty()) {
            val networkResult = newsRemoteDataSource.fetchLatestNews()
            // Thread-safe write to latestNews
            latestNewsMutex.withLock {
                this.latestNews = networkResult
            }
        }

        return latestNewsMutex.withLock { this.latestNews }
    }
}
```
`Mutex`を使ってスレッドセーフにするには、まず`Mutex`のインスタンスを作成し、スレッドセーフにしたい箇所で`withLock`で囲ってあげれば良いです。

## シングルトンにする
シングルトンとは、デザインパターンの一種で**インスタンスが1つしかないことを保証する**ことを意味しています。
navigationの前後で必ず**同じ値にアクセス**する必要があるため、アプリケーション内でキャッシュが1つしかないことを保証してあげましょう。

#### シングルトンにする方法
Hiltを用いている方は`@Singleton`アノテーションをつけてあげることで、簡単にシングルトンにすることができます。
```kotlin: @Singleton
@Singleton
class ArgsRepositoryImpl @Inject constructor(
    @Dispatcher(QRDispatchers.IO) private val ioDispatcher: CoroutineDispatcher,
) : ArgsRepository {
    ...
}
```

## 生存期間を長くする
navigationの前後でキャッシュが消滅してしまっては正しく値が渡せないので、キャッシュを持っているインスタンスの生存期間を長くしてあげましょう。

#### 生存期間を長くする方法
先ほど`Singleton`のアノテーションをつけましたが、このアノテーションをつけることで`@InstallIn(SingletonComponent::class)`というアノテーションをDIパッケージに追加できるようになります。
```kotlin: DIパッケージ
@Module
@InstallIn(SingletonComponent::class)
abstract class ArgsRepositoryModule {
    @Singleton
    @Binds
    abstract fun bindArgsRepository(
        impl: ArgsRepositoryImpl
    ): ArgsRepository
}

```
このアノテーションは**インスタンスをApplicationにInjectする**ことを意味しており、インスタンスは**アプリケーションが終了するまで生存**するようになります。


## エラーを定義する
最後に、キャッシュの書き込みや読み込みに失敗した場合のエラーを定義してあげましょう。

例えば、キャッシュに何も書き込まれていなかった時にエラーを投げるよう`requireNotNull`で囲んでやることで、`IllegalArgumentException`のエラーを返すことができます。

```kotlin: 読み込み
override suspend fun getQRCodeResultArgs(): Result<QRCodeStringResult> =
    withContext(Dispatchers.IO) {
        try {
            val args = requireNotNull(latestArgs) {
                "引数が正しくセットされていません"
            }
            Result.success(args)
        } catch (exception: Exception) {
            Result.failure(exception)
        }
    }
```
また、`repeat`を用いることで失敗した場合にretry処理を行うことができます。
```kotlin: 書き込み
override suspend fun writeQRCodeResultArgs(qrCodeStringResult: QRCodeStringResult): Result<Unit> =
    withContext(ioDispatcher) {
        repeat(5) {
            try {
                latestArgsMutex.withLock {
                    latestArgs = qrCodeStringResult
                }
                Result.success(Unit)
            } catch (exception: Exception) {
                Log.e("Args", exception.toString())
            }
        }
        Result.failure(IOException("引数の書き込みに失敗しました"))
    }
```

これらのエラーを定義し、ViewModeでエラー処理をしてやりましょう。
```kotlin: ViewModel
fun pushArgs(args: QRCodeStringResult) {
        viewModelScope.launch {
            // ローディング開始
            _state.value = _state.value.copy(proceeding = true)

            val result = argsRepository.writeQRCodeResultArgs(args)
            result.fold(
                onSuccess = {},
                onFailure = {
                    // ViewModelイベント発行
                    val newEvents =
                        _state.value.events.plus(QRCodeUiState.Event.Error(it.toString()))
                    // 値をセット
                    _state.value.copy(events = newEvents)
                },
            )
            // ローディング終了
            _state.value = _state.value.copy(proceeding = false)
        }
    }
```

UI側で以下のようにダイアログを表示するなどしてやると尚良いでしょう。
```kotlin: UI
...
if (showDialog) {
    Dialog(
        dismissDialog = dismissDialog,
        exception = exception,
        retry = retry
...

@Composable
fun Dialog(
    dismissDialog: () -> Unit,
    exception: String,
    retry: () -> Unit,
) {
    AlertDialog(
        onDismissRequest = {
            dismissDialog()
        },
        confirmButton = {
            TextButton(
                onClick = {
                    dismissDialog()
                    retry()
                }
            ) {
                Text("リトライ")
            }
        },
...
```
## まとめ
- Jetpack ComposeでSingle Activityのプロジェクトにおいて、Parcelableのような複雑な型を引数として渡す際に、キャッシュを用いてnavigationを行う方法は有力な手である。

- キャッシュを用いる際は、より安全にnavigationを行うため、上記に述べた4つのポイントを押さえましょう。


また、今回実装に用いたソースコードはこちらです。

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

## 最後に

最後まで読んでいただきありがとうございました。よければ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>