مكان الرفع

في تطبيق 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 والانتقال إلى أسفل القائمة عند وصول رسائل جديدة

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

شجرة الدوال المركّبة للدردشة
الشكل 2. شجرة العناصر المركّبة للمحادثة

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

نقل حالة LazyColumn من LazyColumn إلى ConversationScreen
الشكل 3. الاحتفاظ بحالة LazyColumn من LazyColumn إلى ConversationScreen

إذًا، تكون العناصر المركّبة في النهاية كما يلي:

شجرة الدوال المركّبة في Chat مع LazyListState تم نقلها إلى ConversationScreen
الشكل 4. شجرة العناصر المركّبة للمحادثة مع الاحتفاظ بـ 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

صف عادي من صفوف عنصر الاحتفاظ بالحالة بصفتها مالكة للحالة

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

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

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

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

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

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

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

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

نماذج العرض ViewModel بصفتها مالكة للحالة

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

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

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

لا يتم تخزين نماذج العرض كجزء من عملية الإنشاء. يوفّرها الإطار، ويتم تحديد نطاقها لـ 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 غير محدّد النطاق لعملية الـ Compose.

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

ومع ذلك، يؤدي استدعاء طريقة DrawerState في close() باستخدام الـ 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، يُرجى الاطّلاع على المراجع الإضافية التالية.

نماذج

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

الفيديوهات