変数(var)は更新可能だが…?
『UIの状態を更新するにはどうすれば良いか』を考えるにあたって、まずはUI状態管理の具体例を一つ用意しておきたいと思います。
ありがちですが、ここでは『ボタンを押すと、画面に表示される数値(整数)が0から1ずつ増えていく機能』を例にしてみましょう。
このような機能を実装する方法を考える際、Kotlinの基礎を学んだ人であれば次のように考えるかもしれません。
「UIの状態を管理する整数(初期値は0)をvarで宣言した変数に格納しておき、ボタンがクリックされた時にその変数を更新するようにすれば良い」
この考え方は決して間違っているというわけではありません。確かに、varで宣言された変数は再代入が可能であり、更新可能だからです。
しかし、結論から言うとただ単にvarで変数を宣言するだけでは、ボタンを押しても画面は更新されません。
論より証拠ということで、サンプルコードと表示結果を確認してみましょう。
@Composable
fun UiStateSample(
modifier: Modifier = Modifier
) {
// This won't work
var count = 0
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.fillMaxSize()
) {
Text(
text = "$count",
fontSize = 32.sp
)
Button(
onClick = {
count++
println(count)
}
) {
Text(
text = "Count Up",
fontSize = 32.sp
)
}
}
}
表示結果の画像は静止画なので分かりづらいかもしれませんが、ボタンを押しても画面に表示されている数値は0のまま更新されません。
しかし、ButtonコンポーザブルのonClickパラメータに { cout++ }
を渡しているので、ボタンが押されるたびに変数countに格納されている数値が1ずつ増えていっているはずですよね。
実際、ログを確認すると確かに1ずつ増えていることが確認できます。
そして、Textコンポーザブルでは表示テキストを $count
としているので、countに代入される数値がテキストとして表示されるはずです。
それなのに、なぜ画面が更新されないのでしょうか?
それは、UiStateSampleコンポーザブルのUI状態に応じた再コンポーズ(リロード)が行われていないからです。
実は、Jetpack Composeに限らず、Webアプリ開発におけるReactやiOSアプリ開発におけるSwift UIなど、モダンなフレームワークでは無駄な再コンポーズ(再レンダリング)が行われないように設計されています。
変数には様々なデータが格納されますが、その全てがUIに関わるものではありません。
例えば、開発者がユーザーから見えない範囲内で把握できれば良いようなものもありますよね。
そういった、ユーザーに見せる必要のない変数の状態が更新されるたびに画面(コンポーザブル)が再コンポーズされてしまったらどうなるでしょう?
きっと、無駄にアプリの動作が重くなったり、安定しなくなったりして、ユーザー体験を悪くしてしまうかと思います。
ということで、UI状態管理用の変数として設定しておかない限り、ただ単にvarで宣言された変数が更新されたというだけでは、Jetpack Composeは再コンポーズを行わない仕組みになっているんですね。
まずは、この基本的な仕組みをざっくりと理解しておくことが大切です。
特に、『変数が更新されないのではなく、状態に応じた再コンポーズが行われないから画面が変わらない』という点をしっかり把握しておきましょう!
次のセクションでは、変数の更新と同時に画面の再コンポーズが行われるようにするための基本的な手法をお伝えします。
POINT!
- ・varで宣言された変数は、Jetpack Composeでも再代入(更新)はもちろん可能!
- ・ただし、単に変数の値が更新されたというだけでは再コンポーズは行われない!
- ・画面が変わらないのは変数の値が変わっていないのではなく、再コンポーズが行われないから!
簡易的なUI状態管理
Jetpack ComposeにはUIの状態を管理する方法が大きく分けて2つ用意されています。
1つは、簡易的な方法で、もう1つは応用的な方法です。
簡易的な方法は、ちょっとしたテストを行いたい時やごく小規模なアプリを作りたい時、または特定のコンポーザブルだけで状態を管理できれば良い(他のコンポーザブルと状態管理を共有する必要がない)場合によく使われます。
まずは、簡易的な方法でUI状態の管理を行ってみましょう。
ただ単にvarで変数を宣言するだけでは、値を更新することは可能なものの、その状態が監視されずに画面の再コンポーズが行われないというのは既に確認済みですね。ではどうすれば良いのでしょうか?
実は、Kotlinの知識とKotlinで用意されている基本的な機能だけではこの問題を解決することはできません。
Jetpack Compose側で用意されている、変数の状態の監視と記憶を可能とするためのAPIを利用して変数を定義する必要があります。
UI状態を管理するための変数は、次のように定義することができます。
// This will work
var count by remember {
mutableStateOf(0)
}
remember
と、mutableStateOf()
というものが新しく出てきましたね。
ものすごく簡単に言えば、mutableStateOf()
は値の更新を監視し、値が更新されたら再コンポーズが行われるようにするためのもので、remember
は再コンポーズされる際に直前の状態を記憶しておくためのものです。
値の更新に伴って再コンポーズされても、値が記憶されずに初期値に戻ってしまったら意味がありませんからね。
これで変数coutの値が変更されると再コンポーズが行われるようになり、なおかつ値の情報が記憶されるようになるため、ボタンを押すたびに画面が更新され、表示テキストが現在のcountの数値になります。
全体的なサンプルコードは下の通りです。remember
や mutableStateOf()
の追加にあたって、importしなければならないものも増えているので、その点に注意してください。
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
@Composable
fun UiStateSample(
modifier: Modifier = Modifier
) {
// This will work
var count by remember {
mutableStateOf(0)
}
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.fillMaxSize()
) {
Text(
text = "$count",
fontSize = 32.sp
)
Button(
onClick = {
count++
println(count)
}
) {
Text(
text = "Count Up",
fontSize = 32.sp)
}
}
}
@Preview(showBackground = true)
@Composable
fun UiStateSamplePreview() {
UiStateSample()
}
シンプルなUI状態管理が必要なだけであれば、この簡易的な方法で十分です。
この後、UI状態管理の応用(ViewModel)についてもお伝えしますが、そっちの方が、いかなる場合においても remember
と mutableStateOf()
を使った簡易的な方法より優れているというわけではありません。
確かに、この簡易的な方法は複雑なUI状態管理が必要な場合には向かず、基本的にモバイルアプリ開発では複雑な状態管理が必要となることが多いので、ViewModelの利用が推奨されるケースの方が多いと言えるかと思います。
ですが、だからと言って簡易的な状態管理が非推奨とされているわけではないです。
適材適所。remember + mutableStateOf()
を利用した状態管理の方が向いていると判断される場合はバンバン利用しましょう!
POINT!
- ・mutableStateOf()で値を指定することで、値が更新されると再コンポーズされようになる!
- ・rememberで値の状態を記憶させておくことができる!
- ・この方法は、シンプルなアプリやテストなどでよく利用される!
応用的なUI状態管理(ViewModel)
Andoridアプリ開発では、複数のUI状態を複数のコンポーザブルで共有したいという場合が少なくありません。
イメージとしては次のような感じです。
このような場合、remember + mutableStateOf()
を使う方法では、それを定義したコンポーザブルから状態を共有したいコンポーザブルへ値を逐一渡さなければならなくなり、コードが煩雑になってしまいます。
また、UI状態を管理する値を直接渡すとコードが煩雑になるだけでなく、意図せず値が書き換えられてしまうなどの危険性も伴うため、基本的には推奨されません。
UI状態の管理が一つのコンポーザブルだけでは完結しない場合は、ViewModelと呼ばれる、UIに関連するデータを保存・管理するためのクラスを利用した方が良いです。
ViewModelを利用することで、UIに関するコードを各コンポーザブルに細かく記述する必要がなくなり、一つのクラスで一元的に管理することができるようになります。
具体例として、次のような機能・要件を満たすサンプルアプリをViewModelを利用して実装してみましょう。
- ・クリックするとカウント(整数)を1ずつ増やすボタンがある
- ・オンとオフ(真偽値)を切り替えるトグルスイッチがある
- ・カウントアップのボタンと、オンオフを切り替えるスイッチはコンポーザブルAに配置
- ・現在のカウントをテキストとして表示するコンポーザブルBがある
- ・現在のオン/オフの状態をテキストとして表示するコンポーザブルCがある
- ・コンポーザブルA〜Cを、Homeコンポーザブルの子コンポーザブルとして同じ画面内にレイアウトして表示させる
ViewModelを利用しない場合、どのコンポーザブルからどのコンポーザブルへ何の値を渡せば良いかを整理するのが少し大変ですが、ViewModelを利用すればそのような心配をする必要はありません。
では、早速作成を進めていきましょう。
1: 依存関係の設定
ViewModelを利用するには、まず依存関係にライブラリを追加する必要があります。
下に示す必要なライブラリの設定が完了したら、Sync Now をクリックして同期状態を完了させておきましょう。
build.gradle.kts(Module :app)
// ViewModel for Compose
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version")
なお、バージョンは最新の安定版を指定しておくことをおすすめします。
ViewModelのバージョン情報は、公式サイト: Jetpackライブラリ(Lifecycle)で確認できるので、ブックマークしておくと便利です。
2: 各コンポーザブルファイルの作成
uiパッケージ内に新しくscreenパッケージを作成し、その中にHomeコンポーザブル、コンポーザブルA〜Cをそれぞれ作成します。(コンポーザブルA〜Cは同じファイルにまとめてしまっても良いでしょう)
ただし、この時点ではUI状態に関するコードは完成させられないので、現時点で記述できない部分は空白や仮の状態を指定しておきます。(後で修正します)
どのようにレイアウトするかは決まっていないので正解はありませんが、ここでは一つの例を示しておきます。
@Composable
fun Home(modifier: Modifier = Modifier) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
ComposableA(modifier = modifier.weight(1F))
ComposableB(modifier = modifier.weight(1F))
ComposableC(modifier = modifier.weight(1F))
}
}
Kotlin(SampleComposables.kt)
@Composable
fun ComposableA(modifier: Modifier = Modifier) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = modifier
.fillMaxWidth()
) {
Button(
onClick = { /*TODO*/ },
) {
Text(
text = "Count Up",
fontSize = 24.sp
)
}
Switch(checked = false, onCheckedChange = {})
}
}
@Composable
fun ComposableB(modifier: Modifier = Modifier) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
Text(text = "0", fontSize = 36.sp)
}
}
@Composable
fun ComposableC(modifier: Modifier = Modifier) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
Text(text = "false", fontSize = 36.sp)
}
}
この状態でビルドしてエミュレータで表示結果を確認すると、次のようになります。
なお、当然ですがこの時点でボタンやスイッチをタップしても何も起こらず、表示も変わりません。
3: UiStateファイルの作成
uiパッケージ内に SapmleUiState.kt
と名付けたファイルを作成し、UiStateを定義するためのdata classを宣言しておきます。
今回、管理が必要なUI状態はカウントアップの数値(Int)と、トグルスイッチの真偽値(Boolean)なので、その二つを定義します。
これはViewModelを定義するファイル内に記述しても良いのですが、ViewModelクラス内に含まれるものではない(手順的にはViewModelの作成と異なる)ので、今回はファイルを分ける形にしました。
Kotlin(SampleUiState.kt)
data class SampleUiState(
val count: Int = 0,
val switchState: Boolean = false
)
4: ViewModelファイルの作成
ViewModelはUIの管理を行うためのものなので、ファイルの作成場所はSampleUiState.ktを作成した場所と同じ、uiパッケージ内が適切でしょう。
ファイル名は、ここでは SampleAppViewModel.kt
としておきます。
ファイルを作成したらまず、ViewModel()
を継承するようにクラスを指定します。これでViewModelに準拠したクラスであることが保証されます。
Kotlin(SampleAppViewModel.kt)
class SampleAppViewModel: ViewModel() {
}
次に、ViewModelクラス内にUI状態を管理するためのプライベート定数と、公開用の定数をそれぞれ用意します。
Kotlin(SampleAppViewModel.kt)
private val _uiState = MutableStateFlow(SampleUiState())
val uiState: StateFlow<SampleUiState> = _uiState.asStateFlow()
ここで出てくる StateFlow
というのはKotlinのコルーチンにおけるインターフェースの一つであり、Jetpack Compose専用のものというわけではありません。
StateFlow
は、hot(熱い)ストリームというふうに表現され、状態(変数に格納された値)が常に監視され、更新を捕捉できるようになります。
ただし、既に述べたように、値が更新された時にJetpack Composeにおける画面の更新(再コンポーズ)が必ず行われるというわけではないので、StateFlowで管理すればそれで全てOKというわけではありません。(この点に関しては後ほど説明します。)
なお、asStateFlow()
を使って公開用の定数を別に用意しておくことでViewModel以外のところからUI状態に関する値を変更することが不可能となり、意図せずUI状態が書き換えられてしまうことを防ぐことができます。
このように記述しないとViewModelが使えない(UI状態を更新できない)というわけではないのですが、このようにUI管理の核心となる部分を隠匿できるのがViewModelの大きなメリットの一つなので、基本的にはこのように記述すると覚えてしまっても良いかと思います。
UI状態の宣言が完了したので、次は状態を更新するためのメソッドを用意していきましょう。
今回は、数値を1ずつ追加(カウントアップ)するロジックと、トグルスイッチの真偽値を切り替えるロジックの2つが必要となります。
Kotlin(SampleAppViewModel.kt)
fun countUp() {
_uiState.update { currentState ->
currentState.copy(
count = currentState.count + 1
)
}
}
fun toggleSwitchState() {
_uiState.update { currentState ->
currentState.copy(
switchState = !currentState.switchState
)
}
}
もし、状態管理を行うデータが1つだけで良い場合(例えば管理すべきプロパティがcountのみである場合)は、update{ }
の中で次のように直接的に値を更新しても特に問題とはなりません。
fun countUp() {
_uiState.update {
val newCount = it.count + 1
SampleUiState(count = newCount)
}
}
ですが、今回のサンプルアプリをはじめ、多くのアプリでは一度に複数の状態(プロパティ)を管理する必要があるのが一般的です。
そのため、copy()
で他のプロパティを含めた状態をコピーし、特定のプロパティだけを更新できるようにしておいた方が良いと言えるでしょう。
5: Composableを修正する
UI状態の管理に必要なViewModelの準備が整ったので、あとはルートとなるHomeコンポーザブルでViewModelとUI状態を定義して各コンポーザブルに必要なものを渡し、各コンポーザブルでは渡されたものを利用するように書き換えます。
ComposableBには count(Int)
を、ComposableCには switchState(Boolean)
を渡さなければならない…といったような、複雑なコードになっていないことに注目してみてください。
@Composable
fun Home(
modifier: Modifier = Modifier,
viewModel: SampleAppViewModel = viewModel()
) {
// Enable to use UiState
val uiState by viewModel.uiState.collectAsState()
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
ComposableA(
uiState = uiState,
viewModel = viewModel,
modifier = modifier.weight(1F)
)
ComposableB(
uiState = uiState,
modifier = modifier.weight(1F)
)
ComposableC(
uiState = uiState,
modifier = modifier.weight(1F)
)
}
}
Kotlin(SampleComposables.kt)
@Composable
fun ComposableA(
uiState: SampleUiState,
viewModel: SampleAppViewModel = viewModel(),
modifier: Modifier = Modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = modifier
.fillMaxWidth()
) {
Button(
onClick = { viewModel.countUp() },
) {
Text(
text = "Count Up",
fontSize = 24.sp
)
}
Switch(
checked = uiState.switchState,
onCheckedChange = { viewModel.toggleSwitchState() })
}
}
@Composable
fun ComposableB(
uiState: SampleUiState,
modifier: Modifier = Modifier
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
Text(text = "${uiState.count}", fontSize = 36.sp)
}
}
@Composable
fun ComposableC(
uiState: SampleUiState,
modifier: Modifier = Modifier
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
Text(text = "${uiState.switchState}", fontSize = 36.sp)
}
}
ViewModelのメソッドを利用する必要があるコンポーザブル(ComposableA)には SampleAppViewModel
を渡し、UI状態(uiState)を利用する必要があるコンポーザブルにはHome Composableで定義した uiState
を渡しています。
uiState
の宣言は、val uiState by viewModel.uiState.collectAsState()
というふうに記述していますが、ここでは collectAsState()
を使ってStateFlow型で管理されている状態をJetpack Composeで利用できるように変換しています。
繰り返しになりますが、Jetpack Composeでは『どうすれば再コンポーズ(画面の再描画)が行われるか』を理解することが重要です。
単に値が更新されたというだけでは再コンポーズが行われないというのは既に確認した通りで、Jetpack ComposeがUI状態の変化に再コンポーズを行えるようにするには、それに適した形にデータを変換する必要があるというわけです。
StateFlowというのはKotlinの標準的な機能であり、値の状態管理に使われるものですが、それをさらにJetpack ComposeのStateとして扱えるようにするために、collectAsState()
が必要ということになります。
何はともあれ、以上でViewModelを使った状態管理アプリの完成です!下は完成イメージ画像です。
ViewModelファイルの全体的なコードは下を参照してください。
他の記事でもよく書くことなのですが、最初から全てを理解するのはほとんど不可能に近いことだと思います。
もちろん、このことは今回のテーマであるViewModelにも当てはまります。
最初は『細かいことはよくわからないけれど、とにかくサンプルコードの通りにコードを書いて動かしてみる』…といった感じで十分です。
最初から理解することを諦めてなんでもコピペで済ましてしまうと、そこで成長は止まってしまいますが、理解しようと思いながら何度か繰り返しコードを書いていけば、必ず理解できるようになると思います。
何よりもまず、コーディング、プログラミングを楽しみましょう!ハッピーコーディング!😀
POINT!
- ・複雑なUI状態の管理が必要な場合はViewModelを利用しよう!
- ・ViewModelを利用することで、UIの状態管理に関するプロパティやメソッドを一箇所にまとめることができ、管理しやすくなる!
- ・一度に全てを理解するなんて無理!なので、コードを書きながら少しずつ理解を深めていこう!