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

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

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

שלושת השלבים של מסגרת

התהליך של יצירת מוזיקה מורכב משלושה שלבים עיקריים:

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

הסדר של השלבים האלה בדרך כלל זהה, כך שהנתונים זורמים בכיוון אחד מהקומפוזיציה לפריסה לציור כדי ליצור פריים (שנקרא גם זרימת נתונים חד-כיוונית). BoxWithConstraints, LazyColumn, and LazyRow are notable exceptions, where the composition of its children depends on the parent's layout phase.

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

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

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

הרכב

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

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

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

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

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

פריסה

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

שלב 1: יצירה מוזיקלית

קריאות של מצב בתוך פונקציה @Composable או בלוק lambda משפיעות על ההרכב ועשויות להשפיע על השלבים הבאים. כשערך המצב value משתנה, הרכיב שיוצר קומפוזיציה מחדש מתזמן הפעלה מחדש של כל הפונקציות הקומפוזביליות שקוראות את ערך המצב 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 מריץ רק את שלב הציור.

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

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