Jetpack ComposeのViewModelは、最初は少し分かりづらいですが、一度理解すると「UIとデータを分離するための仕組み」だと分かってきます。
Android開発ではかなり重要なので、基礎から順番に説明します。
ViewModelとは?
一言で言うと
画面(UI)とは別に、画面で使うデータや処理を管理するクラス
です。
例えばチャット画面なら
ChatScreen
├── メッセージ一覧
├── 入力欄
└── 送信ボタン
この画面では
- メッセージ一覧
- 入力中の文字
- メッセージ送信
- Firebase通信
などがあります。
これを全部Composeに書くと…
@Composable
fun ChatScreen() {
var messages = ...
var input = ...
// Firebase通信
// メッセージ追加
// 削除
// 読み込み
}
画面がどんどん巨大になります。
そこで
UI
↓
ViewModel
↓
Repository
↓
Firebase
のように役割を分けます。
ViewModelの役割
例えば
ChatScreen
には
ChatViewModel
を用意します。
ChatScreen
↓
ChatViewModel
ViewModelには
- データ
- 処理
を書きます。
例えば
class ChatViewModel : ViewModel() {
val messages = mutableStateListOf<String>()
fun sendMessage(text: String) {
messages.add(text)
}
}
Compose側
Composeでは
@Composable
fun ChatScreen() {
val viewModel: ChatViewModel = viewModel()
Button(
onClick = {
viewModel.sendMessage("こんにちは")
}
) {
Text("送信")
}
}
これだけです。
UIは
「表示だけ」
になります。
なぜ必要なの?
例えば画面を回転すると
縦
↓
横
画面は作り直されます。
Composeの中だけで
var count by remember {
mutableStateOf(0)
}
としていると
0
に戻る場合があります。
でもViewModelなら
画面が作り直されても
ViewModelは生きている
ので
count = 15
のままです。
StateFlowとの組み合わせ
最近はこちらが主流です。
ViewModel
class CounterViewModel : ViewModel() {
private val _count = MutableStateFlow(0)
val count = _count.asStateFlow()
fun plus() {
_count.value++
}
}
Compose
@Composable
fun CounterScreen() {
val vm: CounterViewModel = viewModel()
val count by vm.count.collectAsState()
Column {
Text("$count")
Button(
onClick = {
vm.plus()
}
) {
Text("+")
}
}
}
これが現在よく使われる形です。
Repositoryも挟む
さらに規模が大きくなると
Compose
↓
ViewModel
↓
Repository
↓
Firebase
になります。
例えば
ChatRepository
class ChatRepository {
fun loadMessages() {
// Firebase
}
fun sendMessage() {
// Firebase
}
}
ViewModel
class ChatViewModel(
private val repository: ChatRepository
) : ViewModel() {
fun send(text: String) {
repository.sendMessage(text)
}
}
UIは
Button(
onClick = {
vm.send(text)
}
)
だけになります。
ViewModelでCoroutineも使える
ViewModelには
viewModelScope
があります。
例えば
fun load() {
viewModelScope.launch {
val messages = repository.load()
}
}
これで
- Firebase
- API通信
- Room
などを安全に実行できます。
どこまでViewModelに書く?
基本的な役割分担は次のイメージです。
| Compose(UI) | ViewModel | Repository |
|---|---|---|
| ボタン | データ管理 | 通信 |
| Text表示 | 状態管理 | Firebase |
| レイアウト | バリデーション | Room |
| アニメーション | ビジネスロジック | API |
最初に覚えておくと良い3つ
- Composeは「表示」に専念する。
- ViewModelは画面の状態(State)と画面に関する処理を管理する。
- データベースやネットワーク通信はRepositoryに任せる。
この3つを意識するだけで、コードの見通しがかなり良くなります。
ViewModelStoreOwner
@Composable
fun ChatScreen() {
val viewModel: ChatViewModel = viewModel()
Button( onClick = { viewModel.sendMessage(“こんにちは”)
} ) {
Text(“送信”)
} }
これval viewModel: ChatViewModel = viewModel()でインスタンス化しているように見えますが再描写で消えないのなら他の@Composableでもval viewModel: ChatViewModel = viewModel()して使っていた場合は共有されて表示を維持できるのですか?
結論から言うと、
「どの
ViewModelStoreOwnerに紐付いているか」によって共有されるかどうかが決まります。
viewModel()は毎回新しくインスタンスを作っているわけではありません。
まず、このコードの裏側
val viewModel: ChatViewModel = viewModel()
実際には
「この画面(ActivityやNavBackStackEntry)が既に持っている
ChatViewModelをください。」
という意味です。
裏側ではイメージとして
ViewModelStore
├── ChatViewModel
├── UserViewModel
└── SettingViewModel
という保管庫(ViewModelStore)があります。
同じ画面内で何回呼んでも?
例えば
@Composable
fun ChatScreen() {
val vm = viewModel<ChatViewModel>()
ChatHeader()
ChatBody()
}
そして
@Composable
fun ChatBody() {
val vm = viewModel<ChatViewModel>()
}
この2つは
ChatViewModel
同じインスタンスになります。
つまり
ChatScreen
│
├──viewModel()
│
└──ChatBody
│
└──viewModel()
どちらも同じものを取得しています。
では別のComposableなら?
例えば
MainActivity
│
├── HomeScreen
│
└── ChatScreen
もし
val vm = viewModel<ChatViewModel>()
を両方で呼んだ場合、
同じActivity内でNavigationを使っていない場合
同じActivityに紐付くので
同じViewModelになります。
Navigation Composeでは?
ここが一番混乱しやすいです。
例えば
NavHost
HomeScreen
ChatScreen
となっている場合
HomeScreen
val vm = viewModel<ChatViewModel>()
と
ChatScreen
val vm = viewModel<ChatViewModel>()
では
別インスタンスになります。
なぜなら
HomeScreen
↓
HomeのViewModelStore
ChatScreen
↓
ChatのViewModelStore
と、それぞれ別の保管庫を持っているからです。
つまり
HomeScreen
↓
Home ViewModelStore
↓
ChatViewModel①
ChatScreen
↓
Chat ViewModelStore
↓
ChatViewModel②
になります。
共有したいなら?
例えば
チャット一覧
↓
チャット詳細
で同じデータを使いたいなら、
親となるナビゲーショングラフやActivityに紐付けた ViewModel を取得する方法があります。
イメージは
Activity
│
├── Home
├── Chat
└── Setting
↑
共通ViewModel
こうすると
Homeでも
Chatでも
Settingでも
同じViewModelを使えます。
再描画ではどうなる?
Composeは
@Composable
fun ChatScreen() {
val vm = viewModel()
Text(...)
}
が何度も再実行されます。
でも
viewModel()
は
ChatViewModelある?
↓
ある!
↓
それ返す!
なので
毎回同じインスタンスです。
新しく
ChatViewModel()
しているわけではありません。
覚えておくと分かりやすい考え方
viewModel()は
「インスタンスを作る関数」ではなく、「現在の画面に紐付いているViewModelを取り出す関数」
と考えると、ComposeでのViewModelの動きがぐっと理解しやすくなります。これを理解できると、次は「ViewModelはいつ作られて、いつ破棄されるのか」というライフサイクルも自然につながってきますよ。
ViewModelをActivityに紐付ける方法(全体で共有する)
実は、viewModel()は何も指定しなければ、多くの場合Activityに紐付いた ViewModelStoreOwner を利用します(Navigation Compose内では各画面の NavBackStackEntry がOwnerになるため、画面ごとのViewModelになります)。
「Activityに紐付ける」という意味を理解しやすいように、まずはNavigationを使わない場合から説明します。
① ActivityにViewModelを作る
例えば MainActivity があるとします。
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApp()
}
}
}
ViewModelを用意します。
class ChatViewModel : ViewModel() {
private val _count = MutableStateFlow(0)
val count = _count.asStateFlow()
fun plus() {
_count.value++
}
}
② 子Composableで取得する
例えば
@Composable
fun MyApp() {
HomeScreen()
ChatScreen()
}
HomeScreen
@Composable
fun HomeScreen() {
val vm: ChatViewModel = viewModel()
val count by vm.count.collectAsState()
Text("Home : $count")
Button(
onClick = {
vm.plus()
}
) {
Text("+")
}
}
ChatScreen
@Composable
fun ChatScreen() {
val vm: ChatViewModel = viewModel()
val count by vm.count.collectAsState()
Text("Chat : $count")
}
すると
Home : 0
Chat : 0
↓
Homeでボタンを押す
↓
Home : 1
Chat : 1
になります。
なぜなら
MainActivity
│
└── ChatViewModel
↑
HomeもChatも同じもの
だからです。
③ Navigation Composeでは?
ここからが少し違います。
例えば
NavHost(...)
の中で
composable("home") {
HomeScreen()
}
composable("chat") {
ChatScreen()
}
とすると
各画面が
HomeScreen
↓
Home用ViewModelStore
ChatScreen
↓
Chat用ViewModelStore
を持っています。
なので
val vm: ChatViewModel = viewModel()
だけでは
Home用
Chat用
の別々のViewModelになります。
④ Activityに紐付けたい場合
その場合は、ActivityをOwnerとして渡します。
@Composable
fun HomeScreen() {
val activity = LocalContext.current as ComponentActivity
val vm: ChatViewModel = viewModel(
viewModelStoreOwner = activity
)
}
ChatScreenでも
@Composable
fun ChatScreen() {
val activity = LocalContext.current as ComponentActivity
val vm: ChatViewModel = viewModel(
viewModelStoreOwner = activity
)
}
すると
MainActivity
│
└── ChatViewModel
↑
HomeもChatも取得
になります。
⑤ もっとおすすめの書き方
実は毎回
val activity = LocalContext.current as ComponentActivity
と書くより、
親Composableで一度取得して渡す方がComposeらしいです。
@Composable
fun MyApp() {
val vm: ChatViewModel = viewModel()
HomeScreen(vm)
ChatScreen(vm)
}
Home
@Composable
fun HomeScreen(
vm: ChatViewModel
) {
...
}
Chat
@Composable
fun ChatScreen(
vm: ChatViewModel
) {
...
}
これなら
MyApp
│
├──HomeScreen(vm)
└──ChatScreen(vm)
となり、親が「このViewModelを使う」と明示できるので、テストもしやすく、依存関係も分かりやすくなります。
MainActivity
│
├── ChatListScreen
├── ChatScreen
└── ProfileScreen
なら、
ChatListViewModel… チャット一覧専用ChatViewModel… チャット画面専用ProfileViewModel… プロフィール専用
とするのが基本です。
一方で、
MainActivity
│
└── UserViewModel
のようにActivityに紐付けた UserViewModel を1つ持ち、
- ログイン中のユーザー情報
- オンライン状態
- 未読件数
- アプリ全体で共有したい設定
などを管理する、という設計はよく使われます。
全体共有ではなく、分けてViewModelを持たせる
実はこちらの方がActivity共有よりも実践ではよく使われます。
まず、なぜActivity共有ではダメなの?
例えばメッセージアプリを考えてみます。
MainActivity
│
├── ホーム
├── チャット
├── 設定
└── プロフィール
Activityに
ChatViewModel
を置くと
MainActivity
│
└── ChatViewModel
↑
ホーム
チャット
設定
プロフィール
全部の画面から見えてしまいます。
でも
設定画面は
チャット情報なんて必要ありません。
つまり
寿命が長すぎるんです。
Navigation Graph単位とは?
例えば
NavHost
│
├── Home
│
├── ChatGraph
│ │
│ ├── ChatList
│ └── ChatRoom
│
└── Settings
ここで
ChatGraph
だけで
ChatViewModel
を共有します。
すると
ChatList
↑
│
ChatViewModel
│
↓
ChatRoom
になります。
設定画面には見えません。
これが一番自然です。
Navigationを書く
例えば
NavHost(
navController,
startDestination = "home"
) {
composable("home") {
HomeScreen()
}
navigation(
startDestination = "chatList",
route = "chatGraph"
) {
composable("chatList") {
ChatListScreen()
}
composable("chatRoom/{id}") {
ChatRoomScreen()
}
}
composable("settings") {
SettingsScreen()
}
}
ポイントは
navigation(
startDestination = "chatList",
route = "chatGraph"
)
です。
これで
chatGraph
という親ができます。
ChatGraphのViewModelを取得する
普通なら
val vm: ChatViewModel = viewModel()
ですが、
これでは
ChatList専用
ChatRoom専用
になります。
そこで
親Graphを取得します。
val parentEntry = remember {
navController.getBackStackEntry("chatGraph")
}
この
chatGraph
が
ViewModelStoreOwnerになります。
ViewModel取得
すると
val vm: ChatViewModel = viewModel(
viewModelStoreOwner = parentEntry
)
となります。
これだけです。
ChatList
@Composable
fun ChatListScreen(
navController: NavController
) {
val parentEntry = remember {
navController.getBackStackEntry("chatGraph")
}
val vm: ChatViewModel =
viewModel(parentEntry)
}
ChatRoom
@Composable
fun ChatRoomScreen(
navController: NavController
) {
val parentEntry = remember {
navController.getBackStackEntry("chatGraph")
}
val vm: ChatViewModel =
viewModel(parentEntry)
}
なんと
この2つは同じViewModelになります。
イメージ
普通は
ChatList
│
└── ViewModel①
ChatRoom
│
└── ViewModel②
ですが
親Graphを指定すると
ChatGraph
│
ChatViewModel
↑ ↑
│ │
ChatList ChatRoom
になります。
これの何が嬉しいの?
例えば
チャット一覧
Aさん
Bさん
Cさん
Aさんを押す
↓
ChatRoom
へ行く。
この時
vm.selectedChat = "Aさん"
としておけば
ChatRoomでは
Text(vm.selectedChat)
だけで表示できます。
わざわざ
ChatList
↓
Repository
↓
DB
↓
ChatRoom
と読み直す必要がありません。
メッセージアプリではよくある構成
例えば
ChatGraph
│
├── ChatList
├── ChatRoom
├── SearchUser
└── MemberInfo
これら全部が
ChatViewModel
を共有できます。
だから
現在開いているチャット
選択中のユーザー
未送信メッセージ
検索結果
などを全部保持できます。
さらにおすすめの設計
実際のメッセージアプリでは、1つの巨大な ChatViewModel に何でも入れるより、役割ごとに分けることが多いです。
例えば、
ChatGraph
│
├── ChatListViewModel
│ └─ チャット一覧、未読件数
│
└── ChatRoomViewModel
└─ 現在のチャット、メッセージ送受信
のようにすると責務が明確になります。
そして、
ChatList↔ChatRoomでだけ共有したい状態はChatGraphに紐付けたViewModel- アプリ全体で共有したいログインユーザー情報はActivityやアプリ全体の管理クラス
というように、「どの範囲で状態を共有したいか」に応じてViewModelの持ち主(ViewModelStoreOwner)を決めるのがComposeでは重要な考え方です。
この考え方を身につけると、「このViewModelはどこに置くべきか?」という設計判断がしやすくなります。
コメント