UI 事件

UI 事件是 UI 層中應由 UI 或 ViewModel 處理的動作。最常見的事件類型是使用者事件。使用者透過與應用程式互動 (例如:輕觸螢幕或產生手勢) 產生使用者事件。然後 UI 會使用回呼 (例如:onClick() 事件監聽器) 使用這些事件。

ViewModel 通常負責處理特定使用者事件的商業邏輯,例如:使用者點擊按鈕重新整理部分資料。一般來說,ViewModel 會公開 UI 可呼叫的函式來進行處理。使用者事件也可能有 UI 可直接處理的 UI 行為邏輯,例如:前往不同畫面或顯示 Snackbar

雖然相同應用程式中的「商業邏輯」在不同行動平台或板型規格仍會保持不變,但「UI 行為邏輯」是可能區分這些情況之差異的實作詳細資料。UI 層頁面定義了以下類型的邏輯:

  • 商業邏輯」是指狀態變更的「處理方式」,例如:付款或儲存使用者偏好設定。網域和資料層通常會處理這個邏輯。在本指南中,「架構元件 ViewModel」類別會做處理商業邏輯的類別相關解決方案使用。
  • UI 行為邏輯UI 邏輯是指狀態變更的顯示方式,例如:導覽邏輯或向使用者顯示訊息的方式。UI 會處理這個邏輯。

UI 事件決策樹

以下圖表顯示尋找處理特定事件用途最佳方式時的決策樹。本指南的其餘部分會詳細說明這些方法。

如果事件源自於 ViewModel,請更新 UI 狀態。如果事件源自 UI 且需要商業邏輯,請將商業邏輯委派給 ViewModel。如果事件源自 UI 且需要 UI 行為邏輯,請直接在 UI 修改 UI 元素狀態。
圖 1 處理事件的決策樹。 。

處理使用者事件

如果事件涉及修改 UI 元素的狀態 (例如:可展開項目的狀態),使用者介面即可直接處理使用者事件。如果事件需要執行商業邏輯,例如:重新整理畫面中的資料,則 ViewModel 應會處理此事件。

以下範例說明如何使用不同的按鈕展開 UI 元素 (UI 邏輯),並重新整理螢幕上的資料 (商業邏輯):

觀看次數

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.
        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 NewsApp() {
    val navController = rememberNavController()
    NavHost(navController = navController, startDestination = "latestNews") {
        composable("latestNews") {
            LatestNewsScreen(
                // The navigation event is processed by calling the NavController
                // navigate function that mutates its internal state.
                onProfileClick = { navController.navigate("profile") }
            )
        }
        /* ... */
    }
}

@Composable
fun LatestNewsScreen(
    viewModel: LatestNewsViewModel = viewModel(),
    onProfileClick: () -> Unit
) {
    Column {
        // 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")
        }
        Button(onClick = onProfileClick) {
            Text("Profile")
        }
    }
}

RecyclerViews 中的使用者事件

如果動作是進一步在 UI 樹狀圖下方產生 (例如:RecyclerView 項目或自訂 View 中),則 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 的活動類別,就是分隔所需處理的內容。這可確保 UI 專屬的物件 (例如:檢視畫面或 RecyclerView 轉接程式) 不會與 ViewModel 直接互動。

使用者事件函式的命名慣例

在本指南中,用於處理使用者事件的 ViewModel 函式會根據其處理的動作來命名,例如:addBookmark(id)logIn(username, password)

處理 ViewModel 事件

源自 ViewModel (ViewModel 事件) 的 UI 動作應一律在 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 狀態的變更,然後視需要前往正確的目的地:

觀看次數

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 於畫面顯示訊息時立即觸發另一個狀態更新。該 UI 狀態的模型如下:

// Models the message to show on the screen.
data class UserMessage(val id: Long, val message: String)

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

當商業邏輯需要向使用者顯示新的暫時性訊息時,ViewModel 會以下列方式更新 UI 狀態:

觀看次數

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 ->
                    val messages = currentUiState.userMessages + UserMessage(
                        id = UUID.randomUUID().mostSignificantBits,
                        message = "No Internet connection"
                    )
                    currentUiState.copy(userMessages = messages)
                }
                return@launch
            }

            // Do something else.
        }
    }

    fun userMessageShown(messageId: Long) {
        _uiState.update { currentUiState ->
            val messages = currentUiState.userMessages.filterNot { it.id == messageId }
            currentUiState.copy(userMessages = messages)
        }
    }
}

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()) {
                val messages = uiState.userMessages + UserMessage(
                    id = UUID.randomUUID().mostSignificantBits,
                    message = "No Internet connection"
                )
                uiState = uiState.copy(userMessages = messages)
                return@launch
            }

            // Do something else.
        }
    }

    fun userMessageShown(messageId: Long) {
        val messages = uiState.userMessages.filterNot { it.id == messageId }
        uiState = uiState.copy(userMessages = messages)
    }
}

ViewModel 無需瞭解 UI 如何在螢幕上顯示訊息,而是只知道是否有需要顯示的使用者訊息。顯示暫時訊息後,UI 就必須通知該訊息的 ViewModel,進而導致其他 UI 狀態更新:

觀看次數

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.userMessages.firstOrNull()?.let { userMessage ->
                        // TODO: Show Snackbar with userMessage.
                        // Once the message is displayed and
                        // dismissed, notify the ViewModel.
                        viewModel.userMessageShown(userMessage.id)
                    }
                    ...
                }
            }
        }
    }
}

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 the first one and notify the ViewModel.
    viewModel.uiState.userMessages.firstOrNull()?.let { userMessage ->
        LaunchedEffect(userMessage) {
            snackbarHostState.showSnackbar(userMessage.message)
            // Once the message is displayed and dismissed, notify the ViewModel.
            viewModel.userMessageShown(userMessage.id)
        }
    }
}

其他使用情況

如果您認為 UI 事件用途無法透過 UI 狀態更新解決,您可能需要重新思考應用程式中資料流動的方式。請思考以下原則:

  • 每個類別只需完成各自必須負責的工作。 UI 負責的是畫面特定的行為邏輯,例如:導航呼叫、點擊事件和取得權限要求。ViewModel 提供商業邏輯,並將層級中較低層級的結果轉換為 UI 狀態。
  • 您要思考的是事件來源。 依照本指南開頭提供的決策樹,然後使各類別處理各自負責的工作。舉例來說,如果事件源自 UI,且會產生導覽事件,則該事件必須在 UI 中進行處理。某些邏輯可能會委派給 ViewModel,但處理事件無法完全委派給 ViewModel。
  • 如果您有多個取用者,且擔心該事件會多次消耗,您可能需要重新思考應用程式架構。 有多個並行取用者會導致合約的一次性提交變得及難保證,因此複雜性和輕微行為的數量會急遽增加。如果遇到此問題,請考慮在 UI 樹狀結構中將這些問題的層級往上提升。您可能需要在階層的較高層級定義不同實體的範圍。
  • 思考需要消耗狀態的時機。 在某些情況下,您可能不希望應用程式在背景執行時保持使用狀態,例如:顯示 Toast 時。在這種情況下,請考慮當 UI 在前景中時消耗狀態。