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

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

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

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

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

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

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

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

השימוש בדפוס הזה ב-Jetpack Compose מספק כמה יתרונות:

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

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

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

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

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

הגדרת פרמטרים שאפשר להרכיב

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

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

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

אירועים במצב יצירת מוזיקה

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

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

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

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

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

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

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

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

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

דוגמאות