כמו רוב ערכות הכלים האחרות של ממשק המשתמש, Compose מעבד פריים דרך כמה שלבים נפרדים. לדוגמה, למערכת Android View יש שלושה שלבים עיקריים: מדידה, פריסה וציור. Compose דומה מאוד, אבל יש לו שלב נוסף וחשוב שנקרא קומפוזיציה בהתחלה.
במסמכי התיעוד של Compose מתוארת קומפוזיציה בחשיבה ב-Compose ובמצב ו-Jetpack Compose.
שלושת השלבים של פריים
ל-Compose יש שלושה שלבים עיקריים:
- קומפוזיציה: איזה ממשק משתמש להציג. Compose מריץ פונקציות קומפוזביליות ויוצר תיאור של ממשק המשתמש.
- פריסה: איפה למקם את ממשק המשתמש. השלב הזה כולל שני שלבים: מדידה ומיקום. רכיבי הפריסה מודדים וממקמים את עצמם ואת כל רכיבי הצאצא בקואורדינטות דו-ממדיות, לכל צומת בעץ הפריסה.
- שרטוט: איך הוא מוצג. רכיבים בממשק המשתמש מצוירים על גבי Canvas, בדרך כלל מסך המכשיר.
הסדר של השלבים האלה בדרך כלל זהה, כך שהנתונים יכולים לזרום בכיוון אחד מהקומפוזיציה לפריסה ולשרטוט כדי ליצור פריים (נקרא גם זרימת נתונים חד-כיוונית). BoxWithConstraints, LazyColumn ו-LazyRow הם חריגים בולטים, שבהם הקומפוזיציה של הצאצאים תלויה בשלב הפריסה של ההורה.
באופן תיאורטי, כל אחד מהשלבים האלה מתרחש בכל פריים. עם זאת, כדי לשפר את הביצועים, Compose נמנע מחזרה על פעולות שיחשבו את אותן תוצאות מאותם קלטים בכל השלבים האלה. פיתוח נייטיב מדלג על הפעלה של פונקציה קומפוזבילית אם הוא יכול להשתמש שוב בתוצאה קודמת, וממשק המשתמש של פיתוח נייטיב לא משנה את הפריסה או משרטט מחדש את כל העץ אם אין צורך בכך. Compose מבצע רק את כמות העבודה המינימלית שנדרשת כדי לעדכן את ממשק המשתמש. האופטימיזציה הזו אפשרית כי Compose עוקב אחרי קריאות המצב בשלבים השונים.
הסבר על השלבים
בקטע הזה נסביר בפירוט איך שלושת השלבים של Compose מבוצעים עבור פונקציות קומפוזביליות.
הרכב
בשלב הקומפוזיציה, סביבת זמן הריצה של Compose מריצה פונקציות קומפוזביליות ויוצרת מבנה דמוי עץ שמייצג את ממשק המשתמש. עץ ממשק המשתמש הזה מורכב מצומתי פריסה שמכילים את כל המידע שנדרש לשלבים הבאים, כמו שמוצג בסרטון הבא:
איור 2. העץ שמייצג את ממשק המשתמש שנוצר בשלב הקומפוזיציה.
סעיף משנה של הקוד ועץ ממשק המשתמש נראה כך:
בדוגמאות האלה, כל פונקציה קומפוזבילית בקוד ממפה לצומת פריסה אחד בעץ ממשק המשתמש. בדוגמאות מורכבות יותר, פונקציות קומפוזביליות יכולות להכיל לוגיקה וזרימת בקרה, וליצור עץ שונה בהתאם למצבים שונים.
פריסה
בשלב הפריסה, Compose משתמש בעץ ממשק המשתמש שנוצר בשלב הקומפוזיציה כקלט. אוסף צומתי הפריסה מכיל את כל המידע שדרוש כדי להחליט על הגודל והמיקום של כל צומת במרחב דו-ממדי.
איור 4. המדידה והמיקום של כל צומת פריסה בעץ ממשק המשתמש במהלך שלב הפריסה.
בשלב הפריסה, המערכת עוברת על העץ באמצעות אלגוריתם של שלושה שלבים:
- מדידת צאצאים: צומת מודד את הצאצאים שלו, אם יש כאלה.
- החלטה על הגודל: על סמך המדידות האלה, הצומת מחליט על הגודל שלו.
- מיקום צאצאים: כל צומת צאצא ממוקם ביחס למיקום של הצומת עצמו.
בסוף השלב הזה, לכל צומת פריסה יש:
- רוחב וגובה מוגדרים
- קואורדינטת x, y שבה צריך לשרטט את הצורה
נזכיר את עץ ממשק המשתמש מהקטע הקודם:
בעץ הזה, האלגוריתם פועל באופן הבא:
- ה-
Rowמודד את הצאצאים שלו,Imageו-Column. - ה-
Imageנמדד. אין לו רכיבים צאצאים, אז הוא קובע את הגודל שלו ומדווח על הגודל ל-Row. - השלב הבא הוא מדידת
Column. הוא מודד קודם את הצאצאים שלו (שני רכיביTextהניתנים ליצירה). - ה-
Textהראשון נמדד. אין לו רכיבי צאצא, אז הוא קובע את הגודל שלו ומדווח עליו ל-Column.- השנייה
Textנמדדת. אין לו צאצאים, אז הוא מחליט על הגודל שלו ומדווח עליו בחזרה ל-Column.
- השנייה
- ה-
Columnמשתמש במידות של הצאצא כדי לקבוע את הגודל שלו. הוא משתמש ברוחב המקסימלי של הצאצא ובסכום הגובה של הצאצאים שלו. - הרכיב
Columnממקם את הצאצאים שלו ביחס לעצמו, ומציב אותם אחד מתחת לשני בצורה אנכית. - ה-
Rowמשתמש במידות של הצאצא כדי לקבוע את הגודל שלו. הוא משתמש בגובה המקסימלי של הצאצא ובסכום הרוחב של הצאצאים שלו. ואז הוא ממקם את הצאצאים שלו.
שימו לב שכל צומת נסרק רק פעם אחת. כדי למדוד ולמקם את כל הצמתים, זמן הריצה של Compose דורש רק מעבר אחד בעץ ממשק המשתמש, מה שמשפר את הביצועים. ככל שמספר הצמתים בעץ גדל, הזמן שנדרש כדי לעבור עליו גדל באופן לינארי. לעומת זאת, אם כל צומת נבדק כמה פעמים, זמן המעבר גדל באופן מעריכי.
שרטוט
בשלב השרטוט, המערכת עוברת שוב על העץ מלמעלה למטה, וכל צומת משרטט את עצמו על המסך בתורו.
איור 5. בשלב השרטוט, הפיקסלים משורטטים על המסך.
לפי הדוגמה הקודמת, תוכן העץ משורטט באופן הבא:
- ה-
Rowמציג את התוכן שהוא מכיל, כמו צבע רקע. - ה
Imageמצייר את עצמו. - ה
Columnמצייר את עצמו. - ה-
Textהראשון והשני מציירים את עצמם, בהתאמה.
איור 6. עץ ממשק משתמש והייצוג המצויר שלו.
קריאות מצב
כשקוראים את ה-value של snapshot state באחד מהשלבים שצוינו למעלה, Compose עוקב באופן אוטומטי אחרי מה שהוא עשה כשהוא קרא את ה-value. המעקב הזה מאפשר ל-Compose להפעיל מחדש את הקורא כשערך value של הערך הדינמי משתנה, והוא הבסיס לניראות (observability) של הערך הדינמי ב-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 של המצב ולעדכן אותו. הפונקציות האלה של getter ו-setter מופעלות רק כשמפנים למאפיין כערך, ולא כשהוא נוצר. לכן שתי הדרכים שתוארו קודם שוות ערך.
כל בלוק של קוד שאפשר להפעיל מחדש כשמצב הקריאה משתנה הוא היקף הפעלה מחדש. Compose עוקב אחרי שינויים במצב value ומפעיל מחדש היקפים בשלבים שונים.
קריאות מצב לפי שלבים
כמו שציינו קודם, יש שלושה שלבים עיקריים ב-Compose, והוא עוקב אחרי המצבים שנקראים בכל אחד מהם. כך Compose יכול להודיע רק לשלבים הספציפיים שצריך לבצע בהם עבודה לכל רכיב מושפע בממשק המשתמש.
בקטעים הבאים מתואר כל שלב ומה קורה כשקוראים ערך של מצב בתוכו.
שלב 1: קומפוזיציה
קריאות של מצב בתוך פונקציית @Composable או בלוק למבדה משפיעות על הקומפוזיציה, ויכולות להשפיע על השלבים הבאים. כשהערך של 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: פריסה
שלב הפריסה מורכב משני שלבים: מדידה ומיקום. שלב המדידה מריץ את פונקציית הלמבדה למדידה שמועברת לפונקציה הקומפוזבילית של Layout, את השיטה MeasureScope.measure של הממשק LayoutModifier, ועוד.
שלב המיקום מריץ את בלוק המיקום של הפונקציה layout, את בלוק הלמבדה של 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 משתנה, ממשק המשתמש של פיתוח נייטיב מריץ רק את שלב השרטוט.
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() שמשתמש ב-modifier של ההיסט כדי לשנות את מיקום הפריסה הסופי שלו, וכתוצאה מכך נוצר אפקט פרלקס כשהמשתמש גולל.
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.
זו דוגמה לקריאת מצב בשלב הקומפוזיציה. זה לא בהכרח דבר רע, ולמעשה זה הבסיס ליצירה מחדש, שמאפשרת לשינויים בנתונים להפיק ממשק משתמש חדש.
נקודה חשובה: הדוגמה הזו לא אופטימלית כי כל אירוע גלילה גורם להערכה מחדש, למדידה, לפריסה ולבסוף לשרטוט של כל התוכן הקומפוזבילי. אתם מפעילים את שלב ה-Compose בכל גלילה, למרות שהתוכן שמוצג לא השתנה, אלא רק המיקום שלו. אפשר לבצע אופטימיזציה של קריאת המצב כדי להפעיל מחדש רק את שלב הפריסה.
היסט עם למדא
יש גרסה נוספת של משנה ההיסט:
Modifier.offset(offset: Density.() -> IntOffset).
הגרסה הזו מקבלת פרמטר למבדה, שבו ההיסט שנוצר מוחזר על ידי בלוק הלמבדה. כדי להשתמש בקוד, צריך לעדכן אותו:
Box { val listState = rememberLazyListState() Image( // ... Modifier.offset { // State read of firstVisibleItemScrollOffset in Layout IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2) } ) LazyColumn(state = listState) { // ... } }
אז למה זה משפר את הביצועים? בלוק הלמבדה שאתם מספקים ל-modifier מופעל במהלך שלב ה-layout (באופן ספציפי, במהלך שלב המיקום של שלב ה-layout), כלומר המצב 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 לגובה האמיתי של התמונה. לאחר מכן, Compose מתזמן קומפוזיציה מחדש לפריים הבא. עם זאת, בשלב השרטוט הנוכחי, הטקסט מוצג עם מרווח פנימי של 0, כי הערך המעודכן של imageHeightPx עדיין לא משתקף.
קומפוזיציה של פריים שני
Compose מתחיל את הפריים השני, שמופעל על ידי השינוי בערך של imageHeightPx. בשלב הקומפוזיציה של הפריים הזה, המצב נקרא בתוך בלוק התוכן Box. עכשיו הטקסט כולל ריפוד שתואם בדיוק לגובה של התמונה. בשלב הפריסה, הערך של imageHeightPx מוגדר שוב, אבל לא מתוזמנת עוד הרכבה מחדש כי הערך נשאר עקבי.
הדוגמה הזו אולי נראית מאולצת, אבל חשוב להיזהר מהתבנית הכללית הזו:
Modifier.onSizeChanged(),onGloballyPositioned()או פעולות פריסה אחרות- עדכון של מצב מסוים
- משתמשים במצב הזה כקלט לשינוי פריסה (
padding(),height()או משהו דומה) - יכול להיות שתצטרכו לחזור על הפעולה
כדי לתקן את הבעיה בדוגמה שלמעלה, צריך להשתמש בפרימיטיבים המתאימים של הפריסה. אפשר להטמיע את הדוגמה הקודמת באמצעות Column(), אבל יכול להיות שתהיה לכם דוגמה מורכבת יותר שתדרוש משהו בהתאמה אישית, וזה ידרוש כתיבה של פריסה בהתאמה אישית. מידע נוסף זמין במדריך בנושא פריסות בהתאמה אישית.
העיקרון הכללי כאן הוא ליצור מקור מידע אמין יחיד לכמה רכיבים בממשק המשתמש, שצריך למדוד ולמקם אותם ביחס זה לזה. שימוש בפרימיטיב פריסה מתאים או יצירת פריסה בהתאמה אישית פירושם שההורה המשותף המינימלי משמש כמקור האמת שיכול לתאם את הקשר בין כמה רכיבים. הוספה של מצב דינמי מפרה את העיקרון הזה.
מידע נוסף על לולאות של קומפוזיציה חוזרת ואיך להימנע מכתיבה למצב בשלבים שונים זמין במאמר כתיבה לאחור ב-Compose.
מומלץ בשבילך
- הערה: טקסט הקישור מוצג כש-JavaScript מושבת
- מצב ו-Jetpack פיתוח נייטיב
- רשימות ורשתות
- Kotlin ל-Jetpack Compose