UI イベント

コレクションでコンテンツを整理 必要に応じて、コンテンツの保存と分類を行います。

UI イベントは、UI または ViewModel によって UI レイヤで処理する必要があるアクションです。最も一般的なタイプのイベントは、ユーザー イベントです。ユーザーは画面をタップすることで、またはジェスチャーを生成することで、アプリを操作してユーザー イベントを生成します。UI は、onClick() リスナーなどのコールバックを使用してこれらのイベントを消費します。

ViewModel は通常、特定のユーザー イベント(ユーザーがボタンをクリックしてデータを更新するなど)のビジネス ロジックを処理します。通常、ViewModel は UI が呼び出せる関数を公開することにより、この処理を行います。ユーザー イベントは、別の画面に移動する、Snackbar を表示するなど、UI が直接処理できる UI 動作ロジックを持つ場合もあります。

同じアプリを異なるモバイル プラットフォームやフォーム ファクタで使用してもビジネス ロジックは変わりませんが、UI の動作ロジックは実装の詳細であり、ケース間で異なる可能性があります。UI レイヤのページでは、このようなロジックを次のように定義しています。

  • ビジネス ロジックとは、支払いやユーザー設定の保存など、状態の変化を処理する方法を指します。通常、ドメインレイヤとデータレイヤがこのロジックを処理します。このガイドでは、ビジネス ロジックを処理するクラス向けの独自のソリューションとして、アーキテクチャ コンポーネントの ViewModel クラスを使用します。
  • UI 動作ロジックまたは UI ロジックとは、ナビゲーション ロジックやユーザーへのメッセージ表示方法など、状態の変化を表示する方法を指します。UI がこのロジックを処理します。

UI イベントの決定木

次の図は、特定のイベントのユースケースを処理するために最適なアプローチを見つける決定木を示しています。以降、このガイドではこうしたアプローチについて詳しく説明します。

イベントが ViewModel で発生した場合は、UI の状態を更新します。イベントが UI で発生し、ビジネス ロジックを必要とする場合は、ビジネス ロジックを ViewModel にデリゲートします。イベントが UI で発生し、UI の動作ロジックを必要とする場合は、UI 要素の状態を UI で直接変更します。
図 1. イベント処理の決定木。

ユーザー イベントを処理する

ユーザー イベントが UI 要素の状態(展開可能なアイテムの状態など)の変更に関連する場合、UI でユーザー イベントを直接処理できます。画面上のデータを更新するなど、イベントがビジネス ロジックを実施する必要がある場合は、ViewModel で処理する必要があります。

次の例は、さまざまなボタンを使用して、UI 要素を開く方法(UI ロジック)と、画面上のデータを更新する方法(ビジネス ロジック)を示しています。

View

class LatestNewsActivity : AppCompatActivity() {

    private lateinit var binding: ActivityLatestNewsBinding
    private val viewModel: LatestNewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        // The expand details event is processed by the UI that
        // modifies a View's internal state.
        binding.expandButton.setOnClickListener {
            binding.expandedSection.visibility = View.VISIBLE
        }

        // The refresh event is processed by the ViewModel that is in charge
        // of the business logic.
        binding.refreshButton.setOnClickListener {
            viewModel.refreshNews()
        }
    }
}

Compose

@Composable
fun LatestNewsScreen(viewModel: LatestNewsViewModel = viewModel()) {

    // State of whether more details should be shown
    var expanded by remember { mutableStateOf(false) }

    Column {
        Text("Some text")
        if (expanded) {
            Text("More details")
        }

        Button(
          // The expand details event is processed by the UI that
          // modifies this composable's internal state.
          onClick = { expanded = !expanded }
        ) {
          val expandText = if (expanded) "Collapse" else "Expand"
          Text("$expandText details")
        }

        // The refresh event is processed by the ViewModel that is in charge
        // of the UI's business logic.
        Button(onClick = { viewModel.refreshNews() }) {
            Text("Refresh data")
        }
    }
}

RecyclerView でのユーザー イベント

RecyclerView アイテムやカスタム View のように、UI ツリーの下位でアクションが生成される場合であっても、ViewModel はユーザー イベントを処理する必要があります。

たとえば、NewsActivity のすべてのニュース アイテムにブックマーク ボタンがあるとします。ViewModel は、ブックマークされたニュース アイテムの ID を知る必要があります。ユーザーがニュース アイテムをブックマークしたとき、RecyclerView アダプタは ViewModel から公開されている addBookmark(newsId) 関数を呼び出しません(ViewModel の依存関係が必要です)。代わりに ViewModel は、イベントを処理するための実装が含まれる NewsItemUiState という状態オブジェクトを公開します。

data class NewsItemUiState(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    val publicationDate: String,
    val onBookmark: () -> Unit
)

class LatestNewsViewModel(
    private val formatDateUseCase: FormatDateUseCase,
    private val repository: NewsRepository
)
    val newsListUiItems = repository.latestNews.map { news ->
        NewsItemUiState(
            title = news.title,
            body = news.body,
            bookmarked = news.bookmarked,
            publicationDate = formatDateUseCase(news.publicationDate),
            // Business logic is passed as a lambda function that the
            // UI calls on click events.
            onBookmark = {
                repository.addBookmark(news.id)
            }
        )
    }
}

この方法で、RecyclerView アダプターは必要なデータ(NewsItemUiState オブジェクトのリスト)のみを処理します。アダプタは ViewModel 全体にアクセスできるわけではないため、ViewModel が公開する機能が悪用される可能性が低くなります。アクティビティ クラスのみが ViewModel と連携できるようにすると、役割を分離できます。これにより、ビューや RecyclerView アダプタなどの UI 固有のオブジェクトが ViewModel を直接操作しなくなります。

ユーザー イベント関数の命名規則

このガイドで、ユーザー イベントを処理する ViewModel 関数には、処理するアクションに基づいた動詞で名前が付けられています(addBookmark(id)logIn(username, password) など)。

ViewModel イベントを処理する

ViewModel からの UI アクション(ViewModel イベント)は、常に UI の状態を更新する結果となる必要があります。これは、単方向データフローの原則に従っています。これにより、設定変更後にイベントが再現可能になり、UI アクションが失われないことが保証されます。保存済み状態モジュールを使用している場合は、プロセス終了後にイベントを再現可能にすることもできます。

UI アクションを UI の状態にマッピングすることは必ずしも簡単な処理ではありませんが、ロジックは単純になります。たとえば、UI を特定の画面に移動させる方法を決定するだけで思考プロセスを終えてはなりません。思考を進めて、ユーザーフローを UI の状態で表現する方法を検討する必要があります。つまり、UI が行う必要のあるアクションを考えるのではなく、そのアクションが UI の状態に与える影響を考えてください。

たとえば、ユーザーがログイン画面でログインしているときホーム画面に移動するケースについて考えてみましょう。UI の状態では、次のようにモデル化できます。

data class LoginUiState(
    val isLoading: Boolean = false,
    val errorMessage: String? = null,
    val isUserLoggedIn: Boolean = false
)

この UI は isUserLoggedIn 状態の変化に反応し、必要に応じて適切な移動先に移動します。

View

class LoginViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(LoginUiState())
    val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
    /* ... */
}

class LoginActivity : AppCompatActivity() {
    private val viewModel: LoginViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { uiState ->
                    if (uiState.isUserLoggedIn) {
                        // Navigate to the Home screen.
                    }
                    ...
                }
            }
        }
    }
}

Compose

class LoginViewModel : ViewModel() {
    var uiState by mutableStateOf(LoginUiState())
        private set
    /* ... */
}

@Composable
fun LoginScreen(
    viewModel: LoginViewModel = viewModel(),
    onUserLogIn: () -> Unit
) {
    val currentOnUserLogIn by rememberUpdatedState(onUserLogIn)

    // Whenever the uiState changes, check if the user is logged in.
    LaunchedEffect(viewModel.uiState)  {
        if (viewModel.uiState.isUserLoggedIn) {
            currentOnUserLogIn()
        }
    }

    // Rest of the UI for the login screen.
}

イベントの消費によって状態の更新をトリガーできる

UI で特定の ViewModel イベントを消費することで、他の UI の状態が更新される場合があります。たとえば何かが起こったことをユーザーに知らせるために、画面に一時的なメッセージを表示する場合、メッセージが画面に表示されたときに、別の状態の更新をトリガーするよう UI が ViewModel に通知する必要があります。ユーザーがメッセージを消費したとき(メッセージを閉じる処理、タイムアウト後の処理など)に発生するイベントは「ユーザー入力」として扱うことができるため、このようなイベントを ViewModel が認識する必要があります。こうした状況での UI の状態は、次のようにモデル化できます。

// Models the UI state for the Latest news screen.
data class LatestNewsUiState(
    val news: List<News> = emptyList(),
    val isLoading: Boolean = false,
    val userMessage: String? = null
)

ビジネス ロジックでユーザーに新しい一時的なメッセージを表示する必要がある場合、ViewModel は UI の状態を次のように更新します。

View

class LatestNewsViewModel(/* ... */) : ViewModel() {

    private val _uiState = MutableStateFlow(LatestNewsUiState(isLoading = true))
    val uiState: StateFlow<LatestNewsUiState> = _uiState

    fun refreshNews() {
        viewModelScope.launch {
            // If there isn't internet connection, show a new message on the screen.
            if (!internetConnection()) {
                _uiState.update { currentUiState ->
                    currentUiState.copy(userMessage = "No Internet connection")
                }
                return@launch
            }

            // Do something else.
        }
    }

    fun userMessageShown() {
        _uiState.update { currentUiState ->
            currentUiState.copy(userMessage = null)
        }
    }
}

Compose

class LatestNewsViewModel(/* ... */) : ViewModel() {

    var uiState by mutableStateOf(LatestNewsUiState())
        private set

    fun refreshNews() {
        viewModelScope.launch {
            // If there isn't internet connection, show a new message on the screen.
            if (!internetConnection()) {
                uiState = uiState.copy(userMessage = "No Internet connection")
                return@launch
            }

            // Do something else.
        }
    }

    fun userMessageShown() {
        uiState = uiState.copy(userMessage = null)
    }
}

ViewModel は、UI がメッセージをどのように画面に表示しているかを認識する必要はありません。表示する必要のあるユーザー メッセージがあるということだけを認識します。一時的なメッセージが表示されると、UI はそれを ViewModel に通知する必要があります。これにより、別の UI の状態が更新されて、userMessage プロパティが消去されます。

View

class LatestNewsActivity : AppCompatActivity() {
    private val viewModel: LatestNewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { uiState ->
                    uiState.userMessage?.let {
                        // TODO: Show Snackbar with userMessage.

                        // Once the message is displayed and
                        // dismissed, notify the ViewModel.
                        viewModel.userMessageShown()
                    }
                    ...
                }
            }
        }
    }
}

Compose

@Composable
fun LatestNewsScreen(
    snackbarHostState: SnackbarHostState,
    viewModel: LatestNewsViewModel = viewModel(),
) {
    // Rest of the UI content.

    // If there are user messages to show on the screen,
    // show it and notify the ViewModel.
    viewModel.uiState.userMessage?.let { userMessage ->
        LaunchedEffect(userMessage) {
            snackbarHostState.showSnackbar(userMessage)
            // Once the message is displayed and dismissed, notify the ViewModel.
            viewModel.userMessageShown()
        }
    }
}

その他のユースケース

UI イベントのユースケースを UI イベントの更新では解決できないと思われる場合は、アプリのデータフローを再検討する必要が生じることがあります。次の原則を考慮してください。

  • 各クラスがそれぞれの役割だけを担い、それ以上は行わないようにします。UI は、ナビゲーション呼び出し、クリック イベント、権限リクエストの取得など、画面固有の動作ロジックを担います。ViewModel にはビジネス ロジックが含まれており、階層の下位レイヤからの結果を UI の状態に変換します。
  • イベントの発生源を考えます。このガイドの冒頭で示した決定木に沿って、各クラスが担うものを処理するようにします。たとえば、イベントが UI から発生し、ナビゲーション イベントとなる場合、そのイベントは UI で処理する必要があります。一部のロジックは ViewModel にデリゲートされますが、イベントの処理を完全に ViewModel にデリゲートすることはできません。
  • 複数のコンシューマーが存在し、イベントが複数回消費されることが心配な場合、アプリのアーキテクチャを再検討する必要が生じることがあります。複数のコンシューマーが同時に存在すると、1 回だけ配信されるコントラクトを保証することが非常に難しくなるため、複雑さと微妙な動作が爆発的に増加します。この問題が発生した場合は、そうした事項を UI ツリーの上位に出すことを検討してください。階層の上位にスコープ設定された別のエンティティが必要になる場合があります。
  • 状態を消費する必要が生じるタイミングを考えます。状況によっては、アプリがバックグラウンドにあるときに状態の消費を維持したくない場合があります(Toast を表示するなど)。そのような場合は、UI がフォアグラウンドにあるときに状態を消費することを検討してください。