ViewModel(データ保持クラス)

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)ViewModelRepository
ボタンデータ管理通信
Text表示状態管理Firebase
レイアウトバリデーションRoom
アニメーションビジネスロジックAPI

最初に覚えておくと良い3つ

  1. Composeは「表示」に専念する。
  2. ViewModelは画面の状態(State)と画面に関する処理を管理する。
  3. データベースやネットワーク通信は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はどこに置くべきか?」という設計判断がしやすくなります。

コメント

タイトルとURLをコピーしました