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

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

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

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


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

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

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

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

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

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

التنقل للأمام خلال المراحل

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

مرحلة القراءة كتابة السياق هل هو مقبول؟ السبب
التصميم (Modifier.offset { }) التركيبة نعم تعدّل Composition الحالة → يقرأ Layout الحالة لاحقًا في الإطار نفسه بدون إعادة الإنشاء.
السحب (graphicsLayer { }، drawBehind { }) التركيبة نعم تعدّل المقطوعة الموسيقية الحالة → تقرأ أداة الرسم الحالة في المرحلة النهائية. يتم تخطّي التكوين والتنسيق بالكامل.
رسم التصميم نعم يعدّل التنسيق الحالة، ويقرأ الرسم الحالة، وهو مسار صالح.
التركيبة تغيير حالة العرض بسبب Event Callback (onClick، onValueChange) ملاحظة: لا يتم احتساب عمليات معاودة الاتصال بالتنسيق كأحداث. نعم يؤدي الحدث إلى تغيير الحالة المستخدَمة لتنفيذ التركيب. إذا حدث الحدث خارج الإطار (ليس في "التركيب" أو "التصميم" أو "الرسم")، يكون ذلك صالحًا.
التركيبة الكوروتين (LaunchedEffect) نعم، ولكن يجب توخّي الحذر تعديل الحالة بشكل غير متزامن استجابةً لدورة الحياة/الأحداث يمكن أن تكون عمليات الكتابة من التأثيرات صالحة، ولكن يمكن أن تشير إلى عدم كفاءة في ترتيب الحالة. ويجب تجنُّبها قدر الإمكان.
موضع الإعلان (في التصميم) القياس (في التنسيق) نعم في Layout، يمكن تعديل الحالة ثم قراءتها في موضع الإعلان.
القياس (في التنسيق) موضع الإعلان (في التصميم) لا - الكتابة للخلف الكتابة إلى حالة في موضع الإعلان تكون لاحقًا في حالة القراءة، ما يؤدي إلى تكرار عملية إعادة القياس.
التركيبة التصميم (onSizeChanged، LayoutModifier) لا - الكتابة من اليسار إلى اليمين يؤدي عدم صحة التنسيق إلى تكرار عملية إعادة التكوين.
التركيبة السحب (drawWithContent، Canvas) لا - الكتابة من اليسار إلى اليمين يؤدي الرسم إلى إبطال صحة التركيب ← حلقة إعادة التركيب.

مجموعات المراحل: التراجع مقابل التقدم

في ما يلي أمثلة على عمليات الكتابة السابقة في 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)
        }
    }
}

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

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