ארכיטקטורת ממשק המשתמש של Compose

ב-Compose, ממשק המשתמש לא ניתן לשינוי – אי אפשר לעדכן אותו אחרי שהוא נוצר. מה שאתם יכולים לשלוט בו הוא המצב של ממשק המשתמש. בכל פעם שהמצב של ממשק המשתמש משתנה, Compose יוצר מחדש את החלקים של עץ ממשק המשתמש שהשתנו. רכיבי Composable יכולים לקבל מצב ולחשוף אירועים. לדוגמה, TextField מקבל ערך וחושף קריאה חוזרת (callback) onValueChange שמבקשת מהגורם המטפל בקריאה החוזרת לשנות את הערך.

var name by remember { mutableStateOf("") }
OutlinedTextField(
    value = name,
    onValueChange = { name = it },
    label = { Text("Name") }
)

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

זרימת נתונים חד-כיוונית

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

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

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

כשמשתמשים ב-Jetpack Compose, יש כמה יתרונות לשימוש בתבנית הזו:

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

זרימת נתונים חד-כיוונית ב-Jetpack Compose

רכיבי Composable פועלים על סמך מצב ואירועים. לדוגמה, TextField מתעדכן רק כשהפרמטר value שלו מתעדכן, והוא חושף קריאה חוזרת (callback) מסוג onValueChange – אירוע שמבקש לשנות את הערך לערך חדש. מערכת פיתוח נייטיב מגדירה את האובייקט State כמחזיק ערך, ושינויים בערך המצב מפעילים קומפוזיציה מחדש. אפשר לשמור את המצב ב-remember { mutableStateOf(value) } או ב-rememberSaveable { mutableStateOf(value) }, בהתאם למשך הזמן שבו צריך לזכור את הערך.

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

  • הפונקציה mutableStateOf(value) יוצרת MutableState, שהוא סוג שניתן לצפייה בפיתוח נייטיב. כל שינוי בערך שלו מתזמן הרכבה מחדש של כל פונקציה קומפוזבילית שקוראת את הערך הזה.
  • הפונקציה remember שומרת אובייקטים בקומפוזיציה, ומוחקת את האובייקט כשהפונקציה ה-composable שקראה ל-remember מוסרת מהקומפוזיציה.
  • הפונקציה rememberSaveable שומרת את המצב ב-Bundle כדי לשמור עליו גם כשמשנים את ההגדרות.

הגדרת פרמטרים של פונקציות קומפוזביליות

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

  • עד כמה ה-composable גמיש או ניתן לשימוש חוזר?
  • איך הפרמטרים של המצב משפיעים על הביצועים של ה-composable הזה?

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

@Composable
fun Header(title: String, subtitle: String) {
    // Recomposes when title or subtitle have changed.
}

@Composable
fun Header(news: News) {
    // Recomposes when a new instance of News is passed in.
}

לפעמים, שימוש בפרמטרים נפרדים גם משפר את הביצועים. לדוגמה, אם News מכיל יותר מידע מאשר רק title ו-subtitle, בכל פעם שמופע חדש של News מועבר אל Header(news), הפונקציה הקומפוזבילית תורכב מחדש, גם אם title ו-subtitle לא השתנו.

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

אירועים ב-Compose

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

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

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

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

לדוגמה, אפשר לקרוא לפונקציה קומפוזבילית שמקבלת String ולמבדה כפרמטרים מהרבה הקשרים, והיא ניתנת לשימוש חוזר במידה רבה. נניח שסרגל האפליקציה העליון באפליקציה תמיד מציג טקסט ויש בו הכפתור "הקודם". אפשר להגדיר פונקציית MyAppTopAppBar קומפוזבילית כללית יותר שמקבלת את הטקסט ואת ה-handler של הכפתור "הקודם" כפרמטרים:

@Composable
fun MyAppTopAppBar(topAppBarText: String, onBackPressed: () -> Unit) {
    TopAppBar(
        title = {
            Text(
                text = topAppBarText,
                textAlign = TextAlign.Center,
                modifier = Modifier
                    .fillMaxSize()
                    .wrapContentSize(Alignment.Center)
            )
        },
        navigationIcon = {
            IconButton(onClick = onBackPressed) {
                Icon(
                    Icons.AutoMirrored.Filled.ArrowBack,
                    contentDescription = localizedString
                )
            }
        },
        // ...
    )
}

‫ViewModels, מצבים ואירועים: דוגמה

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

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

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

למסך יש ארבעה מצבים:

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

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

class MyViewModel : ViewModel() {
    private val _uiState = mutableStateOf<UiState>(UiState.SignedOut)
    val uiState: State<UiState>
        get() = _uiState

    // ...
}

בנוסף ל-API של mutableStateOf, ב-Compose יש תוספים ל-LiveData, ל-Flow ול-Observable כדי להירשם כמאזין ולייצג את הערך כמצב.

class MyViewModel : ViewModel() {
    private val _uiState = MutableLiveData<UiState>(UiState.SignedOut)
    val uiState: LiveData<UiState>
        get() = _uiState

    // ...
}

@Composable
fun MyComposable(viewModel: MyViewModel) {
    val uiState = viewModel.uiState.observeAsState()
    // ...
}

מידע נוסף

במקורות המידע הבאים אפשר למצוא מידע נוסף על ארכיטקטורה ב-Jetpack Compose:

דוגמאות