Compose מריץ פריים בשלושה שלבים שמתבצעים בסדר קבוע, מההתחלה לסוף:
- קומפוזיציה: הפעלת פונקציות
@Composableכדי ליצור ולעדכן את עץ ממשק המשתמש. - פריסה: מודדת את הצאצאים ואז ממקמת אותם.
- ציור: פקודות ציור ב-canvas כדי לעבד פיקסלים במסך.
בכל פעם ש-State של Compose נקרא במהלך שלב כלשהו, Compose מתעד באופן אוטומטי תלות בין המצב הזה לבין השלב המתאים.
מה הופך כתיבה ל "הפוכה"?
כתיבה לאחור מתרחשת בכל פעם שמצב משתנה בשלב מאוחר יותר (או בהיקף במורד הזרם – כלומר היקף של פונקציה קומפוזבילית שמופעלת מאוחר יותר בסדר באותו מעבר קומפוזיציה) מהמקום שבו הוא נקרא, מה שמאלץ את Compose לבצע קומפוזיציה מחדש על ידי תזמון של שלב מוקדם יותר או פונקציה קומפוזבילית להפעלה חוזרת. כתיבה לאחור היא לולאת קומפוזיציה מחדש שלא עברה אופטימיזציה.
ההשלכות של כתיבה לאחור
כתיבה לאחור היא לא בהכרח דבר רע, והיא לא תמיד גורמת לקריסות, אבל היא לא יעילה ויכולה לפגוע בביצועי האפליקציה בכמה דרכים:
- עיבוד מיותר של פריימים ופריימים חסרים: כתיבה לאחור מאלצת את Compose לבצע שלבי קומפוזיציה מיותרים בפריימים עוקבים, מה שגורם לבזבוז של משאבי CPU ו-GPU ועלול לגרום לבעיות בביצועים.
- בעיות שקשורות לתקינות של הפריים הראשון: אם הרכיב דורש כתיבה לאחור כדי לפתור את המימדים או המצב הסופיים שלו, הפריים הראשון מוצג עם נתונים לא תקינים, נתוני ברירת מחדל או נתונים לא יציבים (כמו גודל אפס או היסט שגוי). זה גורם להבהוב חזותי או להבהוב בפריסה כשהפריים השני מוצג.
- לולאות אינסופיות של הרכבה מחדש: אם שינוי במצב משנה את גודל הפריסה, וגודל הפריסה כותב כל הזמן ערך חדש למצב, אתם עלולים ליצור לולאת פריימים אינסופית שבה המסך מרכיב מחדש כל פריים בלי להתייצב אף פעם.
מעבר קדימה בין השלבים
השינויים בסטטוס צריכים תמיד להתקדם קדימה בשלבים:
| שלב הקריאה | הקשר לכתיבה | קביל? | למה |
|---|---|---|---|
פריסה (Modifier.offset { }) |
קומפוזיציה | כן | הקומפוזיציה מעדכנת את המצב ← הפריסה קוראת אותו מאוחר יותר באותו פריים בלי ליצור קומפוזיציה מחדש. |
תיקו (graphicsLayer { }, drawBehind { }) |
קומפוזיציה | כן | הקומפוזיציה מעדכנת את המצב ← Draw קורא אותו בשלב הסופי. המערכת מדלגת לגמרי על שלבי הקומפוזיציה והפריסה. |
| ציור | פריסה | כן | הפריסה מעדכנת את המצב, והשרטוט קורא אותו, תהליך תקין. |
| קומפוזיציה | הקריאה החוזרת (callback) של האירוע (onClick, onValueChange) משנה את מצב הנהיגה. הערה: פונקציות קריאה חוזרת (callback) של פריסה לא נחשבות כאירועים. |
כן | אירוע משנה את המצב שמשמש להפעלת הקומפוזיציה. אם האירוע מתרחש מחוץ למסגרת (לא ב-Composition, ב-Layout או ב-Draw), זה תקין. |
| קומפוזיציה | Coroutine (LaunchedEffect) |
כן – בזהירות | מעדכן את המצב באופן אסינכרוני בתגובה למחזור החיים או לאירועים. כתיבה מאפקטים יכולה להיות תקינה, אבל היא יכולה להצביע על שכבות מצב לא יעילות. מומלץ להימנע מהם ככל האפשר. |
| מיקום (בפריסה) | מדידה (בפריסה) | כן | ב-Layout, אפשר לעדכן את המצב ואז לקרוא את המצב הזה ב-placement. |
| מדידה (בפריסה) | מיקום (בפריסה) | לא – כתיבה הפוכה | כתיבה למצב במיקום שמופיע בהמשך במצב הקריאה גורמת ללולאת מדידה מחדש. |
| קומפוזיציה | פריסה (onSizeChanged, LayoutModifier) |
לא – כתיבה הפוכה | הפריסה מבטלת את הקומפוזיציה ← לולאת קומפוזיציה מחדש. |
| קומפוזיציה | תיקו (drawWithContent, Canvas) |
לא – כתיבה הפוכה | הציור מבטל את הלולאה "קומפוזיציה ← קומפוזיציה מחדש". |
שילובים של שלבים: אחורה לעומת קדימה
הדוגמאות הבאות מראות כתיבה לאחור ב-Compose ואיך אפשר לפתור אותן.
תאימות לאחור: קריאה ב-Composition, כתיבה ב-Layout
- מה קורה: הקומפוזיציה קוראת את
componentHeightכדי לקבוע איזה ממשק משתמש להפיק. בשלב מאוחר יותר בפריים, שלב הפריסה מודד או ממקם תצוגות וכותב ערך חדש ב-componentHeight(לדוגמה, באמצעותonSizeChanged,onGloballyPositionedאוLayoutModifierבהתאמה אישית). - תוצאה: שינוי של
componentHeightבפריסה מבטל את שלב הקומפוזיציה שהושלם. חשוב לזכור שהגודל שלonSizeChangedמדווח אחרי שסבב המדידה של הפריסה מסתיים. אם ערך המצב המעודכן מתייצב במעבר הבא, יכול להיות שההרכבה מחדש תיפסק אחרי פריים אחד נוסף. עם זאת, אם הערך החדש ממשיך לשנות את הגודל, נוצרת לולאת פריימים אינסופית. בנוסף, הפונקציהonGloballyPositionedפועלת אחרי הפריסה והמיקום, ולכן כתיבת מצבים בתוכה חשופה עוד יותר ללולאות של יצירה מחדש ופריסה מחדש ברצף של פריימים.
// ❌ BAD: Read in Composition, Written in Layout (onSizeChanged) @Composable fun BadAspectRatioImage(painter: Painter) { var calculatedHeight by remember { mutableStateOf(0.dp) } val density = LocalDensity.current // State read during COMPOSITION: Image( painter = painter, contentDescription = "Dynamic Image", modifier = Modifier .fillMaxWidth() .height(calculatedHeight) .onSizeChanged { size -> // State write during LAYOUT phase! // Triggers backwards write and recomposition pass val aspectRatio = 16f / 9f val widthDp = with(density) { size.width.toDp() } calculatedHeight = widthDp / aspectRatio } ) } // ✅ GOOD: Measure and calculate aspect ratio height in Phase 2 (Layout) without recomposition @Composable fun GoodAspectRatioImage( painter: Painter, aspectRatio: Float = 16f / 9f, modifier: Modifier = Modifier ) { Layout( content = { Image( painter = painter, contentDescription = "Dynamic Image" ) }, modifier = modifier ) { measurables, constraints -> val width = constraints.maxWidth val height = (width / aspectRatio).toInt() // Illustrative, you can use Modifier.aspectRatio() val imageConstraints = constraints.copy( minWidth = width, maxWidth = width, minHeight = height, maxHeight = height ) val placeable = measurables.first().measure(imageConstraints) layout(width, height) { placeable.placeRelative(0, 0) } } }
הפוך: קריאה ב-Composition, כתיבה ב-Drawing
- מה קורה: המצב נקרא בגוף הרכיב הקומפוזבילי (שלב הקומפוזיציה), אבל משתנה בתוך
Modifier.drawWithContent,Modifier.drawBehindאוCanvas(שלב השרטוט). - תוצאה: שלב השרטוט משנה את המצב ← הקומפוזיציה לא תקינה ← לולאה אינסופית.
// ❌ BAD: Read in Composition, Written in Draw () @Composable fun BadBackwardsWriteDraw() { var componentHeight by remember { mutableStateOf(0.dp) } // State read during COMPOSITION: Text( text = "Height is: $componentHeight", modifier = Modifier.drawBehind { // State write during the DRAW phase! // Invalidates Composition -> triggers recomposition loop! componentHeight = size.height.dp } ) }
אחורה: קריאה בקומפוזיציה, כתיבה בקומפוזיציה (אותו שלב)
- מה קורה: קריאת
countבפונקציה קומפוזבילית, ושינוי שלcountישירות במשבצת תוכן של פונקציה קומפוזבילית אחרת אחרי הקריאה. - תוצאה: מערכת תמונת המצב מתעדת את הקריאה ואת הכתיבה הבאה באותו מעבר קומפוזיציה, ומבטלת באופן מיידי את ההיקף הנוכחי.
// ❌ BAD: Direct write in Composable body after read @Composable fun BadCounter() { var count by remember { mutableIntStateOf(0) } Text("Count: $count") // State read in Composition Button(onClick = {}) { count++ // State write in Composition (Backwards write!) } } // Acceptable - but error-prone as someone may add a read before the write : Direct write in Composable body before read @Composable fun OkCounter() { var count by remember { mutableIntStateOf(0) } Button(onClick = {}) { count++ // State write in Composition } Text("Count: $count") // State read in Composition }
כללים חשובים למניעת כתיבה לאחור
- אל תכתבו למצב ב-
onGloballyPositioned, ב-onSizeChanged, ב-onPlaced, ב-onLayoutRectChangedאו ב-LayoutModifierאם המצב הזה נקרא ב-Composition, כי זה גורם לבעיה של תקינות הפריים הראשון.- אם הקואורדינטות או הגדלים של הפריסה נדרשים רק לשרטוט בהתאמה אישית, קוראים אותם ישירות בשלב השרטוט או הפריסה (לדוגמה, באמצעות
Modifier.drawWithCacheאוModifier.layout). - לגודל ברמת החלון (
WindowWidthSizeClass): העלאת תצפית הגודל לרמת החלון. הקומפוזיציה מסתעפת לפי קטגוריות של גודל חלון לפני שמתבצעת מדידה מקומית. - שמירה על קומפוזיציה אחידה: משתמשים בפריסה מותאמת אישית אחת או ברכיבים כמו
FlowRowאוLazyVerticalGridשמתאימים את המדידה והמיקום במהלך שלב 2 בלי ליצור מחדש או לשנות את המצב שבו משתמשים בקומפוזיציה. - שימוש בקומפוזיציית משנה: משתמשים ב-
BoxWithConstraintsאו ב-SubcomposeLayoutכשרכיבים קומפוזביליים צאצאים צריכים להסתעף על סמך הרוחב או הגובה המקומיים. השתמשו בזהירות: קומפוזיציית משנה כרוכה בעלות ביצועים ובדרך כלל אפשר להימנע ממנה. - כמוצא אחרון: מאפשרים לפריים הראשון להיות שגוי, ושומרים את הגודל ב-
onSizeChangedכדי להפעיל הרכבה מחדש של פריים שני. זה גורם לשינויים פתאומיים בפריסה, לבעיות בביצועים ולסיכון ללולאות אינסופיות.
- אם הקואורדינטות או הגדלים של הפריסה נדרשים רק לשרטוט בהתאמה אישית, קוראים אותם ישירות בשלב השרטוט או הפריסה (לדוגמה, באמצעות
- אל תשנו את המצב אחרי שהוא נקרא בפעם הראשונה בקומפוזיציה:
- אפשר לכתוב בבטחה לאובייקטים של
MutableStateבמהלך קומפוזיציה מחוץ ל-SideEffect, אבל חשוב לוודא שלא כותבים למצב שכבר קראתם בקומפוזיציה. מומלץ להשתמש ב-rememberUpdatedStateכשצריך לכתוב מצב בקומפוזיציה. כתיבה למצב במהלך קומפוזיציה בדרך אחרת היא בדרך כלל סימן לאפקט חסר או למצב או פונקציה קומפוזבילית שלא תוכננו כראוי. חשוב לזכור שהקומפוזיציה היא אופטימית ותמיד פועלת עם הערך האחרון של מצב, לכן יכול להיות שלא תראו את כל השינויים במצב בקומפוזיציות מחדש. עדכוני ממשק המשתמש לא אמורים לשמש כדרך לטיפול באירועים חד-פעמיים, ולכן לא נפוץ שצריך לעדכן את הערך של המצב כתוצאה מיצירה מחדש. - הימנעו משינוי מצבים שנצפים מחוץ לקומפוזיציה (לדוגמה, שדות
ViewModelאו דגלים שלisVisible). כתיבת מצבים שמשפיעה על הקומפוזיציה שייכת לביטויי למבדה של אירועים (onClick), פונקציות Coroutine (LaunchedEffect) או תופעות לוואי (SideEffect).rememberUpdatedStateהוא חריג כי הוא נועד לשנות מצב שמשמש רק בגוף של@Composable.
- אפשר לכתוב בבטחה לאובייקטים של
- דחיית קריאות המצב לשלב המאוחר ביותר האפשרי:
- קריאת מצבים ב-Draw (
Modifier.graphicsLayer { alpha = ... }) או ב-Layout (Modifier.offset { IntOffset(...) }) מבטיחה שהשינויים יבטלו רק את שלב 2 או 3, וידלגו לחלוטין על שלב 1 (קומפוזיציה).
- קריאת מצבים ב-Draw (