הפונקציה Compose מריצה פריים בשלושה שלבים מסודרים בקפדנות, שמתקדמים קדימה:
- הרכבה: מפעיל פונקציות
@Composableכדי ליצור ולעדכן את עץ ממשק המשתמש. - פריסה: המערכת מודדת את הילדים ואז ממקמת אותם.
- ציור: שולח פקודות ציור של בד הציור כדי לעבד פיקסלים במסך.
בכל פעם שרכיב Compose State נקרא במהלך שלב כלשהו, Compose מתעד באופן אוטומטי תלות בין המצב הזה לבין השלב המתאים.
מה הופך פעולת כתיבה ל'לאחור'?
כתיבה לאחור מתרחשת בכל פעם שמצב משתנה בשלב מאוחר יותר (או בהיקף בהמשך השרשרת – כלומר, היקף של קומפוזיציה שניתן להרכבה שמופעל מאוחר יותר בסדר בתוך אותו מעבר הרכבה) ממקום הקריאה שלו, וכך נכפה על Compose לבצע הרכבה מחדש על ידי תזמון של שלב מוקדם יותר או של קומפוזיציה שניתנת להרכבה להפעלה חוזרת. כתיבה לאחור היא לולאת רה-קומפוזיציה לא אופטימלית.
השלכות של כתיבות לאחור
כתיבה לאחור היא לא בהכרח דבר רע, והיא לא תמיד גורמת לקריסות, אבל היא לא יעילה ויכולה לפגוע בביצועי האפליקציה בכמה דרכים:
- רינדור נוסף של פריימים ופריימים חסרים: כתיבה לאחור מאלצת את Compose לבצע מעברים מיותרים של קומפוזיציה על פני פריימים עוקבים, מה שגורם לבזבוז של משאבי מעבד (CPU) ו-GPU ועלול לגרום לבעיות בממשק (jank).
- בעיות בתיקון המסגרת הראשונה: אם הרכיב שלכם דורש כתיבה לאחור כדי לפתור את המידות או המצב הסופיים שלו, המסגרת הראשונה עוברת עיבוד עם נתונים לא חוקיים, נתוני ברירת מחדל או נתונים לא סופיים (כמו גודל אפס או היסט שגוי). התוצאה היא קפיצה חזותית או הבהוב של הפריסה כשמסגרת השנייה מוצגת.
- לולאות אינסופיות של קומפוזיציה מחדש: אם שינוי במצב משנה את גודל הפריסה, וגודל הפריסה כותב באופן רציף ערך חדש למצב, אתם עלולים ליצור לולאה אינסופית של פריימים שבה המסך עובר קומפוזיציה מחדש כל הזמן בכל פריים בלי להתייצב.
העברת זרימה בין שלבים
המעבר בין המצבים צריך להיות תמיד קדימה, בין השלבים:
| שלב הקריאה | כתיבת הקשר | מקובל? | למה |
|---|---|---|---|
פריסה (Modifier.offset { }) |
הרכב | כן | הקומפוזיציה מעדכנת את המצב ← רכיב הפריסה קורא אותו מאוחר יותר באותה מסגרת בלי לבצע קומפוזיציה מחדש. |
הגרלה (graphicsLayer { }, drawBehind { }) |
הרכב | כן | הקומפוזיציה מעדכנת את המצב → כלי הציור קורא אותו בשלב הסופי. השלבים 'קומפוזיציה' ו'פריסה' נדלגים לחלוטין. |
| ציור | פריסה | כן | הפריסה מעדכנת את המצב, והציור קורא אותו. זהו תהליך תקין. |
| הרכב | התקשרות חזרה לאירוע (onClick, onValueChange) שגורמת לשינוי במצב. הערה: קריאות חוזרות (callback) של פריסות לא נחשבות כאירועים. |
כן | אירוע משנה את המצב שמשמש להרכבה. אם האירוע מתרחש מחוץ למסגרת (לא ב-Composition, ב-Layout או ב-Draw), זה תקין. |
| הרכב | קורוטינה (LaunchedEffect) |
כן – בזהירות | עדכון מצב באופן אסינכרוני בתגובה למחזור חיים או לאירועים. פעולות כתיבה מאפקטים יכולות להיות תקינות, אבל הן יכולות להצביע על שכבות מצב לא יעילות. אם אפשר, כדאי להימנע מהם. |
| מיקום (בפריסה) | מדידה (בפריסה) | כן | בפריסה, מותר לעדכן את המצב ואז לקרוא את המצב במיקום. |
| מדידה (בפריסה) | מיקום (בפריסה) | לא – כתיבה הפוכה | כתיבה למצב במיקום שנמצא בהמשך במצב הקריאה, גורמת ללולאת מדידה חוזרת. |
| הרכב | פריסה (onSizeChanged, LayoutModifier) |
לא – כתיבה הפוכה | הפריסה מבטלת את הלולאה של קומפוזיציה ← רה-קומפוזיציה. |
| הרכב | הגרלה (drawWithContent, Canvas) |
לא – כתיבה הפוכה | הפונקציה Draw מבטלת את הלולאה Composition → Recomposition. |
שילובים של שלבים: אחורה לעומת קדימה
הנה דוגמאות לכתיבה לאחור ב-Compose ואיך אפשר לפתור את הבעיות האלה.
הפוך: קריאה בהלחנה, כתיבה בפריסה
- מה קורה: הקומפוזיציה קוראת את
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) } } }
הפוך: קריאה ב-Compose, כתיבה ב-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או ב-LayoutModifierאם המצב הזה נקרא ב-Composition, כי זה גורם לבעיה של דיוק הפריים הראשון.- אם נדרשים קואורדינטים או גדלים של פריסות רק לצורך ציור מותאם אישית, אפשר לקרוא אותם ישירות בשלב הציור או הפריסה (למשל, באמצעות
Modifier.drawWithCacheאוModifier.layout). - לשינוי גודל ברמת החלון (
WindowWidthSizeClass): העלאת גודל החלון לרמת החלון. ענפי הקומפוזיציה בכיתות של גודל החלון לפני שהמדידה המקומית מתבצעת. - שמירה על אחידות היצירה: שימוש בפריסה מותאמת אישית אחת או ברכיבים כמו
FlowRowאוLazyVerticalGridשמתאימים את המדידה והמיקום במהלך שלב 2 בלי ליצור מחדש או לשנות את המצב שבו נעשה שימוש ביצירה. - שימוש ב-Sub-composition: משתמשים ב-
BoxWithConstraintsאו ב-SubcomposeLayoutכאשר רכיבי ה-Composable צריכים להתפצל בהתאם לרוחב או לגובה המקומיים. שימו לב: שימוש בתת-קומפוזיציה כרוך בעלות ביצועים, ובדרך כלל אפשר להימנע ממנו. - כמוצא אחרון: מאפשרים למסגרת הראשונה להיות שגויה, שומרים את הגודל ב-
onSizeChangedכדי להפעיל יצירה מחדש של המסגרת השנייה. הדבר גורם לקפיצות בפריסה, לבעיות בממשק ולסיכון של לולאות אינסופיות.
- אם נדרשים קואורדינטים או גדלים של פריסות רק לצורך ציור מותאם אישית, אפשר לקרוא אותם ישירות בשלב הציור או הפריסה (למשל, באמצעות
- Don't mutate state after its read the first time in composition:
- אפשר לכתוב בבטחה לאובייקטים של
MutableStateבמהלך יצירה מחוץ ל-SideEffect, אבל צריך לוודא שלא כותבים למצב שאולי קראתם קודם במהלך היצירה. מומלץ להשתמש ב-rememberUpdatedStateכשמתעורר הצורך בכתיבת מצב בקומפוזיציה. כתיבה למצב במהלך קומפוזיציה בדרך אחרת היא בדרך כלל סימן לאפקט חסר או למצב או לרכיב שאפשר להרכבה שלא תוכננו בצורה מתאימה. חשוב לזכור שהקומפוזיציה היא אופטימית ומופעלת תמיד עם הערך העדכני ביותר של מצב, ולכן יכול להיות שלא תראו את כל שינויי המצב בקומפוזיציות מחדש. אין להשתמש בעדכוני ממשק משתמש כדי לטפל באירועים חד-פעמיים, ולכן לא נפוץ שערך של מצב צריך להתעדכן כתוצאה מהרכבה מחדש. - אל תשנו מצבים שנצפים מחוץ לקומפוזיציה (לדוגמה, שדות
ViewModelאו דגליםisVisible). פעולות כתיבה של מצב שמשפיעות על הקומפוזיציה צריכות להיות ב-lambdas של אירועים (onClick), ב-coroutines (LaunchedEffect) או בתופעות לוואי (SideEffect).rememberUpdatedStateהוא יוצא דופן כי הוא מיועד לשינוי מצב שמשמש רק בגוף של@Composable.
- אפשר לכתוב בבטחה לאובייקטים של
- דחיית קריאות של מצב לשלב האחרון האפשרי:
- מצבי קריאה ב-Draw (
Modifier.graphicsLayer { alpha = ... }) או בפריסה (Modifier.offset { IntOffset(...) }) מבטיחים שהשינויים יבטלו רק את שלב 2 או 3, וידלגו לחלוטין על שלב 1 (ההרכב).
- מצבי קריאה ב-Draw (