เหตุการณ์ UI คือการดำเนินการที่ควรจัดการในเลเยอร์ UI ไม่ว่าจะเป็นโดย UI หรือ ViewModel เหตุการณ์ประเภทที่พบบ่อยที่สุดคือ เหตุการณ์ของผู้ใช้ ผู้ใช้สร้างเหตุการณ์ของผู้ใช้โดยการโต้ตอบกับแอป เช่น การแตะหน้าจอหรือการสร้างท่าทางสัมผัส จากนั้น UI จะใช้เหตุการณ์เหล่านี้โดยใช้ การเรียกกลับ เช่น แลมบ์ดาที่กำหนดไว้ใน Composables ต่างๆ
โดยปกติแล้ว ViewModel จะมีหน้าที่จัดการตรรกะทางธุรกิจของเหตุการณ์ของผู้ใช้หนึ่งๆ เช่น ผู้ใช้คลิกปุ่มเพื่อรีเฟรชข้อมูลบางอย่าง โดยปกติแล้ว ViewModel จะจัดการเรื่องนี้ด้วยการแสดงฟังก์ชันที่ UI เรียกได้ เหตุการณ์ของผู้ใช้อาจมีตรรกะลักษณะการทำงานของ UI ที่ UI จัดการได้
โดยตรง เช่น การไปยังหน้าจออื่นหรือการแสดง
Snackbar
แม้ว่า ตรรกะทางธุรกิจ จะยังคงเหมือนเดิมสำหรับแอปเดียวกันในแพลตฟอร์มอุปกรณ์เคลื่อนที่หรือฟอร์มแฟกเตอร์ต่างๆ แต่ ตรรกะลักษณะการทำงานของ UI เป็นรายละเอียดการติดตั้งใช้งานที่อาจแตกต่างกันไปในแต่ละกรณี หน้าเลเยอร์ UI จะกำหนดตรรกะประเภทต่างๆ ดัง นี้
- ตรรกะทางธุรกิจ หมายถึง สิ่งที่ต้องทำ กับการเปลี่ยนแปลงสถานะ เช่น การชำระเงินหรือการจัดเก็บค่ากำหนดของผู้ใช้ โดยปกติแล้วเลเยอร์โดเมนและเลเยอร์ข้อมูลจะเป็นผู้จัดการตรรกะนี้ ตลอดทั้งคู่มือนี้ เราจะใช้คลาส Architecture Components ViewModel ของคอมโพเนนต์สถาปัตยกรรมเป็นโซลูชันที่แนะนำสำหรับคลาสที่จัดการตรรกะทางธุรกิจ
- ตรรกะลักษณะการทำงานของ UI หรือ ตรรกะ UI หมายถึง วิธีแสดง การเปลี่ยนแปลงสถานะ เช่น ตรรกะการนำทางหรือวิธีแสดงข้อความแก่ผู้ใช้ โดย UI จะเป็นผู้จัดการตรรกะนี้
แผนผังการตัดสินใจเกี่ยวกับเหตุการณ์ UI
แผนภาพต่อไปนี้แสดงแผนผังการตัดสินใจเพื่อค้นหาวิธีที่ดีที่สุดในการจัดการ Use Case ของเหตุการณ์หนึ่งๆ ส่วนที่เหลือของคู่มือนี้จะอธิบายแนวทางเหล่านี้โดยละเอียด
จัดการเหตุการณ์ของผู้ใช้
UI สามารถจัดการเหตุการณ์ของผู้ใช้ได้โดยตรงหากเหตุการณ์เหล่านั้นเกี่ยวข้องกับการแก้ไขสถานะขององค์ประกอบ UI เช่น สถานะของรายการที่ขยายได้ หากเหตุการณ์ต้องใช้ตรรกะทางธุรกิจ เช่น การรีเฟรชข้อมูลบนหน้าจอ ViewModel ควรเป็นผู้ประมวลผล
ตัวอย่างต่อไปนี้แสดงวิธีใช้ปุ่มต่างๆ เพื่อขยายองค์ประกอบ UI (ตรรกะ UI) และรีเฟรชข้อมูลบนหน้าจอ (ตรรกะทางธุรกิจ)
@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")
}
}
}
เหตุการณ์ของผู้ใช้ในรายการแบบ Lazy
หากการดำเนินการเกิดขึ้นในส่วนล่างของแผนผัง UI เช่น ในรายการ LazyColumn ViewModel ควรเป็นผู้จัดการเหตุการณ์ของผู้ใช้
ตัวอย่างเช่น พิจารณารายการรายการที่คลิกได้ อย่าส่งอินสแตนซ์ ViewModel ลงใน Composables ของรายการ (MyList) เนื่องจากวิธีนี้จะผูกคอมโพเนนต์ UI กับรายละเอียดการติดตั้งใช้งานอย่างแน่นหนา
แต่ให้แสดงเหตุการณ์เป็นพารามิเตอร์ฟังก์ชันแลมบ์ดาใน Composables วิธีนี้จะช่วยให้รายการทริกเกอร์เหตุการณ์ได้โดยไม่ต้องทราบว่าใครเป็นผู้จัดการหรือจัดการอย่างไร
data class MyItem(val id: Int)
@Composable
fun MyList(
items: List<String>,
onItemClick: (MyItem) -> Unit
) {
Card {
LazyColumn {
itemsIndexed(items) { index, string ->
ListItem(
modifier = Modifier.clickable {
onItemClick(MyItem(index))
},
headlineContent = {
Text(text = string)
}
)
}
}
}
}
ในแนวทางนี้ Composables MyList จะทำงานกับข้อมูลที่แสดงและเหตุการณ์ที่แสดงเท่านั้น โดยจะไม่มีสิทธิ์เข้าถึง ViewModel เหตุการณ์จะถูกยกขึ้นและส่งไปยัง ViewModel ใน Composables ก่อนหน้า
ดูข้อมูลเพิ่มเติมเกี่ยวกับการจัดการเหตุการณ์ได้ที่ เหตุการณ์ใน Compose
แบบแผนการตั้งชื่อสำหรับฟังก์ชันเหตุการณ์ของผู้ใช้และตัวจัดการเหตุการณ์
ในคู่มือนี้ ฟังก์ชัน ViewModel ที่จัดการเหตุการณ์ของผู้ใช้จะตั้งชื่อด้วยคำกริยาตามการดำเนินการที่ฟังก์ชันนั้นจัดการ เช่น validateInput() หรือ login()
ตัวจัดการเหตุการณ์ใน Compose เป็นไปตามแบบแผนการตั้งชื่อมาตรฐานเพื่อให้การไหลของข้อมูลชัดเจน ดังนี้
- ชื่อพารามิเตอร์:
on+Verb+Target(เช่นonExpandClickedหรือonValueChange) - นิพจน์แลมบ์ดา: เมื่อเรียก Composables แลมบ์ดามักจะเป็นเพียงการติดตั้งใช้งานเหตุการณ์นั้น
จัดการเหตุการณ์ ViewModel
การดำเนินการ UI ที่มาจาก ViewModel หรือเหตุการณ์ ViewModel ควร ส่งผลให้มีการอัปเดตสถานะ UI เสมอ ซึ่งเป็นไปตามหลักการของการไหลของข้อมูลแบบทิศทางเดียว วิธีนี้จะทำให้เหตุการณ์สามารถทำซ้ำได้หลังจากการเปลี่ยนแปลงการกำหนดค่า และรับประกันว่าการดำเนินการ UI จะไม่สูญหาย นอกจากนี้ คุณยังทำให้เหตุการณ์สามารถทำซ้ำได้หลังจากการสิ้นสุดการประมวลผลหากใช้โมดูลสถานะที่บันทึกไว้
การแมปการดำเนินการ UI กับสถานะ UI อาจไม่ใช่กระบวนการที่ง่ายเสมอไป แต่จะนำไปสู่ตรรกะที่ง่ายขึ้น กระบวนการคิดของคุณไม่ควรจบลงด้วยการกำหนดวิธีทำให้ UI ไปยังหน้าจอหนึ่งๆ เช่น คุณต้องคิดให้ไกลกว่านั้นและพิจารณาว่าจะแสดงโฟลว์ของผู้ใช้ดังกล่าวในสถานะ UI ได้อย่างไร กล่าวอีกนัยหนึ่งคือ อย่าคิดว่า UI ต้องดำเนินการใดบ้าง แต่ให้คิดว่า การดำเนินการเหล่านั้นส่งผลต่อสถานะ UI อย่างไร
ตัวอย่างเช่น พิจารณากรณีของหน้าจอเข้าสู่ระบบ คุณสามารถสร้างโมเดลสถานะ UI ของหน้าจอนี้ได้ดังนี้
data class LoginUiState(
val isLoginInProgress: Boolean = false,
val errorMessage: String? = null,
val isUserLoggedIn: Boolean = false
)
หน้าจอเข้าสู่ระบบจะตอบสนองต่อการเปลี่ยนแปลงสถานะ UI
class LoginViewModel : ViewModel() {
var uiState by mutableStateOf(LoginUiState())
fun tryLogin(username: String, password: String) {
viewModelScope.launch {
// Emit a new state indicating that login is in progress
uiState = uiState.copy(isLoginInProgress = true)
uiState = if (login(username, password)) {
// Emit a new state indicating that login was successful
uiState.copy(isLoginInProgress = false, isUserLoggedIn = true)
} else {
// Emit a new state with the error message
LoginUiState(isLoginInProgress = false, errorMessage = "Login failed")
}
}
}
private suspend fun login(username: String, password: String): Boolean {
delay(1000)
return (username == "Hello" && password == "World!")
}
}
@Composable
fun LoginScreen(viewModel: LoginViewModel, onSuccessfulLogin: () -> Unit) {
val uiState = viewModel.uiState
LaunchedEffect(uiState) {
if (uiState.isUserLoggedIn) {
onSuccessfulLogin()
}
}
if (uiState.isLoginInProgress) {
CircularProgressIndicator()
} else {
LoginForm(
onLoginAttempt = { username, password ->
viewModel.tryLogin(username, password)
},
errorMessage = uiState.errorMessage
)
}
}
การใช้เหตุการณ์อาจทริกเกอร์การอัปเดตสถานะ
การใช้เหตุการณ์ ViewModel บางอย่างใน UI อาจส่งผลให้มีการอัปเดตสถานะ UI อื่นๆ ตัวอย่างเช่น เมื่อแสดงข้อความชั่วคราวบนหน้าจอเพื่อแจ้งให้ผู้ใช้ทราบว่ามีบางอย่างเกิดขึ้น UI ต้องแจ้งให้ ViewModel ทริกเกอร์การอัปเดตสถานะอื่นเมื่อข้อความแสดงบนหน้าจอ เหตุการณ์ที่เกิดขึ้นเมื่อผู้ใช้ใช้ข้อความแล้ว (โดยการปิดข้อความหรือหลังจากหมดเวลา) สามารถถือเป็น "ข้อมูลจากผู้ใช้" และ ViewModel ควรทราบถึงเหตุการณ์ดังกล่าว ในสถานการณ์นี้ คุณสามารถสร้างโมเดลสถานะ UI ได้ดังนี้
// 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 จะอัปเดตสถานะ UI ดังนี้เมื่อตรรกะทางธุรกิจกำหนดให้แสดงข้อความชั่วคราวใหม่แก่ผู้ใช้
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 ไม่จำเป็นต้องทราบว่า UI แสดงข้อความบนหน้าจออย่างไร เพียงแต่ทราบว่ามีข้อความสำหรับผู้ใช้ที่ต้องแสดง เมื่อแสดงข้อความชั่วคราวแล้ว UI ต้องแจ้งให้ ViewModel ทราบ ซึ่งจะทำให้มีการอัปเดตสถานะ UI อีกครั้งเพื่อล้างพร็อพเพอร์ตี้ userMessage
@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()
}
}
}
แม้ว่าข้อความจะเป็นข้อความชั่วคราว แต่สถานะ UI ก็แสดงสิ่งที่แสดงบนหน้าจออย่างถูกต้องในทุกช่วงเวลา ไม่ว่าข้อความสำหรับผู้ใช้จะแสดงหรือไม่ก็ตาม
เหตุการณ์การนำทาง
ส่วนการใช้เหตุการณ์อาจทริกเกอร์การอัปเดตสถานะ จะอธิบายรายละเอียดเกี่ยวกับวิธีใช้สถานะ UI เพื่อแสดงข้อความสำหรับผู้ใช้บน หน้าจอ เหตุการณ์การนำทางยังเป็นเหตุการณ์ประเภทที่พบบ่อยในแอป Android
หากเหตุการณ์ทริกเกอร์ใน UI เนื่องจากผู้ใช้แตะปุ่ม UI จะจัดการเหตุการณ์นั้นโดยการแสดงเหตุการณ์ต่อ Composables ที่เรียก
@Composable
fun LoginScreen(
onHelp: () -> Unit, // Caller navigates to the help screen
viewModel: LoginViewModel = viewModel()
) {
// Rest of the UI
Button(
onClick = dropUnlessResumed { onHelp() }
) {
Text("Get help")
}
}
dropUnlessResumed เป็นส่วนหนึ่งของไลบรารี Lifecycle และช่วยให้คุณเรียกใช้ฟังก์ชัน onHelp ได้เฉพาะเมื่อ Lifecycle อยู่ในสถานะ RESUMED เป็นอย่างน้อย
หากอินพุตข้อมูลต้องมีการตรวจสอบตรรกะทางธุรกิจก่อนการนำทาง ViewModel จะต้องแสดงสถานะนั้นต่อ UI UI จะตอบสนองต่อการเปลี่ยนแปลงสถานะนั้นและนำทางตามความเหมาะสม ส่วนการจัดการเหตุการณ์ ViewModel จะครอบคลุม Use Case นี้ ตัวอย่างโค้ดที่คล้ายกันมีดังนี้
@Composable
fun LoginScreen(
onUserLogIn: () -> Unit, // Caller navigates to the right screen
viewModel: LoginViewModel = viewModel()
) {
Button(
onClick = {
// ViewModel validation is triggered
viewModel.tryLogin()
}
) {
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()
}
}
}
ในตัวอย่างข้างต้น แอปจะทำงานตามที่คาดไว้เนื่องจากระบบจะไม่เก็บปลายทางปัจจุบัน (เข้าสู่ระบบ) ไว้ใน Back Stack ผู้ใช้จะกลับไปยังปลายทางดังกล่าวไม่ได้หากกดปุ่มย้อนกลับ อย่างไรก็ตาม ในกรณีที่อาจเกิดขึ้นได้ โซลูชันจะต้องมีตรรกะเพิ่มเติม
เหตุการณ์การนำทางเมื่อเก็บปลายทางไว้ใน Back Stack
เมื่อ ViewModel ตั้งค่าสถานะบางอย่างที่สร้างเหตุการณ์การนำทางจากหน้าจอ A ไปยังหน้าจอ B และระบบเก็บหน้าจอ A ไว้ใน Back Stack การนำทาง คุณอาจต้องใช้ตรรกะเพิ่มเติมเพื่อไม่ให้ระบบไปยังหน้าจอ B โดยอัตโนมัติ หากต้องการติดตั้งใช้งานฟังก์ชันนี้ คุณต้องมีสถานะเพิ่มเติมเพื่อระบุว่า UI ควรนำทางไปยังหน้าจออื่นหรือไม่ โดยปกติแล้ว สถานะดังกล่าวจะอยู่ใน UI เนื่องจากตรรกะการนำทางเป็นหน้าที่ของ UI ไม่ใช่ ViewModel ตัวอย่างเช่น พิจารณา Use Case ต่อไปนี้
สมมติว่าคุณอยู่ในโฟลว์การลงทะเบียนของแอป ในหน้าจอการตรวจสอบ วันเกิด เมื่อผู้ใช้ป้อนวันที่ ViewModel จะตรวจสอบวันที่เมื่อผู้ใช้แตะปุ่ม "ดำเนินการต่อ" ViewModel จะมอบหมายตรรกะการตรวจสอบไปยังเลเยอร์ข้อมูล หากวันที่ถูกต้อง ผู้ใช้จะไปยังหน้าจอถัดไป นอกจากนี้ ผู้ใช้ยังสามารถย้อนกลับไปมาระหว่างหน้าจอการลงทะเบียนต่างๆ ได้ในกรณีที่ต้องการเปลี่ยนข้อมูลบางอย่าง ดังนั้น ระบบจะเก็บปลายทางทั้งหมดในโฟลว์การลงทะเบียนไว้ใน Back Stack เดียวกัน เมื่อพิจารณาข้อกำหนดเหล่านี้ คุณสามารถติดตั้งใช้งานหน้าจอนี้ได้ดังนี้
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()
}
}
}
}
การตรวจสอบวันเกิดเป็น ตรรกะทางธุรกิจ ที่ ViewModel มีหน้าที่รับผิดชอบ โดยส่วนใหญ่แล้ว ViewModel จะมอบหมายตรรกะดังกล่าวไปยังเลเยอร์ข้อมูล ตรรกะในการนำผู้ใช้ไปยังหน้าจอถัดไปคือ ตรรกะ UI เนื่องจากข้อกำหนดเหล่านี้อาจเปลี่ยนแปลงไปตามการกำหนดค่า UI ตัวอย่างเช่น คุณอาจไม่ต้องการไปยังหน้าจออื่นโดยอัตโนมัติในแท็บเล็ตหากแสดงขั้นตอนการลงทะเบียนหลายขั้นตอนพร้อมกัน ตัวแปร validationInProgress ในโค้ดด้านบนจะติดตั้งใช้งานฟังก์ชันนี้และจัดการว่า UI ควรนำทางโดยอัตโนมัติหรือไม่เมื่อวันเกิดถูกต้องและผู้ใช้ต้องการไปยังขั้นตอนการลงทะเบียนถัดไป
Use Case อื่นๆ
หากคิดว่า Use Case ของเหตุการณ์ UI ไม่สามารถแก้ไขได้ด้วยการอัปเดตสถานะ UI คุณอาจต้องพิจารณาใหม่ว่าข้อมูลไหลเวียนในแอปอย่างไร โดยพิจารณาหลักการต่อไปนี้
- แต่ละคลาสควรทำหน้าที่ที่ตนรับผิดชอบเท่านั้น UI มีหน้าที่รับผิดชอบตรรกะลักษณะการทำงานที่เฉพาะเจาะจงกับหน้าจอ เช่น การเรียกการนำทาง เหตุการณ์การคลิก และการรับคำขอสิทธิ์ ViewModel มีตรรกะทางธุรกิจและแปลงผลลัพธ์จากเลเยอร์ล่างของลำดับชั้นเป็นสถานะ UI
- พิจารณาว่าเหตุการณ์มาจากที่ใด ทำตามแผนผังการตัดสินใจที่แสดงไว้ที่ตอนต้นของคู่มือนี้ และให้แต่ละ คลาสจัดการสิ่งที่ตนรับผิดชอบ ตัวอย่างเช่น หากเหตุการณ์มาจาก UI และส่งผลให้เกิดเหตุการณ์การนำทาง เหตุการณ์นั้นจะต้องได้รับการจัดการใน UI คุณอาจมอบหมายตรรกะบางอย่างไปยัง ViewModel แต่ไม่สามารถมอบหมายการจัดการเหตุการณ์ทั้งหมดไปยัง ViewModel ได้
- หากคุณมีผู้ใช้หลายรายและกังวลว่าเหตุการณ์จะถูก ใช้หลายครั้ง คุณอาจต้องพิจารณาสถาปัตยกรรมแอปใหม่ การมีผู้ใช้หลายรายพร้อมกันจะทำให้สัญญา ส่งมอบเพียงครั้งเดียว เป็นเรื่องยากมากที่จะรับประกันได้ ดังนั้นความซับซ้อนและลักษณะการทำงานที่ละเอียดอ่อนจึงเพิ่มขึ้นอย่างมาก หากคุณพบปัญหานี้ ให้พิจารณาผลักดันความกังวลเหล่านั้นขึ้นไปในแผนผัง UI คุณอาจต้องใช้เอนทิตีอื่นที่มีขอบเขตสูงขึ้นในลำดับชั้น
- พิจารณาว่าเมื่อใดที่ต้องใช้สถานะ ในบางสถานการณ์ คุณอาจไม่ต้องการใช้สถานะต่อไปเมื่อแอปทำงานอยู่เบื้องหลัง เช่น การแสดง
Toastในกรณีดังกล่าว ให้พิจารณาใช้สถานะเมื่อ UI ทำงานอยู่เบื้องหน้า
ตัวอย่าง
ตัวอย่างต่อไปนี้ของ Google แสดงเหตุการณ์ UI ในเลเยอร์ UI ลองสำรวจตัวอย่างเหล่านี้เพื่อดูคำแนะนำนี้ในทางปฏิบัติ
แหล่งข้อมูลเพิ่มเติม
ดูข้อมูลเพิ่มเติมเกี่ยวกับเหตุการณ์ UI ได้จากแหล่งข้อมูลเพิ่มเติมต่อไปนี้
Codelab
เอกสารประกอบ
เนื้อหา Views
แนะนำสำหรับคุณ
- หมายเหตุ: ข้อความลิงก์จะแสดงเมื่อ JavaScript ปิดอยู่
- เลเยอร์ UI
- ตัวยึดสถานะและสถานะ UI {:#mad-arch}
- คู่มือสถาปัตยกรรมแอป