はじめに
この記事は、Jetpack ComposeでSingle Activityのプロジェクトにおいて、Parcelableのような複雑な型を引数として渡す際に、キャッシュを使ってみたらどのようなコードになるかを検証するものとなっています。
Compose NavigationでParcelableを渡す際の問題点
従来のnavigationでは、「引数をカスタムクラスにまとめてParcelableとして渡す」という方法がよく用いられていました。
一方でCompose Navigationでは、複雑な引数を渡すことはアンチパターンとして紹介されており、公式のドキュメントでは、必要最低限の情報のみを渡してデータを取得するべきであると説明されています。
移動時には複雑なデータ オブジェクトを渡すのではなく、ナビゲーション アクションの実行時に引数として必要最低限の情報(一意の識別子やその他の形式の ID など)を渡すことを強くおすすめします。
しかし、この必要最低限の情報のみを渡してデータを取得する方法については未だベストプラクティスが定められておらず、以下のようなさまざまな方法が提案されています。
- IDを用いてData層から値を取得
- キャッシュとしてdata層に保存する
- 共通のViewModelを用いる
- Jsonとして保存してパースする
色々と方法が紹介されていますが、個人的にはData層に保存して取り出す方法が良いと考えています。
その理由について、マルチモジュールのプロジェクトを取り上げて解説をします。
マルチモジュールのNavigationで考慮すべき点
プロジェクトがマルチモジュールの場合、Navigationでデータを渡しあうモジュールは互いに通信すべきでないと言われています。1
モジュールが連携して頻繁に情報を交換する場合でも、結合度を低く抑えることが重要です。2 つのモジュール間の直接通信は、アーキテクチャの制約の場合のように、望ましくない場合があります。また、循環依存関係の場合など、不可能な場合もあります。
それゆえ、マルチモジュールでcompose navigationを行う際は、appモジュールにnavigation hostを置き、拡張関数を用いてappモジュールから呼び出すことで、循環参照を避けるという方法が用いられることが多いです。
Now in Androidのコードでも、appモジュールに以下のようなNavHostが置かれており、
@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を行うという方法をとることで、モジュール間の結合が疎になるようにしています。
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
この方法をとることで、Single Source of Truthの原則を守ることにもなるため、データ層を用いたnavigationをcompose navigationにおいても適用すべきではないかと考えました。
Data層に保存する方法
Data層にParcelableの引数を保存する方法はたくさんあり、Roomを用いてSQLiteに保存する方法や、ProtoDataStoreで保存する方法、キャッシュを用いる方法などが考えられます。
しかし、Roomに保存する方法はMigrationが面倒で、ProtoDataStoreはProtoファイルの設定が面倒であり、キャッシュ(変数に保存すること)は信頼できる情報源とは言い難いと言えます。
信頼度を上げたキャッシュでの実装
そこでキャッシュを信頼できる情報源にするため、以下の4つのポイントを押さえて実装してみたいと思います。
抑えるべきポイント
- スレッドセーフにする
- シングルトンにする
- 生存期間を長くする
- エラーを定義する
スレッドセーフにする
まず、スレッドセーフについて解説をします。
Dispatchers.IO
やDefalut
などのバッググラウンドのスレッドでは、同時に書き込みがあった場合にどちらかの情報が失われてしまうことがあります。
この状態を競合といい、例を挙げるとMori Atsushiさんのブログ記事では以下のような状態が示されています。
var i = 0
runBlocking {
repeat(100_000) {
launch(Dispatchers.Default) {
i += 1
}
}
}
println(i)
100000が出力されるように思われますが、100000回よりも少ない数が出力されています。
99622
このようにバックグラウンドで確実に値を書き込みたい時には、競合を防ぐ工夫をする必要があり、競合が防がれた状態のことをスレッドセーフと言います。
スレッドセーフにする方法
スレッドセーフにする方法の一つとして、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
アノテーションをつけてあげることで、簡単にシングルトンにすることができます。
@Singleton
class ArgsRepositoryImpl @Inject constructor(
@Dispatcher(QRDispatchers.IO) private val ioDispatcher: CoroutineDispatcher,
) : ArgsRepository {
...
}
生存期間を長くする
navigationの前後でキャッシュが消滅してしまっては正しく値が渡せないので、キャッシュを持っているインスタンスの生存期間を長くしてあげましょう。
生存期間を長くする方法
先ほどSingleton
のアノテーションをつけましたが、このアノテーションをつけることで@InstallIn(SingletonComponent::class)
というアノテーションをDIパッケージに追加できるようになります。
@Module
@InstallIn(SingletonComponent::class)
abstract class ArgsRepositoryModule {
@Singleton
@Binds
abstract fun bindArgsRepository(
impl: ArgsRepositoryImpl
): ArgsRepository
}
このアノテーションはインスタンスをApplicationにInjectすることを意味しており、インスタンスはアプリケーションが終了するまで生存するようになります。
エラーを定義する
最後に、キャッシュの書き込みや読み込みに失敗した場合のエラーを定義してあげましょう。
例えば、キャッシュに何も書き込まれていなかった時にエラーを投げるようrequireNotNull
で囲んでやることで、IllegalArgumentException
のエラーを返すことができます。
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処理を行うことができます。
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でエラー処理をしてやりましょう。
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側で以下のようにダイアログを表示するなどしてやると尚良いでしょう。
...
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つのポイントを押さえましょう。
また、今回実装に用いたソースコードはこちらです。
最後に
最後まで読んでいただきありがとうございました。よければtwitterのフォローよろしくお願いします。
⚡️ "たかっしーの開発日記"https://t.co/aGE0WGERzU
— たかっしー(開発垢)@東北放浪中 (@takashiho_2) November 15, 2022