שלבי העבודה ב-Jetpack פיתוח נייטיב

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

במסמכי התיעוד של Compose מוסבר על קומפוזיציה במאמרים Thinking in Compose ו-State and Jetpack Compose.

שלושת השלבים של פריים

התכונה 'יצירה' כוללת שלושה שלבים עיקריים:

  1. קומפוזיציה: ממשק המשתמש של What שיוצג. ‫Compose מריצה פונקציות הניתנות להגדרה ויוצרת תיאור של ממשק המשתמש.
  2. פריסה: איפה למקם את ממשק המשתמש. השלב הזה כולל שני חלקים: מדידה ומיקום. רכיבי פריסה מודדים את עצמם ואת רכיבי הצאצא שלהם וממקמים אותם בקואורדינטות דו-ממדיות, לכל צומת בעץ הפריסה.
  3. שרטוט: איך הוא מוצג. רכיבי ממשק המשתמש מצוירים ב-Canvas, בדרך כלל במסך של מכשיר.
שלושת השלבים שבהם Compose הופך נתונים לממשק משתמש (בסדר הבא: נתונים, קומפוזיציה, פריסה, ציור, ממשק משתמש).
איור 1. שלושת השלבים שבהם Compose מבצע טרנספורמציה של נתונים לממשק משתמש.

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

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

הסבר על השלבים

בקטע הזה מתואר בפירוט רב יותר איך מתבצעות שלוש הפעולות של Compose עבור קומפוזיציות.

יצירה מוזיקלית

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

איור 2. העץ שמייצג את ממשק המשתמש שנוצר בשלב ההרכבה.

קטע משנה של עץ הקוד וממשק המשתמש נראה כך:

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

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

פריסה

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

איור 4. המדידה והמיקום של כל צומת פריסה בעץ ממשק המשתמש במהלך שלב הפריסה.

במהלך שלב הפריסה, המערכת עוברת על העץ באמצעות האלגוריתם הבא בן שלושת השלבים:

  1. מדידת ילדים: צומת מודד את הילדים שלו אם יש כאלה.
  2. קביעת הגודל: על סמך המידות האלה, הצומת קובע את הגודל שלו.
  3. מיקום ילדים: כל צומת צאצא ממוקם ביחס למיקום של הצומת עצמו.

בסוף השלב הזה, לכל צומת פריסה יש:

  • רוחב וגובה שהוקצו
  • קואורדינטות x, y של המקום שבו צריך לצייר את הצורה

נזכרים בעץ ממשק המשתמש מהקטע הקודם:

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

בדוגמה הזו, האלגוריתם פועל באופן הבא:

  1. התג Row מודד את הילדים שלו, Image ו-Column.
  2. המדד Image נמדד. אין לו צאצאים, לכן הוא קובע את הגודל שלו ומדווח על הגודל בחזרה אל Row.
  3. המדד הבא הוא Column. הוא מודד קודם את הילדים שלו (שני רכיבי Composable).Text
  4. הערך הראשון של Text נמדד. אין לו צאצאים, לכן הוא מחליט על הגודל שלו ומדווח על הגודל שלו בחזרה אל Column.
    1. הפריט השני מסוג Text נמדד. אין לו צאצאים, ולכן הוא קובע את הגודל שלו ומדווח עליו בחזרה אל Column.
  5. המידות של הילד/ה משמשות את Column כדי לקבוע את הגודל שלו. הוא משתמש ברוחב המקסימלי של רכיב הצאצא ובסכום הגובה של רכיבי הצאצא.
  6. הפריסה Column ממקמת את הצאצאים שלה ביחס לעצמה, ומציבה אותם אחד מתחת לשני בצורה אנכית.
  7. המידות של הילד/ה משמשות את Row כדי לקבוע את הגודל שלו. הוא משתמש בגובה המקסימלי של הילד ובסכום הרוחבים של הילדים שלו. לאחר מכן הוא ממקם את רכיבי הצאצא שלו.

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

שרטוט

בשלב הציור, המערכת עוברת שוב על העץ מלמעלה למטה, וכל צומת מצייר את עצמו על המסך בתורו.

איור 5. בשלב הציור, הפיקסלים מצוירים על המסך.

בדוגמה הקודמת, התוכן של העץ מצויר באופן הבא:

  1. התג Row מצייר כל תוכן שיש לו, כמו צבע רקע.
  2. הצורה Image מצוירת בעצמה.
  3. הצורה Column מצוירת בעצמה.
  4. הצורה הראשונה Text מציירת את עצמה, וכך גם הצורה השנייה.

איור 6. עץ של ממשק משתמש והייצוג שלו.

קריאות של מצב

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

בדרך כלל יוצרים מצב באמצעות mutableStateOf() ואז ניגשים אליו באחת משתי דרכים: גישה ישירה למאפיין value, או שימוש בנציג מאפיין של Kotlin. מידע נוסף על כך מופיע במאמר State in composables. לצורך המדריך הזה, 'קריאת מצב' מתייחסת לאחת משיטות הגישה המקבילות האלה.

// State read without property delegate.
val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(paddingState.value)
)

// State read with property delegate.
var padding: Dp by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(padding)
)

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

כל בלוק קוד שאפשר להפעיל מחדש כשמצב הקריאה משתנה הוא היקף הפעלה מחדש. התכונה 'יצירת תוכן' עוקבת אחרי שינויים במצב value ומפעילה מחדש את ההיקפים בשלבים שונים.

קריאות של מצב מדורג

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

בקטעים הבאים מתואר כל שלב ומה קורה כשקוראים ערך של מצב בתוך השלב.

שלב 1: יצירה

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

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

var padding by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    // The `padding` state is read in the composition phase
    // when the modifier is constructed.
    // Changes in `padding` will invoke recomposition.
    modifier = Modifier.padding(padding)
)

שלב 2: פריסה

שלב הפריסה מורכב משני שלבים: מדידה ומיקום. שלב המדידה מפעיל את פונקציית ה-lambda של המדידה שהועברה אל הרכיב שאפשר להרכיב Layout, השיטה MeasureScope.measure של הממשק LayoutModifier, ועוד. בשלב המיקום מופעל בלוק המיקום של הפונקציה layout, בלוק ה-lambda של Modifier.offset { … } ופונקציות דומות.

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

var offsetX by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.offset {
        // The `offsetX` state is read in the placement step
        // of the layout phase when the offset is calculated.
        // Changes in `offsetX` restart the layout.
        IntOffset(offsetX.roundToPx(), 0)
    }
)

שלב 3: שרטוט

קריאות של מצב במהלך קוד הציור משפיעות על שלב הציור. דוגמאות נפוצות: Canvas(), Modifier.drawBehind ו-Modifier.drawWithContent. כשמצב ה-value משתנה, Compose UI מריץ רק את שלב הציור.

var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
    // The `color` state is read in the drawing phase
    // when the canvas is rendered.
    // Changes in `color` restart the drawing.
    drawRect(color)
}

תרשים שמראה שקריאת מצב במהלך שלב הציור מפעילה רק את שלב הציור שוב.

קריאות של סטטוס האופטימיזציה

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

לדוגמה: בדוגמה הזו יש Image() שמשתמש במאפיין offset כדי להזיז את מיקום הפריסה הסופי שלו, וכך נוצר אפקט פרלקסה בזמן שהמשתמש גולל.

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        // Non-optimal implementation!
        Modifier.offset(
            with(LocalDensity.current) {
                // State read of firstVisibleItemScrollOffset in composition
                (listState.firstVisibleItemScrollOffset / 2).toDp()
            }
        )
    )

    LazyColumn(state = listState) {
        // ...
    }
}

הקוד הזה פועל, אבל הביצועים שלו לא אופטימליים. הקוד שכתוב קורא את value של מצב firstVisibleItemScrollOffset ומעביר אותו לפונקציה Modifier.offset(offset: Dp). כשמשתמש גולל, value של firstVisibleItemScrollOffset משתנה. כמו שלמדתם, Compose עוקב אחרי כל קריאה של מצב כדי שיוכל להפעיל מחדש (להפעיל שוב) את קוד הקריאה, שבמקרה הזה הוא התוכן של Box.

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

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

הזחה באמצעות lambda

יש גרסה נוספת של משנה ההיסט: Modifier.offset(offset: Density.() -> IntOffset).

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

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        Modifier.offset {
            // State read of firstVisibleItemScrollOffset in Layout
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
        }
    )

    LazyColumn(state = listState) {
        // ...
    }
}

אז למה השיטה הזו יעילה יותר? הבלוק של lambda שמעבירים ל-modifier מופעל במהלך שלב הפריסה (בספציפיות, במהלך שלב המיקום של הפריסה), כלומר המצב של firstVisibleItemScrollOffset כבר לא נקרא במהלך הקומפוזיציה. מכיוון ש-Compose עוקב אחרי המצב בזמן הקריאה, השינוי הזה אומר שאם value של firstVisibleItemScrollOffset משתנה,‏ Compose צריך להפעיל מחדש רק את שלבי הפריסה והציור.

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

לולאת הרכבה מחדש (תלות מחזורית בשלב)

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

Box {
    var imageHeightPx by remember { mutableStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = "I'm above the text",
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { size ->
                // Don't do this
                imageHeightPx = size.height
            }
    )

    Text(
        text = "I'm below the image",
        modifier = Modifier.padding(
            top = with(LocalDensity.current) { imageHeightPx.toDp() }
        )
    )
}

בדוגמה הזו מוטמעת עמודה אנכית, עם התמונה בחלק העליון והטקסט מתחתיה. הוא משתמש ב-Modifier.onSizeChanged() כדי לקבל את הגודל של התמונה, ואז משתמש ב-Modifier.padding() על הטקסט כדי להזיז אותו למטה. ההמרה הלא טבעית מ-Px בחזרה ל-Dp כבר מצביעה על כך שיש בעיה בקוד.

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

הפריים הראשון

במהלך שלב ההרכבה של הפריים הראשון, imageHeightPx הוא בהתחלה 0. לכן, הקוד מספק את הטקסט עם Modifier.padding(top = 0). בשלב הפריסה הבא מופעלת קריאה חוזרת (callback) של המשנה onSizeChanged, שמעדכנת את imageHeightPx לגובה בפועל של התמונה. הוא יוצר את התמונה ואז מתזמן יצירה מחדש של התמונה למסגרת הבאה. עם זאת, בשלב הנוכחי של הציור, הטקסט מוצג עם ריווח פנימי של 0, כי הערך המעודכן imageHeightPx עדיין לא משתקף.

הפריים השני

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

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

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

  • Modifier.onSizeChanged(),‏ onGloballyPositioned() או פעולות אחרות של פריסת מקלדת
  • עדכון של מצב מסוים
  • משתמשים במצב הזה כקלט לשינוי פריסה (padding(), height() או דומה)
  • יכול להיות שהפעולה תחזור על עצמה

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

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