Compose-driven architectureとは?
DroidKaigi 2024の公式アプリでは、ViewModelやRepositoryにおいてもComposable関数を用いた実装がなされており、個人的にかなり衝撃を受けました。
そこから気になって色々調べてみると、このようなComposable中心のアーキテクチャ、Composable-driven architecture はCashやSlackのような海外のTech企業においても採用されていることを知りました。
本記事では、このような設計が生まれた背景や、この設計によりもたらされる効果についてまとめようと思います。
Jetpack Composeとは?
そもそもJetpack Composeとは何者なのか、というところからみてみましょう。
https://developer.android.com/compose では以下のような一文で説明されています:
Jetpack Compose is Android’s recommended modern toolkit for building native UI.
(訳: Jetpack Composeは、native UIを構築するためのAndroid推奨のモダンなツールキットです。)
Jetpack ComposeはUIフレームワークという位置付けで紹介されていますね。
しかし、2020/12/30に公開された Jake Wharton氏の記事『A Jetpack Compose by any other name』の中では以下のように語られています。
What this means is that Compose is, at its core, a general-purpose tool for managing a tree of nodes of any type. Well a “tree of nodes” describes just about anything, and as a result Compose can target just about anything.
(訳: つまり、Composeはあらゆるタイプのnode treeを管理する汎用ツールなのだ。”nodeのtree "というのはあらゆるものを指すので、Composeはあらゆるものを対象とすることができる。)
The nodes don’t have to be UI related at all. The tree could exist at the presenter layer as view state objects, at the data layer as model objects, or simply be a value tree of pure data.
(訳: nodeはUIに関連する必要は全くない。treeはpresenter layerでview state objectとして、data layerでmodel objectとして、あるいは単に純粋なdataのvalue treeとして存在することができる。)
Separately, however, Compose is also a new UI toolkit and DSL which renders applications on Android and Desktop.
(訳: それとは別に、Composeは新しいUIツールキットであり、Androidやデスクトップ上でアプリケーションをレンダリングするDSLでもある。)
The two parts are called the Compose compiler/runtime and Compose UI, respectively.
(訳: この2つの部分は、それぞれCompose compiler / runtimeとCompose UIと呼ばれる。)
要約すると:
- Jetpack Composeは単なるUIフレームワークではなく、あらゆるタイプのnode treeを管理する汎用的なツールである
- node treeの管理部分をCompose compiler / runtime、UIをレンダリングするDSL部分をCompose UIと呼ぶ
とのことでした。
実際にComposeのライブラリ自体も androidx.compose.compiler / androidx.compose.runtime 、 androidx.compose.ui で分かれており、イメージしやすいかなと思います。
Molecule
その後2021/11/11、同じくJake Wharton氏はCash社のブログにて『The state of managing state (with Compose)』の記事を執筆し、Composeによってstateを生成するアプローチを取るMoleculeというライブラリを紹介しました。
Moleculeについて、具体的なコードを交えて少し説明します。
例えば、従来のようにKotlinコルーチンを用いてプレゼンテーションレイヤーのロジックを書く場合、以下のようになるかと思います。
sealed interface ProfileModel {
object Loading : ProfileModel
data class Data(
val name: String,
val balance: Long,
) : ProfileModel
}
class ProfilePresenter(
private val db: Db,
) {
fun transform(): Flow<ProfileModel> {
return combine(
db.users().onStart { emit(null) },
db.balances().onStart { emit(0L) },
) { user, balance ->
if (user == null) {
Loading
} else {
Data(user.name, balance)
}
}
}
}
ここでは以下のような課題があります:
- 使用するデータソースが増えるほどにロジックが複雑になり理解が難しくなる
- Loading状態を示すためにプレゼンテーションレイヤーがモデルに対して責務の境界を超えて初期値を要求してしまっている
Moleculeではこの二つの課題を解決する上で、PresenterをComposable関数として実装して StateFlow<ProfileModel>
を返すようにします。
@Composable
fun ProfilePresenter(
userFlow: Flow<User>,
balanceFlow: Flow<Long>,
): ProfileModel {
val user by userFlow.collectAsState(null)
val balance by balanceFlow.collectAsState(0L)
return if (user == null) {
Loading
} else {
Data(user.name, balance)
}
}
このモデルを生成するComposable関数は、 launchMolecule
で実行することができます。
val userFlow = db.users()
val balanceFlow = db.balances()
val models: StateFlow<ProfileModel> = scope.launchMolecule(mode = ContextClock) {
ProfilePresenter(userFlow, balanceFlow)
}
このようにComposable関数でプレゼンテーションロジックを書くことで、ネストを少なくコードを書くことができ、 collectAsState
でプレゼンテーションレイヤーに閉じて初期値を設定できるようになりました。
Broadway architecture
そして、 2022/09 に開催されたdroidcon NewYorkにおいて、『Architecture at Scale』のセッションではCash社で採用しているBroadway architectureについて紹介されました。
ViewがPresenterについて知らなくて済むように各ModuleではViewFactory / PresenterFactoryのみを公開し、Daggerで各Factoryの配列をinjectするというものです。
このアーキテクチャではMoleculeが活用されており、プレゼンテーションレイヤーはComposable関数で組まれています。
より詳しくはぜひセッションをご覧ください。
Circuit
その後、Slack社はCircuitというCompose-driven architectureのライブラリを公開しました。
(本記事タイトルにも使用しているCompose-driven architectureという単語自体は、CircuitのGitHub RepositoryのDescriptionから拝借したものになります)
Circuitは、MoleculeのようなComposable関数によるPresenterの実装を可能としつつ、Broadway architectureのようにUIとPresenterが互いに疎となるような設計を提供しています。
公式ドキュメントにおいても、CashのBroadway architectureに強く影響を受けたと示されています。
It’s heavily influenced by Cash App’s Broadway architecture (talked about at Droidcon NYC, also very derived from our conversations with them).
具体的なコードも少し見ていきましょう。
CircuitではまずKeyとなるScreenを用意します。
@Parcelize // backstackへ保持したりdeeplinkで使用できるようにParcelizeしている
data object CounterScreen : Screen {
data class CounterState(
val count: Int,
val eventSink: (CounterEvent) -> Unit,
) : CircuitUiState
sealed interface CounterEvent : CircuitUiEvent {
data object Increment : CounterEvent
data object Decrement : CounterEvent
}
}
次に、このScreenに対応するPresenterを用意して、Stateを返します。
@CircuitInject(
CounterScreen::class, // PresenterがどのScreenに対応するのかをmarkする
AppScope::class
)
@Composable
fun CounterPresenter(): CounterState {
var count by rememberSaveable { mutableStateOf(0) }
return CounterState(count) { event ->
when (event) {
CounterEvent.Increment -> count++
CounterEvent.Decrement -> count--
}
}
}
そして、Screenに対応するUIを用意して、Stateを参照してレイアウトを組みつつeventを伝播します。
@CircuitInject(
CounterScreen::class, // UIがどのScreenに対応するのかをmarkする
AppScope::class
)
@Composable
fun Counter(state: CounterState) {
Box(Modifier.fillMaxSize()) {
Column(Modifier.align(Alignment.Center)) {
Text(
modifier = Modifier.align(CenterHorizontally),
text = "Count: ${state.count}",
style = MaterialTheme.typography.displayLarge
)
Spacer(modifier = Modifier.height(16.dp))
Button(
modifier = Modifier.align(CenterHorizontally),
onClick = { state.eventSink(CounterEvent.Increment) }
) { Icon(rememberVectorPainter(Icons.Filled.Add), "Increment") }
Button(
modifier = Modifier.align(CenterHorizontally),
onClick = { state.eventSink(CounterEvent.Decrement) }
) { Icon(rememberVectorPainter(Icons.Filled.Remove), "Decrement") }
}
}
}
このように、Screenという一つのKeyに紐づいたUIとPresenterを用意して自動的に結合するような設計をとっています。
より詳しくはぜひ公式ドキュメントをご覧ください。
DroidKaigi 2024公式アプリ
Broadway architecture, CircuitではどちらもUI / PresenterでのみComposable関数を用いていましたが、RepositoryのようなData層などすべてのlayerをComposable関数で実装してみるというのがDroidKaigi 2024公式アプリでした。
READMEでは “This Year’s Experimental Challenges” とあるように、まだ実験的な試みと評されています。
個人的には、DroidKaigiでの公式アプリの設計についてのトークセッションでお聞きしたtakahiromさんの「RxJava移行と同じようにFlowをComposableへ移行する流れは来るのではないか」という発言が印象に残っており、これから先どのような技術の変遷を辿っていくのか非常に興味深いなと思いました。
消えた?ViewModelについて
Androidアプリを開発する上で避けて通れないのがAAC ViewModelだと思います。
Configuration changeなどを超えて状態を保持するState HolderとしてのViewModelは、上述のどのアプローチにおいても必要です。
Navigation Composeにおいてはback stack entryへ状態を関連づける上でも必須と言えるでしょう。
DroidKaigi 2024公式アプリではtakahiromさん作の Rin というライブラリを用いてAAC ViewModelと同等のlifecycleをComposition内で扱えるようにしています。
Rinを開発したモチベーションは、READMEにて以下のように綴られています。
Initially, I integrated ViewModel with Molecule, but making it aware of the lifecycle was tougher than expected.
(訳: 当初、ViewModelをMoleculeに統合したのだが、ライフサイクルを認識させるのは予想以上に大変だった。)
I found Circuit particularly beneficial for its use of Compose's lifecycle for state management and its support for rememberRetained{}, mirroring ViewModel's lifecycle. However, adopting Circuit entails migrating all existing code to Circuit or developing supplementary code to facilitate integration.
(訳: Compose のライフサイクルを状態管理に利用し、ViewModel のライフサイクルを反映した rememberRetained{} をサポートする Circuit は特に有益だと感じました。しかし、Circuitを採用するには、既存のコードをすべてCircuitに移行するか、統合を容易にするための補足コードを開発する必要がある。)
What if we applied Circuit's rememberRetained{} approach using Compose Multiplatform's ViewModel and Navigation? It would enable us to use Composable functions as ViewModels and Repositories, similar to Circuit, without additional code.
(訳: もし、CircuitのrememberRetained{}の手法をCompose MultiplatformのViewModelとNavigationに適用したらどうなるでしょうか?Circuitと同じように、Compose MultiplatformのViewModelとNavigationを使えば、コードを追加することなく、Compose Multiplatformの関数をViewModelとRepositoriesとして使うことができる。)
つまりRinは、Circuitでサポートされている rememberRetained{}
関数の機能をより扱いやすいように仕上げたライブラリといえます。
このように、Compose-driven architectureにおいて一見ViewModelがないように見えて裏側ではしっかり活用されているようです。
まとめ
まとめると:
- Jetpack ComposeはUI以外のレイヤーにおいても価値を発揮する
- 実際にCashやSlackではプレゼンテーションレイヤーをComposable関数として実装している
- このようなCompose-driven architectureにおいてもAAC ViewModelは内部実装として存在している
というお話でした。
Discussion