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 状態を使用して画面にユーザー メッセージを表示する方法については、イベントの消費によって状態の更新をトリガーできるをご覧ください。ナビゲーション イベントも、Android アプリで一般的なタイプのイベントです。

ユーザーがボタンをタップしたために UI でイベントがトリガーされた場合、UI はナビゲーション コントローラを呼び出すか、必要に応じて呼び出し元のコンポーザブルにイベントを公開することでこれを処理します。

View

class LoginActivity : AppCompatActivity() {

    private lateinit var binding: ActivityLoginBinding
    private val viewModel: LoginViewModel by viewModels()

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

        binding.helpButton.setOnClickListener {
            navController.navigate(...) // Open help screen
        }
    }
}

Compose

@Composable
fun LoginScreen(
    onHelp: () -> Unit, // Caller navigates to the right screen
    viewModel: LoginViewModel = viewModel()
) {
    // Rest of the UI

    Button(onClick = onHelp) {
        Text("Get help")
    }
}

データ入力がナビゲーションの前にビジネス ロジックの検証を必要とする場合、ViewModel はその状態を UI に公開する必要があります。UI がその状態の変化に反応し、それに応じてナビゲーションを行います。このユースケースについては、ViewModel イベントを処理するをご覧ください。同様のコードを次に示します。

View

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

@Composable
fun LoginScreen(
    onUserLogIn: () -> Unit, // Caller navigates to the right screen
    viewModel: LoginViewModel = viewModel()
) {
    Button(
        onClick = {
            // ViewModel validation is triggered
            viewModel.login()
        }
    ) {
        Text("Log in")
    }
    // Rest of the UI

    val lifecycle = LocalLifecycleOwner.current.lifecycle
    val currentOnUserLogIn by rememberUpdatedState(onUserLogIn)
    LaunchedEffect(viewModel, lifecycle)  {
        // Whenever the uiState changes, check if the user is logged in and
        // call the `onUserLogin` event when `lifecycle` is at least STARTED
        snapshotFlow { viewModel.uiState }
            .filter { it.isUserLoggedIn }
            .flowWithLifecycle(lifecycle)
            .collect {
                currentOnUserLogIn()
            }
    }
}

上記の例では、現在のデスティネーションである Login がバックスタックに保持されないため、アプリは想定どおりに機能します。ユーザーは、[戻る] ボタンを押しても戻れません。ただし、この場合はソリューションに追加のロジックが必要になります。

画面 A から画面 B へのナビゲーション イベントを生成する状態を ViewModel が設定し、画面 A がナビゲーション バックスタックに保持される場合、B に自動的に進まないようにするための追加のロジックが必要になる可能性があります。これを実装するには、UI が別の画面への移動を考慮する必要があるかどうかを示す追加の状態が必要になります。通常、ナビゲーション ロジックは ViewModel ではなく UI の問題であるため、状態は UI に保持されます。これを説明するために、次のユースケースを考えてみましょう。

たとえば、ユーザーがアプリの登録フローにいるとします。生年月日の検証画面で、ユーザーが日付を入力して [続行] ボタンをタップすると、ViewModel によって日付が検証されます。ViewModel は、検証ロジックをデータレイヤに委任します。日付が有効な場合、ユーザーは次の画面に進みます。追加機能として、ユーザーはデータを変更したい場合に異なる登録画面間を移動できます。そのため、登録フロー内のすべてのデスティネーションが同じバックスタックに保持されます。こうした要件がある場合には、次のようにこの画面を実装できます。

View

// Key that identifies the `validationInProgress` state in the Bundle
private const val DOB_VALIDATION_KEY = "dobValidationKey"

class DobValidationFragment : Fragment() {

    private var validationInProgress: Boolean = false
    private val viewModel: DobValidationViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val binding = // ...
        validationInProgress = savedInstanceState?.getBoolean(DOB_VALIDATION_KEY) ?: false

        binding.continueButton.setOnClickListener {
            viewModel.validateDob()
            validationInProgress = true
        }

        viewLifecycleOwner.lifecycleScope.launch {
            viewModel.uiState
                .flowWithLifecycle(viewLifecycleOwner.lifecycle)
                .collect { uiState ->
                    // Update other parts of the UI ...

                    // If the input is valid and the user wants
                    // to navigate, navigate to the next screen
                    // and reset `validationInProgress` flag
                    if (uiState.isDobValid && validationInProgress) {
                        validationInProgress = false
                        navController.navigate(...) // Navigate to next screen
                    }
                }
        }

        return binding
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putBoolean(DOB_VALIDATION_KEY, validationInProgress)
    }
}

Compose

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

@Composable
fun DobValidationScreen(
    onNavigateToNextScreen: () -> Unit, // Caller navigates to the right screen
    viewModel: DobValidationViewModel = viewModel()
) {
    // TextField that updates the ViewModel when a date of birth is selected

    var validationInProgress by rememberSaveable { mutableStateOf(false) }

    Button(
        onClick = {
            viewModel.validateInput()
            validationInProgress = true
        }
    ) {
        Text("Continue")
    }
    // Rest of the UI

    /*
     * The following code implements the requirement of advancing automatically
     * to the next screen when a valid date of birth has been introduced
     * and the user wanted to continue with the registration process.
     */

    if (validationInProgress) {
        val lifecycle = LocalLifecycleOwner.current.lifecycle
        val currentNavigateToNextScreen by rememberUpdatedState(onNavigateToNextScreen)
        LaunchedEffect(viewModel, lifecycle) {
            // If the date of birth is valid and the validation is in progress,
            // navigate to the next screen when `lifecycle` is at least STARTED,
            // which is the default Lifecycle.State for the `flowWithLifecycle` operator.
            snapshotFlow { viewModel.uiState }
                .filter { it.isDobValid }
                .flowWithLifecycle(lifecycle)
                .collect {
                    validationInProgress = false
                    currentNavigateToNextScreen()
                }
        }
    }
}

生年月日の検証は、ViewModel が担うビジネス ロジックです。ほとんどの場合、ViewModel はそのロジックをデータレイヤに委任します。ユーザーを次の画面に移動させるための要件は UI 構成によって異なる可能性があるため、そのようなロジックは UI ロジックです。たとえば、複数の登録ステップを同時に表示している場合に、タブレット内の別の画面に自動的に進むことは避けてください。上記のコードの validationInProgress 変数はこの機能を実装しており、生年月日が有効で、ユーザーが次の登録手順に進むことを希望している場合は常に、UI が自動的にナビゲートするかどうかを処理します。

その他のユースケース

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

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

サンプル

次の Google サンプルは、UI レイヤでの UI イベントを示しています。このガイダンスを実践するためにご利用ください。