مكان الرفع

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

أفضل الممارسات

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

يمكن أن يكون السلف المشترك الأدنى أيضًا خارج التركيب. على سبيل المثال، عند رفع الحالة في ViewModel بسبب استخدام منطق النشاط التجاري.

توضّح هذه الصفحة أفضل الممارسات بالتفصيل، بالإضافة إلى تحذير يجب أخذه في الاعتبار.

أنواع حالة واجهة المستخدم ومنطق واجهة المستخدم

في ما يلي تعريفات لأنواع حالات واجهة المستخدم والمنطق المستخدَم في هذا المستند.

حالة واجهة المستخدم

حالة واجهة المستخدم هي السمة التي تصف واجهة المستخدم. هناك نوعان من حالة واجهة المستخدم:

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

المنطق

يمكن أن يكون المنطق في التطبيق إما منطقًا تجاريًا أو منطقًا لواجهة المستخدم:

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

منطق واجهة المستخدم

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

في ما يلي وصف لكلّ من الحلَّين وشرح للحالات التي يجب فيها استخدام كلّ منهما.

الدوال القابلة للإنشاء كمالك للحالة

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

لا حاجة إلى نقل القيمة

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

@Composable
fun ChatBubble(
    message: Message
) {
    var showDetails by rememberSaveable { mutableStateOf(false) } // Define the UI element expanded state

    Text(
        text = AnnotatedString(message.content),
        modifier = Modifier.clickable {
            showDetails = !showDetails // Apply UI logic
        }
    )

    if (showDetails) {
        Text(message.timestamp)
    }
}

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

رفع الحالة داخل الدوال المركّبة

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

المثال التالي هو تطبيق محادثة ينفّذ وظيفتَين:

  • يؤدي النقر على الزر JumpToBottom إلى الانتقال إلى أسفل قائمة الرسائل. ينفّذ الزر منطق واجهة المستخدم على حالة القائمة.
  • يتم الانتقال إلى أسفل قائمة "MessagesList" بعد أن يرسل المستخدم رسائل جديدة. تنفّذ UserInput منطق واجهة المستخدم على حالة القائمة.
تطبيق محادثات يتضمّن زر JumpToBottom وينتقل إلى أسفل الشاشة عند تلقّي رسائل جديدة
الشكل 1: تطبيق محادثات يتضمّن زر JumpToBottom وميزة الانتقال إلى أسفل الصفحة عند تلقّي رسائل جديدة

يكون التسلسل الهرمي للدوال المركّبة على النحو التالي:

شجرة الدوال المركّبة في Chat
الشكل 2. شجرة الدوال المركّبة في Chat

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

رفع حالة LazyColumn من LazyColumn إلى ConversationScreen
الشكل 3. رفع حالة LazyColumn من LazyColumn إلى ConversationScreen

إذًا، الدوال المركّبة هي:

شجرة دوال مركّبة للمحادثة مع LazyListState مرفوعة إلى ConversationScreen
الشكل 4. شجرة الدوال المركّبة في Chat مع رفع LazyListState إلى ConversationScreen

الرمز هو كالتالي:

@Composable
private fun ConversationScreen(/*...*/) {
    val scope = rememberCoroutineScope()

    val lazyListState = rememberLazyListState() // State hoisted to the ConversationScreen

    MessagesList(messages, lazyListState) // Reuse same state in MessageList

    UserInput(
        onMessageSent = { // Apply UI logic to lazyListState
            scope.launch {
                lazyListState.scrollToItem(0)
            }
        },
    )
}

@Composable
private fun MessagesList(
    messages: List<Message>,
    lazyListState: LazyListState = rememberLazyListState() // LazyListState has a default value
) {

    LazyColumn(
        state = lazyListState // Pass hoisted state to LazyColumn
    ) {
        items(messages, key = { message -> message.id }) { item ->
            Message(/*...*/)
        }
    }

    val scope = rememberCoroutineScope()

    JumpToBottom(onClicked = {
        scope.launch {
            lazyListState.scrollToItem(0) // UI logic being applied to lazyListState
        }
    })
}

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

يُرجى العِلم أنّه يتم تحديد lazyListState في طريقة MessagesList، مع القيمة التلقائية rememberLazyListState(). هذا نمط شائع في Compose. ويجعل الدوال المركّبة أكثر مرونة وقابلية لإعادة الاستخدام. يمكنك بعد ذلك استخدام الدالة المركّبة في أجزاء مختلفة من التطبيق قد لا تحتاج إلى التحكّم في الحالة. ويحدث ذلك عادةً أثناء اختبار دالة مركّبة أو معاينتها. هذه هي الطريقة التي يحدّد بها "LazyColumn" حالته.

أدنى سلف مشترك لـ LazyListState هو ConversationScreen
الشكل 5. أدنى سلف مشترك لـ "LazyListState" هو "ConversationScreen"

فئة عنصر الاحتفاظ بالحالة البسيطة كمالك للحالة

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

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

يتم إنشاء هذه الفئات البسيطة وتذكّرها في Composition. وبما أنّها تتبع مراحل نشاط الدالة المركّبة، يمكنها استخدام الأنواع التي توفّرها مكتبة Compose، مثل rememberNavController() أو rememberLazyListState().

ومن الأمثلة على ذلك فئة عنصر الاحتفاظ بالحالة العادي LazyListState التي تم تنفيذها في Compose للتحكّم في مدى تعقيد واجهة المستخدم في LazyColumn أو LazyRow.

// LazyListState.kt

@Stable
class LazyListState constructor(
    firstVisibleItemIndex: Int = 0,
    firstVisibleItemScrollOffset: Int = 0
) : ScrollableState {
    /**
     *   The holder class for the current scroll position.
     */
    private val scrollPosition = LazyListScrollPosition(
        firstVisibleItemIndex, firstVisibleItemScrollOffset
    )

    suspend fun scrollToItem(/*...*/) { /*...*/ }

    override suspend fun scroll() { /*...*/ }

    suspend fun animateScrollToItem() { /*...*/ }
}

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

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

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

منطق النشاط التجاري

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

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

‫ViewModels كمالك للحالة

إنّ مزايا نماذج العرض في "مكوّنات بنية Android" في تطوير تطبيقات Android تجعلها مناسبة لتوفير الوصول إلى منطق النشاط التجاري وإعداد بيانات التطبيق للعرض على الشاشة.

عند رفع حالة واجهة المستخدم في ViewModel، يتم نقلها خارج الإنشاء.

يتم تخزين الحالة التي تم نقلها إلى ViewModel خارج Composition.
الشكل 6. يتم تخزين الحالة التي تم نقلها إلى ViewModel خارج التركيب.

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

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

حالة واجهة مستخدم الشاشة

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

لنفترض أنّ لدينا ConversationViewModel لتطبيق محادثات، ونريد معرفة كيف يعرض حالة واجهة المستخدم على الشاشة والأحداث التي تعدّلها:

class ConversationViewModel(
    channelId: String,
    messagesRepository: MessagesRepository
) : ViewModel() {

    val messages = messagesRepository
        .getLatestMessages(channelId)
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = emptyList()
        )

    // Business logic
    fun sendMessage(message: Message) { /* ... */ }
}

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

في ما يلي مثال على ViewModel مستخدَم في دالة مركّبة على مستوى الشاشة. في هذا المثال، تستهلك الدالة المركّبة ConversationScreen() حالة واجهة المستخدم الخاصة بالشاشة التي تم نقل قيمتها في ViewModel:

@Composable
private fun ConversationScreen(
    conversationViewModel: ConversationViewModel = viewModel()
) {

    val messages by conversationViewModel.messages.collectAsStateWithLifecycle()

    ConversationScreen(
        messages = messages,
        onSendMessage = { message: Message -> conversationViewModel.sendMessage(message) }
    )
}

@Composable
private fun ConversationScreen(
    messages: List<Message>,
    onSendMessage: (Message) -> Unit
) {

    MessagesList(messages, onSendMessage)
    /* ... */
}

الوصول إلى تفاصيل الموقع

يشير مصطلح "حفر الخصائص" إلى تمرير البيانات عبر عدة مكونات فرعية متداخلة إلى الموقع الذي تتم قراءتها فيه.

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

على الرغم من أنّ عرض الأحداث كمَعلمات lambda فردية قد يؤدي إلى زيادة تحميل توقيع الدالة، إلا أنّه يزيد من مستوى ظهور مسؤوليات الدالة المركّبة. يمكنك معرفة ما يفعله في لمحة.

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

تنطبق أفضل الممارسات نفسها إذا كانت هذه الأحداث هي أحداث تنقّل، ويمكنك الاطّلاع على مزيد من المعلومات حول ذلك في مستندات التنقّل.

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

حالة عنصر في واجهة المستخدم

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

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

ميزة تعرض اقتراحات للمستخدمين في محادثة جماعية عندما يكتب المستخدم الرمز `@` وتلميحًا
الشكل 7. ميزة تعرض اقتراحات للمستخدمين في محادثة جماعية عندما يكتب المستخدم @ وتلميحًا

سيبدو عنصر ViewModel الذي ينفّذ هذه الميزة على النحو التالي:

class ConversationViewModel(/*...*/) : ViewModel() {

    // Hoisted state
    var inputMessage by mutableStateOf("")
        private set

    val suggestions: StateFlow<List<Suggestion>> =
        snapshotFlow { inputMessage }
            .filter { hasSocialHandleHint(it) }
            .mapLatest { getHandle(it) }
            .mapLatest { repository.getSuggestions(it) }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = emptyList()
            )

    fun updateInput(newInput: String) {
        inputMessage = newInput
    }
}

‫inputMessage هو متغيّر يخزّن حالة TextField. في كل مرة يُدخل فيها المستخدم نصًا جديدًا، يستدعي التطبيق منطق النشاط التجاري لإنتاج suggestions.

‫suggestions هي حالة واجهة المستخدم على الشاشة ويتم استخدامها من واجهة مستخدم Compose من خلال جمع البيانات من StateFlow.

Caveat

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

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

ومع ذلك، فإن استدعاء طريقة close() في DrawerState باستخدام viewModelScope من واجهة مستخدم Compose يؤدي إلى استثناء وقت التشغيل من النوع IllegalStateException مع رسالة نصها "MonotonicFrameClock غير متاح في CoroutineContext”.

لحلّ هذه المشكلة، استخدِم CoroutineScope ضمن نطاق "الإنشاء". وتوفّر MonotonicFrameClock في CoroutineContext، وهو أمر ضروري لعمل دوال التعليق.

لحلّ هذه المشكلة، يمكنك تبديل CoroutineContext الخاص بالروتين المشترك في ViewModel إلى روتين يقتصر على "التركيب". ويمكن أن يبدو على النحو التالي:

class ConversationViewModel(/*...*/) : ViewModel() {

    val drawerState = DrawerState(initialValue = DrawerValue.Closed)

    private val _drawerContent = MutableStateFlow(DrawerContent.Empty)
    val drawerContent: StateFlow<DrawerContent> = _drawerContent.asStateFlow()

    fun closeDrawer(uiScope: CoroutineScope) {
        viewModelScope.launch {
            withContext(uiScope.coroutineContext) { // Use instead of the default context
                drawerState.close()
            }
            // Fetch drawer content and update state
            _drawerContent.update { content }
        }
    }
}

// in Compose
@Composable
private fun ConversationScreen(
    conversationViewModel: ConversationViewModel = viewModel()
) {
    val scope = rememberCoroutineScope()

    ConversationScreen(onCloseDrawer = { conversationViewModel.closeDrawer(uiScope = scope) })
}

مزيد من المعلومات

لمعرفة المزيد عن الحالة وJetpack Compose، يمكنك الرجوع إلى الموارد الإضافية التالية.

نماذج

اختبارات الرموز

الفيديوهات