מחשבה בכתיבה

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

הפרדיגמה של תכנות פונקציונלי

בעבר, אפשר היה לייצג היררכיית תצוגה של Android כעץ של ווידג'טים של ממשק משתמש. כשמצב האפליקציה משתנה בגלל דברים כמו אינטראקציות של משתמשים, צריך לעדכן את היררכיית ממשק המשתמש כדי להציג את הנתונים הנוכחיים. הדרך הנפוצה ביותר לעדכן את ממשק המשתמש היא לעבור על העץ באמצעות פונקציות כמו findViewById() ולשנות צמתים באמצעות קריאה ל-methods כמו button.setText(String),‏ container.addChild(View) או img.setImageBitmap(Bitmap). השיטות האלה משנות את המצב הפנימי של הווידג'ט.

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

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

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

פונקציה פשוטה שניתנת להגדרה

באמצעות Compose, אפשר ליצור את ממשק המשתמש על ידי הגדרת קבוצה של פונקציות שניתנות ליצירה, שמקבלות נתונים ומפיקות רכיבי ממשק משתמש. דוגמה פשוטה היא ווידג'ט Greeting, שמקבל String ומפיק ווידג'ט Text שמוצגת בו הודעת פתיחה.

צילום מסך של טלפון שבו מוצג הטקסט 'Hello World', והקוד של הפונקציה הפשוטה של Composable שיוצרת את ממשק המשתמש הזה

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

כמה דברים חשובים לגבי הפונקציה הזו:

  • הפונקציה מסומנת בהערה @Composable. כל הפונקציות של Composable חייבות לכלול את ההערה הזו. ההערה הזו מעדכנת את המהדר של Compose שהפונקציה הזו מיועדת להמרת נתונים לממשק משתמש.

  • הפונקציה מקבלת נתונים. פונקציות קומפוזיביות יכולות לקבל פרמטרים, שמאפשרים ללוגיקה של האפליקציה לתאר את ממשק המשתמש. במקרה הזה, הווידג'ט שלנו מקבל את הערך String כדי שיוכל לברך את המשתמש בשם.

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

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

  • הפונקציה הזו מהירה, עקבית ואין לה תופעות לוואי.

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

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

שינוי הפרדיגמה של גישת החיווי

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

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

איור של זרימת הנתונים בממשק המשתמש של Compose, מאובייקטים ברמה גבוהה ועד לצאצאים שלהם.

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

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

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

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

תוכן דינמי

מכיוון שפונקציות מורכבות נכתבות ב-Kotlin במקום ב-XML, הן יכולות להיות דינמיות כמו כל קוד Kotlin אחר. לדוגמה, נניח שאתם רוצים ליצור ממשק משתמש שמציג רשימה של משתמשים:

@Composable
fun Greeting(names: List<String>) {
    for (name in names) {
        Text("Hello $name")
    }
}

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

יצירת קומפוזיציה מחדש

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

לדוגמה, הנה פונקציה מורכבת שמציגה לחצן:

@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("I've been clicked $clicks times")
    }
}

בכל פעם שמקישים על הלחצן, מבצע הקריאה מעדכן את הערך של clicks. Compose קורא שוב לפונקציית lambda עם הפונקציה Text כדי להציג את הערך החדש. התהליך הזה נקרא הרכבה מחדש. פונקציות אחרות שלא תלויות בערך לא עוברות עיבוד מחדש.

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

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

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

  • כתיבת למאפיין של אובייקט משותף
  • עדכון של משתנה שגלוי לכולם ב-ViewModel
  • עדכון ההעדפות המשותפות

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

לדוגמה, הקוד הזה יוצר רכיב composable כדי לעדכן ערך ב-SharedPreferences. אסור שהרכיב הניתן לקישור יקרא או יכתוב מההעדפות המשותפות בעצמו. במקום זאת, הקוד הזה מעביר את הקריאה והכתיבה ל-ViewModel ב-coroutine ברקע. הלוגיקה של האפליקציה מעבירה את הערך הנוכחי באמצעות קריאה חוזרת (callback) כדי להפעיל עדכון.

@Composable
fun SharedPrefsToggle(
    text: String,
    value: Boolean,
    onValueChanged: (Boolean) -> Unit
) {
    Row {
        Text(text)
        Checkbox(checked = value, onCheckedChange = onValueChanged)
    }
}

במסמך הזה מפורטים כמה דברים שחשוב לדעת כשמשתמשים ב-Compose:

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

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

דילוגים רבים ככל האפשר בזמן הרכבת הסרטון מחדש

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

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

/**
 * Display a list of names the user can click with a header
 */
@Composable
fun NamePicker(
    header: String,
    names: List<String>,
    onNameClicked: (String) -> Unit
) {
    Column {
        // this will recompose when [header] changes, but not when [names] changes
        Text(header, style = MaterialTheme.typography.bodyLarge)
        HorizontalDivider()

        // LazyColumn is the Compose version of a RecyclerView.
        // The lambda passed to items() is similar to a RecyclerView.ViewHolder.
        LazyColumn {
            items(names) { name ->
                // When an item's [name] updates, the adapter for that item
                // will recompose. This will not recompose when [header] changes
                NamePickerItem(name, onNameClicked)
            }
        }
    }
}

/**
 * Display a single name the user can click.
 */
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
    Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}

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

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

הרכבת מחדש היא אופטימיסטית

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

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

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

פונקציות הניתנות להגדרה עשויות לפעול לעיתים קרובות

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

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

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

אפשר להריץ פונקציות הניתנות להגדרה במקביל

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

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

כדי לוודא שהאפליקציה פועלת בצורה תקינה, לכל הפונקציות הניתנות ליצירה לא אמורות להיות תופעות לוואי. במקום זאת, מפעילים תופעות לוואי מהחזרות קריאה (callbacks) כמו onClick שתמיד פועלות בשרשור של ממשק המשתמש.

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

דוגמה לרכיב מורכב שמוצגת בו רשימה ומספר הפריטים בה:

@Composable
fun ListComposable(myList: List<String>) {
    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
            }
        }
        Text("Count: ${myList.size}")
    }
}

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

@Composable
fun ListWithBug(myList: List<String>) {
    var items = 0

    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Card {
                    Text("Item: $item")
                    items++ // Avoid! Side-effect of the column recomposing.
                }
            }
        }
        Text("Count: $items")
    }
}

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

אפשר להריץ פונקציות הניתנות להגדרה בסדר כלשהו

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

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

@Composable
fun ButtonRow() {
    MyFancyNavigation {
        StartScreen()
        MiddleScreen()
        EndScreen()
    }
}

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

מידע נוסף

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

סרטונים