مراحل Jetpack Compose

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

توضّح مستندات Compose مفهوم التركيب في التفكير بطريقة Compose والحالة وJetpack Compose.

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

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

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

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

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

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 { mutableIntStateOf(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()، ولكن قد يكون لديك مثال أكثر تعقيدًا يتطلّب شيئًا مخصّصًا، ما يستلزم كتابة تنسيق مخصّص. راجِع دليل التنسيقات المخصّصة للحصول على مزيد من المعلومات.

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

لمزيد من المعلومات حول حلقات إعادة التركيب وكيفية تجنُّب الكتابة إلى الحالة في جميع المراحل، راجِع عمليات الكتابة السابقة في Compose.