Sự kiện giao diện người dùng

Sự kiện giao diện người dùng là các hành động cần được giao diện người dùng hoặc ViewModel xử lý trong lớp giao diện người dùng. Loại sự kiện phổ biến nhất là sự kiện của người dùng. Người dùng tạo sự kiện của người dùng bằng cách tương tác với ứng dụng—ví dụ: bằng cách nhấn vào màn hình hoặc tạo cử chỉ. Sau đó, giao diện người dùng sẽ xử lý các sự kiện này bằng các lệnh gọi lại, chẳng hạn như trình nghe onClick().

ViewModel thường chịu trách nhiệm xử lý logic kinh doanh của một sự kiện của người dùng cụ thể—ví dụ: người dùng nhấp vào một nút để làm mới một số dữ liệu. Thông thường, ViewModel xử lý vấn đề này bằng cách hiển thị các hàm mà giao diện người dùng có thể gọi. Sự kiện của người dùng cũng có thể có logic của hoạt động trên giao diện người dùng mà giao diện người dùng có thể xử lý trực tiếp—ví dụ: chuyển đến một màn hình khác hoặc hiển thị Snackbar.

Mặc dù logic kinh doanh vẫn giữ nguyên cho cùng một ứng dụng trên các nền tảng hoặc thiết bị di động khác nhau, nhưng logic của hoạt động giao diện người dùng là các nội dung chi tiết triển khai có thể khác nhau giữa những trường hợp đó. Trang lớp giao diện người dùng xác định các loại logic sau:

  • Logic kinh doanhnhững việc nên làm với các thay đổi về trạng thái—ví dụ: thanh toán hoặc lưu trữ các tuỳ chọn của người dùng. Các lớp miền và lớp dữ liệu thường xử lý logic này. Xuyên suốt hướng dẫn này, lớp Các thành phần cấu trúc ViewModel được dùng làm giải pháp quan trọng cho các lớp xử lý logic kinh doanh.
  • Logic của hoạt động giao diện người dùng hoặc logic giao diện người dùng đề cập đến cách hiển thị các thay đổi về trạng thái—ví dụ: logic điều hướng hoặc cách hiển thị thông báo cho người dùng. Giao diện người dùng xử lý logic này.

Cây quyết định sự kiện trên giao diện người dùng

Sơ đồ dưới đây là hình ảnh cây quyết định để tìm phương pháp tốt nhất cho việc xử lý một trường hợp sử dụng sự kiện cụ thể. Phần còn lại của hướng dẫn này sẽ giải thích chi tiết về các cách tiếp cận này.

Nếu sự kiện bắt nguồn trong ViewModel, hãy cập nhật trạng thái giao diện người dùng. Nếu
    sự kiện bắt nguồn trong giao diện người dùng và cần phải có logic kinh doanh, thì hãy ủy quyền
    logic kinh doanh đó cho ViewModel. Nếu sự kiện bắt nguồn từ giao diện người dùng và
    cần phải có logic hoạt động của giao diện người dùng, hãy sửa đổi trạng thái thành phần trên giao diện người dùng ngay trong
    giao diện người dùng đó.
Hình 1. Cây quyết định để xử lý sự kiện.

Xử lý sự kiện của người dùng

Giao diện người dùng có thể trực tiếp xử lý các sự kiện của người dùng nếu những sự kiện đó liên quan đến việc sửa đổi trạng thái của một thành phần trên giao diện người dùng—ví dụ: trạng thái của một mục có thể mở rộng. Nếu sự kiện yêu cầu thực hiện logic kinh doanh, chẳng hạn như làm mới dữ liệu trên màn hình, thì sự kiện này sẽ do ViewModel xử lý.

Ví dụ sau đây cho thấy cách các nút khác nhau được dùng để mở rộng thành phần trên giao diện người dùng (logic giao dịch người dùng) và làm mới dữ liệu trên màn hình (logic kinh doanh):

Số lượt xem

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")
        }
    }
}

Sự kiện của người dùng trong RecyclerViews

Nếu hành động được tạo tiếp theo trong cây giao diện người dùng, chẳng hạn như trong mục RecyclerView hoặc View tuỳ chỉnh, thì ViewModel vẫn phải là mã xử lý các sự kiện của người dùng.

Ví dụ: giả sử tất cả các mục tin tức từ NewsActivity đều chứa một nút đánh dấu trang. ViewModel cần biết mã nhận dạng của mục tin tức được đánh dấu. Khi người dùng đánh dấu một mục tin tức, trình chuyển đổi RecyclerView không gọi hàm addBookmark(newsId) hiển thị từ ViewModel. Việc này sẽ yêu cầu phần phụ thuộc vào ViewModel. Thay vào đó, ViewModel hiển thị một đối tượng trạng thái có tên là NewsItemUiState chứa nội dung triển khai để xử lý sự kiện:

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)
            }
        )
    }
}

Bằng cách này, trình chuyển đổi RecyclerView chỉ hoạt động với dữ liệu cần thiết: danh sách các đối tượng NewsItemUiState. Trình chuyển đổi không có quyền truy cập vào toàn bộ ViewModel, nên ít có khả năng bộ lọc sẽ sử dụng sai chức năng mà ViewModel hiển thị. Khi bạn chỉ cho phép lớp hoạt động làm việc với ViewModel, thì bạn tách riêng các trách nhiệm. Điều này đảm bảo rằng các đối tượng dành riêng cho giao diện người dùng như chế độ xem hoặc trình chuyển đổi RecyclerView không tương tác trực tiếp với ViewModel.

Quy ước đặt tên cho các hàm sự kiện của người dùng

Trong hướng dẫn này, các hàm ViewModel xử lý các sự kiện của người dùng được đặt tên bằng một động từ dựa trên hành động mà các hàm đó xử lý–ví dụ: addBookmark(id) hoặc logIn(username, password).

Xử lý sự kiện ViewModel

Các hành động trên giao diện người dùng bắt nguồn từ ViewModel–sự kiện ViewModel phải–luôn dẫn đến việc cập nhật trạng thái giao diện người dùng. Điều này tuân thủ các nguyên tắc về Luồng dữ liệu một chiều. Điều này giúp các sự kiện có thể tái tạo sau khi thay đổi cấu hình và đảm bảo rằng các hành động trên giao diện người dùng sẽ không bị mất. Nếu muốn, bạn cũng có thể tạo các sự kiện có thể tái tạo sau quá trình bị gián đoạn nếu sử dụng mô-đun trạng thái đã lưu.

Việc liên kết các hành động trên giao diện người dùng với trạng thái giao diện người dùng không phải lúc nào cũng là một quy trình đơn giản, nhưng quy trình này sẽ dẫn đến logic đơn giản hơn. Ví dụ: Quy trình tư duy của bạn không nên kết thúc bằng việc xác định cách giao diện người dùng điều hướng trên một màn hình cụ thể. Bạn cần suy nghĩ thêm và xem xét cách thể hiện luồng người dùng đó trong trạng thái giao diện người dùng. Nói cách khác, đừng nghĩ về những hành động mà giao diện người dùng cần thực hiện, hãy nghĩ về các hành động đó ảnh hưởng như thế nào đến trạng thái giao diện người dùng.

Ví dụ: hãy xem xét trường hợp điều hướng đến màn hình chính khi người dùng đăng nhập trên màn hình đăng nhập. Bạn có thể thiết lập dữ liệu này ở trạng thái giao diện người dùng như sau:

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

Giao diện người dùng này phản ứng với các thay đổi của trạng thái isUserLoggedIn và chuyển đến đúng đích đến nếu cần:

Số lượt xem

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.
}

Việc sử dụng các sự kiện có thể kích hoạt cập nhật trạng thái

Việc sử dụng một số sự kiện ViewModel trong giao diện người dùng có thể dẫn đến các lần cập nhật trạng thái giao diện người dùng khác. Ví dụ: khi hiển thị các thông báo tạm thời trên màn hình để thông báo cho người dùng biết rằng đã có sự kiện xảy ra, thì giao diện người dùng cần phải thông báo cho ViewModel để kích hoạt một nội dung cập nhật trạng thái khác khi thông báo đã hiển thị trên màn hình. Trạng thái giao diện người dùng đó có thể được mô hình hoá như sau:

// 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 sẽ cập nhật trạng thái giao diện người dùng như sau khi logic kinh doanh yêu cầu hiển thị một thông báo tạm thời mới cho người dùng:

Số lượt xem

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 không cần phải biết giao diện người dùng hiển thị thông báo trên màn hình như thế nào; nó chỉ biết rằng cần hiển thị một thông báo cho người dùng. Sau khi thông báo tạm thời được hiển thị, giao diện người dùng cần thông báo cho ViewModel về điều đó, dẫn đến một lần cập nhật trạng thái giao diện người dùng khác:

Số lượt xem

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)
        }
    }
}

Các trường hợp sử dụng khác

Nếu bạn cho rằng các nội dung cập nhật trạng thái giao diện người dùng không thể giải quyết trường hợp sử dụng sự kiện giao diện người dùng của bạn, thì bạn có thể cần xem xét lại cách dữ liệu lưu chuyển trong ứng dụng của bạn. Hãy xem xét các nguyên tắc sau:

  • Mỗi lớp chỉ nên làm những việc thuộc trách nhiệm của nó, chứ không phải nhiều việc hơn. Giao diện người dùng chịu trách nhiệm về logic hoạt động trên một màn hình cụ thể như lệnh gọi điều hướng, nhấp vào sự kiện và yêu cầu quyền truy cập. ViewModel chứa logic kinh doanh và chuyển đổi kết quả từ các lớp thấp hơn trong hệ phân cấp sang trạng thái giao diện người dùng.
  • Hãy nghĩ về nguồn gốc của sự kiện. Thực hiện theo cây quyết định được trình bày ở đầu hướng dẫn này và thiết lập sao cho mỗi lớp chỉ xử lý những việc thuộc trách nhiệm của nó. Ví dụ: nếu sự kiện xuất phát từ giao diện người dùng và dẫn đến một sự kiện điều hướng, thì sự kiện đó phải được xử lý trong giao diện người dùng. Một số logic có thể được ủy quyền cho ViewModel, nhưng bạn không thể ủy quyền hoàn toàn việc xử lý sự kiện cho ViewModel.
  • Nếu có nhiều người dùng và bạn lo ngại rằng sự kiện này sẽ được sử dụng nhiều lần, thì bạn có thể phải xem xét lại cấu trúc ứng dụng của mình. Việc có nhiều người dùng đồng thời dẫn đến việc cực kỳ khó đảm bảo hợp đồng được phân phối chính xác một lần, do đó, số lượng hoạt động phức tạp và sẽ bùng nổ. Nếu bạn gặp sự cố này, hãy xem xét việc đẩy các mối quan tâm đó lên trên cây giao diện người dùng; bạn có thể cần một thực thể khác ở cấp cao hơn trong hệ thống phân cấp.
  • Hãy nghĩ về thời điểm cần sử dụng trạng thái đó. Trong một số trường hợp, bạn có thể không muốn tiếp tục sử dụng trạng thái khi ứng dụng chạy ở chế độ nền, ví dụ: hiển thị một Toast. Trong những trường hợp đó, hãy cân nhắc sử dụng trạng thái khi giao diện người dùng chạy trên nền trước.