13
4

More than 1 year has passed since last update.

Guide to app architectureからプラクティスを拾うメモ UIイベント編

Last updated at Posted at 2022-01-23

の続きです

用語整理

UIイベントとはなにか?

Key terms:
UI: View-based or Compose code that handles the user interface.
UI events: Actions that should be handled in the UI layer.
User events: Events that the user produces when interacting with the app.

UI: ViewかComposeかのコードでユーザーインターフェースを扱う。(例: MoviesFragment)
UIイベント: UIレイヤーでハンドルするべきイベント。(例: Snackbarの表示)
Userイベント: アプリに触れたときにユーザーが発行するイベント。(例: クリックイベント)

Business logic refers to what to do with state changes—for example, making a payment or storing user preferences. The domain and data layers usually handle this logic. Throughout this guide, the Architecture Components ViewModel class is used as an opinionated solution for classes that handle business logic.
UI behavior logic or UI logic refers to how to display state changes—for example, navigation logic or how to show messages to the user. The UI handles this logic.

Business logic: 状態の変化をどう処理するのか。
UI behavior logic or UI logic: 状態変化

どこからイベントが発生したか?
├── (UI)→ イベントに必要なロジックは?
│   ├── (Business logic)→ViewModelにビジネスロジックを委譲する
│   └── (UI behavior logic or UI logic)→ UIで直接UI elementの状態を変更する
└── (ViewModel)→ UIの状態を変更する

なぜこのような木になるのかは下の一つ一つの理由を見ていけばわかると思います。

イベントがビジネスロジックを必要とするのであれば処理はViewModelで行われなければならない。逆に必要としないのであればViewで完結して良い。

The UI can handle user events directly if those events relate to modifying the state of a UI element—for example, the state of an expandable item. If the event requires performing business logic, such as refreshing the data on the screen, it should be processed by the ViewModel.

The following example shows how different buttons are used to expand a UI element (UI logic) and to refresh the data on the screen (business logic):

class LatestNewsActivity : AppCompatActivity() {

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

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

        // The expand section event is processed by the UI that
        // modifies a View's internal state.
        // セクションを大きくするイベントはUIによって処理されていて、
        // Viewの状態を変えている。
        binding.expandButton.setOnClickListener {
            binding.expandedSection.visibility = View.VISIBLE
        }

        // The refresh event is processed by the ViewModel that is in charge
        // of the business logic.
        // 更新イベントはViewModelによって処理される。
        // ViewModelはビジネスロジックを担当している。
        binding.refreshButton.setOnClickListener {
            viewModel.refreshNews()
        }
    }
}

なぜ?

The refresh event is processed by the ViewModel that is in charge
// of the business logic.

ViewModelはビジネスロジックを担当しているので、ViewModelを経由しないとビジネスロジックで処理できないため。

RecyclerViewのAdapterやCustomViewなどActivityやFragment以外でViewModelを持ってはならない。

If the action is produced further down the UI tree, like in a RecyclerView item or a custom View, the ViewModel should still be the one handling user events.

This way, the RecyclerView adapter only works with the data that it needs: the list of NewsItemUiState objects. The adapter doesn’t have access to the entire ViewModel, making it less likely to abuse the functionality exposed by the ViewModel. When you allow only the activity class to work with the ViewModel, you separate responsibilities. This ensures that UI-specific objects like views or RecyclerView adapters don't interact directly with the ViewModel.

Warning: It's bad practice to pass the ViewModel into the RecyclerView adapter because that tightly couples the adapter with the ViewModel class.

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.
            // ビジネスロジックがラムダとして渡され
            // UIがクリックイベントで動かす。
            onBookmark = {
                repository.addBookmark(news.id)
            }
        )
    }
}

Note: Another common pattern is for the RecyclerView adapter to have a Callback interface for user actions. In that case, the activity or fragment can handle the binding and call the ViewModel functions directly from the callback interface.

(Adapterがコールバックを持つパターンでも良いそうです。(逆にラムダを渡すパターン知らなかったけど、これちゃんとonBookmarkのラムダをフィールドに持たないとComposeが差分検知しちゃいそう?))

なぜ?

関心事を分離するため。(AdapterによってViewModelによって公開されている機能を悪用する可能性が低くなるため。)

ViewModelから発生するイベントは常にUI Stateを変更する

UI actions that originate from the ViewModel—ViewModel events—should always result in a UI state update. This complies with the principles of Unidirectional Data Flow. It makes events reproducible after configuration changes and guarantees that UI actions won't be lost. Optionally, you can also make events reproducible after process death if you use the saved state module.

Mapping UI actions to UI state is not always a simple process, but it does lead to simpler logic. Your thought process shouldn't end with determining how to make the UI navigate to a particular screen, for example. You need to think further and consider how to represent that user flow in your UI state. In other words: don't think about what actions the UI needs to make; think about how those actions affect the UI state.

Key Point: ViewModel events should always result in a UI state update.

data class LoginUiState(
    val isLoading: Boolean = false,
    val errorMessage: String? = null,
    val isUserLoggedIn: Boolean = false
)
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.
                        // ホーム画面に遷移させる
                    }
                    ...
                }
            }
        }
    }
}

なぜ?

makes events reproducible after configuration changes and guarantees that UI actions won't be lost.

lead to simpler logic

configuration changes後に再現できるようにするため。UI actionが失われないようにするため。またロジックをシンプルにできるため。

Snackbarとかのときはどうするのか?

(人によって賛否が分かれる実装が載っているので御覧ください)

イベントが複数箇所から消費されていて、複数回消費されることを心配している場合はその部分をUIツリーの上部に持っておくとよい

If you have multiple consumers and you're worried about the event being consumed multiple times, you might need to reconsider your app architecture. Having multiple concurrent consumers results in the delivered exactly once contract becoming extremely difficult to guarantee, so the amount of complexity and subtle behavior explodes. If you're having this problem, consider pushing those concerns upwards in your UI tree; you might need a different entity scoped higher up in the hierarchy.

なぜ?

消費する場所が複数あるとちゃんと届けられたことを保証するのが難しい。UIツリーの上に持っておくことで、解決できる。

適切なタイミングでイベントを消費する

Think about when the state needs to be consumed. In certain situations, you might not want to keep consuming state when the app is in the background—for example, showing a Toast. In those cases, consider consuming the state when the UI is in the foreground.

なぜ?

画面が裏にいるときにToastが出てほしくないなど。

Channelなどを使ってイベントラッパーなどを使っている場合はUI Stateに変換する

Note: In some apps, you might have seen ViewModel events being exposed to the UI using Kotlin Channels or other reactive streams. These solutions usually require workarounds such as event wrappers in order to guarantee that events are not lost and that they're consumed only once.

Requiring workarounds is an indication that there's a problem with these approaches. The problem with exposing events from the ViewModel is that it goes against the state-down-events-up principle of Unidirectional Data Flow.

If you're in that situation, reconsider what that one-off ViewModel event actually means for your UI and convert it to UI state. UI state better represents the UI at a given point in time, it gives you more delivery and processing guarantees, it's usually easier to test, and it integrates consistently with the rest of your app.

なぜ?
Single source of truthの原則に反する。イベントが届けられることが保証できる。テストが通常楽にできる。他のアプリの部分と首尾一貫した書き方ができる。

13
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
4