في تطبيق 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 والانتقال إلى أسفل الصفحة عند تلقّي رسائل جديدةالتسلسل الهرمي للدوال القابلة للإنشاء هو كما يلي:
يتم نقل حالة LazyColumn إلى شاشة المحادثة حتى يتمكّن التطبيق من تنفيذ منطق واجهة المستخدم وقراءة الحالة من جميع العناصر القابلة للإنشاء التي تتطلّب ذلك:
LazyColumn من LazyColumn إلى ConversationScreenإذًا، أصبحت الدوال البرمجية القابلة للإنشاء هي:
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فئة عنصر الاحتفاظ بالحالة العادية بصفتها مالك الحالة
عندما تحتوي دالة مركّبة على منطق معقّد لواجهة المستخدم يتضمّن حقل حالة واحدًا أو أكثر لعنصر في واجهة المستخدم، يجب تفويض هذه المسؤولية إلى عناصر الاحتفاظ بالحالة، مثل فئة عادية لعنصر الاحتفاظ بالحالة. ويجعل ذلك منطق العنصر القابل للإنشاء أكثر قابلية للاختبار بشكل منفصل، ويقلّل من تعقيده. يُفضّل هذا النهج مبدأ فصل الاهتمامات: تكون الدالة المركّبة مسؤولة عن عرض عناصر واجهة المستخدم، ويحتوي عنصر الاحتفاظ بالحالة على منطق واجهة المستخدم وحالة عناصر واجهة المستخدم.
توفّر فئات عناصر الاحتفاظ بالحالة العادية دوال ملائمة للمتصلين بالدالة المركّبة، ما يغنيهم عن كتابة هذا المنطق بأنفسهم.
يتم إنشاء هذه الفئات العادية وتذكُّرها في Composition. وبما أنّها تتبع دورة حياة العناصر القابلة للإنشاء، يمكنها استخدام أنواع توفّرها مكتبة Compose، مثل rememberNavController() أو rememberLazyListState().
ومن الأمثلة على ذلك فئة LazyListState plain state holder
التي تم تنفيذها في 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 بصفتها مالكة للحالة
تتوفّر مزايا في نماذج AAC ViewModels عند تطوير تطبيقات Android، ما يجعلها مناسبة لتوفير إمكانية الوصول إلى منطق النشاط التجاري وإعداد بيانات التطبيق لعرضها على الشاشة.
عند رفع حالة واجهة المستخدم في ViewModel، يتم نقلها خارج
Composition.
ViewModel خارج التركيب.لا يتم تخزين ViewModels كجزء من Composition. ويوفّرها إطار العمل، ويتم تحديد نطاقها ضمن ViewModelStoreOwner يمكن أن يكون Activity أو Fragment أو رسمًا بيانيًا للتنقّل أو وجهة في رسم بياني للتنقّل. لمزيد من المعلومات حول نطاقات 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 قد يؤدي إلى زيادة الحمل على توقيع الدالة، إلا أنّه يزيد من إمكانية رؤية مسؤوليات الدالة القابلة للإنشاء. ويمكنك الاطّلاع على وظيفتها بنظرة سريعة.
يُفضَّل استخدام ميزة "التنقيب في المواقع" على إنشاء فئات حاوية لتغليف الحالة والأحداث في مكان واحد، لأنّ ذلك يقلّل من مستوى ظهور مسؤوليات العناصر القابلة للإنشاء. عند عدم استخدام فئات التغليف، من المرجّح أيضًا أن تمرِّر إلى العناصر القابلة للإنشاء المَعلمات التي تحتاج إليها فقط، وهذا يُعد إحدى أفضل الممارسات.
تنطبق أفضل الممارسات نفسها إذا كانت هذه الأحداث أحداث تنقّل، ويمكنك الاطّلاع على مزيد من المعلومات حول ذلك في مستندات التنقّل.
إذا حدّدت مشكلة في الأداء، يمكنك أيضًا اختيار تأجيل قراءة الحالة. يمكنك الاطّلاع على مستندات الأداء لمعرفة المزيد.
حالة عنصر في واجهة المستخدم
يمكنك نقل حالة عنصر واجهة المستخدم إلى أداة الاحتفاظ بالحالة على مستوى الشاشة إذا كانت هناك منطق أعمال يحتاج إلى قراءتها أو كتابتها.
بالاستمرار في مثال تطبيق الدردشة، يعرض التطبيق اقتراحات للمستخدمين في محادثة جماعية عندما يكتب المستخدم @ وتلميحًا. تأتي هذه الاقتراحات من طبقة البيانات، ويُعدّ منطق احتساب قائمة باقتراحات المستخدمين منطقًا تجاريًا. تبدو الميزة على النحو التالي:
@ وتلميحًاسيبدو 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 ضمن نطاق Composition. وهي توفّر MonotonicFrameClock في CoroutineContext ضروريًا لكي تعمل دوال التعليق.
لإصلاح هذا التعطُّل، بدِّل CoroutineContext الروتين الفرعي في ViewModel إلى روتين فرعي ضمن نطاق Composition. قد يبدو على النحو التالي:
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، يُرجى الاطّلاع على المراجع الإضافية التالية.
نماذج
اختبارات الرموز
الفيديوهات
مُقترَحة لك
- ملاحظة: يتم عرض نص الرابط عندما تكون JavaScript غير مفعّلة.
- حفظ حالة واجهة المستخدم في Compose
- القوائم والشبكات
- تصميم بنية واجهة مستخدم Compose