はじめに
Jetpack Compose とOrbit MVI を組み合わせたらどんな感じになるか試してみたかったので以下のようなポケモン図鑑アプリを作成してみました。
今回作成したアプリのソースコードはこちらにあります。
Oribit MVI に関する詳細はこちらです。
機能
簡単なアプリなので機能は少ないですが、以下の機能をサポートしています。
- ポケモン151匹のデータの取得・保存・管理
- 全ポケモンの一覧表示&検索
- 各ポケモンのタイプ、弱点、体重、高さ、進化系統などの情報表示
アーキテクチャ
全体図
このアプリですが基本的には MVVM + Repository パターンに倣って設計をしています。View 状態管理は Orbit MVI を利用する都合もあって MVVM ではなく MVI にしています。その他は UseCase や Repository を利用した実装にしていて一般的な Android アプリでよく見られる設計にしています。
モジュール
このアプリではマルチモジュール構成で実装していて3つのモジュールを分割していて役割ごとの実装を各モジュールに格納するようにしています。
名称 | 役割 |
---|---|
App | ユーザーが閲覧&操作する UI を構築に必要な View や ViewModel の実装を格納する。 |
Domain | アプリが保持したデータを取得・変換してUI に表示できるデータに整形する実装を格納する。 |
Data | アプリで利用するデータを保持できるようにするデータ取得・保存の実装を格納する。 |
ライブラリ
このアプリでは以下のライブラリを利用して実装しています。基本的に Google が開発&推進しているライブラリを使っています。一部違うものもありますが作者の好みで採用しているものもあるので Google が開発&推進しているものに置き換えても良いと思います。
名称 | 役割 | コメント |
---|---|---|
Koin | DI フレームワーク | 作者の好みです Hilt を使っても良いと思います。 |
Jetpack Compose | 宣言的 UI フレームワーク | - |
Navigation Compose | Jetpack Compose 画面遷移ライブラリ | - |
Orbit MVI | MVI ライブラリ | MVI はとっつきにくい印象がありますが Orbit MVI は学習コストが低く初学者でもわかりやすいライブラリなのでおすすめです。 |
Coil | 画像読み込みライブラリ | - |
Room | ORマッパー | - |
Kotlin-Serialiazation | JSON シリアライザ/デシリアライザ | - |
PokemonGO-Pokedex | Pokemon Go の JSON データ | 作っているときに見つけたので使った感じです。探せばもっとリッチなデータがありそうなので別のを使うのもありです。 |
データフロー
このアプリでは Orbit MVI と呼ばれる MVI ライブラリを利用して実装しているので MVI ライクなデータフローになるように実装をしています。下記のような手順で MVI View から Action を通知、それに応じて MVI Model が新たな状態・イベントを通知する形で単方向にデータが流れるようにしています。
- MVI View から MVI Model に Action (Intent) を送信する
- 受信した Action(Intent)をトリガーにして MVI Model にて UseCase を実行する
- UseCase は Repository にアクセスしてデータを取得する
- UseCase は Repository から取得した結果を MVI Model に返す
- MVI Model は取得した結果から新たな状態を作成する
- MVI Model は作成した新たな状態、またはイベントを MVI View に送信する
実装
このアプリの Jetpack Compose や Orbit MVI の実装の内容について説明したいと思います。Domain や Data モジュールの実装に関してはごく一般的な実装方法になっているので App モジュールにフォーカスして説明をしようと思います。
UIコンポーネント
このアプリでは実験的に Jetpack Compose でUIコンポーネントを記述する際には Atomic Design を参考にした以下の分類に従って定義をすることに決めました。
分類 | 内容 |
---|---|
atoms | Jetpack Compose が提供しているコンポーネント群 |
molecules | 複数の atoms で構成されるコンポーネント群 |
organisms | 複数の atoms や molecules で構成されるコンポーネント群 |
pages | 複数の orgranisms で構成されるユーザーが操作する画面のこと |
例. atoms
@Composable
fun Text(
text: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current
) {
︙ 省略
}
例. molecules
@Composable
fun TopBar(modifier: Modifier = Modifier) {
TopAppBar(
title = {
Text(
text = stringResource(id = R.string.app_name),
textAlign = TextAlign.Left,
color = MaterialTheme.colors.onPrimary,
style = MaterialTheme.typography.h4,
fontStyle = FontStyle.Italic,
fontWeight = FontWeight.Bold
)
},
modifier = modifier
)
}
例. organisms
@Composable
fun DownloadingMessage(modifier: Modifier = Modifier) {
Column(modifier = modifier) {
LoadingIndicator(
modifier = Modifier
.wrapContentSize()
.align(Alignment.CenterHorizontally)
)
Spacer(modifier = Modifier.height(8.dp))
LoadingMessage(
message = "Downloading",
modifier = Modifier.wrapContentSize()
)
}
}
例. pages
@Composable
fun InitPage(
viewModel: InitViewModel,
onCompleted: () -> Unit
) {
val state = viewModel.container.stateFlow.collectAsState().value
LaunchedEffect(viewModel) {
viewModel.container.sideEffectFlow.collect {
when (it) {
is InitSideEffect.Completed -> onCompleted()
}
}
}
Scaffold {
Box(modifier = Modifier.fillMaxSize()) {
when (state.status) {
UiStatus.Loading -> {
DownloadingMessage(
modifier = Modifier
.wrapContentSize()
.align(Alignment.Center)
)
}
is UiStatus.Failed -> {
DownloadRetryMessage(
onRetry = { viewModel.retry() },
modifier = Modifier
.wrapContentSize()
.align(Alignment.Center)
)
}
UiStatus.Success -> Unit
}
}
}
}
やってみて
最終的には下記のようなコンポーネントが作成されましたが 30%くらいは分類に沿って実装は進められたのですが 70%くらいはまだ分類に沿って進めることができませんでした。個人開発レベルのアプリでデザイナーがいない環境で Atomic Design のエッセンスを完全に理解して分類してくのはなかなか難しいなと感じました。今回はやってみた止まりなのですが Jetpack Compose × Atomic Design の組み合わせがプロダクトでも良さそうなのか今後調べられたら良さそうです。
画面遷移
このアプリでは3つの画面が存在しますが、これらの画面遷移は Navigation Compose を利用して実装しました。
NavHost と NavController を定義する
Navigation Compose で画面遷移するには NavHost と NavController が必要になるので MainActivity に下記のように実装をしました。
- rememberNavController で NavController を取得する
- NavHost を定義して NavHost に NavController を渡す
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
OrbitTheme {
Box(modifier = Modifier.fillMaxSize()) {
val navController = rememberNavController()
NavHost(navController, startDestination = Screen.Init.route) {
︙ 省略
}
}
}
}
}
}
遷移先の画面を comopsable で定義する
あとは遷移先の画面を comopsable で定義したあと、NavController の navigate で UI 操作に応じて画面遷移できるようにします。
- 各 comopsable の route には Screen に定義しておいたルート定義を使って指定しておく
- 各 composable の block には InitPage、LibraryPage、DetailsPage を配置しておく。
- InitPage、LibraryPage、DetailsPage などの引数の onCompleted、onShowDetail、onBack にて NavController.navigate または NavController.popBackStack を呼び出すようにしておく。NavController.navigate を呼び出す際の route には Screen に定義しておいたルート情報を渡す。
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
OrbitTheme {
Box(modifier = Modifier.fillMaxSize()) {
val navController = rememberNavController()
NavHost(navController, startDestination = Screen.Init.route) {
composable(route = Screen.Init.route) {
InitPage(
viewModel = getComposeViewModel(),
onCompleted = { navController.navigate(Screen.Library.route) }
)
}
composable(route = Screen.Library.route) {
LibraryPage(
viewModel = getComposeViewModel(),
onShowDetail = { id ->
navController.navigate(Screen.Details.createRoute(id))
}
)
}
composable(route = Screen.Details.route) {
DetailsPage(
viewModel = getComposeViewModel(
parameters = { parametersOf(Screen.Details.getArgumentId(it)) }
),
onBack = { navController.popBackStack() }
)
}
}
}
}
}
}
}
Screen でまとめてルート情報は管理する
今回はルート情報は Screen クラスにまとめるようにしてみました。Navigation Compose で画面遷移するときに引数を渡すときには Screen クラスを作って管理するきれいにまとめられて良いです。
- 各 composable に渡す route は Screen クラスに定義する
- 各 composable に遷移する際に navigate に渡す route は Screen クラスに定義する
- 画面遷移するときに引数を渡すときには route に引数を埋め込む必要がある。この埋め込む処理や取り出す処理は Screen クラスで管理する
sealed class Screen(val route: String) {
object Init : Screen(route = "init")
object Library : Screen(route = "library")
object Details : Screen(route = "details/{id}") {
fun createRoute(id: Int) = "details/$id"
fun getArgumentId(entry: NavBackStackEntry): Int {
return entry.arguments?.getString("id")?.toInt() ?: 0
}
}
}
あと ViewModel ですが Navigation Compose と Koin を連携させて、各画面のライフサイクルに合わせて生成・破棄されるようにしています。
- 各画面のライフサイクルに従って ViewModel が生成・破棄されるように getComposeViewModelOwner と getComposeViewModel を定義した。
- 詳細はこちらの記事にまとまっているのでこちらの説明は省略します。
composable(route = Screen.Init.route) {
InitPage(
viewModel = getComposeViewModel(),
onCompleted = { navController.navigate(Screen.Library.route) }
)
}
@Composable
fun getComposeViewModelOwner(): ViewModelOwner {
return ViewModelOwner.from(
LocalViewModelStoreOwner.current!!,
LocalSavedStateRegistryOwner.current
)
}
@Composable
inline fun <reified T : ViewModel> getComposeViewModel(
qualifier: Qualifier? = null,
noinline parameters: ParametersDefinition? = null,
): T {
val viewModelOwner = getComposeViewModelOwner()
return KoinJavaComponent.getKoin().getViewModel(qualifier, { viewModelOwner }, parameters)
}
あと各画面には NavController を渡さないようにしています。この形式にして BackStack を管理するコードをできるだけ散らばらないようにしています。
- NavController を各画面に渡すと BackStack が自由に操作できる
- 各画面が自由に BackStack を操作できる環境は危険、なので代わりに関数を渡すこと
- 関数を渡すことで NavController を利用する処理はNavHost に集まるので BackStack の管理がやりやすそう
@Composable
fun DetailsPage(
viewModel: DetailsViewModel,
onBack: () -> Unit
) {
︙ 省略
}
@Composable
fun InitPage(
viewModel: InitViewModel,
onCompleted: () -> Unit
) {
︙ 省略
}
@Composable
fun LibraryPage(
viewModel: LibraryViewModel,
onShowDetail: (id: Int) -> Unit
) {
︙ 省略
}
やってみて
Jetpack Compose で画面遷移をするなら Navigation Compose がまず候補にあがるので使ってみましたが思ったよりも簡単に使えるなと思いました。ですがルート情報の定義など独自でやらなければならないのは面倒ですね。アノテーションを付けてコードを自動生成するものもあるようですが、はやく公式ライブラリが対応してくれるとありがたいですね。
UI 状態管理
このアプリでは Orbit MVI と呼ばれるライブラリを利用して MVI になるように実装しています。
ContainerHost を継承して State と SideEffect を定義する
Orbit MVI では以下のように Android ViewModel を ContainerHost で拡張して MVI を実現します。
- ContainerHost<A, B> という定義になっており、A には View に通知する状態(State)、B には View に通知するイベント(SideEffect)を定義します。ContainerHost の実装は簡単でContainerHost を継承して container を override すれば OK です。
- 以下の実装は LibraryPage 用のものになります。LibraryPage ではポケモン一覧の表示・ポケモンの検索・詳細画面遷移を実装する必要があるので下記のような LibraryViewModel と LibraryState と LibrarySideEffect を利用して画面を更新します。
class LibraryViewModel(
private val searchPokemonUseCase: SearchPokemonFromNameUseCase
) : ContainerHost<LibraryState, LibrarySideEffect>, ViewModel() {
override val container = container<LibraryState, LibrarySideEffect>(
LibraryState()
)
}
data class LibraryState(
val status: UiStatus = UiStatus.Loading,
val searchText: String = "",
val detailsList: List<PokemonDetails> = emptyList()
)
sealed class LibrarySideEffect {
data class ShowDetails(val id: Int) : LibrarySideEffect()
}
ViewModel にて State を更新する
Orbit MVI では State を更新する際には intent スコープ内で reduce を呼び出すことで新しい State を作成して View に通知します。
- 以下の実装は LibraryPage でポケモン一覧取得と検索結果取得を実装してものになります。実装はシンプルで intent スコープのなかで reduce を呼び出して、Loading → Success または Loading → Failed となるように State を更新しています。
package jp.kaleidot725.orbit.ui.components.pages.library
import androidx.lifecycle.ViewModel
import jp.kaleidot725.orbit.domain.usecase.SearchPokemonFromNameUseCase
import jp.kaleidot725.orbit.ui.common.UiStatus
import org.orbitmvi.orbit.ContainerHost
import org.orbitmvi.orbit.syntax.simple.intent
import org.orbitmvi.orbit.syntax.simple.postSideEffect
import org.orbitmvi.orbit.syntax.simple.reduce
import org.orbitmvi.orbit.viewmodel.container
class LibraryViewModel(
private val searchPokemonUseCase: SearchPokemonFromNameUseCase
) : ContainerHost<LibraryState, LibrarySideEffect>, ViewModel() {
override val container = container<LibraryState, LibrarySideEffect>(
LibraryState()
)
init {
intent {
searchPokemon(state.searchText)
}
}
fun searchPokemon(searchText: String) {
intent {
reduce {
state.copy(
status = UiStatus.Loading,
searchText = searchText,
detailsList = emptyList()
)
}
val details = searchPokemonUseCase(state.searchText)
if (details.isNotEmpty()) {
reduce {
state.copy(
status = UiStatus.Success,
detailsList = details
)
}
} else {
reduce {
state.copy(
status = UiStatus.Failed("Not Found"),
detailsList = details
)
}
}
}
}
}
あとはこの State を各画面で collect してやれば作成した State が View に通知されて UI を更新することができます。
- State は ViewModel.container.stateFlow で Flow が提供されています。なのでこれを collectAsState すれば ViewModel で作成した State を購読できるようになります。
- 以下の実装は LibraryPage で LibraryState を購読したときのものになります。LibraryState には Loading、Success、Failed と3つの状態が含まれるのですが、通知された State の状態に応じて表示を切り替えるようにしています。
@Composable
fun LibraryPage(
viewModel: LibraryViewModel,
onShowDetail: (id: Int) -> Unit
) {
val state by viewModel.container.stateFlow.collectAsState()
Scaffold(
topBar = {
TopBar(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
)
},
content = {
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
item {
SearchBar(
searchText = state.searchText,
onChangedSearchText = { viewModel.searchPokemon(it) },
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
}
if (state.status == UiStatus.Success) {
setupTwoGrid(state.detailsList) { one, two ->
PokemonTwoCard(
one = one,
onClickedOne = { one?.let { viewModel.showDetails(it.pokemon.id) } },
two = two,
onClickedTwo = { two?.let { viewModel.showDetails(it.pokemon.id) } },
modifier = Modifier
.height(150.dp)
.fillMaxWidth()
.padding(horizontal = 8.dp)
.padding(bottom = 8.dp)
)
}
}
}
when (val status = state.status) {
UiStatus.Loading -> {
LoadingIndicator(modifier = Modifier.fillMaxSize())
}
is UiStatus.Failed -> {
ErrorMessage(
message = status.message,
modifier = Modifier.fillMaxSize()
)
}
else -> Unit
}
}
}
)
}
ここまで実装するとこのような感じでポケモン一覧表示や検索結果表示が動くようになります。
- 画面を表示したときに State が Loading → Success と更新されてポケモン一覧が表示される
- 検索テキストを入力すると State が Loading → Success と更新されて検索結果が表示される
- もし検索結果が存在しないときには State → Failed と更新されてエラーが表示される
ViewModel にて SideEffect を更新する
Oribit MVI では SideEffectを通知する際には intent スコープ内で sideEffect を呼び出すことで新しいイベントを作成して View に通知します。
- 以下の実装は LibraryPage でポケモン詳細に遷移するためのイベントを発行する処理になります。実装はシンプルで intent スコープのなかで sideEffect を呼び出してイベントを作成して通知しています。
class LibraryViewModel(
private val searchPokemonUseCase: SearchPokemonFromNameUseCase
) : ContainerHost<LibraryState, LibrarySideEffect>, ViewModel() {
private var searchJob: Job? = null
override val container = container<LibraryState, LibrarySideEffect>(
LibraryState()
)
fun showDetails(id: Int) {
intent {
postSideEffect(LibrarySideEffect.ShowDetails(id))
}
}
}
あとはこの SideEffect を View で collect してやれば作成したイベントが View に通知されてイベントに応じた処理を実行できるようになります。
- SideEffect は ViewModel.container. sideEffectFlow で提供されています。なのでこれをcollect すれば ViewModel で作成した SideEffect を購読できるようになります。
- 以下の実装は LibraryPage で LibrarySideEffect を購読したときのものになります。LibrarySideEffect ではポケモン詳細への遷移イベントが定義されています。なので遷移イベントが通知されたら画面遷移するように処理を呼び出しています。
@Composable
fun LibraryPage(
viewModel: LibraryViewModel,
onShowDetail: (id: Int) -> Unit
) {
LaunchedEffect(viewModel) {
viewModel.container.sideEffectFlow.collect {
when (it) {
is LibrarySideEffect.ShowDetails -> onShowDetail(it.id)
}
}
}
}
ここまで実装するとこのような感じでポケモン詳細画面への遷移ができるようになります。
- ViewModel.showDetails から LibrarySideEffect.ShowDetilas が作成され通知される
- LibraryPage で SideEffect を受け取りポケモン詳細画面への遷移を開始する
やってみて
Orbit MVI では ViewModel を拡張する形で MVI で UI 状態を管理できるようにしているので MVVM をやってきた人はとっつきやすいなと思いました。今回は ContainerHost と State と SideEffect について説明しましたが、これらの使い方を覚えればある程度のアプリを作れるようになるのは学習コストが低くて良いなと感じました。Orbit MVI のシンプルさが個人的には好きなので今後も使っていきたいなと思いました。
おわりに
当初は Jetpack Compose + Orbit MVI の組み合わせを試してみたかっただけだったのですが思ったよりも作り込んでしまって1ヶ月程度開発していました。Orbit MVI サンプルを作る期間としては長めだったのですが多くの知見が得られて良かったです。Orbit MVI は学習コストが低く MVI を試してみたい方には良いライブラリだなと思います。Orbit MVIはまだまだ認知度が低いライブラリですが興味あるかたはぜひ公式リポジトリを見てみてください。
今回作成したアプリのソースコードはこちらにあります。
Oribit MVI に関する詳細はこちらです。