أحداث واجهة المستخدم هي إجراءات يجب معالجتها في طبقة واجهة المستخدم، إما من خلال واجهة المستخدم أو من خلال ViewModel. النوع الأكثر شيوعًا من الأحداث هو أحداث المستخدم. ينشئ المستخدم أحداث المستخدم من خلال التفاعل مع التطبيق، مثلاً من خلال النقر على الشاشة أو إنشاء إيماءات. بعد ذلك، تستهلك واجهة المستخدم هذه الأحداث باستخدام معاودة الاتصال، مثل المستمعين onClick().
عادةً ما يكون ViewModel مسؤولاً عن معالجة منطق العمل لحدث مستخدم معيّن، مثلاً، نقر المستخدم على زر لتحديث بعض البيانات. عادةً ما يعالج ViewModel ذلك من خلال عرض الدوال التي يمكن لواجهة المستخدم استدعاؤها. قد تتضمّن أحداث المستخدم أيضًا منطق سلوك واجهة المستخدم الذي يمكن لواجهة المستخدم معالجته
مباشرةً، مثلاً، الانتقال إلى شاشة مختلفة أو عرض
Snackbar.
في حين أنّ منطق العمل يظلّ نفسه للتطبيق نفسه على منصات الأجهزة الجوّالة أو عوامل شكل مختلفة، فإنّ منطق سلوك واجهة المستخدم هو تفصيل في التنفيذ قد يختلف بين هذه الحالات. تحدّد صفحة طبقة واجهة المستخدم هذه الأنواع من المنطق على النحو التالي:
- يشير منطق العمل إلى ما يجب فعله عند حدوث تغييرات في الحالة، مثلاً، إجراء دفعة أو تخزين الإعدادات المفضَّلة للمستخدم. عادةً ما تعالج طبقة النطاق وطبقة البيانات هذا المنطق. في كلّ أنحاء هذا الدليل، يتم استخدام فئة Architecture Components ViewModel كحلّ مُحدّد للحلول التي تعالج منطق العمل.
- يشير منطق سلوك واجهة المستخدم أو منطق واجهة المستخدم إلى كيفية عرض تغييرات الحالة، مثلاً، منطق التنقّل أو كيفية عرض الرسائل للمستخدم. تعالج واجهة المستخدم هذا المنطق.
شجرة قرارات أحداث واجهة المستخدم
يعرض الرسم البياني التالي شجرة قرارات للعثور على أفضل نهج لمعالجة حالة استخدام حدث معيّن. ويشرح الجزء المتبقي من هذا الدليل هذه النُهج بالتفصيل.
معالجة أحداث المستخدم
يمكن لواجهة المستخدم معالجة أحداث المستخدم مباشرةً إذا كانت هذه الأحداث مرتبطة بتعديل حالة عنصر في واجهة المستخدم، مثلاً، حالة عنصر قابل للتوسيع. إذا كان الحدث يتطلّب تنفيذ منطق العمل، مثل تحديث البيانات على الشاشة، يجب أن يعالجه ViewModel.
يوضّح المثال التالي كيفية استخدام أزرار مختلفة لتوسيع عنصر في واجهة المستخدم (منطق واجهة المستخدم) ولتحديث البيانات على الشاشة (منطق العمل):
المشاهدات
class LatestNewsActivity : AppCompatActivity() {
private lateinit var binding: ActivityLatestNewsBinding
private val viewModel: LatestNewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
// The expand details event is processed by the UI that
// modifies a View's internal state.
binding.expandButton.setOnClickListener {
binding.expandedSection.visibility = View.VISIBLE
}
// The refresh event is processed by the ViewModel that is in charge
// of the business logic.
binding.refreshButton.setOnClickListener {
viewModel.refreshNews()
}
}
}
إنشاء
@Composable
fun LatestNewsScreen(viewModel: LatestNewsViewModel = viewModel()) {
// State of whether more details should be shown
var expanded by remember { mutableStateOf(false) }
Column {
Text("Some text")
if (expanded) {
Text("More details")
}
Button(
// The expand details event is processed by the UI that
// modifies this composable's internal state.
onClick = { expanded = !expanded }
) {
val expandText = if (expanded) "Collapse" else "Expand"
Text("$expandText details")
}
// The refresh event is processed by the ViewModel that is in charge
// of the UI's business logic.
Button(onClick = { viewModel.refreshNews() }) {
Text("Refresh data")
}
}
}
أحداث المستخدم في `RecyclerViews`
إذا تم إنشاء الإجراء في مكان أبعد في شجرة واجهة المستخدم، مثلاً في عنصر RecyclerView
أو View مخصّص، يجب أن يظلّ ViewModel هو المسؤول عن معالجة أحداث المستخدم.
على سبيل المثال، لنفترض أنّ جميع الأخبار من NewsActivity تحتوي على زرّ وضع إشارة مرجعية. يحتاج ViewModel إلى معرفة رقم تعريف الخبر الذي تم وضع إشارة مرجعية عليه. عندما يضع المستخدم إشارة مرجعية على خبر، لا يستدعي محوّل RecyclerView الدالة addBookmark(newsId) المعروضة من ViewModel، ما يتطلّب الاعتماد على ViewModel. بدلاً من ذلك، يعرض ViewModel كائن حالة يُسمّى NewsItemUiState يحتوي على عملية التنفيذ لمعالجة الحدث:
data class NewsItemUiState(
val title: String,
val body: String,
val bookmarked: Boolean = false,
val publicationDate: String,
val onBookmark: () -> Unit
)
class LatestNewsViewModel(
private val formatDateUseCase: FormatDateUseCase,
private val repository: NewsRepository
)
val newsListUiItems = repository.latestNews.map { news ->
NewsItemUiState(
title = news.title,
body = news.body,
bookmarked = news.bookmarked,
publicationDate = formatDateUseCase(news.publicationDate),
// Business logic is passed as a lambda function that the
// UI calls on click events.
onBookmark = {
repository.addBookmark(news.id)
}
)
}
}
بهذه الطريقة، لا يعمل محوّل RecyclerView إلا مع البيانات التي يحتاج إليها: قائمة كائنات NewsItemUiState. لا يمكن للمحوّل الوصول إلى ViewModel بالكامل، ما يقلّل من احتمالية إساءة استخدام الوظائف التي يعرضها ViewModel. عندما تسمح لفئة النشاط فقط بالعمل مع ViewModel، فإنّك تفصل المسؤوليات. يضمن ذلك عدم تفاعل الكائنات الخاصة بواجهة المستخدم، مثل طرق العرض أو محوّلات RecyclerView، مباشرةً مع ViewModel.
اصطلاحات تسمية دوال أحداث المستخدم
في هذا الدليل، يتم تسمية دوال ViewModel التي تعالج أحداث المستخدم بفعل استنادًا إلى الإجراء الذي تعالجه، مثلاً: addBookmark(id) أو logIn(username, password).
معالجة أحداث ViewModel
يجب أن تؤدي دائمًا إجراءات واجهة المستخدم التي تنشأ من ViewModel، أي أحداث ViewModel، إلى تعديل حالة واجهة المستخدم. يتوافق ذلك مع مبادئ تدفق البيانات أحادي الاتجاه Flow. ويجعل الأحداث قابلة للتكرار بعد تغييرات الإعدادات ويضمن عدم فقدان إجراءات واجهة المستخدم. يمكنك أيضًا اختياريًا جعل الأحداث قابلة للتكرار بعد إيقاف العملية نهائيًا إذا كنت تستخدم وحدة الحالة المحفوظة.
إنّ ربط إجراءات واجهة المستخدم بحالة واجهة المستخدم ليس عملية بسيطة دائمًا، ولكنّه يؤدي إلى منطق أبسط. يجب ألا تتوقف عملية التفكير عند تحديد كيفية انتقال واجهة المستخدم إلى شاشة معيّنة، مثلاً. عليك التفكير أكثر في كيفية تمثيل تدفق المستخدم هذا في حالة واجهة المستخدم. بعبارة أخرى: لا تفكّر في الإجراءات التي يجب أن تتخذها واجهة المستخدم، بل فكّر في كيفية تأثير هذه الإجراءات في حالة واجهة المستخدم.
على سبيل المثال، لنفترض أنّك تنتقل إلى الشاشة الرئيسية عندما يكون المستخدم مسجّلاً الدخول على شاشة تسجيل الدخول. يمكنك نمذجة ذلك في حالة واجهة المستخدم على النحو التالي:
data class LoginUiState(
val isLoading: Boolean = false,
val errorMessage: String? = null,
val isUserLoggedIn: Boolean = false
)
تتفاعل واجهة المستخدم هذه مع التغييرات في حالة isUserLoggedIn وتنتقل إلى الوجهة الصحيحة حسب الحاجة:
المشاهدات
class LoginViewModel : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
/* ... */
}
class LoginActivity : AppCompatActivity() {
private val viewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
if (uiState.isUserLoggedIn) {
// Navigate to the Home screen.
}
...
}
}
}
}
}
إنشاء
class LoginViewModel : ViewModel() {
var uiState by mutableStateOf(LoginUiState())
private set
/* ... */
}
@Composable
fun LoginScreen(
viewModel: LoginViewModel = viewModel(),
onUserLogIn: () -> Unit
) {
val currentOnUserLogIn by rememberUpdatedState(onUserLogIn)
// Whenever the uiState changes, check if the user is logged in.
LaunchedEffect(viewModel.uiState) {
if (viewModel.uiState.isUserLoggedIn) {
currentOnUserLogIn()
}
}
// Rest of the UI for the login screen.
}
يمكن أن يؤدي استهلاك الأحداث إلى تعديلات في الحالة
قد يؤدي استهلاك أحداث ViewModel معيّنة في واجهة المستخدم إلى تعديلات أخرى في حالة واجهة المستخدم. على سبيل المثال، عند عرض رسائل مؤقتة على الشاشة لإعلام المستخدم بحدوث أمر ما، تحتاج واجهة المستخدم إلى إشعار ViewModel لبدء تعديل آخر في الحالة بعد عرض الرسالة على الشاشة. يمكن التعامل مع الحدث الذي يحدث عندما يستهلك المستخدم الرسالة (من خلال إغلاقها أو بعد انتهاء المهلة) على أنّه "بيانات أدخلها المستخدم"، وبالتالي يجب أن يكون ViewModel على علم بذلك. في هذه الحالة، يمكن نمذجة حالة واجهة المستخدم على النحو التالي:
// Models the UI state for the Latest news screen.
data class LatestNewsUiState(
val news: List<News> = emptyList(),
val isLoading: Boolean = false,
val userMessage: String? = null
)
سيعدّل ViewModel حالة واجهة المستخدم على النحو التالي عندما يتطلّب منطق العمل عرض رسالة مؤقتة جديدة للمستخدم:
المشاهدات
class LatestNewsViewModel(/* ... */) : ViewModel() {
private val _uiState = MutableStateFlow(LatestNewsUiState(isLoading = true))
val uiState: StateFlow<LatestNewsUiState> = _uiState
fun refreshNews() {
viewModelScope.launch {
// If there isn't internet connection, show a new message on the screen.
if (!internetConnection()) {
_uiState.update { currentUiState ->
currentUiState.copy(userMessage = "No Internet connection")
}
return@launch
}
// Do something else.
}
}
fun userMessageShown() {
_uiState.update { currentUiState ->
currentUiState.copy(userMessage = null)
}
}
}
إنشاء
class LatestNewsViewModel(/* ... */) : ViewModel() {
var uiState by mutableStateOf(LatestNewsUiState())
private set
fun refreshNews() {
viewModelScope.launch {
// If there isn't internet connection, show a new message on the screen.
if (!internetConnection()) {
uiState = uiState.copy(userMessage = "No Internet connection")
return@launch
}
// Do something else.
}
}
fun userMessageShown() {
uiState = uiState.copy(userMessage = null)
}
}
لا يحتاج ViewModel إلى معرفة كيفية عرض واجهة المستخدم للرسالة على الشاشة، بل يعرف فقط أنّه يجب عرض رسالة للمستخدم. بعد عرض الرسالة المؤقتة، تحتاج واجهة المستخدم إلى إشعار ViewModel بذلك، ما يؤدي إلى تعديل آخر في حالة واجهة المستخدم لمحو السمة userMessage:
المشاهدات
class LatestNewsActivity : AppCompatActivity() {
private val viewModel: LatestNewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
uiState.userMessage?.let {
// TODO: Show Snackbar with userMessage.
// Once the message is displayed and
// dismissed, notify the ViewModel.
viewModel.userMessageShown()
}
...
}
}
}
}
}
إنشاء
@Composable
fun LatestNewsScreen(
snackbarHostState: SnackbarHostState,
viewModel: LatestNewsViewModel = viewModel(),
) {
// Rest of the UI content.
// If there are user messages to show on the screen,
// show it and notify the ViewModel.
viewModel.uiState.userMessage?.let { userMessage ->
LaunchedEffect(userMessage) {
snackbarHostState.showSnackbar(userMessage)
// Once the message is displayed and dismissed, notify the ViewModel.
viewModel.userMessageShown()
}
}
}
على الرغم من أنّ الرسالة مؤقتة، فإنّ حالة واجهة المستخدم تمثّل بدقة ما يتم عرضه على الشاشة في كل نقطة زمنية. إما أن يتم عرض رسالة المستخدم أو لا يتم عرضها.
أحداث التنقّل
يوضّح قسم يمكن أن يؤدي استهلاك الأحداث إلى تعديلات في الحالة كيفية استخدام حالة واجهة المستخدم لعرض رسائل المستخدمين على الشاشة. أحداث التنقّل هي أيضًا نوع شائع من الأحداث في تطبيق Android.
إذا تم تشغيل الحدث في واجهة المستخدم لأنّ المستخدم نقر على زر، تتولّى واجهة المستخدم ذلك من خلال استدعاء وحدة التحكّم في التنقّل أو عرض الحدث على العنصر المركّب الذي يستدعيها حسب الاقتضاء.
المشاهدات
class LoginActivity : AppCompatActivity() {
private lateinit var binding: ActivityLoginBinding
private val viewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
binding.helpButton.setOnClickListener {
navController.navigate(...) // Open help screen
}
}
}
إنشاء
@Composable
fun LoginScreen(
onHelp: () -> Unit, // Caller navigates to the right screen
viewModel: LoginViewModel = viewModel()
) {
// Rest of the UI
Button(onClick = onHelp) {
Text("Get help")
}
}
إذا كان إدخال البيانات يتطلّب بعض التحقّق من صحة منطق العمل قبل التنقّل، سيحتاج ViewModel إلى عرض هذه الحالة على واجهة المستخدم. ستتفاعل واجهة المستخدم مع تغيير الحالة هذا وتنتقل وفقًا لذلك. يغطّي قسم معالجة أحداث ViewModel حالة الاستخدام هذه. في ما يلي رمز مشابه:
المشاهدات
class LoginActivity : AppCompatActivity() {
private val viewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
if (uiState.isUserLoggedIn) {
// Navigate to the Home screen.
}
...
}
}
}
}
}
إنشاء
@Composable
fun LoginScreen(
onUserLogIn: () -> Unit, // Caller navigates to the right screen
viewModel: LoginViewModel = viewModel()
) {
Button(
onClick = {
// ViewModel validation is triggered
viewModel.login()
}
) {
Text("Log in")
}
// Rest of the UI
val lifecycle = LocalLifecycleOwner.current.lifecycle
val currentOnUserLogIn by rememberUpdatedState(onUserLogIn)
LaunchedEffect(viewModel, lifecycle) {
// Whenever the uiState changes, check if the user is logged in and
// call the `onUserLogin` event when `lifecycle` is at least STARTED
snapshotFlow { viewModel.uiState }
.filter { it.isUserLoggedIn }
.flowWithLifecycle(lifecycle)
.collect {
currentOnUserLogIn()
}
}
}
في المثال أعلاه، يعمل التطبيق على النحو المتوقّع لأنّه لن يتم الاحتفاظ بالوجهة الحالية، وهي "تسجيل الدخول"، في سجلّ التتبّع السابق. لا يمكن للمستخدمين الرجوع إليها إذا ضغطوا على زر الرجوع. ومع ذلك، في الحالات التي قد يحدث فيها ذلك، سيتطلّب الحلّ منطقًا إضافيًا.
أحداث التنقّل عندما يتم الاحتفاظ بالوجهة في سجلّ التتبّع السابق
عندما يضبط ViewModel حالة معيّنة تؤدي إلى حدث تنقّل من الشاشة "أ" إلى الشاشة "ب" ويتم الاحتفاظ بالشاشة "أ" في سجلّ التتبّع السابق للتنقّل، قد تحتاج إلى منطق إضافي لعدم الانتقال تلقائيًا إلى الشاشة "ب". لتنفيذ ذلك، يجب أن تكون هناك حالة إضافية تشير إلى ما إذا كان على واجهة المستخدم مراعاة التنقّل إلى الشاشة الأخرى أم لا. عادةً ما يتم الاحتفاظ بهذه الحالة في واجهة المستخدم لأنّ منطق التنقّل يخص واجهة المستخدم، وليس ViewModel. لتوضيح ذلك، لنأخذ حالة الاستخدام التالية.
لنفترض أنّك في مسار التسجيل في تطبيقك. في شاشة التحقّق من صحة تاريخ الميلاد ، عندما يُدخل المستخدم تاريخًا، يتحقّق ViewModel من صحة التاريخ عندما ينقر المستخدم على الزر "متابعة". يفوّض ViewModel منطق التحقّق من الصحة إلى طبقة البيانات. إذا كان التاريخ صالحًا، ينتقل المستخدم إلى الشاشة التالية. كميزة إضافية، يمكن للمستخدمين الرجوع إلى شاشات التسجيل المختلفة والعودة منها إذا أرادوا تغيير بعض البيانات. لذلك، يتم الاحتفاظ بجميع الوجهات في مسار التسجيل في سجلّ الأنشطة السابقة نفسه. بالنظر إلى هذه المتطلبات، يمكنك تنفيذ هذه الشاشة على النحو التالي:
المشاهدات
// Key that identifies the `validationInProgress` state in the Bundle
private const val DOB_VALIDATION_KEY = "dobValidationKey"
class DobValidationFragment : Fragment() {
private var validationInProgress: Boolean = false
private val viewModel: DobValidationViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val binding = // ...
validationInProgress = savedInstanceState?.getBoolean(DOB_VALIDATION_KEY) ?: false
binding.continueButton.setOnClickListener {
viewModel.validateDob()
validationInProgress = true
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiState
.flowWithLifecycle(viewLifecycleOwner.lifecycle)
.collect { uiState ->
// Update other parts of the UI ...
// If the input is valid and the user wants
// to navigate, navigate to the next screen
// and reset `validationInProgress` flag
if (uiState.isDobValid && validationInProgress) {
validationInProgress = false
navController.navigate(...) // Navigate to next screen
}
}
}
return binding
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(DOB_VALIDATION_KEY, validationInProgress)
}
}
إنشاء
class DobValidationViewModel(/* ... */) : ViewModel() {
var uiState by mutableStateOf(DobValidationUiState())
private set
}
@Composable
fun DobValidationScreen(
onNavigateToNextScreen: () -> Unit, // Caller navigates to the right screen
viewModel: DobValidationViewModel = viewModel()
) {
// TextField that updates the ViewModel when a date of birth is selected
var validationInProgress by rememberSaveable { mutableStateOf(false) }
Button(
onClick = {
viewModel.validateInput()
validationInProgress = true
}
) {
Text("Continue")
}
// Rest of the UI
/*
* The following code implements the requirement of advancing automatically
* to the next screen when a valid date of birth has been introduced
* and the user wanted to continue with the registration process.
*/
if (validationInProgress) {
val lifecycle = LocalLifecycleOwner.current.lifecycle
val currentNavigateToNextScreen by rememberUpdatedState(onNavigateToNextScreen)
LaunchedEffect(viewModel, lifecycle) {
// If the date of birth is valid and the validation is in progress,
// navigate to the next screen when `lifecycle` is at least STARTED,
// which is the default Lifecycle.State for the `flowWithLifecycle` operator.
snapshotFlow { viewModel.uiState }
.filter { it.isDobValid }
.flowWithLifecycle(lifecycle)
.collect {
validationInProgress = false
currentNavigateToNextScreen()
}
}
}
}
إنّ التحقّق من صحة تاريخ الميلاد هو منطق عمل مسؤول عنه ViewModel. في معظم الأحيان، يفوّض ViewModel هذا المنطق إلى طبقة البيانات. إنّ منطق تنقّل المستخدم إلى الشاشة التالية هو منطق واجهة المستخدم لأنّ هذه المتطلبات قد تتغيّر استنادًا إلى إعدادات واجهة المستخدم. على سبيل المثال، قد لا تريد الانتقال تلقائيًا إلى شاشة أخرى على جهاز لوحي إذا كنت تعرض خطوات تسجيل متعددة في الوقت نفسه. تنفّذ المتغيّرة validationInProgress في الرمز أعلاه هذه الوظيفة وتحدّد ما إذا كان على واجهة المستخدم التنقّل تلقائيًا كلما كان تاريخ الميلاد صالحًا وأراد المستخدم المتابعة إلى خطوة التسجيل التالية أم لا.
حالات استخدام أخرى
إذا كنت تعتقد أنّه لا يمكن حلّ حالة استخدام حدث واجهة المستخدم من خلال تعديلات حالة واجهة المستخدم، قد تحتاج إلى إعادة النظر في كيفية تدفق البيانات في تطبيقك. ضع في اعتبارك المبادئ التالية:
- يجب أن تنفّذ كل فئة ما هي مسؤولة عنه، وليس أكثر من ذلك. واجهة المستخدم مسؤولة عن منطق سلوك الشاشة، مثل طلبات التنقّل وأحداث النقر وطلبات الحصول على الأذونات. يحتوي ViewModel على منطق العمل ويحوّل النتائج من الطبقات السفلية من التسلسل الهرمي إلى حالة واجهة المستخدم.
- فكّر في مصدر الحدث. اتّبِع شجرة القرارات المعروضة في بداية هذا الدليل، واجعل كل فئة تعالج ما هي مسؤولة عنه. على سبيل المثال، إذا كان مصدر الحدث هو واجهة المستخدم وأدى إلى حدث تنقّل، يجب معالجة هذا الحدث في واجهة المستخدم. قد يتم تفويض بعض المنطق إلى ViewModel، ولكن لا يمكن تفويض معالجة الحدث بالكامل إلى ViewModel.
- إذا كان لديك مستهلكون متعدّدون وكنت قلقًا بشأن استهلاك الحدث عدة مرات، قد تحتاج إلى إعادة النظر في بنية تطبيقك. يؤدي وجود مستهلكين متعدّدين في الوقت نفسه إلى صعوبة بالغة في ضمان العقد الذي يتم تسليم البيانات فيه مرة واحدة فقط ، لذا يزداد مستوى التعقيد والسلوك الدقيق بشكل كبير. إذا كنت تواجه هذه المشكلة، ننصحك بنقل هذه المشاكل إلى أعلى في شجرة واجهة المستخدم، وقد تحتاج إلى كيان مختلف في نطاق أعلى في التسلسل الهرمي.
- فكّر في الوقت الذي يجب فيه استهلاك الحالة. في حالات معيّنة، قد لا تريد مواصلة استهلاك الحالة عندما يكون التطبيق في الخلفية، مثلاً، عرض
Toast. في هذه الحالات، ننصحك باستهلاك الحالة عندما تكون واجهة المستخدم في المقدّمة.
نماذج
توضّح نماذج Google التالية أحداث واجهة المستخدم في طبقة واجهة المستخدم. ننصحك باستكشافها للاطّلاع على هذا الإرشاد عمليًا:
اقتراحات مخصصة لك
- ملاحظة: يتم عرض نص الرابط عندما تكون JavaScript غير مفعّلة
- طبقة واجهة المستخدم
- عناصر الاحتفاظ بالحالة وحالة واجهة المستخدم {:#mad-arch}
- دليل بنية التطبيق