Produksi Status UI

UI modern jarang bersifat statis. Status UI berubah saat pengguna berinteraksi dengan UI atau saat aplikasi harus menampilkan data baru.

Dokumen ini menentukan pedoman untuk produksi dan pengelolaan status UI. Di akhir proses, Anda akan dapat:

  • Mengetahui API yang harus digunakan untuk menghasilkan status UI. Jenis API tersebut bergantung pada sumber perubahan status yang tersedia di holder status, dengan mengikuti prinsip aliran data searah.
  • Mengetahui cara menentukan cakupan produksi status UI untuk memahami resource sistem.
  • Mengetahui cara menampilkan status UI untuk pemakaian oleh UI.

Pada dasarnya, produksi status adalah penerapan inkremental dari perubahan ini pada status UI. Status selalu ada, dan berubah sebagai akibat dari peristiwa. Perbedaan antara peristiwa dan status dirangkum dalam tabel di bawah:

Peristiwa Negara Bagian
Bersifat sementara, tidak dapat diprediksi, dan ada untuk periode yang terbatas. Selalu ada.
Input produksi status. Output produksi status.
Produk UI atau sumber lainnya. Digunakan oleh UI.

Agar mudah diingat, hal-hal di atas dapat dirangkum sebagai berikut: status adalah; peristiwa terjadi. Diagram di bawah ini membantu memvisualisasikan perubahan status saat peristiwa terjadi di linimasa. Setiap peristiwa diproses oleh holder status yang sesuai dan menghasilkan perubahan status:

Peristiwa vs. status
Gambar 1: Peristiwa mengakibatkan perubahan status

Peristiwa dapat berasal dari:

  • Pengguna: Saat pengguna berinteraksi dengan UI aplikasi.
  • Sumber perubahan status lainnya: API yang menampilkan data aplikasi dari UI, domain, atau lapisan data seperti peristiwa waktu tunggu snackbar, kasus penggunaan, atau repositori.

Pipeline produksi status UI

Produksi status di aplikasi Android dapat dianggap sebagai pipeline pemrosesan yang terdiri dari:

  • Input: Sumber perubahan status. Input ini mungkin saja:
    • Lokal untuk lapisan UI: Dapat berupa peristiwa pengguna seperti pengguna yang memasukkan judul untuk "daftar tugas" di aplikasi pengelolaan tugas, atau API yang menyediakan akses ke logika UI yang memicu perubahan dalam status UI. Misalnya, memanggil metode open di DrawerState di Jetpack Compose.
    • Eksternal ke lapisan UI: Merupakan sumber dari lapisan domain atau data yang menyebabkan perubahan pada status UI. Misalnya, berita yang selesai dimuat dari NewsRepository atau peristiwa lainnya.
    • Kombinasi dari semua hal di atas.
  • Holder status: Jenis yang menerapkan logika bisnis dan/atau logika UI ke sumber perubahan status dan memproses peristiwa pengguna untuk menghasilkan status UI.
  • Output: Status UI yang dapat dirender oleh aplikasi untuk memberikan informasi yang dibutuhkan pengguna.
Pipeline produksi status
Gambar 2: Pipeline produksi status

API produksi status

Ada dua API utama yang digunakan dalam produksi status, bergantung pada tahap pipeline yang sedang Anda lalui:

Tahap pipeline API
Input Anda harus menggunakan API asinkron untuk menjalankan pekerjaan di luar UI thread agar UI tetap bebas jank. Misalnya, Coroutine atau Flow di Kotlin, dan RxJava atau callback di Bahasa Pemrograman Java.
Output Anda harus menggunakan API holder data yang dapat diamati untuk membatalkan validasi dan merender ulang UI saat status berubah. Misalnya, StateFlow, Compose State, atau LiveData. Holder data yang dapat diamati menjamin UI selalu memiliki status UI untuk ditampilkan di layar

Dari dua pilihan tersebut, pilihan API asinkron untuk input memiliki pengaruh yang lebih besar terhadap sifat pipeline produksi status daripada pilihan API yang dapat diamati untuk output. Hal ini karena input mendikte jenis pemrosesan yang dapat diterapkan ke pipeline.

Penyusunan pipeline produksi status

Bagian berikutnya membahas teknik produksi status yang paling sesuai untuk berbagai input, dan API output yang cocok. Setiap pipeline produksi status merupakan kombinasi input dan output dan harus:

  • Memperhatikan siklus proses: Jika UI tidak terlihat atau aktif, pipeline produksi status tidak boleh menggunakan resource apa pun kecuali jika secara eksplisit diperlukan.
  • Mudah digunakan: UI harus dapat dengan mudah merender status UI yang dihasilkan. Pertimbangan untuk output pipeline produksi status akan berbeda-beda di berbagai View API seperti sistem View atau Jetpack Compose.

Input dalam pipeline produksi status

Input dalam pipeline produksi status dapat memberikan sumber perubahan status melalui:

  • Operasi satu kali yang mungkin bersifat sinkron atau asinkron, misalnya panggilan ke fungsi suspend.
  • API stream, misalnya Flows.
  • Semua yang di atas.

Bagian berikut membahas cara menyusun pipeline produksi status untuk setiap input di atas.

API satu kali sebagai sumber perubahan status

Gunakan MutableStateFlow API sebagai penampung status yang dapat diamati dan berubah. Di aplikasi Jetpack Compose, Anda juga dapat mempertimbangkan mutableStateOf terutama saat menggunakan API teks Compose. Kedua API menawarkan metode yang memungkinkan update atomik yang aman pada nilai yang dihosting, baik update tersebut bersifat sinkron maupun asinkron.

Misalnya, update status di aplikasi lempar dadu yang sederhana. Setiap lemparan dadu dari pengguna akan memanggil metode Random.nextInt() sinkron, dan hasilnya ditulis ke dalam status UI.

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

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

Mengubah status UI dari panggilan asinkron

Untuk perubahan status yang memerlukan hasil asinkron, luncurkan Coroutine di CoroutineScope yang sesuai. Tindakan ini memungkinkan aplikasi menghapus pekerjaan saat CoroutineScope dibatalkan. Holder status kemudian akan menulis hasil panggilan metode penangguhan ke API yang dapat diamati yang digunakan untuk menampilkan status UI.

Misalnya, pertimbangkan AddEditTaskViewModel dalam contoh Arsitektur. Jika metode saveTask() penangguhan menyimpan tugas secara asinkron, metode update di MutableStateFlow akan menyebarkan perubahan status ke status UI.

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

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

Mengubah status UI dari thread latar belakang

Sebaiknya luncurkan Coroutine pada dispatcher utama untuk produksi status UI. Artinya, di luar blok withContext dalam cuplikan kode di bawah. Namun, jika harus memperbarui status UI dalam konteks latar belakang yang berbeda, Anda dapat melakukannya menggunakan API berikut:

  • Gunakan metode withContext untuk menjalankan Coroutine dalam konteks serentak yang berbeda.
  • Saat menggunakan MutableStateFlow, gunakan metode update seperti biasa.
  • Saat menggunakan Status Compose, gunakan Snapshot.withMutableSnapshot untuk memastikan update atomik ke Status dalam konteks serentak.

Misalnya, asumsikan dalam cuplikan DiceRollViewModel di bawah bahwa SlowRandom.nextInt() adalah fungsi suspend yang membutuhkan banyak komputasi, yang perlu dipanggil dari Coroutine yang terikat ke 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,
                    )
                }
            }
        }
    }
}

Status 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 stream sebagai sumber perubahan status

Untuk sumber perubahan status yang menghasilkan beberapa nilai seiring waktu dalam stream, agregasi output semua sumber menjadi satu kesatuan yang kohesif merupakan pendekatan yang mudah untuk produksi status.

Saat menggunakan Flow Kotlin, Anda dapat mencapainya dengan fungsi combine. Contohnya dapat dilihat dalam contoh "Now in Android" di 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
    )
}

Dengan menggunakan operator stateIn untuk membuat StateFlows, UI dapat mengontrol aktivitas pipeline produksi status dengan lebih terperinci karena mungkin hanya perlu aktif saat UI terlihat.

  • Gunakan SharingStarted.WhileSubscribed() jika pipeline hanya diizinkan aktif saat UI terlihat sambil mengumpulkan alur dengan cara yang mendukung siklus proses.
  • Gunakan SharingStarted.Lazily jika pipeline harus aktif selama pengguna dapat kembali ke UI, yaitu ketika UI berada di data sebelumnya, atau di tab lain di luar layar.

Jika agregasi sumber status berbasis stream tidak berlaku, API streaming seperti Flow Kotlin akan menawarkan beragam rangkaian transformasi seperti penggabungan, perataan, dan sebagainya untuk membantu pemrosesan streaming menjadi status UI.

API satu kali dan stream sebagai sumber perubahan status

Jika pipeline produksi status bergantung pada panggilan satu kali dan streaming sebagai sumber perubahan status, streaming akan menjadi batasan penentunya. Oleh karena itu, konversikan panggilan satu kali ke API stream, atau transfer outputnya ke dalam stream dan lanjutkan pemrosesan seperti yang dijelaskan di bagian stream di atas.

Dengan flow, tindakan ini biasanya berarti membuat satu atau beberapa instance MutableStateFlow pendukung pribadi untuk menyebarkan perubahan status. Anda juga dapat membuat alur snapshot dari status Compose.

Pertimbangkan TaskDetailViewModel dari repositori architecture-samples di bawah ini:

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

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

Jenis output dalam pipeline produksi status

Pilihan output API untuk status UI, dan sifat presentasinya sangat bergantung pada API yang digunakan oleh aplikasi Anda untuk merender UI. Di aplikasi Android, Anda dapat memilih untuk menggunakan View atau Jetpack Compose. Pertimbangan di sini meliputi:

Tabel berikut meringkas API yang akan digunakan untuk pipeline produksi status untuk input dan konsumen tertentu:

Input Konsumen Output
API satu kali View StateFlow atau LiveData
API satu kali Compose StateFlow atau Compose State
API stream View StateFlow atau LiveData
API stream Compose StateFlow
API satu kali dan stream View StateFlow atau LiveData
API satu kali dan stream Compose StateFlow

Inisialisasi pipeline produksi status

Untuk melakukan inisialisasi pipeline produksi status, Anda harus menetapkan kondisi awal agar pipeline dijalankan. Hal ini mungkin melibatkan penyediaan nilai input awal yang penting untuk awal pipeline, misalnya id untuk tampilan detail artikel berita, atau memulai pemuatan asinkron.

Jika memungkinkan, Anda harus melakukan inisialisasi pipeline produksi status dengan lambat untuk menghemat resource sistem. Dari segi kepraktisan, sering kali hal ini berarti menunggu sampai ada konsumen dari output. API Flow memungkinkan hal ini dengan argumen started dalam metode stateIn. Dalam kasus jika hal ini tidak berlaku, tentukan fungsi initialize() idempoten untuk memulai pipeline produksi status secara eksplisit seperti yang ditampilkan dalam cuplikan berikut:

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

Contoh

Contoh Google berikut menunjukkan produksi status di lapisan UI. Jelajahi untuk melihat panduan ini dalam praktik: