בדומה לרוב ערכות הכלים האחרות של ממשק המשתמש, Compose מעבד מסגרת באמצעות כמה שלבים נפרדים. לדוגמה, למערכת Android View יש שלושה שלבים עיקריים: מדידה, פריסה וציור. השלב של יצירת המוזיקה דומה מאוד, אבל יש בו שלב נוסף חשוב שנקרא קומפוזיציה, והוא מתבצע בהתחלה.
במאמרי העזרה בנושא Compose מוסבר על קומפוזיציה במאמרים Thinking in Compose ו-State and Jetpack Compose.
שלושת השלבים של מסגרת
התהליך של יצירת מוזיקה ב-Compose כולל שלושה שלבים עיקריים:
- קומפוזיציה: ממשק המשתמש של What שיוצג. Compose מריץ פונקציות קומפוזביליות ויוצר תיאור של ממשק המשתמש.
- פריסה: איפה למקם את ממשק המשתמש. השלב הזה כולל שני תהליכים: מדידה ומיקום. רכיבי פריסה מודדים את עצמם ואת רכיבי הצאצא שלהם וממקמים אותם בקואורדינטות דו-ממדיות, לכל צומת בעץ הפריסה.
- שרטוט: איך הוא מוצג. רכיבים בממשק המשתמש מצוירים ב-Canvas, בדרך כלל במסך של מכשיר.
הסדר של השלבים האלה בדרך כלל זהה, כך שהנתונים זורמים בכיוון אחד מהקומפוזיציה לפריסה ולציור כדי ליצור פריים (שנקרא גם זרימת נתונים חד-כיוונית). BoxWithConstraints, LazyColumn ו-LazyRow הם יוצאים מן הכלל, כי הקומפוזיציה של רכיבי הצאצא שלהם תלויה בשלב הפריסה של רכיב האב.
מבחינה קונספטואלית, כל אחד מהשלבים האלה מתרחש בכל פריים. עם זאת, כדי לשפר את הביצועים, Compose נמנעת מחזרה על פעולות שיחשבו את אותן תוצאות מאותם קלטים בכל השלבים האלה. Compose מדלג על הפעלה של פונקציה קומפוזבילית אם הוא יכול לעשות שימוש חוזר בתוצאה קודמת, וממשק המשתמש של Compose לא מבצע פריסה מחדש או ציור מחדש של העץ כולו אם אין צורך בכך. Compose מבצעת רק את כמות העבודה המינימלית שנדרשת לעדכון ממשק המשתמש. האופטימיזציה הזו אפשרית כי Compose קורא את מצב הטראקים בשלבים השונים.
הסבר על השלבים
בקטע הזה מתואר בפירוט רב יותר איך מתבצעות שלוש הפעולות של Compose עבור פונקציות Composable.
הרכב
בשלב הקומפוזיציה, זמן הריצה של Compose מריץ פונקציות קומפוזביליות ומפיק מבנה דמוי עץ שמייצג את ממשק המשתמש. עץ ממשק המשתמש הזה מורכב מצמתים של פריסות שמכילים את כל המידע שנדרש לשלבים הבאים, כפי שמוצג בסרטון הבא:
איור 2. העץ שמייצג את ממשק המשתמש שנוצר בשלב הקומפוזיציה.
קטע משני של קוד ועץ ממשק המשתמש נראה כך:
בדוגמאות האלה, כל פונקציה קומפוזבילית בקוד ממופה לצומת פריסה יחיד בעץ ממשק המשתמש. בדוגמאות מורכבות יותר, רכיבי Composable יכולים להכיל לוגיקה וזרימת בקרה, ולהפיק עץ שונה בהינתן מצבים שונים.
פריסה
בשלב הפריסה, Compose משתמש בעץ ממשק המשתמש שנוצר בשלב הקומפוזיציה כקלט. אוסף הצמתים של הפריסה מכיל את כל המידע שדרוש כדי לקבוע את הגודל והמיקום של כל צומת במרחב דו-ממדי.
איור 4. המדידה והמיקום של כל צומת פריסה בעץ ממשק המשתמש במהלך שלב הפריסה.
במהלך שלב הפריסה, המערכת עוברת על העץ באמצעות האלגוריתם הבא בן שלושת השלבים:
- מדידת ילדים: צומת מודד את הילדים שלו אם יש כאלה.
- קביעת גודל עצמאי: על סמך המדידות האלה, הצומת קובע את הגודל שלו.
- מיקום ילדים: כל צומת צאצא ממוקם ביחס למיקום של הצומת עצמו.
בסוף השלב הזה, לכל צומת פריסה יש:
- רוחב וגובה שהוקצו
- קואורדינטות x, y שבהן צריך לצייר את הצורה
נזכרים בעץ ממשק המשתמש מהקטע הקודם:
עבור העץ הזה, האלגוריתם פועל באופן הבא:
- ה-
Rowמודד את הילדים שלו,Imageו-Column. - המדד
Imageנמדד. אין לו צאצאים, אז הוא מחליט על הגודל שלו ומדווח על הגודל בחזרה ל-Row. - המדד הבא שיימדד הוא
Column. הוא מודד קודם את הילדים שלו (שניTextרכיבי Composable). - ה-
Textהראשון נמדד. אין לו צאצאים, לכן הוא מחליט על הגודל שלו ומדווח על הגודל שלו בחזרה אלColumn.- הערך השני
Textנמדד. אין לו צאצאים, ולכן הוא קובע את הגודל שלו ומדווח עליו בחזרה אלColumn.
- הערך השני
- ה
Columnמשתמש במידות של הילד או הילדה כדי לקבוע את הגודל שלו. הוא משתמש ברוחב המקסימלי של רכיב הצאצא ובסכום הגובה של רכיבי הצאצא שלו. - הפריסה
Columnממקמת את הצאצאים שלה ביחס לעצמה, אחד מתחת לשני בצורה אנכית. - ה
Rowמשתמש במידות של הילד או הילדה כדי לקבוע את הגודל שלו. הוא משתמש בגובה המקסימלי של הילד ובסכום הרוחבים של הילדים שלו. לאחר מכן הוא ממקם את רכיבי הצאצא שלו.
שימו לב: כל צומת נסרק רק פעם אחת. זמן הריצה של Compose דורש רק מעבר אחד דרך עץ ממשק המשתמש כדי למדוד את כל הצמתים ולמקם אותם, וכך משפר את הביצועים. ככל שמספר הצמתים בעץ גדל, הזמן שנדרש למעבר בין הצמתים גדל באופן לינארי. לעומת זאת, אם כל צומת מבקר כמה פעמים, זמן המעבר גדל באופן מעריכי.
שרטוט
בשלב הציור, המערכת עוברת שוב על העץ מלמעלה למטה, וכל צומת מצייר את עצמו על המסך בתורו.
איור 5. בשלב הציור, הפיקסלים מצוירים על המסך.
בדוגמה הקודמת, התוכן של העץ מצויר באופן הבא:
- התג
Rowמצייר כל תוכן שיש לו, כמו צבע רקע. - ה-
Imageמצייר את עצמו. - הצורה
Columnמצוירת. - הצורה הראשונה והשנייה
Textמצוירות בעצמן, בהתאמה.
איור 6. עץ של ממשק משתמש והייצוג שלו.
קריאות של מצב
כשקוראים את value של snapshot state במהלך אחד מהשלבים שצוינו למעלה, כלי הכתיבה של Gemini עוקב באופן אוטומטי אחרי הפעולות שהוא ביצע כשקרא את 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) )
מתחת לפני השטח של הפונקציה property delegate, נעשה שימוש בפונקציות getter ו-setter כדי לגשת ל-State's value ולעדכן אותו. הפונקציות האלה של getter ו-setter מופעלות רק כשמפנים למאפיין כערך, ולא כשהוא נוצר. לכן שתי הדרכים שתיארנו קודם הן שוות ערך.
כל בלוק קוד שאפשר להפעיל מחדש כשמצב הקריאה משתנה הוא היקף הפעלה מחדש. התכונה 'יצירת מוזיקה' עוקבת אחרי שינויים במצב value ומפעילה מחדש את ההיקפים בשלבים שונים.
קריאות של מצב מדורג
כמו שצוין קודם, יש שלושה שלבים עיקריים ב-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.
זו דוגמה לקריאת מצב בשלב הקומפוזיציה. זה לא בהכרח דבר רע, ולמעשה זה הבסיס של יצירה מחדש (recomposition), שמאפשרת לשינויים בנתונים ליצור ממשק משתמש חדש.
נקודה חשובה: הדוגמה הזו לא אופטימלית כי כל אירוע גלילה גורם להערכה מחדש של כל התוכן הקומפוזבילי, למדידה, לפריסה ולציור שלו. הפעלתם את שלב ההרכבה בכל גלילה, למרות שהתוכן שמוצג לא השתנה, רק המיקום שלו. אפשר לבצע אופטימיזציה של קריאת המצב כדי להפעיל מחדש רק את שלב הפריסה.
הזחה באמצעות lambda
יש גרסה נוספת של מקש הצירוף offset:
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 שמעבירים לשינוי מופעל במהלך שלב הפריסה (במיוחד במהלך שלב המיקום של הפריסה), כלומר המצב firstVisibleItemScrollOffset כבר לא נקרא במהלך ההרכבה. מכיוון ש-Compose עוקב אחרי קריאת המצב, השינוי הזה אומר שאם firstVisibleItemScrollOffset של value משתנה, מערכת 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).
בשלב הפריסה הבא מופעל הקריאה החוזרת של המשנה onSizeChanged, שמעדכן את imageHeightPx לגובה בפועל של התמונה. הוא יוצר את התמונה ואז מתזמן רה-קומפוזיציה למסגרת הבאה. עם זאת, במהלך שלב הציור הנוכחי, הטקסט מוצג עם ריווח פנימי של 0, כי הערך המעודכן imageHeightPx עדיין לא משתקף.
הפריים השני
הפונקציה Compose מתחילה את המסגרת השנייה, שמופעלת על ידי השינוי בערך של imageHeightPx. בשלב ההרכבה של הפריים הזה, המצב נקרא בתוך בלוק התוכן Box. עכשיו הטקסט מרופד כך שיתאים בדיוק לגובה התמונה. במהלך שלב הפריסה, הערך של imageHeightPx מוגדר מחדש, אבל לא מתוזמנת שום רה-קומפוזיציה כי הערך נשאר עקבי.
הדוגמה הזו אולי נראית מומצאת, אבל חשוב להיזהר מהתבנית הכללית הזו:
Modifier.onSizeChanged(),onGloballyPositioned()או פעולות אחרות של פריסת המסך- עדכון של מצב מסוים
- השתמשו במצב הזה כקלט לשינוי פריסה (
padding(),height()או דומה) - יכול להיות שהפעולה תחזור על עצמה
כדי לתקן את הדוגמה שלמעלה, צריך להשתמש ברכיבי פריסה מתאימים. אפשר להטמיע את הדוגמה הקודמת באמצעות Column(), אבל יכול להיות שיש לכם דוגמה מורכבת יותר שדורשת משהו בהתאמה אישית, ולכן תצטרכו לכתוב פריסה בהתאמה אישית. מידע נוסף זמין במדריך בנושא פריסות בהתאמה אישית.
העיקרון הכללי כאן הוא ליצור מקור מידע אמין יחיד לכמה רכיבי ממשק משתמש שצריך למדוד ולמקם ביחס זה לזה. שימוש בפרימיטיב פריסה מתאים או יצירת פריסה בהתאמה אישית מאפשרים להשתמש בהורה המשותף המינימלי כמקור האמת שיכול לתאם את הקשר בין כמה רכיבים. הוספת מצב דינמי מפרה את העיקרון הזה.
מידע נוסף על לולאות של רה-קומפוזיציה ועל דרכים להימנע מכתיבה לערך דינמי בין שלבים זמין במאמר כתיבות לאחור ב-Compose.
מומלץ בשבילך
- הערה: טקסט הקישור מוצג כש-JavaScript מושבת
- מצב ו-Jetpack פיתוח נייטיב
- רשימות ורשתות
- Kotlin ל-Jetpack Compose