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

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

תוכלו לקרוא על הרכבת רכיבים במאמרים שלנו בנושא Compose, כולל חשיבה ב-Compose וState ו-Jetpack פיתוח נייטיב.

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

ל-Compose יש שלושה שלבים עיקריים:

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

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

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

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

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

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

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

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

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

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

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

פריסה

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

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

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

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

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

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

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

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

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

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

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

שרטוט

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

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

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

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

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

קריאת מצב

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

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

// 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)
)

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

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

קריאות של מצבים בשלבים

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

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

שלב 1: יצירה

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

בהתאם לתוצאה של היצירה, ממשק המשתמש של 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: פריסה

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

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

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

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) {
        // ...
    }
}

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

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

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

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

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

Box {
    val listState = rememberLazyListState()

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

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

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

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

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

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

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

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

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

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