はじめに
最近のAndroid界隈でホットな話題といえば、そう。Jetpack Compose。
こないだやってたI/O Extended Japan 2021 - AndroidでもJetpack Composeの話題で持ちきりで、「xmlはオワコン」なんて言われる始末。
これはうかうかしてられないってことで、早速ポケモン図鑑作ってみた。
こんな感じ。
Home | PokemonDetail |
---|---|
ピカチュウかわいいねえ!!☺️
従来のビューシステムとは考え方から何から何まで違うので、最初は自分もわからん殺しされていたけど、触っているうちにだんだんとJetpack Composeの良いところや実装の仕方がわかってきた。
この記事では、かつてxmlの民だった僕がJetpack Composeでポケモン図鑑を作ってみて思ったことや、どうやって実装したかなどを解説していくよ。
「Jetpack Compose始めたいけどわからんすぎて億劫」「そもそも何から始めたらいいかわからん」って人の助けになればなと思います!👍
採用した技術
今回のポケモン図鑑で採用している、目立った技術やライブラリは下記の通り。
- Jetpack Compose
- MVVM
- Hilt
- マルチモジュール
- Retrofit2
- Coil
それぞれ採用理由を解説していくよ。
Jetpack Compose
今回の肝。
これをやりたいから今回の実装に至ったので採用理由も何もないのだけど、強いていえば下記の二点。
- xmlと比べて必要とするコードが少なく済むので、メンテナンスも楽だしなによりサクサク実装できる。
- 宣言的UIモデルが採用されているので、ステートの変更に応じてUIを更新する必要がなくなり、シンプルな実装を保つことができる。
Flutterや最近のWebなんかも宣言的UIモデルが採用されているらしいので、勉強し得、採用し得な技術ね。
バージョンは1.0.0-rc1
を使用。
この辺Android Studioのバージョンと相談って感じで、なんかしら違ったり古かったりすると動かない、みたいなことが頻繁に起こるので気を付けてほしい。
MVVM
Jetpack Composeは宣言的UIモデルなので、Viewに対して描画の命令をするPresenterを使用するより、Viewの状態を保持/公開するViewModelを使った方が自然(だと思う)。
ので、MVVMを採用。
Twitterでは「Jetpack Compose使うならViewModelはいらん!」みたいな論があるらしいんだけど、よくわからんかった。
Hilt
DIはHiltを採用した。
理由はComposableメソッドに対してのインジェクトがサポートされているため。
実はHiltを使うのはこれが初めてで、Hiltの勉強から始めた。
使ってみた印象は、Daggerよりもだいぶセットアップが楽になったなという感じ。
Daggerを使っている人はすんなり乗り換えて実装を始められると思う。
Koinを使っている人は、いきなりJetpack Compose × Koinで実装するんじゃなくて、一旦はFragmentの中にJetpack Composeを埋め込む形で実装すると、移行が楽に済む気がする。
マルチモジュール
プロジェクトで使用しているのがマルチモジュールだったので、今回もマルチモジュールを採用した。
- モジュールごとに差分ビルドが走るので、ビルドが早くなる
- ファイルの管理が楽
といったメリットがあるけれど、正直Jetpack Compose × マルチモジュールになって変わることはあんまりなかった。ビューシステムとだいたい同じ。
あえて挙げるなら、NagGraphのxmlを書く必要がなくなるので、別のモジュールのFragmentを参照したときに出るxmlのエラーが出なくなるのが嬉しいくらいかしら。
Retrofit2・Coil
この辺は正直なんでも良かった。
使い慣れているRetrofitと、全く触ったことがなかったCoilを採用した。
あとでCoilの代わりにGlideを使ってみたのだけど、Accompanistっていうライブラリのグループがいい感じに差分を吸収してくれていて、どっちを使ってもだいたい同じように実装できるようになっていた。
アーキテクチャ
データフロー図
今回の実装のデータフローはこんな感じ。
微妙なのが、11.通知
の部分。
Jetpack Composeを実装するにあたって、重要になるのがState
の概念。
Jetpack Compose PathwayのCodelabでも、とんでもない長さで解説されている。
ここの理解が甘いとJetpack Composeらしい実装が出来ないと思っているのだけど、正直なところ僕もまだあんまり理解できていないと自覚している。
今回は一旦LiveDataを使って従来のやり方と同じような形で実装したけど、もっといい方法があった気がする。
今後改善予定です!💪
ドメイン層から下はJetpack Composeとはあんまり関係ないから割愛するけど、もしマルチモジュールを採用したレイヤードアーキテクチャを実装したことないよって人がいたら参考になると思うので、チラッと見てみるといいかもしれない。
モジュール図
モジュールの依存関係はこんな感じ。
:presentation:common
はビュー周りで共通化できる部品をまとめようかと思っていたのだけど、画面数が少ないからまとめるほど部品がなかった。
色情報とかベタ書きで使っているので、今後リファクタリングしてまとめて置いておくようにする予定。
:data:local
も同じような感じで、ローカルに保存しておく値があったら入れておくために作ったのだけど、入れるような値がなかったので使っていない。
画面ごとにモジュールを分けて実装をしたのは、正解だったと思っている。
というのも、一つの画面の情報を一つのファイルにまとめると嵩張ってしまい、探したいComposableメソッドがすぐ見つからないことが頻発した。
それを回避するために各部品ごとにファイルを分けて実装したのだけど、これが一つのモジュールで全ての画面の部品を持っていると、また探し辛い、見辛い問題が発生するような気がした。
今は画面数も部品数も少ないからいいけど、プロジェクトでJetpack Composeを採用するなら、こういったComposableメソッドを見つけやすくする工夫は必須だなと思った🤔
実装
ここからはかいつまんで実装を紹介するよ。
基盤の部分
MainActivity
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ComposePokeDexApp()
}
}
}
@Preview
@Composable
fun ComposePokeDexApp() {
ComposePokeDexTheme {
val navController = rememberNavController()
Scaffold(
backgroundColor = Color(0xfff5f5f5)
) {
PokeDexNavHost(navController = navController)
}
}
}
Activityでは、NavHostを使うための基盤を用意しているだけ。
これまでActivityで行っていた初期化処理とかをそのままにしておけるのはとっても嬉しい。
NavHost
@Composable
fun PokeDexNavHost(
navController: NavHostController
) {
NavHost(
navController = navController,
startDestination = PokeDexDestination.HOME.name
) {
composable(route = PokeDexDestination.HOME.name) {
val homeViewModel = hiltViewModel<HomeViewModel>()
HomeScreen(
homeViewModel = homeViewModel,
navigateToPokemonDetail = { number ->
navController.navigate("${PokeDexDestination.POKEMON_DETAIL}/$number")
}
)
}
composable(route = "${PokeDexDestination.POKEMON_DETAIL}/{number}") {
it.arguments?.getString("number")?.let { number ->
val pokemonDetailViewModel = hiltViewModel<PokemonDetailViewModel>()
PokemonDetailScreen(
pokemonDetailViewModel = pokemonDetailViewModel,
number = number.toInt(),
onClickNextPokemon = { it
navController.navigate("${PokeDexDestination.POKEMON_DETAIL}/$it")
},
onClickBack = {
navController.popBackStack()
}
)
}
}
}
}
Jetpack Composeを使用すると、これまでxmlで用意してたNavGraphもKotlinで描けるようになるのは嬉しいポイント高かった。
composable(route = "")
で指定しているのがURLのようなもので、例えばnavigate("${PokeDexDestination.HOME}")
を実行するとHomeに遷移できる。
一つ気になったのが、arguments?.getString("number")
を実行している部分。
getInt("key")
もサポートされていたんだけど、値を取得できなかった。
numberをStringからIntに変換する処理を入れなきゃいけなくてぐぬぬ😠って感じだったので、Intでも取得できるようになってくれると嬉しい。
HomeScreen
@Composable
fun HomeScreen(
homeViewModel: HomeViewModel = viewModel(),
navigateToPokemonDetail: (Int) -> Unit
) {
val pokemonListView by homeViewModel.pokemonListView.observeAsState(PokemonListView.getEmptyInstance())
Scaffold(
topBar = { AppBar() },
backgroundColor = Color(0xfff5f5f5)
) {
LazyColumn(
modifier = Modifier
.fillMaxWidth(),
) {
items(items = pokemonListView.pokemons) { pokemon ->
HomeListItem(
pokemon = pokemon,
onClickItem = { navigateToPokemonDetail.invoke(pokemon.number) }
)
}
}
}
}
Homeは比較的シンプルなLazyColumnの実装。
Scaffoldはマテリアルデザインを踏襲した画面に必要な部品を引数に持っていて、ここに部品を入れるだけでそれっぽくできちゃう。
今回はtopBarだけ使ったけど、FloatingActionButtonやDrawerなんかも使うことができるよ。
とっても便利。
個人的に気に入っているのは、items(items = pokemonListView.pokemons) {}
の部分。
これまでRecyclerViewを使おうと思ったらViewHolderを用意して、Adapterを用意して、RecyclerViewにセットして...っていう超面倒な作業をしなきゃいけなかったのが、「アイテムの数だけこのビューを作ってね!!終わり!!😇」で済むのめちゃくちゃ楽でよかった。
PokemonDetailScreen
詳細画面は割とがちゃがちゃ色々やってるので、分けて解説するよ。
Experimentalのアノテーション
@OptIn(
ExperimentalPagerApi::class,
ExperimentalMaterialApi::class
)
@Composable
fun PokemonDetailScreen(
pokemonDetailViewModel: PokemonDetailViewModel = viewModel(),
number: Int,
onClickNextPokemon: (Int) -> Unit,
onClickBack: () -> Unit
) { }
まずはこれ。
一番上の階層のComposableメソッドなんだけど、見てほしいのは@OptIn
のところ。
今回Pager + IndicatorとBottomSheetを使っているんだけど、どちらもExperimentalなのでアノテーションが必要。
でも、普通にアノテーション書いたらそのComposableメソッドを呼び出しているComposableメソッドにもアノテーションをつけて...っていうふうに、MainActivityまで辿っていかないといけない。
そこでこの@OptIn
ね。これを使うと、Experimentalを使ってるメソッドにだけアノテーションをつければ良くなる。
複数ある場合もこんな感じで書けるので、今Jetpack Composeをお試し実装している人でアノテーションたくさんつけている人がいたらぜひ使ってほしい。
ライフサイクルイベント
val lifecycleObserver = remember(pokemonDetailViewModel) {
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
pokemonDetailViewModel.fetchEvolutionData(number = number)
pokemonDetailViewModel.fetchPokemonDetail(number = number)
}
}
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
lifecycle.addObserver(lifecycleObserver)
onDispose {
lifecycle.removeObserver(lifecycleObserver)
}
}
これまでFragmentのライフサイクルイベントにひっかけてfetchとか実行していた場合は、こんな感じで書くと同じことができる。
yanzmさんの記事のコードをまんま書いただけ。
個人的には、今までのoverrideする書き方の方がコード量少なく済んだので、ここはもっと簡単になればいいなあと思っている。
BottomSheet
val bottomSheetScaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = BottomSheetState(BottomSheetValue.Collapsed)
)
val coroutineScope = rememberCoroutineScope()
BottomSheetScaffold(
scaffoldState = bottomSheetScaffoldState,
sheetContent = {
PokemonDetailBottomSheetContent(evolutionList = evolutionChain.getEvolutionList()) {
onClickNextPokemon.invoke(it)
}
},
sheetPeekHeight = 0.dp,
sheetShape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp),
sheetBackgroundColor = Color(0xfff5f5f5)
) {}
BottomSheetはこんな感じで実装する。
参考にしたのはこの記事。
rememberBottomSheetScaffoldState()
を使って開閉のステートを保持しておける。
開閉はcoroutineScopeを使って、
coroutineScope.launch {
if (bottomSheetScaffoldState.bottomSheetState.isCollapsed) {
bottomSheetScaffoldState.bottomSheetState.expand()
} else {
bottomSheetScaffoldState.bottomSheetState.collapse()
}
}
こんな感じで書く。
だいたい簡単に実装できたのだけど、一点詰まったところがあって、それがsheetContent(BottomSheetのコンテンツ)の実装。
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(top = 24.dp, bottom = 24.dp),
) {
evolutionList.forEach {
PokemonDetailEvolutionCard(species = it, onClickNextPokemon = onClickNextPokemon)
}
}
最初こんな感じで実装していたのだけど、PokemonDetailEvolutionCard
の方が高さをちゃんと指定できていなくて、The initial value must have an associated anchor.
ってエラーが出てクラッシュしていた。
結局PokemonDetailEvolutionCardの中で使っているImageの高さを指定していなかったのが原因だったのだけど、このエラーだけだと何が原因で落ちているのかわからなくて、修正に時間がかかった。
そもそもwrapの中で高さ指定していない僕が悪いのだけど、このエラーの文言もうちょっと原因教えてくれると嬉しいなと思った。
Pager + Indicator
val pagerState = rememberPagerState(pageCount = 2, initialOffscreenLimit = 2)
HorizontalPagerIndicator(
pagerState = pagerState,
activeColor = Color(0xff181818),
inactiveColor = Color(0xffbdbdbd),
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(top = 16.dp)
)
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
) { page ->
when (page) {
0 -> {
PokemonDetailInfoPage1(pokemonDetailView = pokemonDetailView)
}
1 -> {
PokemonDetailInfoPage2(pokemonDetailView = pokemonDetailView)
}
}
}
PagerとIndicatorは恐ろしく簡単に実装できた。
さっきちょろっと紹介したAccompanistのPagerのドキュメント通りにやれば、基本的に迷わず実装できると思う。
こういうビューの状態を保持しておく系は、rememberXXXState()
を使って実装するのが基本っぽいね。
以上、コードの解説でした!
今後挑戦したいこと
とりあえず一旦はリスト表示→詳細遷移ができたので、今後はアニメーションに挑戦したいと思ってる。
特に画面遷移時は今のところめちゃくちゃ味気ないので、Navigationのアニメーションがサポートされ次第実装するつもり。
あとはエラー時のViewの切り替え。
今はエラーが出てもログに出力するだけで、特にViewの切り替えは行っていない。
UiStateをobserveして切り替えるだけでいいので簡単にできるだろうし、何よりプロジェクトでは異常系の制御も必須なはずなので、これも早いうちに実装したい。
細かいアニメーションやリソースの共通化もJetpack Composeならではの良さがあるはずなので、ちょっとずつ改善していく。
最後に
Jetpack Composeの安定版は年内には来るらしいね。
今はAndroid StudioもCanary版でなかなか手を出しづらいけど、いけるよ!ってなってからスタートダッシュ決めるためにも、今ここでざっと実装しておいてよかったなって思ってます🤟✨
ところどころまだ資料がなかったりExperimentalだったりはしたけど、やりたいことはだいたいできるし、慣れればビビり散らかす必要もないなって感じでした。
今回実装したコードはここで公開しているので、Jetpack Composeを試しに使ってみたい!って人がいたら見てみてね。
みんなで新時代の幕開けに備えましょう⭐️
おわり!