مراحل Jetpack Compose

مثل معظم حِزم أدوات واجهة المستخدم الأخرى، يعرض Compose إطارًا من خلال عدة مراحل متمايزة. إذا نظرنا إلى نظام Android View، يتضمّن ثلاث مراحل رئيسية: القياس والتنسيق والرسم. تشبه ميزة "إنشاء" هذه الميزة كثيرًا، ولكنها تتضمّن مرحلة إضافية مهمة في البداية تُعرف باسم الإنشاء.

يتم وصف عملية الإنشاء في مستندات Compose، بما في ذلك التفكير في Compose وState وJetpack Compose.

المراحل الثلاث للإطار

تتضمّن ميزة "الإنشاء" ثلاث مراحل رئيسية:

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

يكون ترتيب هذه المراحل بشكل عام متطابقًا، ما يسمح بتدفق البيانات في اتجاه واحد من التصميم إلى التنسيق إلى الرسم لإنشاء إطار (يُعرف أيضًا بـ تدفق البيانات أحادي الاتجاه). يُعدّ BoxWithConstraints و LazyColumn وLazyRow استثناءًين مهمّين، حيث تعتمد تركيبة عناصرهما الفرعية على مرحلة تنسيق العنصر الرئيسي.

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

فهم المراحل

يوضّح هذا القسم بالتفصيل كيفية تنفيذ المراحل الثلاث من Compose للعناصر القابلة للتجميع.

مقطوعة موسيقية

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

الشكل 2: الشجرة التي تمثّل واجهة المستخدم التي يتم إنشاؤها في مرحلة التركيب

يظهر القسم الفرعي من شجرة الرمز البرمجي وواجهة المستخدم على النحو التالي:

مقتطف رمز يتضمّن خمسة عناصر قابلة للتجميع وشجرة واجهة المستخدم الناتجة، مع تشعّب العقد الفرعية من العقد الرئيسية
الشكل 3. قسم فرعي من شجرة واجهة المستخدم مع الرمز المقابل

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

التنسيق

في مرحلة التنسيق، يستخدِم تطبيق Compose شجرة واجهة المستخدم التي تم إنشاؤها في مرحلة الإنشاء كإدخال. تحتوي مجموعة عقد التنسيق على جميع المعلومات اللازمة لتحديد حجم كل عقدة وموقعها في المساحة ثنائية الأبعاد.

الشكل 4: قياس موضع كل عقدة تنسيق في شجرة واجهة المستخدم أثناء مرحلة التنسيق

خلال مرحلة التنسيق، يتمّ التنقّل في الشجرة باستخدام الخطوات التالية الخوارزمية:

  1. قياس العناصر الفرعية: تقيس العقدة عناصرها الفرعية في حال توفّرها.
  2. تحديد الحجم الخاص بها: استنادًا إلى هذه القياسات، تحدّد العقدة حجمها.
  3. وضع العناصر الفرعية: يتم وضع كل عنصر فرعي بالنسبة إلى موضع العنصر.

في نهاية هذه المرحلة، تحتوي كل عقدة تنسيق على ما يلي:

  • width وheight محدَّدان
  • إحداثي x وy حيث يجب رسمه

تذكَّر شجرة واجهة المستخدم من القسم السابق:

مقتطف رمز يتضمّن خمسة عناصر قابلة للتجميع وشجرة واجهة المستخدم الناتجة، مع تشعّب العقد الفرعية من العقد الرئيسية

بالنسبة إلى هذه الشجرة، تعمل الخوارزمية على النحو التالي:

  1. يقيس Row عنصرَي Image وColumn.
  2. يتم قياس Image. ليس لها أيّ عناصر فرعية، لذا تحدّد حجمها بنفسها وتُبلغ عن الحجم إلى Row.
  3. يتم بعد ذلك قياس Column. وتقيس أولاً عناصرها الثانوية (عنصران Text مكوّنَان).
  4. يتم قياس Text الأولى. لا يتضمّن أي عناصر فرعية، لذا يحدّد هو حجمه ويُبلغ عن حجمه إلى Column.
    1. يتم قياس Text الثانية. لا يحتوي على أي عناصر ثانوية، لذا يحدّد هو حجمه ويُبلغ عن ذلك إلى Column.
  5. يستخدم العنصر Column قياسات الطفل لتحديد حجمه. ويستخدم الحد الأقصى لعرض العنصر التابع ومجموع ارتفاع عناصره.
  6. يضع Column عناصره الثانوية بالنسبة إلى نفسه، ويضع كلّ عنصر تحت الآخر عموديًا.
  7. يستخدم العنصر Row قياسات الطفل لتحديد حجمه. ويستخدم الحد الأقصى لارتفاع العنصر التابع ومجموع مساحات عناصره التابعة. ثم يضع العناصر الفرعية.

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

رسم

في مرحلة الرسم، يتم عبور الشجرة مرة أخرى من أعلى إلى أسفل، وترسم كل ملف شخصي نفسه على الشاشة بدوره.

الشكل 5: ترسم مرحلة الرسم وحدات البكسل على الشاشة.

باستخدام المثال السابق، يتم رسم محتوى الشجرة على النحو التالي:

  1. يرسم الرمز Row أي محتوى قد يتضمّنه، مثل لون الخلفية.
  2. يرسم الرمز Image نفسه.
  3. يرسم الرمز Column نفسه.
  4. يرسم الرمزان Text الأول والثاني نفسيهما، على التوالي.

الشكل 6: شجرة واجهة مستخدم وتمثيلها المرسوم

قراءة حالة

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

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

// State read without property delegate.
val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(paddingState.value)
)

// State read with property delegate.
var padding: Dp by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(padding)
)

في property delegate، تُستخدَم دالة "getter" ودالة "setter" للوصول إلى value في الحالة وتعديله. لا يتمّ استدعاء وظيفتَي الحصول على القيمة وضبطها إلا عند الإشارة إلى السمة كقيمة، وليس عند إنشائها، ولهذا السبب، فإنّ الطريقتَين أعلاه متكافئتان.

كلّ مجموعة من الرموز البرمجية التي يمكن إعادة تنفيذها عند تغيير حالة القراءة هي نطاق إعادة البدء. تتبّع Compose تغييرات قيمة الحالة وإعادة بدء النطاقات في مراحل مختلفة.

عمليات قراءة الحالة على مراحل

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

لنطّلِع على كل مرحلة ونوضّح ما يحدث عند قراءة قيمة الحالة فيها.

المرحلة 1: التركيب

تؤثر عمليات قراءة الحالة ضمن دالة @Composable أو كتلة lambda في التركيب وربما المراحل اللاحقة. عند تغيير قيمة الحالة، يحدّد مجدد المحتوى جدولاً زمنيًا لإعادة تشغيل جميع الدوالّ القابلة للتجميع التي تقرأ قيمة الحالة هذه. يُرجى العِلم أنّ وقت التشغيل قد يقرر تخطّي بعض أو كل الوظائف المركّبة إذا لم تتغيّر المدخلات. اطّلِع على التخطّي إذا لم تتغيّر الإدخالات للحصول على مزيد من المعلومات.

استنادًا إلى نتيجة التركيب، تُشغِّل واجهة مستخدم Compose مرحلتَي التخطيط والرسم. وقد يتخطّى هذه المراحل إذا ظل المحتوى كما هو ولم يتغيّر حجمه وتنسيقه.

var padding by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    // The `padding` state is read in the composition phase
    // when the modifier is constructed.
    // Changes in `padding` will invoke recomposition.
    modifier = Modifier.padding(padding)
)

المرحلة 2: التنسيق

تتألف مرحلة التنسيق من خطوتَين: القياس والموقع. تُنفِّذ خطوة القياس دالة lambda الخاصة بالقياس التي تم تمريرها إلى العنصر القابل للتجميع Layout، وطريقة MeasureScope.measure لواجهة LayoutModifier، وما إلى ذلك. تُشغِّل مرحلة موضع الإعلان كتلة موضع الإعلان لدالة layout، وكتل LAMBDA لدالة Modifier.offset { … }، وما إلى ذلك.

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

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

var offsetX by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.offset {
        // The `offsetX` state is read in the placement step
        // of the layout phase when the offset is calculated.
        // Changes in `offsetX` restart the layout.
        IntOffset(offsetX.roundToPx(), 0)
    }
)

المرحلة 3: الرسم

تؤثر عمليات قراءة الحالة أثناء رمز الرسم في مرحلة الرسم. تشمل الأمثلة الشائعة Canvas() وModifier.drawBehind وModifier.drawWithContent. عندما تتغيّر قيمة الحالة، لا يُجري Compose UI سوى مرحلة الرسم.

var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
    // The `color` state is read in the drawing phase
    // when the canvas is rendered.
    // Changes in `color` restart the drawing.
    drawRect(color)
}

تحسين عمليات قراءة الحالة

بما أنّ Compose تُجري تتبُّعًا لقراءة الحالة المترجَمة، يمكننا تقليل مقدار العمل الذي يتمّ إجراؤه من خلال قراءة كل حالة في مرحلة مناسبة.

لنلقِ نظرة على مثال. لدينا هنا Image() يستخدم المُعدِّل offset لإزاحة موضع التنسيق النهائي، ما يؤدي إلى تأثير التمويه أثناء scrolled المستخدم.

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        // Non-optimal implementation!
        Modifier.offset(
            with(LocalDensity.current) {
                // State read of firstVisibleItemScrollOffset in composition
                (listState.firstVisibleItemScrollOffset / 2).toDp()
            }
        )
    )

    LazyColumn(state = listState) {
        // ...
    }
}

تعمل هذه التعليمات البرمجية، ولكنّها تؤدي إلى أداء غير مثالي. وفقًا لما هو مكتوب، يقرأ الرمز البرمجي قيمة حالة firstVisibleItemScrollOffset ويمررها إلى الدالة Modifier.offset(offset: Dp). وعندما ينتقل المستخدم للأسفل، ستتحوّل قيمة firstVisibleItemScrollOffset. كما نعلم، يتتبّع Compose أي عمليات قراءة للحالة حتى يتمكّن من إعادة بدء (إعادة استدعاء) رمز القراءة، والذي يمثّل في مثالنا محتوى Box.

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

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

يتوفّر إصدار آخر من مُعدِّل البادئة: Modifier.offset(offset: Density.() -> IntOffset).

يأخذ هذا الإصدار مَعلمة LAMBDA، حيث يتم عرض الإزاحة الناتجة من قِبل وحدة LAMBDA. لنعدِّل الرمز البرمجي لاستخدامه:

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        Modifier.offset {
            // State read of firstVisibleItemScrollOffset in Layout
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
        }
    )

    LazyColumn(state = listState) {
        // ...
    }
}

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

يعتمد هذا المثال على مُعدِّلات البادئة المختلفة لكي يتمكّن من تحسين الرمز البرمجي الناتج، ولكن الفكرة العامة صحيحة: حاوِل حصر عمليات قراءة الحالة في أدنى مرحلة ممكنة، ما يتيح لـ Compose تنفيذ الحد الأدنى من العمل.

بالطبع، غالبًا ما يكون من الضروري قراءة الحالات في مرحلة التركيب. ومع ذلك، هناك حالات يمكن فيها تقليل عدد عمليات إعادة التركيب من خلال فلترة تغييرات الحالة. لمزيد من المعلومات حول هذا الموضوع، اطّلِع على derivedStateOf: تحويل عنصر حالة واحد أو أكثر إلى حالة أخرى.

حلقة إعادة التركيب (التبعية المتكررة للمراحل)

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

Box {
    var imageHeightPx by remember { mutableStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = "I'm above the text",
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { size ->
                // Don't do this
                imageHeightPx = size.height
            }
    )

    Text(
        text = "I'm below the image",
        modifier = Modifier.padding(
            top = with(LocalDensity.current) { imageHeightPx.toDp() }
        )
    )
}

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

تكمن المشكلة في هذا المثال في أنّنا لا نصل إلى التنسيق "النهائي" في غضون لقطة واحدة. يعتمد الرمز على عرض لقطات متعددة، ما يؤدي إلى تنفيذ مهمة غير ضرورية ويؤدي إلى تنقّل واجهة المستخدم على الشاشة أمام المستخدم.

لنطّلِع على كل لقطة لمعرفة ما يحدث:

في مرحلة التركيب للإطار الأول، تكون قيمة imageHeightPx هي 0، ويتم تقديم النص مع Modifier.padding(top = 0). بعد ذلك، تأتي مرحلة ملف التنسيق ، ويتم استدعاء دالة الاستدعاء لعامل التعديل onSizeChanged. يحدث ذلك عندما يتم تعديل imageHeightPx إلى الارتفاع الفعلي للصورة. تُستخدَم جداول التركيب لإعادة تركيب اللقطة التالية. في مرحلة الرسم، يتم عرض النص مع إضافة مسافة بادئة 0 لأنّه لم يتم بعد تطبيق تغيير القيمة.

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

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

قد يبدو هذا المثال مصطنَعًا، ولكن عليك الحذر من هذا النمط العام:

  • Modifier.onSizeChanged() أو onGloballyPositioned() أو بعض عمليات التنسيق الأخرى
  • تعديل بعض الحالات
  • استخدِم هذه الحالة كمدخل لمعدِّل تنسيق (padding() أوheight() أو مشابه)
  • يُحتمل أن تتكرّر

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

والمبدأ العام هنا هو توفُّر مصدر واحد للحقائق المتعلّقة بعناصر واجهة المستخدِم المتعدّدة التي يجب قياسها ووضعها مع مراعاة بعضها البعض. إنّ استخدام عنصر أساسي مناسب للتنسيق أو إنشاء تنسيق مخصّص يعني أنّ العنصر العميق المشترَك يمثّل مصدر المعلومات الذي يمكنه تنسيق العلاقة بين عناصر متعدّدة. ويؤدي إدخال حالة ديناميكية إلى مخالفة هذا المبدأ.