מחשבה בכתיבה

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

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

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

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

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

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

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

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

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

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

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

  • הפונקציה מסומנת בהערה @Composable. כל הפונקציות של 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 פיתוח נייטיב.

שינוי ההרכב

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

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

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

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

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

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

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

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

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

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

כשחלקים בממשק המשתמש לא תקינים, 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) }))
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

מידע נוסף

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

סרטונים