כתיבה לאחור

הפונקציה Compose מריצה פריים בשלושה שלבים מסודרים בקפדנות, שמתקדמים קדימה:

שלושה שלבים רציפים: 1. קומפוזיציה, 2. פריסה, 3. שרטוט
איור 1. שלושת השלבים של פריים ב-Compose
  1. הרכבה: מפעיל פונקציות @Composable כדי ליצור ולעדכן את עץ ממשק המשתמש.
  2. פריסה: המערכת מודדת את הילדים ואז ממקמת אותם.
  3. ציור: שולח פקודות ציור של בד הציור כדי לעבד פיקסלים במסך.

בכל פעם שרכיב Compose State נקרא במהלך שלב כלשהו, ‏ Compose מתעד באופן אוטומטי תלות בין המצב הזה לבין השלב המתאים.


מה הופך פעולת כתיבה ל'לאחור'?

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

דוגמה לכתיבה לאחור דרך שלבי לולאת ההרכבה מחדש
איור 2. דוגמה לכתיבה לאחור דרך שלבי לולאת ההרכבה מחדש

השלכות של כתיבות לאחור

כתיבה לאחור היא לא בהכרח דבר רע, והיא לא תמיד גורמת לקריסות, אבל היא לא יעילה ויכולה לפגוע בביצועי האפליקציה בכמה דרכים:

  • רינדור נוסף של פריימים ופריימים חסרים: כתיבה לאחור מאלצת את 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
}


כללים חשובים למניעת כתיבה לאחור

  1. אל תכתבו כדי לציין ב-onGloballyPositioned, ‏ onSizeChanged או ב-LayoutModifier אם המצב הזה נקרא ב-Composition, כי זה גורם לבעיה של דיוק הפריים הראשון.
    • אם נדרשים קואורדינטים או גדלים של פריסות רק לצורך ציור מותאם אישית, אפשר לקרוא אותם ישירות בשלב הציור או הפריסה (למשל, באמצעות Modifier.drawWithCache או Modifier.layout).
    • לשינוי גודל ברמת החלון (WindowWidthSizeClass): העלאת גודל החלון לרמת החלון. ענפי הקומפוזיציה בכיתות של גודל החלון לפני שהמדידה המקומית מתבצעת.
    • שמירה על אחידות היצירה: שימוש בפריסה מותאמת אישית אחת או ברכיבים כמו FlowRow או LazyVerticalGrid שמתאימים את המדידה והמיקום במהלך שלב 2 בלי ליצור מחדש או לשנות את המצב שבו נעשה שימוש ביצירה.
    • שימוש ב-Sub-composition: משתמשים ב-BoxWithConstraints או ב-SubcomposeLayout כאשר רכיבי ה-Composable צריכים להתפצל בהתאם לרוחב או לגובה המקומיים. שימו לב: שימוש בתת-קומפוזיציה כרוך בעלות ביצועים, ובדרך כלל אפשר להימנע ממנו.
    • כמוצא אחרון: מאפשרים למסגרת הראשונה להיות שגויה, שומרים את הגודל ב-onSizeChanged כדי להפעיל יצירה מחדש של המסגרת השנייה. הדבר גורם לקפיצות בפריסה, לבעיות בממשק ולסיכון של לולאות אינסופיות.
  2. Don't mutate state after its read the first time in composition:
    • אפשר לכתוב בבטחה לאובייקטים של MutableState במהלך יצירה מחוץ ל-SideEffect, אבל צריך לוודא שלא כותבים למצב שאולי קראתם קודם במהלך היצירה. מומלץ להשתמש ב-rememberUpdatedState כשמתעורר הצורך בכתיבת מצב בקומפוזיציה. כתיבה למצב במהלך קומפוזיציה בדרך אחרת היא בדרך כלל סימן לאפקט חסר או למצב או לרכיב שאפשר להרכבה שלא תוכננו בצורה מתאימה. חשוב לזכור שהקומפוזיציה היא אופטימית ומופעלת תמיד עם הערך העדכני ביותר של מצב, ולכן יכול להיות שלא תראו את כל שינויי המצב בקומפוזיציות מחדש. אין להשתמש בעדכוני ממשק משתמש כדי לטפל באירועים חד-פעמיים, ולכן לא נפוץ שערך של מצב צריך להתעדכן כתוצאה מהרכבה מחדש.
    • אל תשנו מצבים שנצפים מחוץ לקומפוזיציה (לדוגמה, שדות ViewModel או דגלים isVisible). פעולות כתיבה של מצב שמשפיעות על הקומפוזיציה צריכות להיות ב-lambdas של אירועים (onClick), ב-coroutines (LaunchedEffect) או בתופעות לוואי (SideEffect).‏ rememberUpdatedState הוא יוצא דופן כי הוא מיועד לשינוי מצב שמשמש רק בגוף של @Composable.
  3. דחיית קריאות של מצב לשלב האחרון האפשרי:
    • מצבי קריאה ב-Draw‏ (Modifier.graphicsLayer { alpha = ... }) או בפריסה (Modifier.offset { IntOffset(...) }) מבטיחים שהשינויים יבטלו רק את שלב 2 או 3, וידלגו לחלוטין על שלב 1 (ההרכב).