نوشتن به‌عقب

‫Compose یک فریم را در سه فاز با ترتیب دقیق و جریان رو به جلو اجرا می‌کند:

سه مرحله روبه‌جلو: ۱. ترکیب‌بندی، ۲. چیدمان، ۳. طراحی
شکل ۱. سه فاز قاب Compose
  1. ترکیب‌بندی: توابع @Composable را برای ساختن و به‌روزرسانی درخت میانای کاربر اجرا می‌کند.
  2. چیدمان: فرزندان را اندازه‌گیری می‌کند و سپس آن‌ها را قرار می‌دهد.
  3. ترسیم: فرمان‌های ترسیم بوم را برای رندر کردن پیکسل‌ها در صفحه صادر می‌کند.

هرگاه Compose State درطول هر فازی خوانده شود، Compose به‌طور خودکار یک وابستگی را بین آن حالت و فاز مربوطه ثبت می‌کند.


چه چیزی باعث می‌شود نوشتن «برعکس» شود؟

نوشتن معکوس زمانی رخ می‌دهد که یک حالت در فاز بعدی اصلاح شود (یا در یک محدوده پایین‌دستی—به معنای یک محدوده قابل‌ترکیب که بعداً در همان گذر ترکیب‌بندی اجرا می‌شود) نسبت به جایی که خوانده شده است، که Compose را مجبور می‌کند با زمان‌بندی یک فاز قبلی یا قابل‌ترکیب برای اجرای مجدد، دوباره ترکیب‌بندی کند. نوشتن معکوس یک حلقه بازترکیب بهینه‌سازی‌نشده است.

نمونه‌ای از نوشتن معکوس در مراحل حلقه بازسازی ترکیبی
شکل ۲. نمونه‌ای از نوشتن معکوس در مراحل حلقه بازسازی ترکیبی

پیامدهای نوشتن داده‌های قدیمی‌تر

نوشتن‌های معکوس لزوماً چیز بدی نیستند و همیشه باعث خرابی نمی‌شوند، اما ناکارآمد هستند و می‌توانند به چند روش به عملکرد برنامه آسیب برسانند:

  • پرداخت قاب اضافی و قاب‌های رهاشده: نوشتن معکوس Compose را مجبور می‌کند تا گذرهای ترکیب‌بندی زائد را در قاب‌های متوالی اجرا کند، که باعث هدر رفتن منابع CPU و GPU و احتمالاً ایجاد لرزش می‌شود.
  • مشکلات صحت قاب اول: اگر مؤلفه شما برای حل ابعاد یا وضعیت نهایی خود نیاز به نوشتن معکوس داشته باشد، قاب اول با داده‌های نامعتبر، پیش‌فرض، یا نامشخص (مانند اندازه صفر یا انحراف نادرست) رندر می‌شود. این امر باعث می‌شود که هنگام رندر شدن قاب دوم، پریدن دیداری یا چشمک زدن چیدمان قابل‌مشاهده باشد.
  • حلقه‌های بازترکیب بی‌نهایت: اگر تغییر وضعیت اندازه چیدمان را تغییر دهد، و اندازه چیدمان به‌طور مداوم مقدار جدیدی را به وضعیت برگرداند، ممکن است با خطر ایجاد حلقه قاب بی‌نهایت مواجه شوید که در آن صفحه به‌طور مداوم هر قاب را بازترکیب می‌کند بدون اینکه هرگز پایدار شود.

جریان روبه‌جلو در فازها

تغییرات وضعیت همیشه باید در فازها به‌جلو جریان داشته باشد:

مرحله خواندن نوشتن بافت قابل‌قبول است؟ چرا
چیدمان (Modifier.offset { }) ترکیب‌بندی بله ترکیب‌بندی وضعیت را به‌روز می‌کند → چیدمان آن را بعداً در همان قاب بدون ترکیب‌بندی مجدد می‌خواند.
مساوی (graphicsLayer { }، drawBehind { }) ترکیب‌بندی بله ترکیب‌بندی وضعیت را به‌روز می‌کند → Draw آن را در مرحله نهایی می‌خواند. «ترکیب‌بندی» و «چیدمان» کاملاً نادیده گرفته می‌شوند.
قرعه‌کشی چیدمان بله چیدمان وضعیت را به‌روز می‌کند و ترسیم آن را می‌خواند، جریان معتبر.
ترکیب‌بندی «بازخوان رویداد» (onClick، onValueChange) درحال تغییر وضعیت رانندگی. توجه: بازخوانی‌های چیدمان به‌عنوان رویداد محسوب نمی‌شوند. بله رویداد حالتی را تغییر می‌دهد که برای هدایت ترکیب‌بندی استفاده می‌شود. اگر رویداد خارج از چارچوب اتفاق بیفتد (نه در Composition،‏ Layout، یا Draw)، این مقدار معتبر است.
ترکیب‌بندی روال همکار (LaunchedEffect) بله - با احتیاط به‌طور ناهمزمان وضعیت را در پاسخ به چرخه حیات/رویدادها به‌روز می‌کند. نوشتن از جلوه‌ها می‌تواند معتبر باشد، اما می‌تواند به لایه‌بندی حالت ناکارآمد اشاره کند. درصورت امکان باید از آن‌ها اجتناب شود.
جای‌گذاری (در چیدمان) اندازه‌گیری (در چیدمان) بله در Layout، به‌روزرسانی وضعیت و سپس خواندن آن وضعیت در جای‌گذاری قابل‌قبول است.
اندازه‌گیری (در چیدمان) جای‌گذاری (در چیدمان) نه - نوشتن برعکس نوشتن در وضعیت در جای‌گذاری که سپس در وضعیت خواندن قرار می‌گیرد، باعث ایجاد حلقه اندازه‌گیری مجدد می‌شود.
ترکیب‌بندی چیدمان (onSizeChanged، LayoutModifier) No - Backwards write چیدمان «ترکیب‌بندی» را نامعتبر می‌کند ← حلقه «ترکیب‌بندی مجدد».
ترکیب‌بندی مساوی (drawWithContent، Canvas) نه - نوشتن برعکس ترسیم، ترکیب‌بندی را باطل می‌کند → حلقه بازترکیب‌بندی.

ترکیب‌های فاز: عقب درمقابل جلو

در زیر نمونه‌هایی از نوشتن معکوس در Compose و نحوه حل آن‌ها آورده شده است.

برعکس: خواندن در Composition، نوشتن در Layout

  • چه اتفاقی می‌افتد: Composition مقدار 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)
        }
    }
}

برعکس: خواندن در «ترکیب‌بندی»، نوشتن در «طراحی»

  • چه اتفاقی می‌افتد: وضعیت در بدنه Composable (فاز «ترکیب‌بندی») خوانده می‌شود، اما در 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
        }
    )
}

Backwards: Reading in Composition, writing in Composition (same phase)

  • چه اتفاقی می‌افتد: خواندن 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 در وضعیت ننویسید زیرا این کار باعث ایجاد مشکل درستی قاب اول می‌شود.
    • اگر مختصات یا اندازه‌های چیدمان فقط برای طراحی سفارشی موردنیاز است، آن‌ها را مستقیماً در مرحله Draw یا Layout بخوانید (برای مثال، بااستفاده از Modifier.drawWithCache یا Modifier.layout).
    • برای اندازه‌بندی در سطح پنجره (WindowWidthSizeClass): مشاهده اندازه را به سطح پنجره ارتقا دهید. ترکیب‌بندی قبل‌از انجام اندازه‌گیری محلی، روی کلاس‌های اندازه پنجره منشعب می‌شود.
    • ترکیب‌بندی را یکنواخت نگه دارید: از یک «چیدمان» سفارشی یا مؤلفه‌هایی مثل FlowRow یا LazyVerticalGrid استفاده کنید که اندازه‌گیری و جای‌گذاری را درطول «فاز ۲» بدون ترکیب‌بندی مجدد یا تغییر دادن وضعیت استفاده‌شده در ترکیب‌بندی تنظیم می‌کنند.
    • استفاده از زیرترکیب‌بندی: از BoxWithConstraints یا SubcomposeLayout استفاده کنید وقتی‌که ترکیب‌شدنی‌های فرزند باید براساس عرض یا ارتفاع محلی منشعب شوند. احتیاط کنید: «زیرترکیب» هزینه عملکردی دارد و معمولاً می‌توان از آن اجتناب کرد.
    • به‌عنوان آخرین راهکار: اجازه دهید فریم اول اشتباه باشد، اندازه را در onSizeChanged ذخیره کنید تا بازترکیب فریم دوم را راه‌اندازی کنید. این امر باعث پرش چیدمان قابل‌مشاهده، لرزش، و خطر حلقه‌های بی‌نهایت می‌شود.
  2. وضعیت را پس‌از اولین خواندن در ترکیب‌بندی تغییر ندهید:
    • اگرچه می‌توانید با خیال راحت در اشیاء MutableState درطول ترکیب‌بندی خارج از SideEffect بنویسید، اما دقت ویژه‌ای داشته باشید تا مطمئن شوید در وضعیتی که ممکن است قبلاً در ترکیب‌بندی خوانده باشید نمی‌نویسید. توصیه می‌شود وقتی نیاز به نوشتن وضعیت در ترکیب‌بندی پیش می‌آید، از rememberUpdatedState استفاده کنید. نوشتن در یک حالت درطول ترکیب‌بندی به روشی دیگر معمولاً نشانه‌ای از یک اثر ازدست‌رفته یا یک حالت یا ترکیب‌شدنی نامناسب طراحی‌شده است. به‌یاد داشته باشید که ترکیب‌بندی خوش‌بینانه است و همیشه با جدیدترین مقدار یک حالت اجرا می‌شود، بنابراین ممکن است همه تغییرات حالت را در ترکیب‌بندی‌های مجدد نبینید. به‌روزرسانی‌های واسط کاربر نباید به‌عنوان روشی برای مدیریت رویدادهای یک‌باره استفاده شود، که باعث می‌شود غیرمعمول باشد که مقدار یک حالت درنتیجه بازترکیب نیاز به به‌روزرسانی داشته باشد.
    • از تغییر دادن وضعیت‌هایی که خارج از ترکیب‌بندی مشاهده می‌شوند (برای مثال، فیلدهای ViewModel یا پرچم‌های isVisible) خودداری کنید. نوشتن‌های وضعیت که بر ترکیب‌بندی تأثیر می‌گذارند باید در لامبداهای رویداد (onClick)، کوروتین‌ها (LaunchedEffect)، یا عوارض جانبی (SideEffect) قرار گیرند. rememberUpdatedState یک استثنا است زیرا برای تغییر وضعیت طراحی شده است که فقط در بدنه @Composable استفاده می‌شود.
  3. خواندن وضعیت را به آخرین فاز ممکن موکول کنید:
    • خواندن حالت‌ها در Draw (Modifier.graphicsLayer { alpha = ... }) یا Layout (Modifier.offset { IntOffset(...) }) تضمین می‌کند که تغییرات فقط فاز ۲ یا ۳ را باطل می‌کنند و فاز ۱ (ترکیب‌بندی) را کاملاً نادیده می‌گیرند.