Tạo trạng thái giao diện người dùng

Ngày nay, giao diện người dùng hiếm khi ở dạng tĩnh. Trạng thái của giao diện người dùng thay đổi khi người dùng tương tác với giao diện người dùng hoặc khi ứng dụng cần hiển thị dữ liệu mới.

Tài liệu này đặt ra các nguyên tắc về việc tạo và quản lý trạng thái giao diện người dùng. Khi đọc hết tài liệu, bạn sẽ:

  • Biết được loại API nào nên sử dụng để tạo trạng thái giao diện người dùng. Điều này phụ thuộc vào bản chất của các nguồn tạo ra sự thay đổi trạng thái có trong phần tử giữ trạng thái, theo các nguyên tắc luồng dữ liệu một chiều.
  • Biết cách xác định phạm vi tạo trạng thái giao diện người dùng để nắm rõ tài nguyên hệ thống.
  • Biết cách hiển thị trạng thái giao diện người dùng để giao diện người dùng sử dụng.

Về cơ bản, tạo trạng thái là việc tăng cường áp dụng dần những thay đổi này đối với trạng thái giao diện người dùng. Trạng thái luôn tồn tại và thay đổi do các sự kiện. Bảng dưới đây tóm tắt các điểm khác biệt giữa sự kiện và trạng thái:

Sự kiện Trạng thái
Tạm thời, không thể đoán trước và tồn tại trong một khoảng thời gian có hạn. Luôn tồn tại.
Dữ kiện đầu vào của quá trình tạo trạng thái. Là kết quả của quá trình tạo trạng thái.
Là sản phẩm của giao diện người dùng hoặc các nguồn khác. Được giao diện người dùng sử dụng.

Một mẹo tuyệt vời để ghi nhớ những điều trên là trạng thái – thời gian; sự kiện – thời điểm. Sơ đồ dưới đây giúp bạn hình dung về sự thay đổi theo thời gian của trạng thái khi sự kiện xảy ra. Các phần tử giữ trạng thái thích hợp sẽ xử lý sự kiện liên quan, từ đó dẫn đến sự thay đổi trạng thái:

Sự kiện so với trạng thái
Hình 1: Sự kiện khiến trạng thái thay đổi

Sự kiện có thể đến từ:

  • Người dùng: Khi họ tương tác với giao diện người dùng của ứng dụng.
  • Các nguồn khác làm thay đổi trạng thái: Các API trình bày dữ liệu ứng dụng từ giao diện người dùng, miền hoặc các lớp dữ liệu như sự kiện hết thời gian chờ của thanh thông báo nhanh, các trường hợp sử dụng hoặc kho lưu trữ tương ứng.

Quy trình tạo trạng thái giao diện người dùng

Quy trình tạo trạng thái trong ứng dụng Android có thể được coi là một quy trình xử lý bao gồm:

  • Đầu vào: Nguồn thay đổi trạng thái. Các nguồn đó có thể:
    • Nằm trong lớp giao diện người dùng: Chẳng hạn như sự kiện do người dùng tạo (ví dụ: nhập tiêu đề cho "việc cần làm" trong ứng dụng quản lý công việc) hoặc từ các API cung cấp quyền truy cập vào logic giao diện người dùng dẫn đến sự thay đổi về trạng thái giao diện người dùng. Ví dụ: gọi phương thức open trên DrawerState trong Jetpack Compose.
    • Bên ngoài lớp giao diện người dùng: Đây là các nguồn từ các lớp miền hoặc lớp dữ liệu gây ra những thay đổi đối với trạng thái giao diện người dùng. Ví dụ: tin tức đã tải xong từ NewsRepository hoặc các sự kiện khác.
    • Kết hợp cả 2 nguồn trên.
  • Phần tử giữ trạng thái: Là các kiểu áp dụng logic kinh doanh và/hoặc logic giao diện người dùng vào nguồn thay đổi trạng thái để xử lý các sự kiện của người dùng nhằm tạo trạng thái giao diện người dùng.
  • Đầu ra: Là trạng thái giao diện người dùng mà ứng dụng có thể hiển thị để cung cấp cho người dùng thông tin họ cần.
Quy trình tạo trạng thái
Hình 2: Quy trình tạo trạng thái

API tạo trạng thái

Có 2 API chính được dùng trong quá trình tạo trạng thái tuỳ thuộc vào giai đoạn của quy trình mà bạn đang thực hiện:

Giai đoạn của quy trình API
Đầu vào Bạn nên sử dụng các API không đồng bộ để thực hiện công việc ngoài luồng giao diện người dùng, nhờ đó, giao diện người dùng sẽ không bị giật. Ví dụ: Coroutine hoặc Flow trong Kotlin, RxJava hoặc các lệnh gọi lại bằng Ngôn ngữ lập trình Java.
Đầu ra Bạn nên sử dụng các API phần tử giữ dữ liệu có thể ghi nhận được để vô hiệu hoá và hiển thị lại giao diện người dùng khi trạng thái thay đổi. Ví dụ: StateFlow, Compose State hoặc LiveData. Phần tử giữ dữ liệu có thể ghi nhận được đảm bảo giao diện người dùng luôn có trạng thái giao diện người dùng để hiển thị trên màn hình

Trong 2 giai đoạn trên, việc lựa chọn API không đồng bộ cho đầu vào có ảnh hưởng lớn hơn đến bản chất của quy trình tạo trạng thái so với lựa chọn API có thể ghi nhận được cho đầu ra. Lý do là dữ liệu đầu vào mô tả cách xử lý có thể áp dụng cho quy trình.

Tập hợp quy trình tạo trạng thái

Các phần tiếp theo trình bày kỹ thuật tạo trạng thái phù hợp nhất với nhiều dữ liệu đầu vào và API đầu ra trùng khớp. Mỗi quy trình tạo trạng thái là một tổ hợp đầu vào và đầu ra. Quy trình này nên:

  • Nhận biết được vòng đời: Trong trường hợp giao diện người dùng không hiển thị hoặc đang hoạt động, quy trình tạo trạng thái sẽ không dùng bất kỳ tài nguyên nào trừ phi được yêu cầu rõ ràng.
  • Dễ dùng: Giao diện người dùng phải có thể dễ dàng hiển thị trạng thái giao diện người dùng đã tạo. Các cân nhắc về đầu ra của quy trình tạo trạng thái sẽ thay đổi tuỳ theo các View API (API khung hiển thị), chẳng hạn như Hệ thống khung hiển thị hoặc Jetpack Compose.

Đầu vào trong quy trình tạo trạng thái

Đầu vào trong quy trình tạo trạng thái có thể cung cấp nguồn thay đổi trạng thái thông qua:

  • Thao tác một lần có thể đồng bộ hoặc không đồng bộ, ví dụ: lệnh gọi đến các hàm suspend.
  • API luồng, ví dụ: Flows.
  • Tất cả các câu trên.

Các phần sau đây trình bày cách bạn có thể tập hợp quy trình tạo trạng thái cho mỗi đầu vào ở trên.

API một lần dưới dạng nguồn thay đổi trạng thái

Sử dụng API MutableStateFlow làm vùng chứa trạng thái có thể thay đổi và ghi nhận được. Trong các ứng dụng Jetpack Compose, bạn cũng có thể cân nhắc sử dụng mutableStateOf, đặc biệt là khi làm việc với API văn bản của Compose. Cả hai API đều cung cấp phương thức cho phép cập nhật an toàn không thể phân chia đối với các giá trị mà chúng lưu trữ, cho dù các bản cập nhật đồng bộ hay không đồng bộ.

Ví dụ: hãy xem xét thông tin cập nhật trạng thái trong một ứng dụng đơn giản về đổ xúc xắc. Mỗi lần người dùng đổ xúc xắc sẽ gọi phương thức Random.nextInt() đồng bộ và kết quả sẽ được ghi vào trạng thái giao diện người dùng.

StateFlow

data class DiceUiState(
    val firstDieValue: Int? = null,
    val secondDieValue: Int? = null,
    val numberOfRolls: Int = 0,
)

class DiceRollViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(DiceUiState())
    val uiState: StateFlow<DiceUiState> = _uiState.asStateFlow()

    // Called from the UI
    fun rollDice() {
        _uiState.update { currentState ->
            currentState.copy(
            firstDieValue = Random.nextInt(from = 1, until = 7),
            secondDieValue = Random.nextInt(from = 1, until = 7),
            numberOfRolls = currentState.numberOfRolls + 1,
            )
        }
    }
}

Trạng thái Compose

@Stable
interface DiceUiState {
    val firstDieValue: Int?
    val secondDieValue: Int?
    val numberOfRolls: Int?
}

private class MutableDiceUiState: DiceUiState {
    override var firstDieValue: Int? by mutableStateOf(null)
    override var secondDieValue: Int? by mutableStateOf(null)
    override var numberOfRolls: Int by mutableStateOf(0)
}

class DiceRollViewModel : ViewModel() {

    private val _uiState = MutableDiceUiState()
    val uiState: DiceUiState = _uiState

    // Called from the UI
    fun rollDice() {
        _uiState.firstDieValue = Random.nextInt(from = 1, until = 7)
        _uiState.secondDieValue = Random.nextInt(from = 1, until = 7)
        _uiState.numberOfRolls = _uiState.numberOfRolls + 1
    }
}

Thay đổi trạng thái giao diện người dùng từ các lệnh gọi không đồng bộ

Đối với các thay đổi trạng thái đòi hỏi kết quả không đồng bộ, hãy chạy Coroutine trong CoroutineScope thích hợp. Điều này cho phép ứng dụng loại bỏ công việc khi CoroutineScope bị huỷ. Sau đó, phần tử giữ trạng thái sẽ ghi kết quả của lệnh gọi phương thức tạm ngưng vào API có thể ghi nhận được dùng để hiển thị trạng thái giao diện người dùng.

Ví dụ: hãy xem xét AddEditTaskViewModel trong mẫu Kiến trúc. Khi phương thức saveTask() tạm ngưng lưu một tác vụ theo cách không đồng bộ, phương thức update trên MutableStateFlow sẽ cập nhật sự thay đổi trạng thái cho trạng thái giao diện người dùng.

StateFlow

data class AddEditTaskUiState(
    val title: String = "",
    val description: String = "",
    val isTaskCompleted: Boolean = false,
    val isLoading: Boolean = false,
    val userMessage: String? = null,
    val isTaskSaved: Boolean = false
)

class AddEditTaskViewModel(...) : ViewModel() {

   private val _uiState = MutableStateFlow(AddEditTaskUiState())
   val uiState: StateFlow<AddEditTaskUiState> = _uiState.asStateFlow()

   private fun createNewTask() {
        viewModelScope.launch {
            val newTask = Task(uiState.value.title, uiState.value.description)
            try {
                tasksRepository.saveTask(newTask)
                // Write data into the UI state.
                _uiState.update {
                    it.copy(isTaskSaved = true)
                }
            }
            catch(cancellationException: CancellationException) {
                throw cancellationException
            }
            catch(exception: Exception) {
                _uiState.update {
                    it.copy(userMessage = getErrorMessage(exception))
                }
            }
        }
    }
}

Trạng thái Compose

@Stable
interface AddEditTaskUiState {
    val title: String
    val description: String
    val isTaskCompleted: Boolean
    val isLoading: Boolean
    val userMessage: String?
    val isTaskSaved: Boolean
}

private class MutableAddEditTaskUiState : AddEditTaskUiState() {
    override var title: String by mutableStateOf("")
    override var description: String by mutableStateOf("")
    override var isTaskCompleted: Boolean by mutableStateOf(false)
    override var isLoading: Boolean by mutableStateOf(false)
    override var userMessage: String? by mutableStateOf<String?>(null)
    override var isTaskSaved: Boolean by mutableStateOf(false)
}

class AddEditTaskViewModel(...) : ViewModel() {

   private val _uiState = MutableAddEditTaskUiState()
   val uiState: AddEditTaskUiState = _uiState

   private fun createNewTask() {
        viewModelScope.launch {
            val newTask = Task(uiState.value.title, uiState.value.description)
            try {
                tasksRepository.saveTask(newTask)
                // Write data into the UI state.
                _uiState.isTaskSaved = true
            }
            catch(cancellationException: CancellationException) {
                throw cancellationException
            }
            catch(exception: Exception) {
                _uiState.userMessage = getErrorMessage(exception))
            }
        }
    }
}

Thay đổi trạng thái giao diện người dùng từ các luồng trong nền

Bạn nên chạy Coroutine trên trình điều phối chính để tạo trạng thái giao diện người dùng, tức là chạy bên ngoài khối withContext trong các đoạn mã dưới đây. Tuy nhiên, nếu cần cập nhật trạng thái giao diện người dùng trong ngữ cảnh nền khác, bạn có thể thực hiện bằng cách sử dụng các API sau:

  • Sử dụng phương thức withContext để chạy Coroutine trong một ngữ cảnh đồng thời khác.
  • Khi sử dụng MutableStateFlow, hãy dùng phương thức update như bình thường.
  • Khi sử dụng Trạng thái Compose, hãy dùng Snapshot.withMutableSnapshot để đảm bảo cập nhật không thể phân chia cho Trạng thái trong ngữ cảnh đồng thời.

Ví dụ: Trong đoạn mã DiceRollViewModel dưới đây, giả sử rằng SlowRandom.nextInt() là một hàm suspend tính toán chuyên sâu cần được gọi từ Coroutine ràng buộc của CPU.

StateFlow

class DiceRollViewModel(
    private val defaultDispatcher: CoroutineScope = Dispatchers.Default
) : ViewModel() {

    private val _uiState = MutableStateFlow(DiceUiState())
    val uiState: StateFlow<DiceUiState> = _uiState.asStateFlow()

  // Called from the UI
  fun rollDice() {
        viewModelScope.launch() {
            // Other Coroutines that may be called from the current context
            …
            withContext(defaultDispatcher) {
                _uiState.update { currentState ->
                    currentState.copy(
                        firstDieValue = SlowRandom.nextInt(from = 1, until = 7),
                        secondDieValue = SlowRandom.nextInt(from = 1, until = 7),
                        numberOfRolls = currentState.numberOfRolls + 1,
                    )
                }
            }
        }
    }
}

Trạng thái Compose

class DiceRollViewModel(
    private val defaultDispatcher: CoroutineScope = Dispatchers.Default
) : ViewModel() {

    private val _uiState = MutableDiceUiState()
    val uiState: DiceUiState = _uiState

  // Called from the UI
  fun rollDice() {
        viewModelScope.launch() {
            // Other Coroutines that may be called from the current context
            …
            withContext(defaultDispatcher) {
                Snapshot.withMutableSnapshot {
                    _uiState.firstDieValue = SlowRandom.nextInt(from = 1, until = 7)
                    _uiState.secondDieValue = SlowRandom.nextInt(from = 1, until = 7)
                    _uiState.numberOfRolls = _uiState.numberOfRolls + 1
                }
            }
        }
    }
}

API luồng dưới dạng nguồn thay đổi trạng thái

Đối với các nguồn thay đổi trạng thái tạo ra nhiều giá trị theo thời gian trong luồng, việc tổng hợp đầu ra của tất cả các nguồn thành một tổng thể nhất quán là phương pháp đơn giản để tạo trạng thái.

Khi sử dụng Luồng Kotlin, bạn có thể đạt được điều này bằng hàm combine. Bạn có thể xem ví dụ về vấn đề này trong mẫu "Now in Android" trong InterestsViewModel:

class InterestsViewModel(
    authorsRepository: AuthorsRepository,
    topicsRepository: TopicsRepository
) : ViewModel() {

    val uiState = combine(
        authorsRepository.getAuthorsStream(),
        topicsRepository.getTopicsStream(),
    ) { availableAuthors, availableTopics ->
        InterestsUiState.Interests(
            authors = availableAuthors,
            topics = availableTopics
        )
    }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = InterestsUiState.Loading
    )
}

Việc sử dụng toán tử stateIn để tạo StateFlows sẽ giúp giao diện người dùng kiểm soát chặt chẽ hơn hoạt động của quy trình tạo trạng thái vì giao diện này có thể chỉ cần hoạt động khi giao diện người dùng hiển thị.

  • Sử dụng SharingStarted.WhileSubscribed() nếu quy trình chỉ hoạt động khi giao diện người dùng hiển thị trong khi thu thập luồng theo cách nhận biết vòng đời.
  • Sử dụng SharingStarted.Lazily nếu quy trình sẽ hoạt động miễn là người dùng có thể quay lại giao diện người dùng, tức là giao diện người dùng nằm trong ngăn xếp lui hoặc trong một thẻ khác ngoài màn hình.

Trong trường hợp không áp dụng tính năng tổng hợp các nguồn trạng thái dựa trên luồng, các API luồng như Kotlin Flows (Luồng Kotlin) sẽ cung cấp nhiều kiểu biến đổi như hợp nhất, .làm phẳng và các kiểu biến đổi khác để giúp xử lý luồng vào trạng thái giao diện người dùng.

API một lần và API luồng dưới dạng các nguồn thay đổi trạng thái

Trong trường hợp quy trình tạo trạng thái phụ thuộc vào cả lệnh gọi một lần và luồng dưới dạng nguồn thay đổi trạng thái, thì luồng là điều kiện ràng buộc xác định. Do đó, hãy chuyển đổi các lệnh gọi một lần thành API luồng hoặc chuyển đầu ra của chúng vào các luồng và tiếp tục xử lý như mô tả trong mục luồng ở trên.

Với luồng, điều này thường có nghĩa là tạo một hoặc nhiều thực thể MutableStateFlow riêng tư để áp dụng các thay đổi trạng thái. Bạn cũng có thể tạo quy trình tổng quan nhanh từ trạng thái Compose.

Hãy xem xét TaskDetailViewModel trong kho lưu trữ architecture-samples bên dưới:

StateFlow

class TaskDetailViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val _isTaskDeleted = MutableStateFlow(false)
    private val _task = tasksRepository.getTaskStream(taskId)

    val uiState: StateFlow<TaskDetailUiState> = combine(
        _isTaskDeleted,
        _task
    ) { isTaskDeleted, task ->
        TaskDetailUiState(
            task = taskAsync.data,
            isTaskDeleted = isTaskDeleted
        )
    }
        // Convert the result to the appropriate observable API for the UI
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = TaskDetailUiState()
        )

    fun deleteTask() = viewModelScope.launch {
        tasksRepository.deleteTask(taskId)
        _isTaskDeleted.update { true }
    }
}

Trạng thái Compose

class TaskDetailViewModel @Inject constructor(
    private val tasksRepository: TasksRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private var _isTaskDeleted by mutableStateOf(false)
    private val _task = tasksRepository.getTaskStream(taskId)

    val uiState: StateFlow<TaskDetailUiState> = combine(
        snapshotFlow { _isTaskDeleted },
        _task
    ) { isTaskDeleted, task ->
        TaskDetailUiState(
            task = taskAsync.data,
            isTaskDeleted = isTaskDeleted
        )
    }
        // Convert the result to the appropriate observable API for the UI
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = TaskDetailUiState()
        )

    fun deleteTask() = viewModelScope.launch {
        tasksRepository.deleteTask(taskId)
        _isTaskDeleted = true
    }
}

Các loại đầu ra trong quy trình tạo trạng thái

Việc lựa chọn API đầu ra cho trạng thái giao diện người dùng và bản chất của bản trình bày phụ thuộc phần lớn vào API mà ứng dụng dùng để hiển thị giao diện người dùng. Trong ứng dụng Android, bạn có thể chọn sử dụng View (Khung hiển thị) hoặc Jetpack Compose. Sau đây là những yếu tố nên cân nhắc:

Bảng sau đây tóm tắt những API cần sử dụng trong quy trình tạo trạng thái của bạn cho bất kỳ đầu vào và người tiêu dùng nào:

Đầu vào Người tiêu dùng Đầu ra
API một lần Khung hiển thị StateFlow hoặc LiveData
API một lần Compose StateFlow hoặc Compose State
API luồng Khung hiển thị StateFlow hoặc LiveData
API luồng Compose StateFlow
API một lần và API luồng Khung hiển thị StateFlow hoặc LiveData
API một lần và API luồng Compose StateFlow

Khởi động quy trình tạo trạng thái

Việc khởi động quy trình tạo trạng thái bao gồm việc đặt các điều kiện ban đầu để chạy quy trình đó. Điều này có thể liên quan đến việc cung cấp các giá trị đầu vào ban đầu cần để bắt đầu quy trình, chẳng hạn như id để xem chi tiết về một tin bài hoặc bắt đầu một quá trình tải không đồng bộ.

Bạn nên khởi động từng phần của quy trình tạo trạng thái khi có thể để tiết kiệm tài nguyên hệ thống. Trên thực tế, việc này thường có nghĩa là bạn sẽ phải đợi cho đến khi có một đối tượng sử dụng đầu ra. API Flow cho phép thực hiện việc này bằng đối số started trong phương thức stateIn. Trong trường hợp không áp dụng được, hãy xác định hàm initialize() không thay đổi để bắt đầu quy trình tạo trạng thái một cách rõ ràng như minh hoạ trong đoạn mã sau:

class MyViewModel : ViewModel() {

    private var initializeCalled = false

    // This function is idempotent provided it is only called from the UI thread.
    @MainThread
    fun initialize() {
        if(initializeCalled) return
        initializeCalled = true

        viewModelScope.launch {
            // seed the state production pipeline
        }
    }
}

Mẫu

Các mẫu sau đây của Google minh hoạ quá trình tạo trạng thái trong lớp giao diện người dùng. Hãy khám phá những mẫu đó để xem hướng dẫn này trong thực tế: