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 dengan cara 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.
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 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 details 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 LatestNewsScreen(viewModel: LatestNewsViewModel = viewModel()) {
// State of whether more details should be shown
var expanded by remember { mutableStateOf(false) }
Column {
Text("Some text")
if (expanded) {
Text("More details")
}
Button(
// The expand details event is processed by the UI that
// modifies this composable's internal state.
onClick = { expanded = !expanded }
) {
val expandText = if (expanded) "Collapse" else "Expand"
Text("$expandText details")
}
// 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")
}
}
}
Peristiwa pengguna di RecyclerViews
Jika tindakan dihasilkan lebih lanjut ke hierarki UI, seperti dalam
item RecyclerView
atau View
khusus, ViewModel
harus tetap
menangani peristiwa 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 fungsi 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 menyebabkan update pada 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 update status lain saat pesan telah ditampilkan di layar. Peristiwa yang terjadi ketika pengguna menggunakan pesan (dengan menutupnya atau setelah waktu tunggu habis) dapat dianggap sebagai "input pengguna" dan dengan demikian, ViewModel harus menyadari hal tersebut. Dalam situasi ini, status UI dapat dimodelkan sebagai berikut:
// Models the UI state for the Latest news screen.
data class LatestNewsUiState(
val news: List<News> = emptyList(),
val isLoading: Boolean = false,
val userMessage: String? = null
)
ViewModel akan mengupdate 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 ->
currentUiState.copy(userMessage = "No Internet connection")
}
return@launch
}
// Do something else.
}
}
fun userMessageShown() {
_uiState.update { currentUiState ->
currentUiState.copy(userMessage = null)
}
}
}
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()) {
uiState = uiState.copy(userMessage = "No Internet connection")
return@launch
}
// Do something else.
}
}
fun userMessageShown() {
uiState = uiState.copy(userMessage = null)
}
}
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 update status UI lainnya menghapus properti userMessage
:
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.userMessage?.let {
// TODO: Show Snackbar with userMessage.
// Once the message is displayed and
// dismissed, notify the ViewModel.
viewModel.userMessageShown()
}
...
}
}
}
}
}
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 it and notify the ViewModel.
viewModel.uiState.userMessage?.let { userMessage ->
LaunchedEffect(userMessage) {
snackbarHostState.showSnackbar(userMessage)
// Once the message is displayed and dismissed, notify the ViewModel.
viewModel.userMessageShown()
}
}
}
Meskipun pesan bersifat sementara, status UI adalah representasi setia dari apa yang ditampilkan di layar pada setiap titik waktu. Pesan pengguna akan ditampilkan atau tidak.
Peristiwa navigasi
Bagian Menggunakan peristiwa dapat memicu update status menjelaskan cara Anda menggunakan status UI untuk menampilkan pesan pengguna di layar. Peristiwa navigasi juga merupakan jenis peristiwa yang umum di aplikasi Android.
Jika peristiwa dipicu di UI karena pengguna mengetuk tombol, UI akan menanganinya dengan memanggil pengontrol navigasi atau mengekspos peristiwa ke composable pemanggil yang sesuai.
View
class LoginActivity : AppCompatActivity() {
private lateinit var binding: ActivityLoginBinding
private val viewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
binding.helpButton.setOnClickListener {
navController.navigate(...) // Open help screen
}
}
}
Compose
@Composable
fun LoginScreen(
onHelp: () -> Unit, // Caller navigates to the right screen
viewModel: LoginViewModel = viewModel()
) {
// Rest of the UI
Button(onClick = onHelp) {
Text("Get help")
}
}
Jika input data memerlukan validasi logika bisnis sebelum bernavigasi, ViewModel harus mengekspos status tersebut ke UI. UI akan bereaksi terhadap perubahan status tersebut dan menavigasi dengan sesuai. Bagian Menangani peristiwa ViewModel membahas kasus penggunaan ini. Berikut kode yang serupa:
View
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
@Composable
fun LoginScreen(
onUserLogIn: () -> Unit, // Caller navigates to the right screen
viewModel: LoginViewModel = viewModel()
) {
Button(
onClick = {
// ViewModel validation is triggered
viewModel.login()
}
) {
Text("Log in")
}
// Rest of the UI
val lifecycle = LocalLifecycleOwner.current.lifecycle
val currentOnUserLogIn by rememberUpdatedState(onUserLogIn)
LaunchedEffect(viewModel, lifecycle) {
// Whenever the uiState changes, check if the user is logged in and
// call the `onUserLogin` event when `lifecycle` is at least STARTED
snapshotFlow { viewModel.uiState }
.filter { it.isUserLoggedIn }
.flowWithLifecycle(lifecycle)
.collect {
currentOnUserLogIn()
}
}
}
Dalam contoh di atas, aplikasi berfungsi seperti yang diharapkan karena tujuan saat ini, Login, tidak akan disimpan dalam data sebelumnya. Pengguna tidak dapat kembali ke tempat tersebut jika menekan kembali. Namun, jika hal itu terjadi, solusi akan memerlukan logika tambahan.
Peristiwa navigasi saat tujuan disimpan di data sebelumnya
Jika ViewModel menetapkan status yang menghasilkan peristiwa navigasi dari layar A ke layar B dan layar A disimpan di data navigasi sebelumnya, Anda mungkin memerlukan logika tambahan agar tidak terus otomatis melanjutkan ke B. Untuk menerapkannya, diperlukan status tambahan yang menunjukkan apakah UI harus mempertimbangkan navigasi ke layar lain atau tidak. Biasanya, status tersebut disimpan di UI karena logika Navigasi merupakan masalah UI, bukan ViewModel. Untuk menggambarkan hal ini, mari kita lihat kasus penggunaan berikut.
Katakanlah Anda sedang dalam proses pendaftaran aplikasi. Pada layar validasi tanggal lahir, saat pengguna memasukkan tanggal, tanggal akan divalidasi oleh ViewModel saat pengguna mengetuk tombol "Lanjutkan". ViewModel mendelegasikan logika validasi ke lapisan data. Jika tanggal valid, pengguna akan membuka layar berikutnya. Sebagai fitur tambahan, pengguna dapat berpindah-pindah di antara berbagai layar pendaftaran jika ingin mengubah beberapa data. Oleh karena itu, semua tujuan dalam alur pendaftaran disimpan dalam data sebelumnya yang sama. Dengan persyaratan ini, Anda dapat menerapkan layar ini sebagai berikut:
View
// Key that identifies the `validationInProgress` state in the Bundle
private const val DOB_VALIDATION_KEY = "dobValidationKey"
class DobValidationFragment : Fragment() {
private var validationInProgress: Boolean = false
private val viewModel: DobValidationViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val binding = // ...
validationInProgress = savedInstanceState?.getBoolean(DOB_VALIDATION_KEY) ?: false
binding.continueButton.setOnClickListener {
viewModel.validateDob()
validationInProgress = true
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiState
.flowWithLifecycle(viewLifecycleOwner.lifecycle)
.collect { uiState ->
// Update other parts of the UI ...
// If the input is valid and the user wants
// to navigate, navigate to the next screen
// and reset `validationInProgress` flag
if (uiState.isDobValid && validationInProgress) {
validationInProgress = false
navController.navigate(...) // Navigate to next screen
}
}
}
return binding
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(DOB_VALIDATION_KEY, validationInProgress)
}
}
Compose
class DobValidationViewModel(/* ... */) : ViewModel() {
var uiState by mutableStateOf(DobValidationUiState())
private set
}
@Composable
fun DobValidationScreen(
onNavigateToNextScreen: () -> Unit, // Caller navigates to the right screen
viewModel: DobValidationViewModel = viewModel()
) {
// TextField that updates the ViewModel when a date of birth is selected
var validationInProgress by rememberSaveable { mutableStateOf(false) }
Button(
onClick = {
viewModel.validateInput()
validationInProgress = true
}
) {
Text("Continue")
}
// Rest of the UI
/*
* The following code implements the requirement of advancing automatically
* to the next screen when a valid date of birth has been introduced
* and the user wanted to continue with the registration process.
*/
if (validationInProgress) {
val lifecycle = LocalLifecycleOwner.current.lifecycle
val currentNavigateToNextScreen by rememberUpdatedState(onNavigateToNextScreen)
LaunchedEffect(viewModel, lifecycle) {
// If the date of birth is valid and the validation is in progress,
// navigate to the next screen when `lifecycle` is at least STARTED,
// which is the default Lifecycle.State for the `flowWithLifecycle` operator.
snapshotFlow { viewModel.uiState }
.filter { it.isDobValid }
.flowWithLifecycle(lifecycle)
.collect {
validationInProgress = false
currentNavigateToNextScreen()
}
}
}
}
Validasi tanggal lahir adalah logika bisnis yang menjadi tanggung jawab
ViewModel. Biasanya, ViewModel akan mendelegasikan logika tersebut ke
lapisan data. Logika untuk mengarahkan pengguna ke layar berikutnya
adalah logika UI karena persyaratan ini dapat berubah bergantung pada konfigurasi
UI. Misalnya, Anda mungkin tidak ingin melanjutkan secara otomatis ke
layar lain di tablet jika menampilkan beberapa langkah pendaftaran
secara bersamaan. Variabel validationInProgress
dalam kode di atas mengimplementasikan
fungsi ini dan menangani apakah UI harus dinavigasi
secara otomatis setiap kali tanggal lahir valid dan pengguna ingin
melanjutkan ke langkah pendaftaran berikut.
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.
Contoh
Contoh Google berikut menunjukkan peristiwa UI di lapisan UI. Jelajahi untuk melihat panduan ini dalam praktik:
Direkomendasikan untuk Anda
- Catatan: teks link ditampilkan saat JavaScript nonaktif
- Lapisan UI
- Holder status dan Status UI {:#mad-arch}
- Panduan untuk arsitektur aplikasi