אירועים בממשק המשתמש (תצוגות)

מושגים והטמעה ב-Jetpack פיתוח נייטיב

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

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

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

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

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

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

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

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

אירועי משתמשים ב-RecyclerViews

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

לדוגמה, נניח שכל פריטי החדשות מ-NewsActivity מכילים לחצן לסימון במועדפים. התג ViewModel צריך לדעת את המזהה של פריט החדשות שנוסף לסימנייה. כשמשתמש מוסיף סימנייה לפריט חדשות, המתאם RecyclerView לא קורא לפונקציה addBookmark(newsId) שמוצגת מ-ViewModel, מה שמחייב תלות ב-ViewModel. במקום זאת, הרכיב ViewModel חושף אובייקט מצב שנקרא NewsItemUiState, שמכיל את ההטמעה לטיפול באירוע:

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

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

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

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

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

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

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

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

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

ממשק המשתמש הזה מגיב לשינויים במצב isUserLoggedIn ועובר ליעד הנכון לפי הצורך:

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

צריכת אירועים יכולה להפעיל עדכוני סטטוס

צריכה של אירועים מסוימים של 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() {

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

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

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

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

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

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

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

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

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

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

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

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

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

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