כתיבה לאחור

‫Compose מריץ פריים בשלושה שלבים שמתבצעים בסדר קבוע, מההתחלה לסוף:

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

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


מה הופך כתיבה ל "הפוכה"?

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

דוגמה לכתיבה לאחור בשלבי לולאת ה-recomposition
איור 2. דוגמה לכתיבה לאחור בשלבים של לולאת הרה-קומפוזיציה

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

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

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


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

  1. אל תכתבו למצב ב-onGloballyPositioned, ב-onSizeChanged, ב-onPlaced, ב-onLayoutRectChanged או ב-LayoutModifier אם המצב הזה נקרא ב-Composition, כי זה גורם לבעיה של תקינות הפריים הראשון.
    • אם הקואורדינטות או הגדלים של הפריסה נדרשים רק לשרטוט בהתאמה אישית, קוראים אותם ישירות בשלב השרטוט או הפריסה (לדוגמה, באמצעות Modifier.drawWithCache או Modifier.layout).
    • לגודל ברמת החלון (WindowWidthSizeClass): העלאת תצפית הגודל לרמת החלון. הקומפוזיציה מסתעפת לפי קטגוריות של גודל חלון לפני שמתבצעת מדידה מקומית.
    • שמירה על קומפוזיציה אחידה: משתמשים בפריסה מותאמת אישית אחת או ברכיבים כמו FlowRow או LazyVerticalGrid שמתאימים את המדידה והמיקום במהלך שלב 2 בלי ליצור מחדש או לשנות את המצב שבו משתמשים בקומפוזיציה.
    • שימוש בקומפוזיציית משנה: משתמשים ב-BoxWithConstraints או ב-SubcomposeLayout כשרכיבים קומפוזביליים צאצאים צריכים להסתעף על סמך הרוחב או הגובה המקומיים. השתמשו בזהירות: קומפוזיציית משנה כרוכה בעלות ביצועים ובדרך כלל אפשר להימנע ממנה.
    • כמוצא אחרון: מאפשרים לפריים הראשון להיות שגוי, ושומרים את הגודל ב-onSizeChanged כדי להפעיל הרכבה מחדש של פריים שני. זה גורם לשינויים פתאומיים בפריסה, לבעיות בביצועים ולסיכון ללולאות אינסופיות.
  2. אל תשנו את המצב אחרי שהוא נקרא בפעם הראשונה בקומפוזיציה:
    • אפשר לכתוב בבטחה לאובייקטים של MutableState במהלך קומפוזיציה מחוץ ל-SideEffect, אבל חשוב לוודא שלא כותבים למצב שכבר קראתם בקומפוזיציה. מומלץ להשתמש ב-rememberUpdatedState כשצריך לכתוב מצב בקומפוזיציה. כתיבה למצב במהלך קומפוזיציה בדרך אחרת היא בדרך כלל סימן לאפקט חסר או למצב או פונקציה קומפוזבילית שלא תוכננו כראוי. חשוב לזכור שהקומפוזיציה היא אופטימית ותמיד פועלת עם הערך האחרון של מצב, לכן יכול להיות שלא תראו את כל השינויים במצב בקומפוזיציות מחדש. עדכוני ממשק המשתמש לא אמורים לשמש כדרך לטיפול באירועים חד-פעמיים, ולכן לא נפוץ שצריך לעדכן את הערך של המצב כתוצאה מיצירה מחדש.
    • הימנעו משינוי מצבים שנצפים מחוץ לקומפוזיציה (לדוגמה, שדות ViewModel או דגלים של isVisible). כתיבת מצבים שמשפיעה על הקומפוזיציה שייכת לביטויי למבדה של אירועים (onClick), פונקציות Coroutine‏ (LaunchedEffect) או תופעות לוואי (SideEffect). rememberUpdatedState הוא חריג כי הוא נועד לשנות מצב שמשמש רק בגוף של @Composable.
  3. דחיית קריאות המצב לשלב המאוחר ביותר האפשרי:
    • קריאת מצבים ב-Draw (Modifier.graphicsLayer { alpha = ... }) או ב-Layout (Modifier.offset { IntOffset(...) }) מבטיחה שהשינויים יבטלו רק את שלב 2 או 3, וידלגו לחלוטין על שלב 1 (קומפוזיציה).