אירועים בממשק המשתמש

אירועים בממשק המשתמש הם פעולות שצריך לטפל בהן בשכבת ממשק המשתמש, על ידי ממשק המשתמש או על ידי ViewModel. הסוג הנפוץ ביותר של אירועים הוא אירועים שקשורים למשתמשים. המשתמש יוצר אירועים על ידי אינטראקציה עם האפליקציה – למשל, על ידי הקשה על המסך או על ידי יצירת תנועות. לאחר מכן, ממשק המשתמש צורך את האירועים האלה באמצעות קריאות חוזרות (callbacks) כמו lambdas שמוגדרות ברכיבים שונים שניתנים להרכבה.

בדרך כלל, ViewModel אחראי לטיפול בלוגיקה העסקית של אירוע משתמש מסוים – לדוגמה, משתמש לוחץ על לחצן כדי לרענן נתונים מסוימים. בדרך כלל, ה-ViewModel מטפל בזה על ידי חשיפת פונקציות שממשק המשתמש יכול לקרוא להן. יכול להיות שאירועים של משתמשים כוללים גם לוגיקה של התנהגות בממשק המשתמש, שהממשק יכול לטפל בה ישירות – למשל, מעבר למסך אחר או הצגה של Snackbar.

הלוגיקה העסקית נשארת זהה לאותה אפליקציה בפלטפורמות שונות לניידים או בגורמי צורה שונים, אבל הלוגיקה של התנהגות ממשק המשתמש היא פרט הטמעה שעשוי להיות שונה בין המקרים האלה. בדף של שכבת ממשק המשתמש מוגדרים סוגי הלוגיקה האלה:

  • הלוגיקה העסקית מתייחסת למה צריך לעשות עם שינויים במצב – לדוגמה, ביצוע תשלום או שמירת העדפות משתמש. הלוגיקה הזו בדרך כלל מטופלת על ידי הדומיין ושכבות הנתונים. במדריך הזה, המחלקה Architecture Components ViewModel משמשת כפתרון מומלץ למחלקות שמטפלות בלוגיקה עסקית.
  • לוגיקת התנהגות ממשק המשתמש או לוגיקת ממשק המשתמש מתייחסת לאופן ההצגה של שינויים במצב – לדוגמה, לוגיקת הניווט או אופן הצגת ההודעות למשתמש. ממשק המשתמש מטפל בלוגיקה הזו.

עץ החלטות של אירועים בממשק המשתמש

בתרשים הבא מוצג עץ החלטות שיעזור לכם למצוא את הגישה הכי טובה לטיפול בתרחיש שימוש מסוים באירוע. בהמשך המדריך מוסבר בפירוט על הגישות האלה.

אם האירוע נוצר ב-ViewModel, צריך לעדכן את מצב ממשק המשתמש. אם
    האירוע נוצר בממשק המשתמש ודורש לוגיקה עסקית, צריך להעביר את הלוגיקה העסקית ל-ViewModel. אם האירוע נוצר בממשק המשתמש ונדרשת לוגיקת התנהגות של ממשק המשתמש, צריך לשנות את מצב רכיב ממשק המשתמש ישירות בממשק המשתמש.
איור 1. עץ החלטות לטיפול באירועים.

טיפול באירועי משתמשים

ממשק המשתמש יכול לטפל ישירות באירועים של משתמשים אם האירועים האלה קשורים לשינוי המצב של רכיב בממשק המשתמש – לדוגמה, המצב של פריט שניתן להרחבה. אם האירוע דורש ביצוע של לוגיקה עסקית, כמו רענון הנתונים במסך, צריך לעבד אותו באמצעות ViewModel.

בדוגמה הבאה אפשר לראות איך משתמשים בכפתורים שונים כדי להרחיב רכיב בממשק המשתמש (לוגיקת ממשק משתמש) ולרענן את הנתונים במסך (לוגיקה עסקית):

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

אירועי משתמשים ברשימות עצלניות

אם הפעולה מופיעה בהמשך עץ ממשק המשתמש, כמו בLazyColumn item, עדיין צריך להגדיר את ViewModel כרכיב שמטפל באירועי משתמש.

לדוגמה, נניח שיש רשימה של פריטים שאפשר ללחוץ עליהם. לא מעבירים את המופע ViewModel אל רכיב ה-Composable של הרשימה (MyList), כי זה יוצר קשר הדוק בין רכיב ממשק המשתמש לבין פרטי ההטמעה.

במקום זאת, צריך לחשוף את האירוע כפרמטר של פונקציית lambda בקומפוזיציה. כך הרשימה יכולה להפעיל את האירוע בלי לדעת מי מטפל בו או איך.

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

בגישה הזו, רכיב ה-MyList פועל רק עם הנתונים שהוא מציג ועם האירועים שהוא חושף. אין לו גישה ל-ViewModel. האירוע מועבר ל-ViewModel ברכיב Composable קודם.

מידע נוסף על טיפול באירועים זמין במאמר אירועים ב-Compose.

מוסכמות מתן שם לפונקציות של אירועי משתמשים ולגורמי handler של אירועים

במדריך הזה, הפונקציות של ViewModel שמטפלות באירועים של משתמשים נקראות בשם שמתחיל בפועל שמתאר את הפעולה שהן מבצעות – לדוגמה: validateInput() או login().

ב-Compose, יש מוסכמת מתן שמות סטנדרטית ל-Event handlers, כדי שהזרימה של הנתונים תהיה ברורה:

  • שם הפרמטר: on + Verb + Target (לדוגמה, onExpandClicked או onValueChange).
  • ביטוי למבדה: כשקוראים לקומפוזבילי, למבדה היא לרוב רק ההטמעה של האירוע הזה.

טיפול באירועים של ViewModel

פעולות בממשק המשתמש שמקורן ב-ViewModel – אירועי ViewModel – צריכות תמיד להוביל לעדכון של מצב ממשק המשתמש. הפעולה הזו תואמת לעקרונות של זרימת נתונים חד-כיוונית. הוא מאפשר לשחזר אירועים אחרי שינויים בהגדרות, ומבטיח שפעולות בממשק המשתמש לא יאבדו. אופציונלית, אפשר גם לשחזר אירועים אחרי שהתהליך מסתיים אם משתמשים במודול של מצב שמור.

מיפוי פעולות בממשק המשתמש למצב ממשק המשתמש הוא לא תמיד תהליך פשוט, אבל הוא מוביל ללוגיקה פשוטה יותר. תהליך החשיבה שלכם לא צריך להסתיים בקביעה איך להעביר את המשתמש למסך מסוים בממשק המשתמש, למשל. צריך לחשוב מעבר לכך ולשקול איך לייצג את מסלול המשתמש במצב ממשק המשתמש. במילים אחרות: אל תחשבו על הפעולות שממשק המשתמש צריך לבצע, אלא על האופן שבו הפעולות האלה משפיעות על מצב ממשק המשתמש.

לדוגמה, נניח שיש מסך התחברות. אפשר ליצור מודל של מצב ממשק המשתמש במסך הזה באופן הבא:

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

מסך ההתחברות מגיב לשינויים במצב ממשק המשתמש.

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 בממשק המשתמש עשויה להוביל לעדכונים אחרים של מצב ממשק המשתמש. לדוגמה, כשמציגים הודעות זמניות במסך כדי להודיע למשתמש שמשהו קרה, ממשק המשתמש צריך להודיע ל-ViewModel להפעיל עדכון נוסף של המצב אחרי שההודעה הוצגה במסך. האירוע שמתרחש כשהמשתמש צורך את ההודעה (על ידי סגירה או אחרי זמן קצוב לתפוגה) יכול להיחשב כ'קלט של משתמשים', ולכן ה-ViewModel צריך להיות מודע לכך. במקרה כזה, אפשר לתאר את מצב ממשק המשתמש כך:

// 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 יעודכן במצב ממשק המשתמש באופן הבא, כשנדרש להציג למשתמש הודעה זמנית חדשה בגלל הלוגיקה העסקית:

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 לא צריך לדעת איך ממשק המשתמש מציג את ההודעה במסך, הוא רק יודע שיש הודעה למשתמש שצריך להציג. אחרי שההודעה הזמנית מוצגת, ממשק המשתמש צריך להודיע על כך ל-ViewModel, מה שגורם לעדכון נוסף של מצב ממשק המשתמש כדי לנקות את המאפיין 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()
        }
    }
}

למרות שההודעה היא זמנית, מצב ממשק המשתמש הוא ייצוג נאמן של מה שמוצג במסך בכל נקודת זמן. ההודעה למשתמש מוצגת או לא מוצגת.

בקטע אירועים שמופעלים יכולים לעדכן את המצב מוסבר איך משתמשים במצב ממשק המשתמש כדי להציג הודעות למשתמשים במסך. אירועי ניווט הם גם סוג נפוץ של אירועים באפליקציית Android.

אם האירוע מופעל בממשק המשתמש כי המשתמש הקיש על לחצן, ממשק המשתמש מטפל בכך על ידי חשיפת האירוע לרכיב הניתן להרכבה של המתקשר.

@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 רק כשמחזור החיים הוא לפחות RESUMED.

אם נדרש אימות של לוגיקה עסקית מסוימת לפני המעבר, ה-ViewModel צריך לחשוף את המצב הזה לממשק המשתמש. ממשק המשתמש יגיב לשינוי המצב הזה וינווט בהתאם. התרחיש הזה מוסבר בקטע בנושא טיפול באירועים של ViewModel. הנה קוד דומה:

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

בדוגמה שלמעלה, האפליקציה פועלת כמצופה כי היעד הנוכחי, Login, לא יישמר במקבץ פעילויות קודמות (back stack). אם המשתמשים ילחצו על לחצן החזרה, הם לא יוכלו לחזור אליה. עם זאת, במקרים שבהם זה עלול לקרות, הפתרון ידרוש לוגיקה נוספת.

כש-ViewModel מגדיר מצב מסוים שיוצר אירוע ניווט ממסך א' למסך ב', ומסך א' נשמר במקבץ פעילויות קודמות (back stack) של הניווט, יכול להיות שתצטרכו להוסיף לוגיקה כדי שלא תהיה התקדמות אוטומטית למסך ב'. כדי להטמיע את זה, צריך מצב נוסף שיציין אם ממשק המשתמש צריך לנווט למסך השני. בדרך כלל, המצב הזה נשמר בממשק המשתמש כי לוגיקת הניווט היא חלק מממשק המשתמש ולא מ-ViewModel. כדי להמחיש את זה, נתייחס לתרחיש השימוש הבא.

נניח שאתם נמצאים בתהליך ההרשמה באפליקציה. במסך האימות של תאריך הלידה, כשהמשתמש מזין תאריך, ה-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 יעביר את הלוגיקה הזו לשכבת הנתונים. הלוגיקה להעברת המשתמש למסך הבא היא לוגיקת ממשק משתמש, כי הדרישות האלה עשויות להשתנות בהתאם להגדרת ממשק המשתמש. לדוגמה, אם מציגים כמה שלבי הרשמה בו-זמנית בטאבלט, יכול להיות שלא תרצו שהמשתמש יעבור אוטומטית למסך אחר. המשתנה validationInProgress בקוד שלמעלה מטמיע את הפונקציונליות הזו ומטפל בשאלה אם ממשק המשתמש צריך לנווט באופן אוטומטי בכל פעם שתאריך הלידה תקין והמשתמש רוצה להמשיך לשלב הבא בתהליך ההרשמה.

תרחישים אחרים לדוגמה

אם לדעתכם אי אפשר לפתור את תרחיש השימוש באירועים בממשק המשתמש באמצעות עדכונים של מצב ממשק המשתמש, יכול להיות שתצטרכו לשנות את האופן שבו הנתונים זורמים באפליקציה. כדאי להביא בחשבון את העקרונות הבאים:

  • כל מחלקה צריכה לעשות רק את מה שהיא אחראית לו, ולא יותר. ממשק המשתמש אחראי ללוגיקת ההתנהגות הספציפית למסך, כמו קריאות לניווט, אירועי קליקים וקבלת בקשות הרשאה. ה-ViewModel מכיל לוגיקה עסקית וממיר את התוצאות משכבות נמוכות יותר בהיררכיה למצב של ממשק המשתמש.
  • תחשבו מאיפה האירוע מגיע. פועלים לפי עץ ההחלטות שמוצג בתחילת המדריך הזה, ודואגים שכל מחלקה תטפל במה שהיא אחראית לו. לדוגמה, אם האירוע נוצר בממשק המשתמש והוא מוביל לאירוע ניווט, צריך לטפל באירוע הזה בממשק המשתמש. חלק מהלוגיקה עשויה להיות מוקצית ל-ViewModel, אבל אי אפשר להקצות את הטיפול באירוע כולו ל-ViewModel.
  • אם יש לכם כמה צרכנים ואתם חוששים שהאירוע ייצרך כמה פעמים, כדאי לשקול מחדש את ארכיטקטורת האפליקציה. אם יש כמה צרכנים בו-זמניים, קשה מאוד להבטיח את החוזה delivered exactly once, ולכן רמת המורכבות וההתנהגות העדינה עולה באופן משמעותי. אם נתקלתם בבעיה הזו, כדאי להעביר את הבעיות האלה למעלה בעץ ממשק המשתמש. יכול להיות שתצטרכו ישות אחרת בהיקף גבוה יותר בהיררכיה.
  • חשבו מתי צריך להשתמש במצב. במצבים מסוימים, יכול להיות שלא תרצו להמשיך להשתמש במצב הצריכה כשהאפליקציה פועלת ברקע – לדוגמה, כשמוצגת מודעה מסוג Toast. במקרים כאלה, כדאי לצרוך את המצב כשהממשק נמצא בחזית.

דוגמאות

בדוגמאות הבאות של Google מוצגים אירועים בממשק המשתמש בשכבת ממשק המשתמש. כדאי לעיין בהם כדי לראות איך ההנחיות האלה באות לידי ביטוי בפועל:

מקורות מידע נוספים

למידע נוסף על אירועים בממשק המשתמש, אפשר לעיין במקורות המידע הבאים:

Codelabs

מאמרי עזרה

צפיות בתוכן