Peristiwa UI

Peristiwa UI adalah tindakan yang harus ditangani di lapisan UI, baik oleh UI atau oleh ViewModel. Jenis peristiwa yang paling umum adalah peristiwa pengguna. Pengguna menghasilkan peristiwa pengguna yang berinteraksi dengan aplikasi—misalnya, dengan mengetuk layar atau membuat gestur. UI kemudian memakai peristiwa ini menggunakan callback seperti pemroses onClick().

ViewModel biasanya bertanggung jawab untuk menangani logika bisnis peristiwa pengguna tertentu—misalnya, pengguna mengklik tombol untuk memuat ulang beberapa data. Biasanya, ViewModel menangani ini dengan mengekspos fungsi yang dapat dipanggil UI. Peristiwa pengguna juga dapat memiliki logika perilaku UI yang dapat ditangani UI secara langsung—misalnya, membuka layar yang berbeda atau menampilkan Snackbar.

Meskipun logika bisnis tetap sama untuk aplikasi yang sama di platform seluler atau faktor bentuk yang berbeda, logika perilaku UI adalah detail implementasi yang mungkin berbeda di antara kasus-kasus tersebut. Halaman lapisan UI menentukan jenis logika berikut:

  • Logika bisnis mengacu pada apa yang harus dilakukan dengan perubahan status—misalnya, melakukan pembayaran atau menyimpan preferensi pengguna. Domain dan lapisan data biasanya menangani logika ini. Dalam panduan ini, class ViewModel Komponen Arsitektur digunakan sebagai solusi yang tidak fleksibel untuk class yang menangani logika bisnis.
  • Logika perilaku UI atau logika UI mengacu pada cara menampilkan perubahan status—misalnya, logika navigasi atau cara menampilkan pesan ke pengguna. UI menangani logika ini.

Pohon keputusan peristiwa UI

Diagram berikut menunjukkan pohon keputusan untuk menemukan pendekatan terbaik dalam menangani kasus penggunaan peristiwa tertentu. Bagian selanjutnya dari panduan ini akan menjelaskan pendekatan ini secara mendetail.

Jika peristiwa berasal dari ViewModel, update status UI. Jika
    peristiwa berasal dari UI dan memerlukan logika bisnis, delegasikan
    logika bisnis ke ViewModel. Jika peristiwa berasal dari UI dan
    memerlukan logika perilaku UI, ubah status elemen UI secara langsung di
    UI.
Gambar 1. Pohon keputusan untuk menangani peristiwa.

Menangani peristiwa pengguna

UI dapat menangani peristiwa pengguna secara langsung jika peristiwa tersebut terkait dengan mengubah status elemen UI, misalnya, status item yang dapat diperluas. Jika peristiwa tersebut memerlukan logika bisnis seperti memuat ulang data di layar, peristiwa tersebut harus diproses oleh ViewModel.

Contoh berikut menunjukkan cara berbagai tombol digunakan untuk memperluas elemen UI (logika UI) dan untuk memuat ulang data di layar (logika bisnis):

View

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

Peristiwa pengguna di RecyclerViews

Jika tindakan dihasilkan lebih lanjut ke hierarki UI, seperti dalam item RecyclerView atau View khusus, ViewModel harus tetap menjadi peristiwa yang menangani pengguna.

Misalnya, semua item berita dari NewsActivity berisi tombol bookmark. ViewModel perlu mengetahui ID item berita yang diberi bookmark. Ketika pengguna mem-bookmark item berita, adaptor RecyclerView tidak memanggil fungsi addBookmark(newsId) yang terekspos dari ViewModel, yang akan memerlukan dependensi pada ViewModel. Sebagai gantinya, ViewModel mengekspos objek status yang disebut NewsItemUiState yang berisi implementasi untuk menangani peristiwa:

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

Dengan cara ini, adaptor RecyclerView hanya akan berfungsi dengan data yang diperlukan: daftar objek NewsItemUiState. Adaptor tidak memiliki akses ke seluruh ViewModel, sehingga cenderung tidak menyalahgunakan fungsionalitas yang diekspos oleh ViewModel. Jika hanya mengizinkan class aktivitas untuk dijalankan dengan ViewModel, sebaiknya Anda memisahkan tanggung jawab. Hal ini memastikan bahwa objek khusus UI seperti tampilan atau adaptor RecyclerView tidak berinteraksi langsung dengan ViewModel.

Konvensi penamaan untuk fungsi peristiwa pengguna

Dalam panduan ini, fungsi ViewModel yang menangani peristiwa pengguna diberi nama dengan kata kerja berdasarkan tindakan yang ditangani—misalnya: addBookmark(id) atau logIn(username, password).

Menangani peristiwa ViewModel

Tindakan UI yang berasal dari ViewModel—peristiwa ViewModel—harus selalu menghasilkan update status UI. Ini sesuai dengan prinsip-prinsip Aliran Data Searah. Hal ini membuat peristiwa dapat direproduksi setelah perubahan konfigurasi dan akan menjamin bahwa tindakan UI tidak akan hilang. Secara opsional, Anda juga dapat membuat peristiwa yang dapat direproduksi setelah proses dihentikan jika Anda menggunakan modul status tersimpan.

Memetakan tindakan UI ke status UI tidak selalu merupakan proses sederhana, tetapi cara ini menghasilkan logika yang lebih sederhana. Misalnya, proses pemikiran Anda tidak boleh berakhir dengan menentukan cara membuat UI membuka layar tertentu. Anda harus berpikir lebih jauh dan mempertimbangkan cara menggambarkan alur pengguna tersebut di status UI. Dengan kata lain: jangan pikirkan tentang tindakan yang harus dilakukan UI; pikirkan bagaimana tindakan tersebut memengaruhi status UI.

Misalnya, pertimbangkan kasus untuk membuka layar utama saat pengguna login di layar login. Anda dapat membuat model ini di status UI sebagai berikut:

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

UI ini bereaksi terhadap perubahan status isUserLoggedIn dan memilih ke tujuan yang benar sesuai kebutuhan:

View

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

Memakai peristiwa dapat memicu update status

Memakai peristiwa ViewModel tertentu di UI dapat mengakibatkan pengupdatean status UI lainnya. Misalnya, saat menampilkan pesan sementara di layar untuk memberi tahu pengguna bahwa ada sesuatu yang terjadi, UI harus memberi tahu ViewModel untuk memicu pengupdatean status lain saat pesan telah ditampilkan di layar. Status UI tersebut dapat dimodelkan sebagai berikut:

// 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 akan memperbarui status UI sebagai berikut ketika logika bisnis harus menampilkan pesan sementara baru kepada pengguna:

View

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 tidak perlu mengetahui cara UI menampilkan pesan di layar; tetapi hanya mengetahui bahwa ada pesan pengguna yang perlu ditampilkan. Setelah pesan sementara ditampilkan, UI perlu memberi tahu ViewModel tentang hal tersebut, yang menyebabkan pembaruan status UI lainnya:

View

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

Kasus penggunaan lainnya

Jika Anda merasa kasus penggunaan peristiwa UI tidak dapat diselesaikan dengan update status UI, Anda mungkin perlu mempertimbangkan kembali bagaimana data mengalir dalam aplikasi. Perhatikan prinsip-prinsip berikut:

  • Setiap kelas harus melakukan apa yang menjadi tanggung jawabnya, bukan lebih. UI bertanggung jawab atas logika perilaku khusus layar seperti panggilan navigasi, peristiwa klik, dan permintaan izin. ViewModel berisi logika bisnis dan mengonversi hasil dari lapisan hierarki bawah ke dalam status UI.
  • Pikirkan dari mana peristiwa itu berasal. Ikuti pohon keputusan yang ditampilkan di awal panduan ini, dan buat setiap class menangani tugas yang menjadi tanggung jawabnya. Misalnya, jika peristiwa berasal dari UI dan menghasilkan peristiwa navigasi, maka peristiwa tersebut harus ditangani di UI. Beberapa logika mungkin didelegasikan ke ViewModel, tetapi penanganan peristiwa tidak dapat didelegasikan sepenuhnya ke ViewModel.
  • Jika Anda memiliki beberapa konsumen dan Anda khawatir tentang peristiwa yang digunakan beberapa kali, Anda mungkin perlu mempertimbangkan kembali arsitektur aplikasi. Memiliki beberapa konsumen serentak menyebabkan kontrak dikirim tepat satu kali menjadi sangat sulit untuk dijamin sehingga jumlah kompleksitas dan perilaku yang halus meledak. Jika Anda mengalami masalah ini, pertimbangkan untuk mendorong masalah tersebut ke atas di hierarki UI; Anda mungkin memerlukan entitas yang berbeda dengan cakupan yang lebih tinggi dalam hierarki.
  • Pikirkan kapan status perlu digunakan. Dalam situasi tertentu, Anda mungkin tidak ingin terus menggunakan status saat aplikasi berada di latar belakang—misalnya, menampilkan Toast. Dalam kasus tersebut, pertimbangkan untuk menggunakan status saat UI berada di latar depan.