TL;DR
React SWRをJetpack Compose向けに移植して公開したぞ!
前置き
もし今からモダンなAndroidアプリをゼロから書こうと思ったらおそらくJetpack Composeを採用したいという人が多いかと思います、いわゆる宣言的UIと呼ばれるやつですね。
この宣言的と呼ばれるトレンドはUIだけに適用されているものなのでしょうか?
AndroidというプラットフォームだけでみてもCompose以外で宣言的に書ける部分は年々広がってきています。
例えば遷移先の画面から値を受け取るstartActivityForResult()
+onActivityResult()
は非推奨になり、代わりにregisterForActivityResult()を宣言する形に置き換わりつつあるのは周知の通りです。
また、画面のライフサイクルのハンドリングはActivityのonStart()
やonResume()
のオーバーライドではなく、LifecycleObserver()を宣言する形が推奨されてきています。
ComposeにおけるAAC ViewModelの立ち位置
では、この宣言的という文脈でデータの取得や状態管理に目を向けるとどうでしょうか?
今日だとAndroid Architecture Components(=AAC)のViewModelを用いて状態管理やロジックの記述、データ取得を行うことが多いと思います。
現に状態と Jetpack ComposeのドキュメントにもAAC ViewModelが登場します。
しかしながらドキュメントにはこのようにも書かれています。
注: 実際のユースケースで ViewModel のメリットを活用できない場合や、別の方法で行う場合は、ViewModel の役割をプレーンな状態ホルダーのクラスに移行します。
キーポイント: ViewModel は、特定の責任を持つ状態ホルダーの実装の詳細にすぎません。プロジェクトのモジュールを Android の依存関係から切り離したい場合は、インターフェースを利用して、さまざまなコンテキストで実装を切り替えられるようにします。
現状はAAC ViewModelがデファクトスタンダードになっているのでJetpack Composeと併用されることも多いですが、状態ホルダーとしての役割が満たせれば本来何でも良いのです。
AAC ViewModel自体、状態の保持やライフサイクルに癖のあるAndroid Viewにおいて状態をより簡単に管理するために誕生したため、内部では命令的な記述をすることが一般的だと思います。
ゆえにLiveDataやStateFlowなどのリアクティブな機構を除き、データ取得や状態管理などのロジック周りに関しては(少なくとも私の観測範囲では)宣言的に書く流れはあまり見られません。
また、AAC ViewModel自身もライフサイクルがあり、スコープを持ち、なおかつステートフルな存在なので画面をまたいで再利用しにくい側面があることも否めません。
Jetpack Composeと違い、Androidというプラットフォームに依存しその影響を強く受けているものという点も扱いの難しさに拍車をかけています。
AAC ViewModelはAndroid Viewとの相互運用性の高さからも引き続き使われていくとは思いますが、フルJetpackCompose環境においては必ずしもベストな選択肢とは限らないのです。
もちろん、かつてAndroid View環境下においてAAC ViewModelの登場により解決されたことは非常に多く今でも素晴らしいものだとは思っていますが、Jetpack Compose環境ではより宣言的で適した状態ホルダーの代替が登場しても良いのではないでしょうか。
ComposeがReactから受けた影響
Jetpack Composeが大きな影響を受けたであろうフレームワークの一つがReactです。1
Webフロントはさほど詳しくない私の主観ですが、宣言的UIとして先駆者であるReactはJetpack Composeに比べエコシステムが成熟している面が多数あり、今後ComposeがReactの辿った道をある程度なぞる可能性は大いにあると思っています。(もちろん後発であるComposeのほうが洗練されている部分もたくさんあります)
その上でReactにおける状態管理のトレンドを見てみると、少し前はReduxやFluxなどに代表される単一方向データフローが流行りましたが最近はReact Hooksの登場で状況が変わってきているように思います。
Composeにも近い概念は取り入れられていて、例えばReact Hooksの基本機能の一つである useState()
はComposeだと remember {}
で表現されており、例えば下記のコードは全く同じ振る舞いをします。
function Example() {
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
@Composable
fun Example() {
val (count, setCount) = remember { mutableStateOf(0) }
Column {
Text("You clicked $count times")
Button(onClick = { setCount(count + 1) }) {
Text("Click me")
}
}
}
Composeのremember {}
がReact Hooksをベースに輸入されたものかはわかりませんが、近い思想と実装を持っていることは感じ取れると思います。
もし仮にremember {}
がなかったとしたら、それこそViewModelを作ってそちらに値を保持することになっていたでしょう。
React Hooksでは useState()
などのフック関数をラップして独自フックとして再利用性を高めた上で宣言的に活用するケースも増えてきています。
対してCompose界隈ではReact Hooksのように宣言的に機能をまとめて関数を作るケースは(私が観測できていないだけかも知れませんが)アプリケーション開発の現場ではあまり普及していていない印象です。
しかしながら今後ViewModelを作って状態を置いたりロジックを書く代わりに、独自フック的なものを作るのが一般的になっていく可能性はあると思っています。
余談: Composeで独自フックを作ってみる
簡単な例ですが「画面の現在のライフサイクルの状態を監視・取得する」独自フックをComposeで作ってみます。
@Composable
fun rememberLifecycleState(): Lifecycle.State {
var currentState by remember { mutableStateOf(Lifecycle.State.INITIALIZED) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val lifecycleObserver = LifecycleEventObserver { _, event ->
currentState = event.targetState
}
lifecycleOwner.lifecycle.addObserver(lifecycleObserver)
onDispose {
lifecycleOwner.lifecycle.removeObserver(lifecycleObserver)
}
}
return currentState
}
ここまで作ってしまえば、使う側からはこの関数を呼び出すだけで常に最新のstateを取得できます。
@Composable
fun MainScreen() {
val state = rememberLifecycleState()
...
}
ロジックを集約しフック関数内に隠蔽することで使う側からより簡単に利用できるのがフック関数の威力です。
前述したとおり、ライフサイクルイベントがonResume()
やonStart()
のオーバーライドではなくLifecycleEventObserver
を使って宣言的に受け取れることになったことで、このような独自フックの実現がますます容易になっています。
React SWR
そんなReact Hooksをフル活用して宣言的にデータ取得まで担ってしまおうぜ!という思想で誕生したのがSWRやTanStack Queryといった存在です。
いずれもReact Hooksをデータフェッチに活用したライブラリですが、今回はどちらかというとシンプルで軽量なSWRにフォーカスを当ててみます。
SWRのドキュメントは全編通して日本語翻訳されていてめちゃくちゃ読みやすいのでとりあえず読んでくれい!でも十分なんですが、簡単に説明すると
データ取得に関する一般的な要件や問題に対して、こういうのあったらいいよね〜と思う機能をこれでもかというくらいに実装して useSWR()
というフック関数一つにすべて押し込んだよくばりセットみたいなやつです(雑)
SWRの思想
公式ドキュメントの引用そのまんまですが、SWRは下記の戦略に従っています。
“SWR” という名前は、 HTTP RFC 5861 で提唱された HTTP キャッシュ無効化戦略である stale-while-revalidate に由来しています。 SWR は、まずキャッシュからデータを返し(stale)、次にフェッチリクエストを送り(revalidate)、最後に最新のデータを持ってくるという戦略です。
この思想自体は革新的というわけではありませんが、画面上に表示される内容をできる限り最新の状態に更新しつつも、キャッシュがあればそれを先に表示させてUXを落とさないようにしているというわけです。
SWRの基本的な使い方
こちらも公式ドキュメントそのままですがミニマムなSWRの利用方法は下記です。
import useSWR from 'swr'
function Profile() {
const { data, error } = useSWR('/api/user', fetcher)
if (error) return <div>failed to load</div>
if (!data) return <div>loading...</div>
return <div>hello {data.name}!</div>
}
SWRの基本はキーバリューによるキャッシング機構です。
useSWR関数は下記の3つの引数を取ります。
- key ... リクエストするユニークな文字列
- fetcher ... (任意)第一引数に渡したURLを引数に取るfetch関数
- options ... (任意)SWRのオプション
keyはデータキャッシュのキーとしての役割も兼ねています。
そしてこの関数は下記を返却します。
- data ... fetcherによって取得したデータ
- error ... fetcherによってthrowされたエラー
- isLoading ... リクエストの初回読み込み中であるか否かのBool値
- isValidating ... リクエストまたは再検証の読み込みがあるか否かのBool値
- mutate ... キャッシュされたデータを更新する関数
このとき、dataの状態は
取得が完了していない場合はundefined
取得が完了した場合はその内容
となり、dataがundefinedか否かで読み込み中かどうかをUIに伝えることが出来ます。
ちなみにisLoadingはtypeof data === 'undefined' && !error
と等価です。2
シンプルなインターフェースですがとても合理的です。
よくSWRで便利と言われる機能
SWRが便利と言われる所以は下記のようなデータを取得する場合に一般的にほしいと言われる機能の大部分が網羅されているところにあります。
- エラー発生時の効率的な自動再実行
- ウィンドウフォーカス時の再検証
- ネットワーク復帰時の再検証
- ポーリングによる定期的な再検証
- AのデータのフェッチしたあとにAを使ってBのデータを取得するといった依存フェッチ
- ページネーションサポート
- POST処理等における楽観的なデータ更新と失敗時のロールバック
- プリフェッチ機能
- 同一キーによるリクエストの重複排除
useSWR()
関数一つの中にこれだけ(これ以上も!)の機能が詰め込まれており、まさによくばりセットと言わんばかりなのがわかるかと思います。
中でも特に依存フェッチは宣言的UIの仕組みをうまく使った非常に逸品な機能です。
またfetcher
の内容は実装者に委ねられているため、REST APIなのかGraphQLなのかといった通信プロトコルやそれに伴う通信ライブラリに依存せず任意に選択できるのも大きな特徴です。
React SWRについて語るだけで何本か記事がかけてしまいそうなので、詳しく知りたい方は公式ドキュメントか下記の先人様の記事に任せたいと思います。
個人的な感想ですが、「情報更新の余地のある時はとにかくデータフェッチを行って最新にしていくぜ!仮に同じデータを何度も取得することになったとしても古い情報を画面に出し続けてるよりはいいよね!」という勢いを感じます(とはいえ各イベントごとの取得頻度はオプションで調整可能です。)
SWRの機能をComposeで再現してみる
さて、ここまでが前置きです。
これまでComposeにおけるAAC ViewModelの立ち位置、React Hooksの台頭とそこからComposeが受けた影響、宣言的データフェッチライブラリであるSWRの出現、などを語ってきました。
そこでSWRのようなフック機構をComposeに持ち込みつつ、そのまま状態ホルダー(AAC ViewModel)の代わりとして活用することを目指してみます。
ComposeにおいてSWRのインターフェースはベストとは限らないかもしれませんが、そこを吟味していくとキリがないので今回は本家SWRをそのまま再現してみました。
SWR for Compose
その上で移植してみたものが下記になります。
流石にこの場で実装コードまで紹介していくとキリがないのでライブラリとしてMavenCentralで公開しています。
現状Androidでのみ動作します(Compose Multiplatformはサポートしていません)
実際に利用してみる
結論から言うとAAC ViewModel
を一切使わずとも十分実用的なアプリケーションをより少ないコードで構築できました。
本家SWRと同様に高いUXをJetpack Compose上で提供できていると思います。
下記のGIFはリポジトリ内のexampleモジュールで実装した簡単なTODOリストです。
アプリ内にデータの取得や更新に1秒かかるデータストアを持っており、それをサーバーの代わりとして仮想的に通信処理を再現しています。
正直このGIFだけだと便利さが伝わりにくいかもしれないですが、データ取得をSWRで記述するだけでstale-while-revalidate
の戦略に沿ったUXを得ることができます。
一度読み込めばキャッシュされて次回から即時反映されることはもちろんのこと、フォーカス時(=onResume()
時)、ネットワーク復帰時に自動的にフェッチが走って最新に更新され、他の場所で同一キーでの更新があれば自動的にre-composeされてすべての利用箇所で反映されます。
またエラー発生時は指数バックオフアルゴリズムに沿って自動的に再フェッチが走ります。
SWR for Composeの使い方
基本的な使い方
build.gradleのdependenciesに下記を追加してください。
implementation("com.kazakago.swr.compose:swr-android:[LATEST_VERSION]")
使い方は本家と概ね同じです。
private val fetcher: suspend (key: String) -> String = {
getNameApi.execute(key)
}
@Composable
fun Profile() {
val (data, error) = useSWR("/api/user", fetcher)
if (error != null) Text("failed to load")
else if (data == null) Text("loading...")
else Text("hello $data!")
}
useSWR()
が複数の値を返すのに分解宣言を用いています。
ゆえに展開する際、言語仕様の違いから本家SWRと違いプロパティの順番に依存していることに注意してください。
もちろん冗長に感じる場合は分解せず利用することも可能です。
val state = useSWR("/api/user", fetcher)
Text("hello ${state.data}!")
サポートされている機能・オプション
本家を参考に実装しています。
React特有の仕組みを使った機能についてはサポートしていません。
機能名 | ステータス | Note |
---|---|---|
Global Configuration | ✅ | |
Data Fetching | ✅ | |
Error Handling | ✅ | |
Auto Revalidation | ✅ | |
Conditional Data Fetching | ✅ | |
Arguments | ✅ | |
Mutation | ✅ | |
Pagination | ✅ | |
Prefetching Data | ✅ | Available by useSWRPreload()
|
Suspense | ❌ | |
Middleware | ❌ |
React SWRのオプションについても代表的なものはカバーしています。
オプション名 | ステータス | デフォルト値 |
---|---|---|
suspense | ❌ | |
fetcher(args) | ✅ | |
revalidateIfStale | ✅ | true |
revalidateOnMount | ✅ | true if fallbackData is not set |
revalidateOnFocus | ✅ | true |
revalidateOnReconnect | ✅ | true |
refreshInterval | ✅ | 0.seconds (disable) |
refreshWhenHidden | ✅ | false |
refreshWhenOffline | ✅ | false |
shouldRetryOnError | ✅ | true |
dedupingInterval | ✅ | 2.seconds |
focusThrottleInterval | ✅ | 5.seconds |
loadingTimeout | ✅ | 3.seconds |
errorRetryInterval | ✅ | 5.seconds |
errorRetryCount | ✅ | |
fallback | ✅ | |
fallbackData | ✅ | |
keepPreviousData | ✅ | false |
onLoadingSlow(key, config) | ✅ | |
onSuccess(data, key, config) | ✅ | |
onError(err, key, config) | ✅ | |
onErrorRetry(err, key, config, revalidate, revalidateOps) | ✅ | Exponential backoff algorithm |
compare(a, b) | ❌ | |
isPaused() | ✅ | false |
use | ❌ |
細かい使い方に関しては本家ドキュメントを読んでもらえれば本ライブラリもある程度同じように使えるはずです。
また、exampleモジュールでは一通りの機能を使って実装しているのでそちらもご参照下さい。
Androidの推奨アーキテクチャとのギャップについて
ここまでReact Hooksに習った宣言的データフェッチを見てきました。
上記のSWR for Composeは宣言的データフェッチの実装の一例に過ぎませんが、様々な要件を満たす機能を一つの関数に詰め込むことができるため、ボイラープレートコードをできる限り少なくしてアプリケーションを薄く保つことが比較的容易になることが少しは伝わったのではないでしょうか。
SWRのような仕組みを用意して使うとケース次第ではAAC ViewModelどころかRepositoryパターンすら不要と言えるでしょう。
しかしながら、Android Developersのアプリ アーキテクチャ ガイドやAndroid アーキテクチャに関する推奨事項を見てみるとアプリが大規模になればなるほど、レイヤーを分けて責務の分離を行いましょうと案内されています。
今回紹介したSWRによる宣言的データフェッチはアプリケーションを薄く保ちやすい分、UIとデータ取得のロジックが近くなりがちでAndroid Developersで推奨されているレイヤードアーキテクチャとは逆行する側面もあることに留意してください。
(別にSWRとレイヤードアーキテクチャは相反する概念というわけではなく組み合わせられなくはないですが、よくばりセットとしてのSWRの魅力は薄まります)
当然ながらUIとロジックが近いと責務分離は難しくなるので、複雑なデータ処理やオンデバイスの処理に関しては独自フックやAAC ViewModel、ドメインレイヤーなどを活用してうまく責務の分離を行っていくことが大事です。
個人的な主観ですがSWRなどのHooksを軸にした開発は現在Androidアプリ開発で普及しているAAC ViewModel
+Repository
と比べるとEasy(≒書く量が少ない)ですがSimpleではない(≒記述場所に迷いやすい)印象です。
とはいえ昨今のシステムでは複雑なロジックはサーバーに委譲されるのが一般的で、またGraphQLなどの技術の発展もあってフロントエンド自体に求められる責務が以前よりシンプルになってきているはずです。
レイヤーを細かく分けてより責務分離を徹底していくほうが適したアプリケーションもある一方、SWRのような汎用的な仕組みで十分カバーできるアプリケーションも多いのではないでしょうか。
伝えたいこと
宣言的UIを始めとする宣言的のトレンドはモバイルアプリを含むフロントエンド全体で散見されます。
根底にある思想はいずれも近いものの、各コミュニティにおける流行りや注目されているポイントは様々です。
歴史の深いコミュニティで普及している事柄から学ぶことは多いと感じています。
今回私はReact SWRの使い勝手をそのままComposeで再現することを目指しましたが、本当に大事なのは丸パクリすることではなくその文脈や概念を理解して応用できるようになることです。
SWRは宣言的UIの特徴をデータ取得に適用したものでしたが、ほかにも様々な機能で応用できる部分は数多くあると思います。Composeは完全な関数コンポーネントということもあり、Hooksのような仕組みと非常に相性が良いです。
つまり何が言いたいかというと、これから宣言的であることをフルに活用した便利な仕組みやライブラリが数多く生まれてくることを期待しています!(他力本願)
参考
-
I/O 2019のCompose発表時の公演で
It's inspired by frameworks like React, Litho, Vue.js, and Flutter,
と明言されています。 ↩ -
https://github.com/vercel/swr/releases/tag/2.0.0-beta.1 より ↩