אירועים בממשק המשתמש הם פעולות שצריך לטפל בהן בשכבת ממשק המשתמש, או על ידי ממשק המשתמש.
או באמצעות ViewModel. הסוג הנפוץ ביותר של אירועים הוא אירועי משתמש. המשתמש/ת
יוצרת אירועי משתמש על ידי אינטראקציה עם האפליקציה - לדוגמה, על ידי הקשה על
במסך או על ידי יצירת תנועות. לאחר מכן, ממשק המשתמש משתמש באירועים האלה באמצעות
קריאות חוזרות (callback) כמו מאזיני onClick()
.
בדרך כלל, ה-ViewModel אחראי לטיפול בלוגיקה העסקית של
אירוע מסוים של המשתמש - לדוגמה, המשתמש לוחץ על לחצן כדי לרענן
. בדרך כלל, ה-ViewModel עושה זאת על ידי חשיפת פונקציות שממשק המשתמש יכול
שיחה. לאירועי משתמש עשויה להיות גם לוגיקת התנהגות של ממשק המשתמש, שממשק המשתמש יכול לטפל בה
ישירות – לדוגמה, מעבר למסך אחר או הצגת
Snackbar
אומנם הלוגיקה העסקית זהה לזו של אותה אפליקציה במכשירים ניידים שונים פלטפורמות או גורמי צורה, לוגיקת ההתנהגות של ממשק משתמש היא פרט בנוגע להטמעה. שההבדלים ביניהם עשויים להיות שונים. שכבת ממשק המשתמש מגדיר את סוגי הלוגיקה האלה: ככה:
- לוגיקה עסקית מתייחסת למה לעשות עם שינויי מצב – לדוגמה, ביצוע תשלום או שמירת העדפות משתמש. הדומיין ושכבות הנתונים בדרך כלל מגדירים את הלוגיקה הזאת. במדריך הזה מוסבר על רכיבי הארכיטקטורה המחלקה ViewModel משמשת בתור פתרון מקובע למחלקות שמטפלות בלוגיקה עסקית.
- לוגיקת ממשק המשתמש או לוגיקת ממשק משתמש מתייחסים לאופן ההצגה. שינויים מסוימים, לדוגמה, לוגיקת הניווט או אופן הצגת ההודעות למשתמש. ממשק המשתמש מטפל בלוגיקה הזו.
עץ ההחלטות של אירועים בממשק המשתמש
התרשים הבא מציג עץ החלטות שנועד למצוא את הגישה הטובה ביותר ומטפל בתרחיש לדוגמה ספציפי של אירוע. שאר המדריך מסביר על שלושת הפיצ'רים האלה.
![אם המקור של האירוע הוא ViewModel, מעדכנים את מצב ממשק המשתמש. אם המיקום
האירוע הגיע דרך ממשק המשתמש והוא דורש לוגיקה עסקית, ולאחר מכן הענקת גישה
את הלוגיקה העסקית ל-ViewModel. אם המקור של האירוע הוא בממשק המשתמש
מחייבת לוגיקת התנהגות של ממשק המשתמש, ולאחר מכן תשנה את מצב הרכיב של ממשק המשתמש ישירות
ממשק משתמש.](https://developer.android.com/static/topic/libraries/architecture/images/mad-arch-uievents-tree.png?authuser=2&hl=he)
טיפול באירועי משתמשים
ממשק המשתמש יכול לטפל ישירות באירועי משתמש אם האירועים האלה קשורים לשינוי מצב של רכיב בממשק המשתמש - לדוגמה, מצב של פריט שניתן להרחבה. אם האירוע דורש ביצוע לוגיקה עסקית, כמו רענון הנתונים במסך, צריך לעבד אותו באמצעות 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 – צריכות תמיד להיות יובילו לעדכון של מצב ממשק המשתמש. הזה תואם לעקרונות של נתונים חד-כיווניים זרימה. הוא מאפשר לשחזר אירועים אחרי שינויים בתצורה ומבטיחה שהפעולות בממשק המשתמש לא יאבדו. אופציונלי: ניתן גם לשחזר אירועים לאחר מוות של תהליך אם משתמשים .
מיפוי פעולות בממשק המשתמש למצב של ממשק המשתמש הוא לא תמיד תהליך פשוט, אבל הוא כן יוביל בלוגיקה פשוטה יותר. תהליך החשיבה לא צריך להסתיים בקביעת הדרך לגרום לממשק המשתמש לנווט למסך מסוים, לדוגמה. צריך לחשוב ולשקול איך לייצג את זרימת המשתמש במצב ממשק המשתמש שלכם. בתוך במילים אחרות: לא כדאי לחשוב אילו פעולות ממשק המשתמש צריך לבצע. לחשוב על האופן שבו הפעולות האלה משפיעות על המצב של ממשק המשתמש.
נניח, לדוגמה, של מעבר למסך הבית כשהמשתמש בוצעה התחברות במסך ההתחברות. אפשר ליצור את המודל הזה במצב של ממשק המשתמש באופן הבא:
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 לא צריך לדעת איך ממשק המשתמש מציג את ההודעה
screen; הוא רק יודע שיש הודעה למשתמש שצריך להציג. פעם אחת
הוצגה הודעה זמנית, ממשק המשתמש צריך להודיע ל-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 יצטרך לחשוף את המצב הזה לממשק המשתמש. ממשק המשתמש יגיב לשינוי במצב הזה ולנווט בהתאם. אירועי Handle 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 מגדיר מצב שמפיק אירוע ניווט מהמסך השדה A למסך ב' ומסך א' נשמרים במקבץ הניווט האחורי, יכול להיות שיהיה צורך לוגיקה נוספת, כדי לא להמשיך להתקדם אוטומטית ל-B. כדי ליישם את זה, צריך להיות מצב נוסף שמציין אם ממשק המשתמש כדאי לעבור למסך האחר. בדרך כלל, המדינה נמצאת בממשק המשתמש, כי לוגיקת הניווט מתייחסת לממשק המשתמש ולא ל-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}
- מדריך לארכיטקטורת אפליקציות