مراحل Jetpack Compose

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

توضّح مستندات Compose عملية الإنشاء في التفكير في Compose والحالة وJetpack Compose.

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

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

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

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

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

فهم المراحل

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

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

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

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

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

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

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

التنسيق

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

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

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

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

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

  • العرض والارتفاع المحدّدان
  • إحداثيات 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. شجرة واجهة المستخدم وتمثيلها المرئي

قراءات الدولة

عندما تقرأ value snapshot state خلال إحدى المراحل المذكورة سابقًا، يتتبّع Compose تلقائيًا ما كان يفعله عندما قرأ value. يتيح هذا التتبُّع إعادة تنفيذ القارئ في Compose عند حدوث تغييرات في value للحالة، وهو أساس إمكانية مراقبة الحالة في Compose.

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

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

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

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

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

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

توضّح الأقسام التالية كل مرحلة وتصف ما يحدث عند قراءة قيمة حالة ضمنها.

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

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

استنادًا إلى نتيجة التجميع، تنفّذ واجهة مستخدم 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 { … } والدوال المشابهة.

تؤثّر عمليات قراءة الحالة خلال كل خطوة من هذه الخطوات في التنسيق، وربما في مرحلة الرسم. عندما تتغير قيمة value للحالة، يجدول 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. عندما تتغير حالة value، لا ينفّذ 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 لتحديد موضع التنسيق النهائي، ما يؤدي إلى ظهور تأثير المنظر المتغيّر أثناء تنقّل المستخدم.

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) {
        // ...
    }
}

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

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

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

الإزاحة باستخدام lambda

يتوفّر إصدار آخر من أداة تعديل الإزاحة: 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 يتتبّع وقت قراءة الحالة، يعني هذا التغيير أنّه إذا تغيّرت قيمة value في firstVisibleItemScrollOffset، لن يحتاج Compose إلا إلى إعادة تشغيل مرحلتَي التنسيق والرسم.

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

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

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

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، لأنّ قيمة imageHeightPx المعدَّلة لم تظهر بعد.

تركيب الإطار الثاني

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

رسم بياني يعرض حلقة إعادة التركيب حيث يؤدي تغيير الحجم في مرحلة التنسيق إلى إعادة التركيب، ما يؤدي بعد ذلك إلى إعادة التنسيق.

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

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

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

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