מחשבה בכתיבה

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

פרדיגמת התכנות הדקלרטיבית

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

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

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

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

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

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

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

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

  • הערה: הפונקציה מסומנת בהערה @Composable. כל הפונקציות שניתנות להרכבה חייבות לכלול את ההערה הזו. ההערה הזו מודיעה לקומפיילר של Compose שהפונקציה הזו נועדה להמיר נתונים לממשק משתמש.
  • קלט נתונים: הפונקציה מקבלת נתונים. פונקציות קומפוזביליות יכולות לקבל פרמטרים, שמאפשרים ללוגיקה של האפליקציה לתאר את ממשק המשתמש. במקרה הזה, הווידג'ט שלנו מקבל String כדי שהוא יוכל לפנות למשתמש בשם.
  • תצוגה בממשק המשתמש: הפונקציה מציגה טקסט בממשק המשתמש. הוא עושה זאת על ידי קריאה לפונקציה Text() composable, שיוצרת בפועל את רכיב ממשק המשתמש של הטקסט. פונקציות קומפוזביליות פולטות היררכיית ממשק משתמש על ידי קריאה לפונקציות קומפוזביליות אחרות.
  • אין ערך מוחזר: הפונקציה לא מחזירה כלום. פונקציות Compose שיוצרות ממשק משתמש לא צריכות להחזיר ערך כלשהו, כי הן מתארות את מצב המסך הרצוי במקום ליצור רכיבי UI.
  • מאפיינים: הפונקציה הזו מהירה, אידמפוטנטית ואין לה תופעות לוואי.

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

שינוי הרכב

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

כל פונקציה ניתנת להרכבה וכל 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) }))
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

פונקציות הניתנות להגדרה יכולות לפעול בכל סדר

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

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

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

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

מידע נוסף

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

סרטונים