Jetpack Composeで始めるServer Cache State
Server Cache State の解説と Soil ライブラリを使った実装を紹介します。
この記事は、Medley(メドレー) Advent Calendar 2024 の20日目の記事です。 昨日は @Shin-Taro さんによる「フロントエンドから見たGraphQLとBFF」でした。
はじめに
今年の4月、「Soil」という自作ライブラリをGitHub上でアルファ公開しました 🎉
私事ですが、9年ぶりに新たな自作のOSSライブラリを公開しました🚀 ドキュメント(足りてないけど)まで盛大に用意したので、Compose や Kotlin Multiplatform に興味がある方はGWのお供に(学び)どうでしょうか? 🤓 https://t.co/syRPaRrDwY
— ogaclejapan (@ogaclejapan) April 25, 2024
Soil は、React界隈で有名な Tanstack Query(旧React Query)や SWR といったライブラリから着想を得て、 Server Cache State という概念を Jetpack Compose で活用できる Query 機能を含めた Compose Multiplatform 向けのOSSライブラリ です。
Androidエンジニアにはまだ馴染みが薄い1 Server Cache State という概念ですが、 Reactを用いたWebアプリケーション開発では、サーバーからのデータを効率よくキャッシュ・同期し、状態管理を簡素化する手段として広く普及しています。
今年開催された DroidKaigi 2024 の HyunWoo Lee さんによるセッション「Essential concepts to know when learning Declarative UI」では、 “Future of state management in Android” という話題を取り上げており、その中で Server State 2 に注目されていました。 この先、Android界隈にも広く普及していくのではないかと私も予想しております。
この記事では、前半に Server Cache State がなぜ必要されているのかについて解説し、 後半では Jetpack Compose で Soil ライブラリを活用した Server Cache State の実装を紹介します。3
目次
- Server Cache State とは
- Server Cache State を使う理由
- Soil ライブラリの紹介
- Soil を使った Server Cache State の実装
- まとめ
それでは、順を追って説明していきます。
Server Cache State とは
私が Server Cache State という概念を初めて知ったのは、React開発のベストプラクティス4として作られたGitHubリポジトリ Bulletproof React の状態管理に関するドキュメントを読んだことがきっかけです。
このドキュメントでは、アプリケーション内で扱う状態管理をパフォーマンスなどの観点から5つに細分化した状態を定義しています。
- Component State
- Application State
- Server Cache State 👈
- Form State
- URL State
Server Cache State の説明文として書かれている内容を文明の力を借りて日本語に翻訳すると、次のように書かれています。5
サーバー キャッシュ状態とは、サーバーから取得され、将来の使用のためにクライアント側でローカルに保存されるデータを指します。 Redux などの状態管理ストア内にリモート データをキャッシュすることは可能ですが、この方法にはより最適なソリューションが存在します。 パフォーマンスを向上させ、データ取得プロセスを最適化するには、より効率的なキャッシュ メカニズムを検討することが重要です
つまり、サーバーからのデータを効率よくキャッシュ・同期するために特化した専用の状態 が Server Cache State であると言えます。
Server Cache State を使う理由
なぜ Server Cache State がReact界隈の最適解になったのでしょうか?背景にある課題感を調べてみました。 次は、React界隈で利用されているライブラリのドキュメントに書かれている概要文から抜粋して翻訳せずにそのまま引用します。
Tanstack Query: https://tanstack.com/query/latest/docs/framework/react/overview
While most traditional state management libraries are great for working with client state, they are not so great at working with async or server state. This is because server state is totally different.
RTK Query: https://redux-toolkit.js.org/rtk-query/overview
Over the last couple years, the React community has come to realize that “data fetching and caching” is really a different set of concerns than “state management”.
いずれのドキュメントでも Server Cache State が 異質(different) であるということが強調されています。 概要文に書かれている内容を参考にしながら、Client State と Server State における性質の違いを表で整理してみました。
Client State | Server State | |
---|---|---|
小分類 | Component State, Application State, Form State, URL State | Server Cache State |
所有者 | アプリケーション自身が所有 | サーバーがリモートで所有 |
制御方式 | 同期処理 | 非同期処理のみ |
データの鮮度 | 常に最新を参照可能 | 最後に取得した時点のスナップショット |
アプリケーションの多くはサーバーと通信し、外部からデータを取得します。 しかし、ネットワーク通信などの非同期処理に依存する Server State のデータ管理には、次のような実装上の課題がつきまといます。6
-
処理状態の管理
非同期処理には時間がかかる可能性があるため、UXへ配慮しなければならない
👉 読み込み中など処理状態に応じてユーザへの適切なフィードバックを検討する -
再取得やリトライ処理
モバイルネットワーク環境では一時的な通信エラーに配慮しなければならない
👉 再取得の仕組み(ボタン設置やPull to Refreshなど)やリトライ処理を検討する -
画面間のデータ同期(いいねボタン問題)
更新が他の画面に反映されるよう、データの整合性へ配慮しなければならない
👉 画面間でのデータ更新通知の仕組みを検討する -
メモリの管理
重めのデータ(サイズ)を扱うときはメモリ使用量へ配慮しなければならない
👉 同じメモリを共有(参照)するキャッシュ化などの仕組みを検討する7
みなさんも非同期データの取得や管理に悩まされた経験があるのではないでしょうか。 このような課題に対応するためには、処理状態の管理やデータ同期といった手間のかかるボイラープレートコードに多くの時間を費やすことになります。
このような課題を背景に、React 界隈では Server Cache State 専用のライブラリが積極的に活用されるようになっています。 これにより、データ取得ロジックの簡素化やキャッシュによるパフォーマンス向上が実現され、複雑な状態管理の負担が軽減されます。 その結果、開発者はより本質的な機能開発やユーザ体験の改善に注力できるようになっているのです。
Jetpack Compose でも使いたくなりませんか?
Soil ライブラリの紹介
冒頭で触れましたが、 Soil は Compose Multiplatform に対応するライブラリ群です。8
「Simplify Compose, Accelerate Development」をスローガンに掲げて、 Compose-First な開発手法でより簡単に、素早く作るためのライブラリ&ツール提供を目指しています。
“Compose-First Power Packs” として公開するライブラリ群には、Server Cache State を実現するための Query 機能が含まれています。
パッケージ名 | 説明 |
---|---|
query-core | Composeライブラリに依存してない基本コード類 |
query-compose | Compose向けのAPI類(e.g. rememberQuery) |
query-compose-runtime | Reactライクな実験的API類(e.g. Suspense) |
query-receivers-ktor | Ktorクライアント用のレシーバー拡張API類 |
query-test | モック用途のTestクライアントAPI類 |
Query の機能性は、React界隈で有名な Tanstack Query(旧React Query)をベンチマークに拡充させているので、多くの共通点があります。 星取表だと長くなるので、ここでは主要な機能のみを列挙します。
- キャッシュ機構(Stale While Revalidate)
- クエリの自動再取得(Stale Time / Network Reconnect / Window Focus)
- リトライ処理(Exponential Backoff)
- プリフェッチ機能(Prefetch)
- クエリ派生パターン(Infinite Query / Conditional Query / Combined Query)
- クエリ副作用(Query Invalidate / Update)
- クエリ処理状態(Loading / Error / Success)
現在も鋭意開発中9であり、今後も新機能の追加や改善が続く予定です。 足りてない機能や要望があれば、GitHub Discussions でのフィードバックをお待ちしております。
次の表に挙げた機能は、来年入れたいものとして実装を予定しています。
新機能 | 対応時期(目安) |
---|---|
Optimistic Updates | 2025 Q1 |
Devtools | 2025 Q2 |
次の章では、Soil を使った具体的な実装例を紹介します。 Jetpack Compose で Server Cache State をどのように実現するのか、デモコードを交えて解説していきます。
Soil を使った Server Cache State の実装
ここでは、シンプルな例として、GitHub API から Soil リポジトリのスター数を取得し、画面に表示する簡単なアプリケーションを作成します。
コード全文を掲載すると読みづらくなるため、完成版のデモコードを別途用意しました。 記事では主要部分のみを抜粋して説明しますので、詳細な実装コードはリンク先の GitHub リポジトリを参照してください。
デモコードの Android プロジェクトに使う技術スタックは次の通りです。
- AGP 8.7.3
- Kotlin 2.0.21
- Jetpack Compose BOM 2024.11.00
- Soil 1.0.0-alpha09
- Ktor 3.0.0
- Dagger Hilt 2.53.1
依存関係の追加
Soil ライブラリは、Maven Central で配布しています。 デモコードでは、Ktorクライアント用のレシーバー拡張APIを使いますので、少なくとも3つのパッケージが必要です。10
// dependencies
implementation(libs.soil.query.core)
implementation(libs.soil.query.compose)
// Ktorクライアント用のレシーバー拡張API
implementation(libs.soil.query.receivers.ktor)
Query クライアントのインスタンス生成
Query 機能をアプリケーション内で利用するには、Query クライアントを表す SwrClient のインスタンスが必要になります。 デモコードでは、実際のアプリ開発でよく使われるDIライブラリ(Dagger Hilt)を使ってインスタンスの生成を管理します。11
@Singleton
@Provides
fun provideSwrClient(
@ApplicationContext context: Context,
ktorClient: HttpClient
): SwrClient = SwrCache(
policy = SwrCachePolicy(
coroutineScope = SwrCacheScope(),
queryOptions = QueryOptions(
// ...
),
memoryPressure = AndroidMemoryPressure(context),
networkConnectivity = AndroidNetworkConnectivity(context),
windowVisibility = AndroidWindowVisibility()
) {
// soil.query.core.ContextReceiver仕様に沿って拡張プロパティを実装することで任意の型を渡すことができます
httpClient = ktorClient
}
)
細かいキャッシュのポリシー設定は割愛しますが、デモコードでは Ktor クライアントを使いますので、Ktor レシーバー拡張APIが提供する httpClient プロパティに Ktor クライアントを渡しています。
Query クライアントの設定
Composable 関数内では、SwrClientProvider コンポーネントに Query クライアントのインスタンスを渡すことで、下位コンポーネントに参照を伝搬させます。
@Inject
lateinit var swrClient: SwrClient
override fun onCreate(savedInstanceState: Bundle?) {
// ...
setContent {
SwrClientProvider(client = swrClient) {
// 下位コンポーネントは、LocalSwrClient.current で参照できます
}
}
}
インスタンスの参照は、Query Composable API のデフォルト値として使用されます。
なるべく UI ツリーのルートに近い上位コンポーネント内に配置しておくと、Query クライアントを必要とする下位コンポーネントから参照できない状態を回避できます。
QueryKey の定義
Query クライアントでキャッシュを管理するには、データの管理対象ごとに QueryKey が必要です。 デモコードでは、Ktor レシーバー拡張APIが提供する buildKtorQueryKey 関数を使用し、GitHub API からスター数を取得する QueryKey を実装します。12
@Immutable
class DemoQueryKey : KeyEquals(), QueryKey<Repo> by buildKtorQueryKey(
id = Id,
fetch = { // HttpClient.() -> Repo
get("https://api.github.com/repos/soil-kt/soil").body()
}
) {
// 専用のId型を必ずしも用意する必要はありませんが、副作用の実行やプレビュー/テストなどで役立つ場面があります。
object Id : QueryId<Repo>("github-repo/soil")
}
@Serializable
data class Repo(
@SerialName("stargazers_count")
val stargazersCount: Int
)
Query クライアントで管理するキャッシュの一意性は QueryId の等価比較です。
ここまでの実装で、Query クライアントが QueryKey を参照してデータを管理し、Server Cache State として外部データの状態を制御する準備が整いました。 次は、最後にスター数を画面に表示する部分になります。
QueryKey の利用
Composable 関数内で データを取得するには、rememberQuery 関数を使います。 データ取得の状態に応じた QueryObject が返却されるので、状態に応じて UI を構築します。
@Composable
fun Demo(modifier: Modifier = Modifier) {
// 少なくともロジックとビューのコンポーネントは分けるべきですが、デモコードなので簡略化しています
val query = rememberQuery(key = DemoQueryKey())
Row(/* ... */) {
when (query) {
is QuerySuccessObject -> Text("✨ ${query.data.stargazersCount}")
is QueryLoadingObject -> Text("Loading...")
is QueryLoadingErrorObject,
is QueryRefreshErrorObject -> Text("Error :(")
}
Spacer(modifier = Modifier.weight(1f))
Button(/* ... */) {
Text("Refresh")
}
}
}
QueryObject は、Kotlin の Sealed interface として実装されています。 状態に応じた UI を構築する際、when 式を使ったパターンマッチングによる分岐が可能です。
また、QueryObject には、任意のタイミングでデータを再取得するための refresh 関数が用意されています。 UI から簡単に再取得処理をトリガーすることができます。
coroutineScope.launch { query.refresh() }
完成!
これで、GitHub API から Soil リポジトリのスター数を取得し、画面に表示する簡単なアプリケーションが完成しました。
この例はシンプルなものですが、ViewModelによる状態管理や再取得処理を実装せずに、同等の機能をもつアプリケーションが作れることを実感できるのではないでしょうか。
完成版のデモコードには、記事では紹介しきれなかった機能や実装なども含まれていますので、興味をもった方はぜひご覧ください。
- リトライ処理
- データ同期
- コンポーネントのプレビュー・テスト
まとめ
本記事では、Server Cache State がなぜ必要されているのかについて解説し、Jetpack Compose と Soil を用いた Server Cache State の実装を紹介しました。
React開発におけるTanStack Queryのようなライブラリと同様に、Soil はデータ取得ロジックを抽象化し、開発者は簡潔なコードで効率的なデータ取得とUI更新を実現できます。
Server Cache State をうまく使うことで、Jetpack Compose アプリケーション開発の新たな可能性を感じていただけたら幸いです。
明日の21日目は、@rio-song さんによる「ルビと格闘した話」です。
お楽しみに!🎄🎅
脚注
-
GraphQL クライアントも Server Cache State を扱うライブラリの選択肢に含まれています。GraphQL を使っている方は、すでに始められているので馴染みがあるかもしれません。 ↩
-
後述しますが、Server State と Server Cache State は、ほぼ同じ概念として捉えていただいて構いません。 ↩
-
Soil は本記事の公開時点でアルファ公開版(鋭意開発中)のため、今後のアップデートにより内容が変わる可能性がありますので、ご了承ください。 ↩
-
私は「React Application Architecture for Production」というPackt Publishing出版の本で知りましたが、最近のRSC文脈では必ずしもベストプラクティスとは限らない可能性がありますので、ご留意ください。 ↩
-
この説明文の中で言及される Redux は、このドキュメント内で Application State の定義に該当するライブラリに挙げられています。 ↩
-
全体的にネイティブアプリケーション寄りな課題を挙げておりますので、Webアプリケーション開発の方には少し違和感を感じるかもしれませんが、基本的な課題は共通していると考えています。 ↩
-
共有メモリの管理は複雑な問題であり、解放タイミングの難しさからメモリリークが発生する可能性があるため、慎重に設計する必要があります。 ↩
-
Compose Multiplatform 対応ライブラリは、 Android プラットフォーム向けのターゲットが Jetpack Compose のランタイム上で動作します。 ↩
-
記事執筆時点のバージョンは 1.0.0-alpha09 です。最初のアルファ版公開からだいたい月1頻度でリリースをしています。 ↩
-
バージョンカタログ(libs.versions.toml)を利用してパッケージ情報を定義している前提で説明しています。 ↩
-
共有インスタンスとして参照できればどのような方法で管理しても構いません。データやキャッシュの管理はインスタンス単位となるため、原則1つの共有インスタンスを推奨します。 ↩
-
Repositoryなど既存のデータ層を参照して呼び出すこともレシーバー拡張の実装次第で可能です。 ↩