MVI (Model-View-Intent) — un pattern arhitectural reactiv bazat pe fluxul unidirectional de date și starea imutabilă. Spre deosebire de MVVM, unde ViewModel poate avea mai multe StateFlow, MVI definește o stare unică (State), intenții imutabile (Intent) și o funcție reducer pură (Reducer). MVI garantează predictibilitatea stării ecranului în orice moment. Pattern-ul a fost popularizat în comunitatea Android de bibliotecile Mosby și Orbit. Mai multe — în MVIKotlin de Arkadii Ivanov.
Principalele
MVI (Model-View-Intent) — un pattern arhitectural reactiv construit pe principiile Redux și Cycle.js. Model — starea imutabilă a ecranului, Intent — intenția utilizatorului sau sistemului, View — abonarea la stare și trimiterea Intent. Datele se mișcă în ciclu: utilizatorul interacționează cu View → View creează un Intent → Intent este procesat de Reducer → Reducer creează o stare nouă → View primește starea nouă și se redesenează.
Diferența principală dintre MVI și MVVM — o singură sursă de adevăr (Single Source of Truth). În MVVM, ViewModel poate avea mai multe LiveData/StateFlow (userState, loadingState, errorState), ceea ce duce la inconsistență: loading=true și user=null simultan. În MVI există exact o sealed class/interface State care descrie întreaga stare a ecranului. În orice moment, starea ecranului este determinată unic — este imposibil să obțineți loading=true când datele sunt deja încărcate. În IT Sectr aplicăm MVI pentru ecrane cu logică complexă — formulare de comandă, înregistrări în mai mulți pași, ecrane financiare — unde predictibilitatea stării este critică.
| Component | Rol în MVI | Exemplu |
|---|---|---|
| Intent | Intenția utilizatorului sau sistemului | LoadUser, Refresh, SubmitForm |
| State | Starea imutabilă a ecranului | sealed class UserState |
| Reducer | Funcție pură: State + Intent → State | fun reduce(state, intent) -> state |
| Middleware | Procesarea efectelor secundare | Cerere de rețea, scriere în BD |
Ciclul MVI constă din cinci pași: 1) View trimite un Intent (de exemplu, LoadUser(42)); 2) Middleware (EffectHandler) execută efectul secundar — cererea de rețea; 3) Rezultatul revine ca un nou Intent în sistem; 4) Reducer primește starea curentă și Intent, creează o stare nouă; 5) View primește starea nouă și se redesenează. Fiecare pas este predictibil și testat izolat.
MVI pe Android se implementează prin sealed-clase pentru Intent și State, ViewModel cu logică MVI și Jetpack Compose pentru afișarea reactivă. ViewModel primește Intent din View, delegă efectele secundare către Middleware, rulează Reducer și publică noua stare prin StateFlow. Jetpack Compose redesenează UI la modificarea state — ideal pentru ciclul MVI.
// Intent — intențiile utilizatorului
sealed interface UserIntent {
data class LoadUser(val userId: Int) : UserIntent
data object Refresh : UserIntent
}
// State — starea unică a ecranului
sealed interface UserState {
data object Idle : UserState
data object Loading : UserState
data class Success(val user: User) : UserState
data class Error(val message: String) : UserState
}
// Reducer — funcție pură
object UserReducer {
fun reduce(state: UserState, intent: UserIntent): UserState = when (intent) {
is UserIntent.LoadUser -> UserState.Loading
is UserIntent.Refresh -> UserState.Loading
}
}
// ViewModel cu MVI
class UserViewModel(
private val repository: UserRepository
) : ViewModel() {
private val _state = MutableStateFlow<UserState>(UserState.Idle)
val state: StateFlow<UserState> = _state.asStateFlow()
fun process(intent: UserIntent) {
val newState = UserReducer.reduce(_state.value, intent)
_state.value = newState
when (intent) {
is UserIntent.LoadUser -> loadUser(intent.userId)
is UserIntent.Refresh -> loadUser(/* ID anterior */)
}
}
private fun loadUser(userId: Int) {
viewModelScope.launch {
repository.getUser(userId)
.onSuccess { user ->
_state.value = UserState.Success(user)
}
.onFailure { e ->
_state.value = UserState.Error(e.message ?: "Error")
}
}
}
}
// View trimite Intent
fun UserScreen(viewModel: UserViewModel) {
val state by viewModel.state.collectAsState()
LaunchedEffect(Unit) { viewModel.process(UserIntent.LoadUser(42)) }
when (state) {
is UserState.Loading -> CircularProgressIndicator()
is UserState.Success -> UserCard((state as UserState.Success).user)
is UserState.Error -> ErrorView((state as UserState.Error).message)
is UserState.Idle -> Text("Press to load")
}
}
Middleware și efecte secundare — în MVI, Reducer-ul pur nu poate executa cereri de rețea. Middleware (de asemenea EffectHandler sau Bootstrapper) procesează Intent, execută efectul secundar și emite un nou Intent înapoi în ciclu. Bibliotecile Orbit MVI și MVIKotlin oferă suport încorporat pentru Middleware cu efecte testabile. Fără Middleware, MVI degenerează în MVVM cu o structură suplimentară Intent și State.
MVIKotlin de Arkadii Ivanov — cea mai populară bibliotecă MVI pentru Kotlin Multiplatform. Suportă Android, iOS, web și JVM. Oferă componentele: Store (ViewModel), Bootstrapper (efecte inițiale), Reducer, Middleware. Până în octombrie 2025, biblioteca a adunat 2,5K stele pe GitHub și este folosită în proiecte comerciale, inclusiv aplicații ale marilor bănci din Rusia. În IT Sectr folosim MVIKotlin pentru proiecte cross-platform KMP cu logică de afaceri comună.
MVI pe iOS se implementează fără Combine-ViewModel, prin ciclul Intent → State. View trimite Intent printr-o închidere (closure), Reducer — funcție pură, State — struct cu câmpuri imutabile. SwiftUI redesenează View la modificarea State, ceea ce se potrivește perfect în ciclul MVI fără proprietăți @Published suplimentare. MVI pe iOS este deosebit de popular în comunitatea dezvoltatorilor SwiftUI care au trecut de la Redux (JavaScript).
// State — structură imutabilă
struct UserState: Equatable {
var user: User?
var isLoading = false
var errorMessage: String?
}
// Intent — enum cu intenții
enum UserIntent {
case loadUser(id: Int)
case refresh
case userLoaded(User)
case loadFailed(Error)
}
// Reducer — funcție pură
func userReducer(state: UserState, intent: UserIntent) -> UserState {
var newState = state
switch intent {
case .loadUser, .refresh:
newState.isLoading = true
newState.errorMessage = nil
case .userLoaded(let user):
newState.isLoading = false
newState.user = user
case .loadFailed(let error):
newState.isLoading = false
newState.errorMessage = error.localizedDescription
}
return newState
}
// Store — deține starea și gestionează efectele
final class UserStore: ObservableObject {
@Published private(set) var state = UserState()
private let service: UserService
init(service: UserService) {
self.service = service
}
func dispatch(_ intent: UserIntent) {
// 1. Reducer actualizează starea
state = userReducer(state: state, intent: intent)
// 2. Side effects (dacă este necesar)
switch intent {
case .loadUser(let id), .refresh:
service.fetchUser(id: id) { [weak self] result in
switch result {
case .success(let user):
self?.dispatch(.userLoaded(user))
case .failure(let error):
self?.dispatch(.loadFailed(error))
}
}
default: break
}
}
}
TCA (The Composable Architecture) — cea mai populară implementare MVI pentru iOS de la Point-Free, construită pe SwiftUI și Combine. TCA oferă Store, Reducer, Effect și Environment. Până în octombrie 2025, numărul de stele pe GitHub depășește 13K — este standardul de facto pentru MVI pe iOS. TCA este folosit în aplicațiile Starbucks, Airbnb (parțial) și multe proiecte indie. Spre deosebire de MVI scris manual, TCA rezolvă problemele de testare, navigare și efecte secundare din cutie.
MVI vs MVVM pe iOS — TCA/MVI oferă predictibilitatea stării, dar necesită mai mult cod șablon (Reducer, State, Action). MVVM cu @Published este mai simplu pentru ecrane simple. Noi în IT Sectr folosim MVVM pentru 80% din ecrane și MVI (TCA) pentru 20% complexe — tranzacții financiare, formulare în mai mulți pași, interfețe drag-and-drop, unde o eroare de stare poate costa bani utilizatorului.
MVI și MVVM rezolvă aceeași sarcină — organizarea stratului Presentation — dar cu abordări diferite de gestionare a stării. MVVM permite surse reactive multiple (LiveData, @Published), ceea ce poate duce la inconsistență. MVI garantează exact o stare în fiecare moment, ceea ce îl face mai riguros și predictibil, dar crește volumul de cod.
| Criteriu | MVVM | MVI |
|---|---|---|
| Stare | Multe LiveData/StateFlow | O singură sealed class State |
| Flux de date | Bidirecțional (View → ViewModel, LiveData → View) | Unidirecțional (Intent → Reducer → State → View) |
| Efecte secundare | Direct în ViewModel | Prin Middleware/EffectHandler |
| Testare | Teste unitare ViewModel | Teste unitare Reducer + Middleware |
| Cod șablon | Minim | Reducer + State + Intent + Middleware |
Când să alegeți MVI — ecrane unde starea trebuie să fie strict deterministă: operații financiare, coș de cumpărături, formulare în mai mulți pași cu validare la fiecare pas. În aceste scenarii, costul unei erori de stare (de exemplu, afișarea sumei coșului fără un produs din cauza cursei a două LiveData) depășește costul codului suplimentar. În MVVM vă bazați pe disciplina echipei, în MVI — pe arhitectură.
Când MVVM este suficient — 80% din ecranele standard: listă de utilizatori, profil, setări, flux de știri. Aici o singură stare este redundantă, iar structura suplimentară MVI va încetini dezvoltarea. În IT Sectr regula este: dacă ecranul are 3+ stări posibile cu tranziții (încărcare → date → eroare → reîncercare → încărcare → date) — MVI. Dacă ecranul are 1-2 operații asincrone — MVVM.
Sealed State — cea mai bună practică MVI. Starea este definită ca sealed class/interface cu variantele Loading, Success(data), Error(message). Aceasta garantează că View nu va ajunge într-o stare inconsistentă — nu se pot afișa date cu loading=true, deoarece Loading și Success sunt clase diferite. Toate datele referitoare la stare se află în interiorul variantei sealed: Success conține utilizatorul, Error — mesajul de eroare.
Reducer trebuie să rămână o funcție pură — fără apeluri API, BD, SharedPreferences. Funcția pură primește State și Intent, returnează State. Efectele secundare (rețea, BD, navigare, toast-uri) sunt procesate în Middleware sau în Store.dispatch după apelarea Reducer. Dacă Reducer este poluat cu efecte secundare, MVI pierde testabilitatea și predictibilitatea — obțineți MVVM cu o structură suplimentară fără avantaje.
Greșeli tipice — declararea State ca data class cu câmpuri nullable în loc de sealed class: data class UserState(val user: User?, val isLoading: Boolean, val error: String?). Acesta este echivalentul MVVM, nu MVI — View trebuie să verifice combinațiile de câmpuri pentru validitate. În abordarea sealed, combinațiile invalide (isLoading=true și user!=null) sunt imposibile la nivel de tipuri. A doua greșeală — plasarea logicii de afaceri în Intent (Intent.LoadUserBeforeXHours) în loc să creați Intent-uri de comandă simple (Intent.LoadUser), iar logica de afaceri — în Middleware.
Întrebări frecvente
MVI folosește o singură clasă State sealed imutabilă și un flux de date unidirectional prin Reducer. MVVM permite multiple LiveData/StateFlow cu legătură bidirecțională. MVI garantează consistența stării la nivel de tipuri — este imposibil să obțineți loading=true și user=null simultan. MVVM se bazează pe disciplina dezvoltatorului.
Principalele: MVIKotlin (Arkadii Ivanov, 2,5K stele, Kotlin Multiplatform), Orbit MVI (BabyJ, 1,3K stele), Mobius (Spotify, Kotlin/Java). MVIKotlin — cel mai popular pentru Kotlin, Orbit — cel mai simplu de învățat. Toate trei suportă Reducer și Middleware testabile. Pentru Jetpack Compose este suficient să scrieți un MVI simplu fără bibliotecă prin sealed State + Reducer.
Nu — sealed Intent + sealed State + ViewModel + StateFlow oferă un MVI funcțional fără dependențe. Bibliotecile (MVIKotlin, Orbit, TCA) adaugă Middleware, testarea efectelor secundare și integrarea cu DI. Pentru proiecte simple, greutatea bibliotecii este nejustificată. Pentru proiecte complexe cu 20+ ecrane, biblioteca se justifică prin procesarea structurată a efectelor.
MVI este perfect potrivit pentru iOS prin TCA (The Composable Architecture) — cea mai populară arhitectură a comunității SwiftUI. TCA este de fapt MVI + Redux + Combine. Pe iOS se poate implementa MVI și fără TCA prin ObservableObject și o funcție reducer pură. SwiftUI cu State imutabil se potrivește perfect cu ciclul MVI.
Reducer se testează cu teste unitare ca funcție pură: se dă un State inițial, se trimite un Intent, se verifică State-ul final. Middleware se testează cu un repository mock: se verifică că după LoadUser a fost apelat getUser. Test ViewModel: trimiteți Intent, verificați StateFlow. MVI se testează mai ușor decât MVVM, deoarece Reducer este o funcție pură fără dependențe ascunse.
Concluzii
Vom dezvolta o aplicație mobilă la cheie
IT Sectr creează aplicații iOS și Android pentru startup-uri și afaceri din 2017. Vă vom consilia și vă vom propune cea mai bună soluție.
Citiți și