Vị trí để chuyển trạng thái lên trên

Trong một ứng dụng Compose, vị trí để chuyển trạng thái giao diện người dùng lên trên sẽ phụ thuộc vào việc logic giao diện người dùng hoặc logic nghiệp vụ yêu cầu nó. Tài liệu này trình bày hai trường hợp chính nói trên.

Phương pháp hay nhất

Bạn nên chuyển trạng thái giao diện người dùng lên đối tượng cấp trên chung thấp nhất trong số tất cả các thành phần kết hợp có nhiệm vụ đọc và ghi trạng thái. Bạn nên để trạng thái gần nhất với nơi sử dụng trạng thái. Từ chủ sở hữu trạng thái, hãy hiển thị trạng thái và các sự kiện không thể thay đổi cho đối tượng tiêu thụ để sửa đổi trạng thái.

Đối tượng cấp trên chung thấp nhất cũng có thể nằm ngoài Cấu trúc (Composition). Ví dụ như khi chuyển trạng thái lên trên trong ViewModel vì liên quan đến logic nghiệp vụ.

Trang này giải thích chi tiết về phương pháp hay nhất này và một điều quan trọng cần lưu ý.

Các loại trạng thái và logic giao diện người dùng

Dưới đây là định nghĩa về các loại trạng thái và logic của giao diện người dùng được sử dụng xuyên suốt tài liệu này.

Trạng thái giao diện người dùng

Trạng thái giao diện người dùng là thuộc tính mô tả giao diện người dùng. Có hai loại trạng thái giao diện người dùng:

  • Trạng thái giao diện người dùng màn hìnhnội dung bạn cần hiển thị trên màn hình. Ví dụ như một lớp NewsUiState có thể chứa các bài viết tin tức và những thông tin cần thiết khác để kết xuất giao diện người dùng. Trạng thái này thường được kết nối với các lớp khác trong hệ phân cấp vì nó chứa dữ liệu ứng dụng.
  • Trạng thái thành phần giao diện người dùng đề cập đến các thuộc tính hàm nội tại với các thành phần giao diện người dùng ảnh hưởng đến cách hiển thị các thành phần đó. Một thành phần trên giao diện người dùng có thể được hiển thị hoặc bị ẩn đi cũng như chữ của thành phần này có thể có phông, kích thước hoặc màu sắc nhất định. Trong Android Views, lớp View tự quản lý trạng thái này vì nó vốn có trạng thái, hiển thị các phương thức để sửa đổi hoặc truy vấn trạng thái của nó. Ví dụ như phương thức getset của lớp TextView cho văn bản của lớp đó. Trong Jetpack Compose, trạng thái nằm bên ngoài thành phần kết hợp và bạn thậm chí có thể chuyển nó lên trên từ vùng lân cận của thành phần kết hợp vào hàm có thể kết hợp đang gọi hoặc phần tử giữ trạng thái. Ví dụ như ScaffoldState cho thành phần kết hợp Scaffold.

Logic

Logic trong ứng dụng có thể là logic nghiệp vụ hoặc logic giao diện người dùng:

  • Logic nghiệp vụ là cách áp dụng các yêu cầu của sản phẩm cho dữ liệu ứng dụng. Ví dụ như đánh dấu một bài viết trong ứng dụng đọc tin tức khi người dùng nhấn nút. Logic này để lưu dấu trang vào một tệp hoặc cơ sở dữ liệu thường được đặt trong miền hoặc lớp dữ liệu. Phần tử giữ trạng thái thường uỷ quyền logic này cho các lớp đó bằng cách gọi phương thức mà các lớp này hiển thị.
  • Logic giao diện người dùng liên quan đến cách hiển thị trạng thái giao diện người dùng trên màn hình. Ví dụ như nhận được gợi ý phù hợp trên thanh tìm kiếm khi người dùng đã chọn một danh mục, cuộn đến một mục cụ thể trong danh sách hoặc logic điều hướng đến một màn hình cụ thể khi người dùng nhấp vào một nút.

Logic giao diện người dùng

Khi logic giao diện người dùng cần đọc hoặc ghi trạng thái, bạn nên đặt phạm vi trạng thái cho giao diện người dùng theo vòng đời của nó. Để đạt được điều này, bạn nên chuyển trạng thái lên đúng cấp trong một hàm có khả năng kết hợp. Ngoài ra, bạn có thể làm như vậy trong lớp phần tử giữ trạng thái thuần tuý, cũng thuộc phạm vi vòng đời của giao diện người dùng.

Dưới đây là nội dung mô tả về các giải pháp và trình bày trường hợp sử dụng của những giải pháp đó.

Thành phần kết hợp đóng vai trò là chủ sở hữu trạng thái

Việc sử dụng trạng thái thành phần trên giao diện người dùng và logic giao diện người dùng trong thành phần kết hợp là một phương pháp hữu ích nếu trạng thái và logic đó là đơn giản. Bạn có thể để trạng thái của mình trong nội bộ thành phần kết hợp hoặc chuyển trạng thái lên trên nếu cần.

Không cần chuyển trạng thái lên trên

Không phải lúc nào bạn cũng cần chuyển trạng thái lên trên. Trạng thái có thể được giữ nội bộ trong thành phần kết hợp khi không có thành phần kết hợp nào khác cần kiểm soát trạng thái đó. Trong đoạn mã này, có một thành phần kết hợp mở rộng và thu gọn khi nhấn:

@Composable
fun ChatBubble(
    message: Message
) {
    var showDetails by rememberSaveable { mutableStateOf(false) } // Define the UI element expanded state

    ClickableText(
        text = message.content,
        onClick = { showDetails = !showDetails } // Apply simple UI logic
    )

    if (showDetails) {
        Text(message.timestamp)
    }
}

Biến showDetails là trạng thái nội bộ của thành phần trên giao diện người dùng này. Biến này chỉ được đọc và sửa đổi trong thành phần kết hợp ở trên và logic được áp dụng cho biến này rất đơn giản. Do đó, việc chuyển trạng thái lên trên trong trường hợp này sẽ không mang lại nhiều lợi ích, nên bạn có thể để trạng thái đó trong nội bộ. Làm như vậy sẽ biến thành phần kết hợp này trở thành chủ sở hữu và nguồn đáng tin cậy duy nhất của trạng thái mở rộng.

Chuyển trạng thái lên trên trong thành phần kết hợp

Nếu cần chia sẻ trạng thái thành phần giao diện người dùng với các thành phần kết hợp khác và áp dụng logic giao diện người dùng cho trạng thái đó tại nhiều vị trí, bạn có thể chuyển trạng thái lên cao hơn trong hệ phân cấp giao diện người dùng. Điều này cũng giúp các thành phần kết hợp của bạn dễ sử dụng lại và kiểm thử hơn.

Ví dụ sau đây là một ứng dụng trò chuyện triển khai 2 phần chức năng:

  • Nút JumpToBottom sẽ cuộn danh sách tin nhắn xuống dưới cùng. Nút này thực hiện logic giao diện người dùng đối với trạng thái danh sách.
  • Danh sách MessagesList sẽ cuộn xuống dưới cùng sau khi người dùng gửi tin nhắn mới. UserInput thực hiện logic giao diện người dùng đối với trạng thái danh sách.
Ứng dụng nhắn tin có nút JumpToBottom và cuộn xuống dưới cùng đối với các tin nhắn mới
Hình 1. Ứng dụng nhắn tin có nút JumpToBottom và cuộn xuống dưới cùng đối với các tin nhắn mới

Sau đây là hệ phân cấp của thành phần kết hợp:

Cây thành phần kết hợp của cuộc trò chuyện
Hình 2. Cây thành phần kết hợp của cuộc trò chuyện

Trạng thái LazyColumn được chuyển lên màn hình cuộc trò chuyện để ứng dụng có thể thực hiện logic giao diện người dùng và đọc trạng thái từ tất cả các thành phần kết hợp cần trạng thái:

Chuyển trạng thái LazyColumn từ LazyColumn lên ConversationScreen
Hình 3. Chuyển trạng thái LazyColumn từ LazyColumn lên ConversationScreen

Cuối cùng, các thành phần kết hợp là:

Cây thành phần kết hợp của cuộc trò chuyện, trong đó LazyListState đã được chuyển lên ConversationScreen
Hình 4. Cây thành phần kết hợp của cuộc trò chuyện, trong đó LazyListState đã được chuyển lên ConversationScreen

Sau đây là mã:

@Composable
private fun ConversationScreen(...) {
    val scope = rememberCoroutineScope()

    val lazyListState = rememberLazyListState() // State hoisted to the ConversationScreen

    MessagesList(messages, lazyListState) // Reuse same state in MessageList

    UserInput(
        onMessageSent = { // Apply UI logic to lazyListState
            scope.launch {
                lazyListState.scrollToItem(0)
            }
        },
    )
}

@Composable
private fun MessagesList(
    messages: List<Message>,
    lazyListState: LazyListState = rememberLazyListState() // LazyListState has a default value
) {

    LazyColumn(
        state = lazyListState // Pass hoisted state to LazyColumn
    ) {
        items(messages, key = { message -> message.id }) { item ->
            Message(...)
        }
    }

    JumpToBottom(onClicked = {
        scope.launch {
            lazyListState.scrollToItem(0) // UI logic being applied to lazyListState
        }
    })
}

LazyListState được chuyển lên trên cao đến mức cần thiết đối với logic giao diện người dùng phải áp dụng. Vì được khởi tạo trong một hàm có khả năng kết hợp, nên LazyListState được lưu trữ trong Cấu trúc, tuân theo vòng đời của LazyListState.

Hãy lưu ý rằng lazyListState được xác định trong phương thức MessagesList, có giá trị mặc định là rememberLazyListState(). Đây là một kiểu mẫu phổ biến trong Compose. Nó giúp các thành phần kết hợp dễ sử dụng lại và linh hoạt hơn. Sau đó, bạn có thể sử dụng thành phần kết hợp trong các phần khác nhau của ứng dụng mà có thể không cần kiểm soát trạng thái. Trường hợp này thường xảy ra trong quá trình kiểm thử hoặc xem trước một thành phần kết hợp. Đây chính là cách LazyColumn xác định trạng thái.

Đối tượng cấp trên chung thấp nhất của LazyListState là ConversationScreen
Hình 5. Đối tượng cấp trên chung thấp nhất của LazyListState là ConversationScreen

Lớp phần tử giữ trạng thái thuần tuý đóng vai trò là chủ sở hữu trạng thái

Khi một thành phần kết hợp chứa logic giao diện người dùng phức tạp có liên quan đến một hoặc nhiều trường trạng thái của một thành phần trên giao diện người dùng, thành phần kết hợp đó sẽ uỷ quyền trách nhiệm đó cho các phần tử giữ trạng thái, chẳng hạn như một lớp phần tử giữ trạng thái thuần tuý. Điều này giúp logic của thành phần kết hợp dễ kiểm thử hơn khi tách biệt và giảm tính phức tạp của nó. Phương pháp này ưa chuộng nguyên tắc tách biệt vấn đề: thành phần kết hợp chịu trách nhiệm chuyển phát (emit) các thành phần giao diện người dùng còn phần tử giữ trạng thái chứa logic giao diện người dùng và trạng thái của thành phần trên giao diện người dùng.

Các lớp phần tử giữ trạng thái đơn giản cung cấp các hàm thuận tiện cho phương thức gọi của hàm có khả năng kết hợp, nên các lớp đó sẽ không phải tự viết logic này.

Các lớp thuần tuý này được tạo ra và ghi nhớ trong Cấu trúc. Vì tuân theo vòng đời của thành phần kết hợp, nên các lớp này có thể lấy những loại do thư viện Compose cung cấp, chẳng hạn như rememberNavController() hoặc rememberLazyListState().

Ví dụ như lớp phần tử giữ trạng thái thuần tuý LazyListState, được triển khai trong Compose để kiểm soát độ phức tạp của giao diện người dùng của LazyColumn hoặc LazyRow.

// LazyListState.kt

@Stable
class LazyListState constructor(
    firstVisibleItemIndex: Int = 0,
    firstVisibleItemScrollOffset: Int = 0
) : ScrollableState {
    /**
     *   The holder class for the current scroll position.
     */
    private val scrollPosition =
        LazyListScrollPosition(firstVisibleItemIndex, firstVisibleItemScrollOffset)

    suspend fun scrollToItem(...) { ... }

    override suspend fun scroll() { ... }

    suspend fun animateScrollToItem() { ... }
}

LazyListState đóng gói trạng thái của LazyColumn lưu trữ scrollPosition cho thành phần này trên giao diện người dùng. Thành phần này cũng hiển thị các phương thức sửa đổi vị trí cuộn, chẳng hạn như cuộn đến một mục nhất định.

Như bạn có thể thấy, việc gia tăng trách nhiệm của thành phần kết hợp sẽ làm tăng nhu cầu đối với phần tử giữ trạng thái. Bạn có thể chịu trách nhiệm về logic giao diện người dùng hoặc chỉ cần theo dõi số lượng trạng thái.

Một kiểu mẫu phổ biến khác là sử dụng lớp phần tử giữ trạng thái thuần tuý để xử lý độ phức tạp của các hàm có khả năng kết hợp gốc trong ứng dụng. Bạn có thể dùng một lớp như vậy để đóng gói trạng thái ở cấp ứng dụng, chẳng hạn như trạng thái điều hướng và kích thước màn hình. Bạn có thể xem nội dung mô tả đầy đủ về vấn đề này trên trang logic giao diện người dùng và phần tử giữ trạng thái của nó.

Logic nghiệp vụ

Nếu các thành phần kết hợp và lớp phần tử giữ trạng thái thuần tuý chịu trách nhiệm về logic và trạng thái trên thành phần giao diện người dùng, thì phần tử giữ trạng thái ở cấp độ màn hình sẽ có trách nhiệm xử lý các tác vụ sau:

  • Cung cấp quyền truy cập vào logic nghiệp vụ của ứng dụng, logic này thường được đặt trong các lớp khác của hệ phân cấp, chẳng hạn như lớp nghiệp vụ và lớp dữ liệu.
  • Chuẩn bị dữ liệu ứng dụng để trình bày trong một màn hình cụ thể, màn hình này sẽ trở thành trạng thái giao diện người dùng trên màn hình.

ViewModel đóng vai trò là chủ sở hữu trạng thái

Lợi ích của AAC ViewModel trong quá trình phát triển Android giúp lớp này phù hợp với việc cung cấp quyền truy cập vào logic nghiệp vụ và chuẩn bị dữ liệu ứng dụng để hiển thị trên màn hình.

Khi chuyển trạng thái giao diện người dùng lên trên trong ViewModel, bạn sẽ di chuyển trạng thái đó ra ngoài Cấu trúc.

Trạng thái đã chuyển lên ViewModel được lưu trữ bên ngoài Cấu trúc.
Hình 6. Trạng thái đã chuyển lên ViewModel được lưu trữ bên ngoài Cấu trúc.

ViewModel không được lưu trữ như một phần của Cấu trúc. Lớp này do khung cung cấp và thuộc phạm vi một ViewModelStoreOwner, có thể là Hoạt động, Mảnh, biểu đồ điều hướng hoặc đích đến của biểu đồ điều hướng. Để biết thêm thông tin về phạm vi ViewModel, bạn có thể xem tài liệu này.

Khi đó, ViewModel là nguồn đáng tin cậy và là đối tượng cấp trên chung thấp nhất đối với trạng thái giao diện người dùng.

Trạng thái giao diện người dùng trên màn hình

Theo các định nghĩa ở trên, trạng thái giao diện người dùng trên màn hình được tạo bằng cách áp dụng các quy tắc nghiệp vụ. Vì phần tử giữ trạng thái cấp màn hình chịu trách nhiệm về trạng thái này, nghĩa là trạng thái giao diện người dùng trên màn hình thường được chuyển lên trên trong phần tử giữ trạng thái cấp màn hình, trong trường hợp này là ViewModel.

Hãy xem xét ConversationViewModel của một ứng dụng nhắn tin và cách ConversationViewModel hiển thị sự kiện và trạng thái giao diện người dùng trên màn hình để sửa đổi nó:

class ConversationViewModel(
    private val channelId: String,
    private val messagesRepository: MessagesRepository
) : ViewModel() {

   val messages = messagesRepository
       .getLatestMessages(channelId)
       .stateIn(
           scope = viewModelScope,
           started = SharingStarted.WhileSubscribed(5_000),
           initialValue = emptyList()
       )

   // Business logic
   fun sendMessage(message: Message) { /* ... */ }
}

Các thành phần kết hợp sử dụng trạng thái giao diện người dùng trên màn hình được chuyển lên trên trong ViewModel. Bạn nên chèn thực thể ViewModel vào các thành phần kết hợp cấp màn hình để cung cấp quyền truy cập vào logic nghiệp vụ.

Sau đây là ví dụ về ViewModel được sử dụng trong thành phần kết hợp cấp màn hình. Tại đây, thành phần kết hợp ConversationScreen() sẽ sử dụng trạng thái giao diện người dùng trên màn hình được chuyển lên trên trong ViewModel:

@Composable
private fun ConversationScreen(
    conversationViewModel: ConversationViewModel = viewModel()
) {

    val messages by conversationViewModel.messages.collectAsStateWithLifecycle()

    ConversationScreen(messages, { message -> conversationViewModel.sendMessage(message) })

}

@Composable
private fun ConversationScreen(
    messages: List<Messages>, onSendMessage: (Message) -> Unit)
) {

    MessagesList(messages, onSendMessage)
    // ...
}

Truyền dữ liệu qua các thành phần lồng nhau

"Property drilling" là hiện tượng truyền dữ liệu qua một số thành phần con lồng nhau đến vị trí mà chúng được đọc.

Một ví dụ điển hình về nơi có thể xuất hiện trường hợp truyền dữ liệu qua các thành phần lồng nhau (property drilling) trong Compose là khi bạn chèn phần tử giữ trạng thái màn hình ở cấp cao nhất rồi chuyển trạng thái và các sự kiện xuống thành phần kết hợp con. Điều này cũng có thể tạo ra tình trạng quá tải chữ ký của hàm có khả năng kết hợp.

Mặc dù khi cho thấy sự kiện dưới dạng tham số lambda riêng lẻ có thể làm quá tải chữ ký hàm, nhưng sẽ tối đa hoá khả năng hiển thị trong chức năng của hàm có khả năng kết hợp. Bạn có thể xem nhanh về tác động của hiện tượng này.

Thay vì tạo các lớp trình bao bọc, bạn nên ưu tiên sử dụng phương pháp truyền dữ liệu qua các thành phần lồng nhau (property drilling) để đóng gói trạng thái và sự kiện ở cùng một nơi vì điều này giúp giảm mức độ hiển thị các trách nhiệm của thành phần kết hợp. Khi không tạo các lớp trình bao bọc, bạn cũng có nhiều khả năng sẽ chỉ truyền các tham số cần thiết đến thành phần kết hợp, đây là phương pháp hay nhất.

Phương pháp hay nhất tương tự cũng áp dụng nếu các sự kiện này là sự kiện điều hướng, bạn có thể tìm hiểu thêm về điều đó trong tài liệu về điều hướng.

Nếu đã xác định được vấn đề về hiệu suất, bạn cũng có thể chọn trì hoãn việc đọc trạng thái. Bạn có thể tham khảo tài liệu về hiệu suất để tìm hiểu thêm.

Trạng thái thành phần trên giao diện người dùng

Bạn có thể chuyển trạng thái thành phần trên giao diện người dùng lên trên phần tử giữ trạng thái cấp màn hình nếu có logic nghiệp vụ cần đọc hoặc ghi logic trạng thái đó.

Tiếp tục ví dụ về ứng dụng nhắn tin, ứng dụng này sẽ hiển thị các đề xuất cho người dùng trong cuộc trò chuyện nhóm khi người dùng nhập @ và một nội dung gợi ý. Những đề xuất đó đến từ lớp dữ liệu và logic để tính toán danh sách những đề xuất dành cho người dùng được coi là logic nghiệp vụ. Tính năng này trông sẽ như sau:

Tính năng hiển thị các đề xuất cho người dùng trong cuộc trò chuyện nhóm khi người dùng nhập `@` và một nội dung gợi ý
Hình 7. Tính năng hiển thị các đề xuất cho người dùng trong cuộc trò chuyện nhóm khi người dùng nhập `@` và một nội dung gợi ý

Quá trình ViewModel triển khai tính năng này sẽ diễn ra như sau:

class ConversationViewModel(...) : ViewModel() {

    // Hoisted state
    var inputMessage by mutableStateOf("")
       private set

    val suggestions: StateFlow<List<Suggestion>> =
        snapshotFlow { inputMessage }
            .filter { hasSocialHandleHint(it) }
            .mapLatest { getHandle(it) }
            .mapLatest { repository.getSuggestions(it) }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = emptyList()
            )

   fun updateInput(newInput: String){
       inputMessage = newInput
   }
}

inputMessage là một biến lưu trữ trạng thái TextField. Mỗi khi người dùng nhập dữ liệu đầu vào mới, ứng dụng sẽ gọi logic nghiệp vụ để tạo suggestions.

suggestions là trạng thái giao diện người dùng trên màn hình và được sử dụng từ giao diện người dùng Compose bằng cách thu thập từ StateFlow.

Cảnh báo

Đối với một số trạng thái thành phần trên giao diện người dùng Compose, bạn có thể phải cân nhắc một số yếu tố đặc biệt trước khi di chuyển lên ViewModel. Ví dụ: một số phần tử giữ trạng thái của các thành phần trên giao diện người dùng Compose hiển thị các phương thức để sửa đổi trạng thái. Một vài trong số các phần tử đó có thể là hàm tạm ngưng, làm kích hoạt ảnh động. Các hàm tạm ngưng này có thể gửi các trường hợp ngoại lệ nếu bạn gọi các hàm này từ một CoroutineScope không thuộc phạm vi của Cấu trúc.

Giả sử nội dung của ngăn ứng dụng là động và bạn cần tìm nạp cũng như làm mới nội dung đó từ lớp dữ liệu sau khi đóng. Bạn nên chuyển trạng thái ngăn lên ViewModel để có thể gọi cả logic nghiệp vụ và giao diện người dùng trên phần tử này từ chủ sở hữu trạng thái.

Tuy nhiên, việc gọi phương thức close() của DrawerStatebằng viewModelScope từ giao diện người dùng Compose sẽ gây ra ngoại lệ về thời gian chạy thuộc loại IllegalStateException có thông báo cho biết “MonotonicFrameClock không có trong CoroutineContext” này.

Để khắc phục vấn đề này, hãy sử dụng CoroutineScope trong phạm vi Cấu trúc. Khi đó, bạn sẽ có được MonotonicFrameClock trong CoroutineContext cần thiết để các hàm tạm ngưng hoạt động.

Để khắc phục sự cố này, hãy chuyển CoroutineContext của coroutine trong ViewModel thành một coroutine trong phạm vi Cấu trúc. Mã có thể như sau:

class ConversationViewModel(...) : ViewModel() {

   val drawerState = DrawerState(initialValue = DrawerValue.Closed)

   private val _drawerContent = MutableStateFlow<DrawerContent>(DrawerContent.Empty)
   val drawerContent: StateFlow<DrawerContent> = _drawerContent.asStateFlow()

   fun closeDrawer(uiScope: CoroutineScope) {
        viewModelScope.launch {
            withContext(uiScope.coroutineContext) {  // Use instead of the default context
                drawerState.close()
            }
            // Fetch drawer content and update state
            _drawerContent.update { ... }
        }
   }
}

// in Compose

@Composable
private fun ConversationScreen(
    conversationViewModel = viewModel()
) {

    val scope = rememberCoroutineScope()

    ConversationScreen(onCloseDrawer = { viewModel.closeDrawer(uiScope = scope) })

}

Tìm hiểu thêm

Để tìm hiểu thêm về trạng thái và Jetpack Compose, hãy tham khảo thêm các tài nguyên sau đây:

Mẫu

Lớp học lập trình

Video