نوشتن به عقب

Compose یک فریم را در سه مرحله کاملاً مرتب و رو به جلو اجرا می‌کند:

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

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


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

نوشتن معکوس زمانی رخ می‌دهد که یک وضعیت در فاز بعدی (یا در یک دامنه پایین‌دست - به معنای یک دامنه قابل ترکیب که به ترتیب در همان مسیر ترکیب، دیرتر اجرا می‌شود) نسبت به جایی که خوانده شده است ، تغییر کند و Compose را مجبور به ترکیب مجدد با زمان‌بندی یک فاز قبلی یا قابل ترکیب برای اجرای مجدد کند. نوشتن معکوس یک حلقه ترکیب مجدد بهینه‌سازی نشده است.

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

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

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

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

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

تغییرات وضعیت باید همیشه از طریق مراحل زیر به جلو جریان داشته باشند:

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

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

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

به عقب: خواندن در انشا، نوشتن در صفحه‌آرایی

  • چه اتفاقی می‌افتد : کامپوزیشن، componentHeight می‌خواند تا مشخص کند چه رابط کاربری‌ای را منتشر کند. بعداً در فریم، فاز Layout نماها را اندازه‌گیری یا جایگذاری می‌کند و مقدار جدیدی را در componentHeight می‌نویسد (برای مثال، با استفاده از onSizeChanged ، onGloballyPositioned یا LayoutModifier سفارشی).
  • نتیجه : تغییر componentHeight در Layout، مرحله Composition را که به تازگی تکمیل شده است، نامعتبر می‌کند. توجه داشته باشید که onSizeChanged اندازه را پس از اتمام مرحله اندازه‌گیری layout گزارش می‌دهد. اگر مقدار state به‌روزرسانی‌شده در مرحله بعدی تثبیت شود، recomposition ممکن است پس از یک فریم اضافی متوقف شود؛ با این حال، اگر مقدار جدید به تغییر اندازه ادامه دهد، منجر به یک حلقه فریم بی‌نهایت می‌شود. علاوه بر این، onGloballyPositioned پس از هر دو layout و placement اجرا می‌شود و باعث می‌شود نوشتن state در داخل آن حتی بیشتر مستعد recomposition مداوم و حلقه‌های relayout در فریم‌های متوالی باشد.

// ❌ 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)
        }
    }
}

به عقب: خواندن در ترکیب‌بندی، نوشتن در طراحی

  • چه اتفاقی می‌افتد : حالت (State) در بدنه‌ی Composable (مرحله‌ی Composition) خوانده می‌شود، اما در داخل Modifier.drawWithContent ، Modifier.drawBehind یا Canvas (مرحله‌ی Draw) تغییر می‌کند.
  • نتیجه : فاز رسم، حالت را تغییر می‌دهد → ترکیب نامعتبر می‌شود → حلقه بی‌پایان.

// ❌ 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 در تابع composable و تغییر مستقیم count در یک اسلات محتوای composables دیگر پس از خواندن آن.
  • نتیجه : سیستم snapshot عملیات خواندن و نوشتن بعدی را در همان مسیر ترکیب ثبت می‌کند و بلافاصله دامنه فعلی را نامعتبر می‌سازد.

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