الكتابة من اليسار إلى اليمين

ينفّذ Compose إطارًا في ثلاث مراحل مرتّبة بدقة وتتدفق للأمام:

ثلاث مراحل متتالية: 1. Composition, 2. التنسيق، 3. الرسم
الشكل 1. المراحل الثلاث لإطار Compose
  1. الإنشاء: يتم تشغيل دوال @Composable لإنشاء شجرة واجهة المستخدم وتعديلها.
  2. التنسيق: يقيس العناصر الفرعية ثم يضعها.
  3. الرسم: يصدر أوامر رسم اللوحة لعرض وحدات البكسل على الشاشة.

عندما تتم قراءة Compose State خلال أي مرحلة، يسجّل Compose تلقائيًا تبعية بين تلك الحالة والمرحلة المقابلة.


ما الذي يجعل الكتابة "عكسية"؟

تحدث الكتابة العكسية عندما يتم تعديل حالة في مرحلة لاحقة (أو في نطاق لاحق، أي نطاق مركّب يتم تنفيذه لاحقًا بالترتيب ضمن مسار التركيب نفسه) مقارنةً بالمكان الذي تمت قراءته فيه، ما يجبر Compose على إعادة التركيب من خلال جدولة مرحلة سابقة أو دالة مركّبة ليتم تشغيلها مرة أخرى. الكتابة العكسية هي حلقة إعادة تركيب غير محسّنة.

مثال على الكتابة العكسية من خلال مراحل حلقة إعادة التركيب
الشكل 2. مثال على الكتابة العكسية من خلال مراحل حلقة إعادة الإنشاء

عواقب عمليات الكتابة السابقة

لا تُعدّ عمليات الكتابة العكسية أمرًا سيئًا بالضرورة، ولا تؤدي دائمًا إلى حدوث أعطال، ولكنّها غير فعّالة ويمكن أن تضرّ بأداء التطبيق بعدة طرق:

  • عرض الإطارات الإضافية والإطارات التي تم إسقاطها: تفرض الكتابة العكسية على Compose تنفيذ عمليات إنشاء متكررة عبر إطارات متتالية، ما يؤدي إلى إهدار موارد وحدة المعالجة المركزية ووحدة معالجة الرسومات، وقد يتسبب في حدوث بطء في الأداء.
  • مشاكل صحة الإطار الأول: إذا كان المكوّن يتطلب كتابة عكسية لحل أبعاده أو حالته النهائية، يتم عرض الإطار الأول ببيانات غير صالحة أو تلقائية أو غير مستقرة (مثل الحجم الصفري أو الإزاحة غير الصحيحة). يؤدي ذلك إلى ظهور وميض مرئي أو وميض في التنسيق عند عرض الإطار الثاني.
  • حلقات إعادة التركيب اللانهائية: إذا أدى تغيير الحالة إلى تغيير حجم التنسيق، وكان حجم التنسيق يكتب باستمرار قيمة جديدة للحالة، فقد تخاطر بإنشاء حلقة إطار لا نهائية حيث تقوم الشاشة باستمرار بإعادة تركيب كل إطار دون أن تستقر أبدًا.

التدفق الأمامي عبر المراحل

يجب أن تتدفق تغييرات الحالة دائمًا للأمام خلال المراحل:

مرحلة القراءة كتابة السياق هل هذا مقبول؟ السبب
التصميم (Modifier.offset { }) التكوين نعم تعدّل عملية الإنشاء الحالة ← يقرأها التنسيق لاحقًا في الإطار نفسه بدون إعادة الإنشاء.
تعادُل (graphicsLayer { }، drawBehind { }) التكوين نعم تعدّل التركيبة الحالة ← يقرأها Draw في المرحلة النهائية. يتم تخطّي "التكوين والتنسيق" بالكامل.
الرسم التصميم نعم يعدّل التنسيق الحالة، ويقرأها الرسم، وهو مسار صالح.
التكوين يؤدي ردّ الاتصال بالحدث (onClick، onValueChange) إلى تغيير حالة القيادة. ملاحظة: لا يتم احتساب عمليات استدعاء التنسيق كأحداث. نعم يؤدي الحدث إلى تغيير الحالة التي يتم استخدامها لتشغيل عملية الإنشاء. إذا حدث الحدث خارج الإطار (ليس في التكوين أو التخطيط أو الرسم)، فهذا صالح.
التكوين Coroutine (LaunchedEffect) نعم، مع توخّي الحذر يتم تعديل الحالة بشكل غير متزامن استجابةً لدورة الحياة أو الأحداث. قد تكون عمليات الكتابة من التأثيرات صالحة، ولكنّها قد تشير إلى طبقات حالة غير فعّالة. ويجب تجنُّبها قدر الإمكان.
موضع الإعلان (في التنسيق) القياس (في التنسيق) نعم في Layout، من المقبول تعديل الحالة ثم قراءتها في الموضع.
القياس (في التنسيق) موضع الإعلان (في التنسيق) لا - الكتابة من اليسار إلى اليمين تؤدي الكتابة إلى الحالة في الموضع الذي يكون بعد ذلك في حالة القراءة إلى حدوث حلقة إعادة قياس.
التكوين التصميم (onSizeChanged، LayoutModifier) لا: الكتابة من الخلف إلى الأمام يؤدي التنسيق إلى إبطال حلقة "الإنشاء" ← "إعادة الإنشاء".
التكوين تعادُل (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)
        }
    }
}

الرجوع إلى الخلف: القراءة في "التأليف" والكتابة في "الرسم"

  • ما يحدث: تتم قراءة الحالة في نص الدالة المركّبة (مرحلة الإنشاء)، ولكن يتم تغييرها داخل 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): يجب رفع مستوى رصد الحجم إلى مستوى النافذة. يتفرّع Composition إلى فئات حجم النافذة قبل إجراء القياس المحلي.
    • الحفاظ على اتساق التركيب: استخدِم تنسيقًا مخصّصًا واحدًا أو مكوّنات مثل FlowRow أو LazyVerticalGrid لضبط القياس والموضع خلال المرحلة الثانية بدون إعادة تركيب أو تغيير الحالة المستخدَمة في التركيب.
    • استخدام التركيب الفرعي: استخدِم BoxWithConstraints أو SubcomposeLayout عندما يجب أن تتفرّع الدوال المركّبة الفرعية استنادًا إلى العرض أو الارتفاع المحلي. يجب توخي الحذر، لأنّ التركيب الفرعي يؤثر في الأداء ويمكن تجنّبه عادةً.
    • كحلّ أخير: يمكنك السماح بأن يكون الإطار الأول خاطئًا، مع تخزين الحجم في onSizeChanged لتشغيل إعادة تركيب الإطار الثاني. يؤدي ذلك إلى ظهور تخطيط مرئي، وتأخُّر في الاستجابة، ومخاطر حدوث تكرار حلقي لا نهائي.
  2. عدم تغيير الحالة بعد قراءتها للمرة الأولى في التركيب:
    • على الرغم من أنّه يمكنك الكتابة بأمان في عناصر MutableState أثناء الإنشاء خارج SideEffect، يجب الحرص على عدم الكتابة في حالة سبق لك قراءتها في الإنشاء. ننصحك باستخدام rememberUpdatedState عند الحاجة إلى كتابة حالة في دالة الإنشاء. إنّ الكتابة إلى حالة أثناء الإنشاء بطريقة أخرى تشير عادةً إلى تأثير مفقود أو حالة أو دالة مركّبة تم تصميمها بشكل غير مناسب. تذكَّر أنّ التركيب متفائل ويتم تنفيذه دائمًا بأحدث قيمة للحالة، لذا قد لا تظهر لك جميع تغييرات الحالة في عمليات إعادة التركيب. يجب عدم استخدام تحديثات واجهة المستخدم كطريقة للتعامل مع الأحداث التي تحدث لمرة واحدة، ما يجعل من غير المعتاد أن تتطلب قيمة الحالة التحديث نتيجة إعادة الإنشاء.
    • تجنَّب تغيير الحالات التي تتم ملاحظتها خارج التركيب (على سبيل المثال، حقول ViewModel أو علامات isVisible). عمليات كتابة الحالة التي تؤثر في التركيب تنتمي إلى دوال lambda للأحداث (onClick) أو الكوروتينات (LaunchedEffect) أو الآثار الجانبية (SideEffect). يُعدّ rememberUpdatedState استثناءً لأنّه مصمّم لتغيير الحالة التي يتم استخدامها فقط في نص @Composable.
  3. تأجيل قراءة الحالة إلى آخر مرحلة ممكنة:
    • تضمن قراءة الحالات في مرحلة "الرسم" (Modifier.graphicsLayer { alpha = ... }) أو "التنسيق" (Modifier.offset { IntOffset(...) }) أنّ التغييرات تؤدي فقط إلى إبطال المرحلة 2 أو 3، مع تخطّي المرحلة 1 (التركيب) بالكامل.