Jetpack ComposeとViewModelを使って画面を作ってみた
はじめに
こんにちは。ニフティライフスタイルでAndroidのモバイルアプリ開発を担当しているTS-Elfenです。
本記事では、簡単な画面の作成をモチーフに、従来のKotlin+xmlを用いた画面作成の方法と今回紹介するJetpackを使った画面作成の例を示し、変化点やメリットについて解説していきます。
目次
Jetpackとは
Jetpackは、Kotlinでコードをシンプルに書きやすく、高品質にしてくれるライブラリです。GoogleI/O 2018で発表されました。このライブラリを使ってAndroidアプリを作ると、各画面に同じコードを書かねばならない(ボイラープレート)事態や、ライフサイクルの考慮漏れによるメモリリークなどが起きにくくなります。
Compose を使用すると、Android ビューシステムを使用する場合と比べて、少ないコードで多くのことができます。ボタンであれリストであれアニメーションであれ、何を作成する場合でも、記述するコードが少なくなります。
https://developer.android.com/jetpack/compose/why-adopt?hl=ja
従来のAndroidでの画面作成
Androidの従来の画面作成方法は、以下のような手順になっていました。
- xmlファイル上にレイアウトを定義する
- JavaファイルやKotlinファイルからそのインスタンスを取得する
- 画面の表示内容の切り替えやボタンタップ時の処理をインスタンスに対して記述する
このような手順で作成した画面を命令型UIと言います。例えば、一回だけ押せるボタンと、状態を示すテキストがある画面だと、下記のようなコードになります。
package com.example.myapplication
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import androidx.core.view.isVisible
import org.w3c.dom.Text
class MainActivity : AppCompatActivity() {
private var isPushed = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
override fun onResume() {
super.onResume()
val button : Button = findViewById<Button>(R.id.button)
val text : TextView = findViewById<TextView>(R.id.textView)
button.setOnClickListener {
if (!isPushed) {
text.setText("タップ不可")
button.isVisible = false
isPushed = true
}
}
}
}
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="タップ可能"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.196" />
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Click!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
- xmlにボタンとテキストのViewを配置する。
- ActivityやFragmentから
findViewById
を使ってインスタンスを取得する。 - クリックされた時のイベントに、ボタンインスタンスの状態を変更するようなものを設定する。
- ボタンがタップされたかどうかは状態として持っておく。ボタンがタップされていた時のフェールセーフとして、クリックされないようにする。
従来の課題
ここで問題となるのが、以下の4点です。
- ボタンがタップされたかどうかの状態が、ボタンとFragmentに分散することで、不整合が発生する可能性がある
- ボタンがクリックされた時の動作なのに、画面の定義とは別ファイルに定義されている。従って、画面作成時に画面の定義ファイルとソースコードを行き来しなければならない状態になっている
- ボタンのvisibleなどの状態も、画面回転などでActivityが再生成されてしまうと初期の状態に戻ってしまう(xmlに書いてある初期状態と違う可能性があるため)
- 画面に要素が追加されるたびに、表示する情報を取得したり整形するロジックや画面の表示を切り替えるロジックなども増えていく。そのため、FragmentやActivityが肥大化していく
私が以前関わっていたプロダクトでは、従来のJavaとxmlで画面を作成していました。しかし、ロジックが複雑な画面になればなるほど、状態の不一致による不具合が多数起きていました。また、行数が増えていくため可読性も低い状態でした。
Jetpackを使って書き換えてみる
Jetpackを使うとこれらの課題が解消されます。今回は、Jetpackの機能のうち、ViewModelとJetpack Composeを使って上の画面を書き換えてみます。
package com.example.myapplication
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import androidx.activity.compose.setContent
import androidx.core.view.isVisible
import androidx.lifecycle.ViewModelProvider
import org.w3c.dom.Text
class MainActivity : AppCompatActivity() {
private val viewModel: ButtonScreenViewModel by lazy {
ViewModelProvider(
this,
ViewModelProvider.AndroidViewModelFactory(application)
).get(ButtonScreenViewModel::class.java)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ButtonCompose(viewModel)
}
}
}
package com.example.myapplication
import androidx.compose.foundation.layout.*
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerIconDefaults.Text
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun ButtonCompose (
buttonScreenViewModel: ButtonScreenViewModel,
) {
val mytext = buttonScreenViewModel.myTextState
val isTapped = buttonScreenViewModel.isTappedState
Column (
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally, // 横方向
verticalArrangement = Arrangement.Center // 縦方向
) {
Text(
modifier = Modifier.padding(horizontal = 4.dp),
text = mytext.value,
fontSize = 14.sp,
)
if (!isTapped.value) { //isTapped = trueだった場合そもそもButtonの描画がされ_ないようになる
Button(
onClick = {
isTapped.value = true
mytext.value = "タップ不可"
}
) {
Text("Click!")
}
}
}
}
package com.example.myapplication
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
class ButtonScreenViewModel() : ViewModel() {
val myTextState = mutableStateOf("タップ可能")
val isTappedState = mutableStateOf(false)
}
変化点やメリット
Column (
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally, // 横方向
verticalArrangement = Arrangement.Center // 縦方向
) {
Text(
...
)
if (!isTapped.value) { //isTapped = trueだった場合そもそもButtonの描画がされ_ないようになる
Button(
...
) {
Text("Click!")
}
}
}
上記の通り、レイアウトの組み方にしても、並べたいものを上から順番に書いていくだけで基本的によいです。並べ方はGrid(格子状)、Row(横方向)、Column(縦方向)などいろいろあり、直感的に使いやすいものとなっています。ボタンがタップされたらボタンを非可視化する処理も、ViewModel側に状態を見に行って、if文によって表示/非表示を切り替えるだけのシンプルな書き方になっています。
if (!isTapped.value) { //isTapped = trueだった場合そもそもButtonの描画がされ_ないようになる
Button(
onClick = {
isTapped.value = true
mytext.value = "タップ不可"
}
) {
Text("Click!")
}
}
Composeは、State変数の変化をみて、変更があったState変数を持っている部分だけを再描画してくれます。例えば今回で言うなら、isChekedがfalse->trueになったときに再描画されるのはButtonだけで、Textはそのまま保持されるため、少し処理が軽くなります。
package com.example.myapplication
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
class ButtonScreenViewModel() : ViewModel() {
val myTextState = mutableStateOf("タップ可能")
val isTappedState = mutableStateOf(false)
}
Button(
onClick = {
isTapped.value = true
mytext.value = "タップ不可"
}
) {
Text("Click!")
}
}
先ほどFragmentやActivityの中にいた、テキストやボタンは既に押されているかなど画面の状態管理に関してはViewModelに任せています。Composeでは、どのような状態の時にどんな画面を表示するか、といった表示の部分に集中できます。また、ボタンのクリック時の処理もレイアウトの定義内に記載することが可能です。
ViewModelは、基本的に画面の表示に必要な状態や内容、それらを取得・整形するためのロジックを持つ部分です(今回は単純な状態しかないので、ロジックはありませんが)。ViewModelは画面とは違うライフサイクルのため、画面が回転して一から描画し直したとしても、ViewModelは破棄されません。そのため、再描画の際に状態がリセットされません。
また、こちらのStateという型なのですが、非同期処理を行うためのFlowと組み合わせることで、時間がかかる処理の結果を待って、Compose側で監視、値に変化があれば画面の更新を行うといったこともできます。
終わりに
いかがでしたでしょうか。
ニフティライフスタイルでは、主力サービス「ニフティ不動産」アプリの改修を行う際に、Jetpack Compose+ViewModelなどの実装に置き換えています。また、新規画面はJetpack Compose+ViewModelで作成する対応も順次行って行っております。
今後も、こういった便利なフレームワークが発表され、より良い形でアプリ開発を行えるようになる事を期待しております。
参考URL
掲載内容は、記事執筆時点の情報をもとにしています。