規模の大きい Android の MVVM ライクなアーキテクチャについて
Android アプリのアーキテクチャパターンについて以前こういうことを話させていただきました。それからGoogle のガイドラインができ、今自分がある程度の規模の先の長い Android アプリを作るならこうするといいかもといった変化があったのでそれを残しておこうと思います。
あくまで例ですが何かの参考となれば幸いです。
Android アプリアーキテクチャガイドラインについて
まずは Google のアーキテクチャガイドを振り返ります。
ガイドラインにはこのようなデータフローが記されています。これらは疎結合に作るためそれぞれをモジュールとして作り各レイヤー内に公開するための Interface を定義します。そして各クラスを Dagger Hilt などを用いて DI することになるかと思います。また、最低でも各レイヤーごとにマルチモジュールとしておくとビルド時間の高速化などメリットが得られそうです。
UI レイヤー
ガイドラインによると UI レイヤーは次のように定義されています。
UI レイヤは次の 2 つのもので構成されています。
- データを画面にレンダリングする UI 要素。これらの要素は、View または Jetpack Compose 関数を使用して作成します。
- データを保持して UI に公開し、ロジックを処理する状態ホルダー(ViewModel クラスなど)。
データレイヤー
データアクセスを提供するレイヤーになります。ガイドラインでは DataSource というデータアクセスを実現するコンポーネントを Repository パターンを用いて外部に公開することを説明しています。
また、少し話はずれますが Google はオフラインファーストを推奨しており Room などを用いてローカルアクセスできるソースからのデータを公開するようにと記載されています。
ドメインレイヤー
ガイドラインによると、ドメインレイヤーは以下のような特徴を持つオプショナルなレイヤーです。
複雑なビジネス ロジック、または複数の ViewModel で再利用される単純なビジネス ロジックをカプセル化します。すべてのアプリにこのような要件があるわけではないため、このレイヤはオプションです。
そしてこのレイヤーではそれぞれが単一の機能を持つ UseCase
というコンポーネントを公開します。
今回はこのオプショナルなレイヤーにフォーカスした内容になっているかもしれません。
どう作るのか
Google のアーキテクチャガイドはよくできていて、まず Android アプリを作ってみる際にはこれを真似してみるといいと思います。なので Android アプリエンジニアなら知っているだろうことを前提にまずプロジェクトに参画するハードルを下げることをポイントとして、こう作ると言いつつ実際のところはガイドラインをほぼほぼベースとしてこう実装する、みたいな感じになります。
それでは以下のような構成のアーキテクチャの、ユーザのタッチポイントから遠いレイヤーから順にどう実装していくかを紹介していきます。また、前提として Dagger Hilt で DI しますが今回は DI については割愛します。
.
├── app
├── core
│ └── entity
├── datasource
│ ├── api
│ └── repository
├── domain
│ ├── cat
│ └── usecase
└── feature
└── cat
今回のサンプルは GitHub に置いています。動かすと可愛いネコチャンが大量に見れます🐱
Core
先にアプリケーション内で使われるデータなどを定義する Core レイヤーについて触れておきます。その名の通りアプリケーションの core な機能を定義しておくレイヤー(と呼んでいいかわかりませんが...)で、データやユーティリティな設定値などを定義します。
今回は Cat データを用意して、これを UI に表示することをゴールにデータレイヤーから説明していきます。
data class Cat(
val id: String,
val url: Url
)
DataSource
ガイドラインのデータレイヤーであるデータアクセスを担当するレイヤーです。
マルチモジュール的なお話で各アクセス先によってモジュールをわけつつ、それらを Repository を介して外部に公開するような流れになります。
まず api モジュールは Web API で公開されているデータを fetch する機能を公開します。REST であれば Retrofit であったり GraphQL であれば Apollo Android を使うためにクエリを定義します。今回のサンプルでは api しか用意していませんが、DB があれば db モジュール
や Preference などは storage モジュール
などを用意します。
@Serializable
data class CatImageResponse(
val id: String,
val url: String
)
interface CatService {
@Headers("x-api-key: apiKey")
@GET("/v1/images/search")
suspend fun fetchCatImages(@Query("limit") limit: Int): Response<List<CatImageResponse>>
}
これらを Repository を介して外部に公開します。実態のクラス名について Real〇〇 でも 〇〇Imple でもなんでもいいかなとは思いますが、個人的に suffix にしておくと補完しやすかったりファイルをわけてある場合はリストにしたときに interface と実態が並んで管理しやすくなると思います。正直 Impl はうーん...とはなりますが。
interface CatRepository {
suspend fun fetchCats(limit: Int): Result<List<Cat>>
}
@Singleton
class CatRepositoryImpl @Inject internal constructor(
private val catService: CatService
): CatRepository {
override suspend fun fetchCats(limit: Int): Result<List<Cat>> = apiCall {
catService.fetchCatImages(limit).result { it.toEntity() }
}
private fun List<CatImageResponse>.toEntity() = map {
Cat(
id = it.id,
url = Url(it.url)
)
}
}
internal suspend inline fun <T> apiCall(crossinline call: suspend () -> T): Result<T> = withContext(
Dispatchers.IO) {
runCatching { call() }
}
Repository から安全に公開できるように IO スレッドで通信させつつ Result 型で返すようにしています。
ここでのポイントは データアクセスのデータをそのままアプリケーションで引きずり回さない です。
一般的なプロジェクトのアプリでは各プラットフォームが同じ API を使いデータをサーバから取得すると思います。その際に公開されるスキーマは必ずしもアプリケーション(UI)に最適化されているとは言えないことがあったりします。また、その課題を解決するために GraphQL を選択することもあるかもしれませんが、現状 Android アプリで GraphQL を採用する場合のデファクトスタンダードな方法はクライアントライブラリに Apollo Kotlin を使うことだと思います。Apollo は schema とクエリを置いておくとそれに合わせてデータクラスを自動生成してくれますが、これをそのまま使うとライブラリへの依存度が非常に高くなります。それらの解決策として、受け取ったデータをアプリケーションのデータ構造にマッピングして公開し、アプリの外部との依存を減らすようにするといいと考えています。
Domain
この記事での一番のポイントはここになると思います。基本的には冒頭で紹介した記事で話した UseCase がこれに当たります。Repoitory はプレーンなデータアクセスを実現し、後で触れますが ViewModel はライフサイクルアウェアなデータホルダーの役割を担当するためドメインロジックの実行や複数の Repository をマージするレイヤーとしてドメインレイヤーを用意しています。
ドメインレイヤーですが、ガイドラインによるとオプショナルとされていて必要に応じて UseCase を用意しますとあります。ですが、それなりの規模のアプリになるとそれなりの数の UseCase ができることになり、これは UseCase を作るの?作らないの?といった判断が必要となります。迅速なコンテンツデリバリーに対してアーキテクチャのレイヤーをオプショナルとするとそういった本来不要だった思考時間が発生するので、レイヤーとしては存在するべきだと考えました。
ただし実際にチームで運用していると、用意したレイヤーを UseCase という名前にしてしまうと UseCase は単一の機能を持っていることと広く知られているため今回用意するレイヤーとの混乱を生んでしまっていました。なので 〇〇Domain
としています。このあたりはプロジェクトによって最適な名前を選択するといいかもしれません。
そして Domain 内で必要になった共通したい処理などを UseCase として切り出します。今回は例として Breed という data を Cat が持っているとして Breed によってリストをフィルタリングする処理を UseCase としています。
interface CatDomain {
fun cats(): Flow<List<Cat>>
suspend fun fetch(): Result<Unit>
suspend fun filterByBreed(breed: Breed): Result<Unit>
}
class CatDomainImpl @Inject internal constructor(
private val catRepository: CatRepository,
private val filterByBreedUseCase: FilterByBreedUseCase
): CatDomain {
private companion object {
const val IMAGE_LIMIT = 20
}
private val catsFlow = MutableStateFlow<List<Cat>>(emptyList())
override fun cats(): Flow<List<Cat>> = catsFlow.onStart {
if (catsFlow.value.isEmpty()) fetch()
}
override suspend fun fetch(): Result<Unit> {
return catRepository.fetchCats(IMAGE_LIMIT).onSuccess { catsFlow.value = it }.map {}
}
override suspend fun filterByBreed(breed: Breed): Result<Unit> = runCatching {
catsFlow.value = filterByBreedUseCase(catsFlow.value, breed)
}
}
interface FilterByBreedUseCase {
suspend operator fun invoke(cats: List<Cat>, breed: Breed): List<Cat>
}
class FilterByBreedUseCaseImpl @Inject internal constructor(): FilterByBreedUseCase {
override suspend operator fun invoke(cats: List<Cat>, breed: Breed): List<Cat> {
return cats.filter { it.breeds.contains(breed) }
}
}
関係性としては ViewModel : Domain = 1 : 1
としています。画面に紐づくアレコレを解決するレイヤーというポジションでしょうか。なのでライフサイクルとしても Singleton ではなく ViewModel に合わせています。各 Domain は Flow でデータを公開し、そこに作用する suspend 関数を定義します。Flow の運用の仕方は DroidKaigi2023 の ViewModel を参考にしています。
あくまで ある程度の規模のアプリケーションでの話であり、それが冗長になってしまう場合は ViewModel の中に入れ込んでもいいと感じます。
Feature
ガイドラインにおける UI レイヤーがこれに当たります。このモジュール内に ViewModel や Composable などの画面に関わるクラスを定義します。
ViewModel
@HiltViewModel
class CatsViewModel @Inject internal constructor(
private val domain: CatDomain
): ViewModel() {
data class UiState(
val cats: List<Cat> = emptyList()
)
sealed interface UiEvent {
data class Error(val e: Throwable): UiEvent
}
val uiState: StateFlow<UiState> = domain.cats().map { UiState(it) }.stateIn(UiState())
private val _uiEvent: Channel<UiEvent> = Channel()
val uiEvent: Flow<UiEvent> = _uiEvent.receiveAsFlow()
fun fetchCats() {
viewModelScope.launch {
domain.fetch().onFailure { _uiEvent.send(UiEvent.Error(it)) }
}
}
}
private fun <T> Flow<T>.stateIn(initialValue: T): StateFlow<T> {
return stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), initialValue)
}
ViewModel は画面のデータホルダーとして機能し、UiState
と UiEvent
を Flow で公開しそれらに対する操作を行う関数を定義します。
UiState は画面に表示するデータの集合です。Composable を前提として StateFlow ですが AndroidView であれば LiveData がよいかもしれません。
そして画面には非同期に何かを実行した際に発生するワンショットなイベントが存在します(通信エラーなど)。これらを画面に伝搬させるために UiEvent という要素を設けています。
UiState と分けている理由は、それらは基本的にワンショットなのでステートが必要ない(あったら困る)からです。UiEvent には消費するという概念があるため一度発生すれば消えてしまうイベントであり、それがステートになると問題を引き起こす可能性があるためです。UiEvent は所謂 SingleLiveEvent
のようなものを実現するために Channel.receiveAsFlow
を使って公開されています。(参考)
ScreenState
ここで Composable 関数に直接 ViewModel を渡すのではなく ScreenState と呼ばれるコンポーネントを用意しそれを画面に渡します。ScreenState についてはこちらで紹介していて、Acitivty や Fragment で行っていたようなステート管理・イベントハンドリングなどを解決しています。
class RealCatsScreenState(
override val scaffoldState: ScaffoldState,
private val viewModel: CatsViewModel,
private val context: Context,
coroutineScope: CoroutineScope,
lifecycle: Lifecycle
): CatsScreenState {
override val uiState: CatsViewModel.UiState
@Composable get() = viewModel.uiState.collectAsState().value
init {
viewModel.uiEvent.collectOnLifecycle(coroutineScope, lifecycle) {
when (it) {
is CatsViewModel.UiEvent.Error -> {
scaffoldState.snackbarHostState.showSnackbar(it.e.message.orEmpty())
}
}
}
}
override fun onBackPressed() {
(context as Activity).finish()
}
override fun onRefresh() {
viewModel.fetchCats()
}
}
@Composable
fun rememberCatsScreenState(
viewModel: CatsViewModel = hiltViewModel(),
coroutineScope: CoroutineScope = rememberCoroutineScope(),
scaffoldState: ScaffoldState = rememberScaffoldState(),
context: Context = LocalContext.current,
lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle
): CatsScreenState = remember {
RealCatsScreenState(scaffoldState, viewModel, context, coroutineScope, lifecycle)
}
ScreenState では ViewModel の UiState を公開し、UiEvent を監視しつつ適切にハンドリングします。サンプルでは何かしらのエラーが伝搬されると Snackbar にエラーメッセージを表示しています。
画面からのアクションを ViewModel に伝えるための関数を用意しており、それらの関数名は(クリックされた、やスワイプリフレッシュしたなどの)アクションを表しています。この関数名を fetch などにしてしまうと画面がそのアクションで何をするかを意識しないといけなくなるためアクション名にしています。また、画面遷移などもここで対応します。
Preview をしやすくするため ScreenState も Interface を用意しています。
これも DI でうまく画面と繋げたいと思っています。
Composable
最後に画面を実装します。コンポーネントの粒度はプロジェクトによって異なると思いますが ScreenState とのタッチポイントとなる画面に関しては 〇〇Screen
としています。
基本的に ScreenState 内にロジックなどは閉じるため、Composable 関数内はコンポーネントの配置だけに集中します。
@Composable
fun CatsScreen(state: CatsScreenState = rememberCatsScreenState()) {
val uiState = state.uiState
Scaffold(
scaffoldState = state.scaffoldState,
topBar = {
TopAppBar(
title = { Text("Cats") },
navigationIcon = {
IconButton(onClick = { state.onBackPressed() }) {
Icon(Icons.Filled.ArrowBack, contentDescription = "Back Arrow")
}
},
actions = {
IconButton(onClick = { state.onRefresh() }) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh")
}
}
)
},
modifier = Modifier.fillMaxSize()
) {
CatImages(cats = uiState.cats)
}
}
まとめ
以上が個人的な今ならこう作る Android アプリアーキテクチャの紹介となります。Google が MVVM を意識したライブラリ群を提供してくれているためそうなっていますが MVVM と呼んでいいのかはどうなんでしょうか🤔
レイヤー・モジュールにわけることでチーム開発におけるコンフリクトを減らしつつ、実装するときの影響範囲を把握しやすくなるため見積もりしやすくなり、不具合調査のときもある程度のアタリをつけることがしやすくなるため迅速にユーザにバリューを届けることができます。
この他にも Logger など core 化しそうなライブラリをアプリケーション全体に依存させないためにモジュール化するなど、時代によってアプリの形も変わっていくため様々な依存をなるべく最小限に留めるように実装していきたいと考えています。
マルチモジュールとしているためモジュールを implementation すると見えてしまうので api など feature から見えなくてもいいものも参照できてしまったり、Hilt の関係上 Impl クラスのコンストラクタしか internal にできずクラスは参照できてしまったりするのでそのあたりも今後なんとかなれ〜の精神でいます。
プロジェクトの課題やチームなどによって最適なアーキテクチャは異なると思うので参考程度に見て頂けるといいかもしれません。
自分ならこうするなどのフィードバックがあればぜひお願い致します!
Discussion