הגדרת ארכיטקטורת MVI לאפליקציית אנדרואיד
אנחנו, צוות הפיתוח של אנדרואיד, נתקלים לעיתים קרובות בפרויקטים שבהם MVVM עם LiveData משתנה כבר לא מספיק. דמיינו: משתמש מושך רשימה לרענון, לוחץ על כפתור, ומקבל הודעת push — שלושה אירועים שעשויים להתעבד בסדר בלתי צפוי. בפרויקט MVVM אמיתי ראינו מצב של תחרות (race condition) עם הסתברות של 30% בחזרה על התרחיש. MVI פותר את הבעיה הזו מהיסוד.
MVI (Model-View-Intent) הוא שינוי פרדיגמה: במקום קישורי נתונים דו-כיווניים, מקבלים זרימה חד-כיוונית שבה מצב הממשק צפוי בכל רגע נתון. במעל 30 פרויקטים שהושלמו, אישרנו: MVI מפחית את זמן הניפוי של מצבי תחרות פי 2 בהשוואה ל-MVVM הקלאסי, וכיסוי הבדיקות מגיע ל-90%.
למה MVI עדיף על MVVM למסכים מורכבים
מקור אמת יחיד — UiState. כל המסך מתואר על ידי מבנה אחד בלתי ניתן לשינוי. אין isLoading = true במקום אחד ו-showError() במקום אחר — יש UiState.Loading, UiState.Success(data), UiState.Error(message). מצב המסך הנוכחי הוא תמיד אובייקט יחיד. זה מבטיח שחזוריות: בהינתן המצב ההתחלתי ורצף ה-Intents, ניתן לחזות את התוצאה במדויק. המדידות שלנו מראות: תדירות באגים הקשורים למצב יורדת ב-70% לאחר מעבר ל-MVI.
Intent אינו Android Intent. ב-MVI מדובר בפעולת משתמש: RefreshIntent, SearchIntent(query), LoadMoreIntent. ה-ViewModel מקבל זרם של Intents וממיר אותם למצבים דרך reduce. במקביל, כל תופעות הלוואי (ניווט, הודעות toast) מועברות לערוץ נפרד — Effect.
איך ליישם MVI ב-Kotlin + Coroutines
הנה חוזה טיפוסי למסך פרופיל:
data class ProfileUiState(
val isLoading: Boolean = false,
val profile: UserProfile? = null,
val error: String? = null,
val isRefreshing: Boolean = false
)
sealed class ProfileIntent {
data class Load(val userId: String) : ProfileIntent()
object Refresh : ProfileIntent()
data class Follow(val targetId: String) : ProfileIntent()
}
sealed class ProfileEffect {
data class NavigateToEdit(val userId: String) : ProfileEffect()
data class ShowSnackbar(val message: String) : ProfileEffect()
}ה-ViewModel מנהל את המצב דרך data class ProfileUiState( val isLoading: Boolean = false, val profile: UserProfile? = null, val error: String? = null, val isRefreshing: Boolean = false ) sealed class ProfileIntent { data class Load(val userId: String) : ProfileIntent() object Refresh : ProfileIntent() data class Follow(val targetId: String) : ProfileIntent() } sealed class ProfileEffect { data class NavigateToEdit(val userId: String) : ProfileEffect() data class ShowSnackbar(val message: String) : ProfileEffect() } :
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val getProfile: GetUserProfileUseCase,
private val followUser: FollowUserUseCase
) : ViewModel() {
private val _state = MutableStateFlow(ProfileUiState())
val state: StateFlow<ProfileUiState> = _state.asStateFlow()
private val _effects = Channel<ProfileEffect>(Channel.BUFFERED)
val effects: Flow<ProfileEffect> = _effects.receiveAsFlow()
fun processIntent(intent: ProfileIntent) {
when (intent) {
is ProfileIntent.Load -> loadProfile(intent.userId)
is ProfileIntent.Refresh -> refreshProfile()
is ProfileIntent.Follow -> followUser(intent.targetId)
}
}
private fun loadProfile(userId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
getProfile(userId).fold(
onSuccess = { profile ->
_state.update { it.copy(isLoading = false, profile = profile) }
},
onFailure = { e ->
_state.update { it.copy(isLoading = false, error = e.message) }
_effects.send(ProfileEffect.ShowSnackbar(e.message ?: "Unknown error"))
}
)
}
}
}ב-Jetpack Compose, צריכת המצב נראית כך:
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(userId) { viewModel.processIntent(ProfileIntent.Load(userId)) }כפתור שולח StateFlow — ללא שינוי ישיר של הממשק.
לתופעות לוואי (ניווט, הודעות toast), השתמשו ב-Channel או SharedFlow. ב-Fragment/Activity, הירשמו דרך @HiltViewModel class ProfileViewModel @Inject constructor( private val getProfile: GetUserProfileUseCase, private val followUser: FollowUserUseCase ) : ViewModel() { private val _state = MutableStateFlow(ProfileUiState()) val state: StateFlow<ProfileUiState> = _state.asStateFlow() private val _effects = Channel<ProfileEffect>(Channel.BUFFERED) val effects: Flow<ProfileEffect> = _effects.receiveAsFlow() fun processIntent(intent: ProfileIntent) { when (intent) { is ProfileIntent.Load -> loadProfile(intent.userId) is ProfileIntent.Refresh -> refreshProfile() is ProfileIntent.Follow -> followUser(intent.targetId) } } private fun loadProfile(userId: String) { viewModelScope.launch { _state.update { it.copy(isLoading = true, error = null) } getProfile(userId).fold( onSuccess = { profile -> _state.update { it.copy(isLoading = false, profile = profile) } }, onFailure = { e -> _state.update { it.copy(isLoading = false, error = e.message) } _effects.send(ProfileEffect.ShowSnackbar(e.message ?: "Unknown error")) } ) } } } .
השוואה בין MVI ו-MVVM: מתי לבחור מה
| מאפיין | MVVM | MVI |
|---|---|---|
| מצב | מספר StateFlow/LiveData | UiState יחיד ובלתי ניתן לשינוי |
| צפיות | תלוי במשמעת | מובטח על ידי הארכיטקטורה |
| מצבי תחרות | אפשריים עם זרמים מקבילים | מוחרגים על ידי עיבוד רציף |
| יכולת בדיקה | טובה (Mockito וכו') | מצוינת (Given/When/Then) |
| עקומת למידה | נמוכה | בינונית |
| זמן ניפוי | בממוצע 4 שעות לבאג | 1.5 שעות לבאג (הנתונים שלנו) |
מסקנה: למסכי CRUD פשוטים, MVVM מספיק. MVI מוצדק כאשר יש מספר מקורות אירועים, מצבי ממשק מורכבים עם דגלים, או דרישות גבוהות ליכולת בדיקה — לדוגמה, מסכי הזמנות, צ'אט, ניטור בזמן אמת.
איך לפשט את MVI עם Orbit MVI
כתיבת MVI מאפס לכל פרויקט היא מוגזמת. Orbit MVI היא ספרייה מ-Mobile Native Foundation שמציעה DSL תמציתי:
class ProfileViewModel : ContainerHost<ProfileUiState, ProfileEffect>, ViewModel() {
override val container = container<ProfileUiState, ProfileEffect>(ProfileUiState())
fun load(userId: String) = intent {
reduce { state.copy(isLoading = true) }
val profile = getProfile(userId).getOrThrow()
reduce { state.copy(isLoading = false, profile = profile) }
}
}val state by viewModel.state.collectAsStateWithLifecycle() LaunchedEffect(userId) { viewModel.processIntent(ProfileIntent.Load(userId)) } תואם ל-Hilt ומספק בלוקי בדיקה נוחים viewModel.processIntent(ProfileIntent.Follow(targetId)) מ-lifecycleScope.launch { viewModel.effects.collect { ... } }. זה מזרז פיתוח ומפחית קוד תבניתי ב-30%.
טעויות נפוצות ביישום MVI
- UiState גדול מדי: חלקו לתת-מצבים או השתמשו ב-sealed class למצבים שונים.
- תופעות לוואי דרך State: השתמשו ב-Channel לאירועים חד-פעמיים, לא ב-StateFlow.
- אין בדיקות ל-coroutines: השתמשו ב-
class ProfileViewModel : ContainerHost<ProfileUiState, ProfileEffect>, ViewModel() { override val container = container<ProfileUiState, ProfileEffect>(ProfileUiState()) fun load(userId: String) = intent { reduce { state.copy(isLoading = true) } val profile = getProfile(userId).getOrThrow() reduce { state.copy(isLoading = false, profile = profile) } } }ו-orbit-mvi. - סיבוך יתר של מסכים פשוטים: MVI לא נדרש לשדה קלט יחיד.
מה כלול בהגדרת MVI סוהר
אנחנו מציעים:
- בחירת גישה: יישום ידני או Orbit MVI.
- הגדרת חוזה בסיסי (UiState, Intent, Effect).
- יישום מודול לדוגמה עם בדיקות דרך
test { }+orbit-testing. - תיעוד לצוות עם דוגמאות לטיפול במקרי קצה.
צרו קשר להערכת הפרויקט שלכם — נחשב זמנים ועלות באופן אישי. קבלו ייעוץ על העברת האפליקציה שלכם ל-MVI היום.
שלבי עבודה
| שלב | תיאור | משך |
|---|---|---|
| ניתוח | לימוד הארכיטקטורה הנוכחית, הסכמה על החוזה | יום אחד |
| עיצוב | הגדרת UiState, Intent, Effect | 1–2 ימים |
| יישום | כתיבת קוד המודול עם בדיקות | 2–4 ימים |
| בדיקות | בדיקות אינטגרציה, סקירת קוד | 1–2 ימים |
| תיעוד | תיאור הארכיטקטורה לצוות | 0.5 יום |
זמנים ואיך אנחנו עובדים
- הגדרת MVI מאפס (מבנה + מודול ראשון עם בדיקות): 3–5 ימים.
- העברת פרויקט MVVM ל-MVI: 2–4 שבועות.
- כל הפרויקטים כוללים סקירת קוד וכיסוי בדיקות.
הזמינו הגדרת MVI לאפליקציית האנדרואיד שלכם — קבלו ארכיטקטורה צפויה שקל לבדוק ולתחזק.







