MVI (Model-View-Intent) — reaktywny wzorzec architektoniczny oparty na jednokierunkowym przepływie danych i niezmiennym stanie. W przeciwieństwie do MVVM, gdzie ViewModel może mieć wiele StateFlow, MVI definiuje jeden stan (State), niezmienne intencje (Intent) i czystą funkcję reduktora (Reducer). MVI gwarantuje przewidywalność stanu ekranu w każdym momencie. Wzorzec został spopularyzowany w społeczności Android przez biblioteki Mosby i Orbit. Więcej — w MVIKotlin od Arkadii Ivanov.
Najważniejsze
MVI (Model-View-Intent) — reaktywny wzorzec architektoniczny zbudowany na zasadach Redux i Cycle.js. Model — niezmienny stan ekranu, Intent — intencja użytkownika lub systemu, View — subskrypcja stanu i wysyłanie Intent. Dane płyną w cyklu: użytkownik wchodzi w interakcję z View → View tworzy Intent → Intent jest przetwarzany przez Reducer → Reducer tworzy nowy stan → View otrzymuje nowy stan i przerysowuje się.
Główna różnica między MVI a MVVM — pojedyncze źródło prawdy (Single Source of Truth). W MVVM ViewModel może mieć wiele LiveData/StateFlow (userState, loadingState, errorState), co prowadzi do niespójności: loading=true i user=null jednocześnie. W MVI istnieje dokładnie jedna sealed class/interface State, opisująca cały stan ekranu. W każdym momencie stan ekranu jest jednoznacznie określony — nie można otrzymać loading=true przy już załadowanych danych. W IT Sectr stosujemy MVI dla ekranów ze złożoną logiką — formularze zamówień, rejestracje wieloetapowe, ekrany finansowe — gdzie przewidywalność stanu jest krytyczna.
| Komponent | Rola w MVI | Przykład |
|---|---|---|
| Intent | Intencja użytkownika lub systemu | LoadUser, Refresh, SubmitForm |
| State | Niezmienny stan ekranu | sealed class UserState |
| Reducer | Czysta funkcja: State + Intent → State | fun reduce(state, intent) -> state |
| Middleware | Obsługa side effects | Żądanie sieciowe, zapis do BD |
Cykl MVI składa się z pięciu kroków: 1) View wysyła Intent (np. LoadUser(42)); 2) Middleware (EffectHandler) wykonuje side effect — żądanie sieciowe; 3) Wynik wraca jako nowy Intent do systemu; 4) Reducer przyjmuje bieżący stan i Intent, tworzy nowy stan; 5) View otrzymuje nowy stan i przerysowuje się. Każdy krok jest przewidywalny i testowany w izolacji.
MVI na Android jest implementowany przez sealed-klasy dla Intent i State, ViewModel z logiką MVI i Jetpack Compose do reaktywnego wyświetlania. ViewModel przyjmuje Intent z View, deleguje side effects do Middleware, uruchamia Reducer i publikuje nowy stan przez StateFlow. Jetpack Compose przerysowuje UI przy zmianie state — idealnie do cyklu MVI.
// Intent — intencje użytkownika
sealed interface UserIntent {
data class LoadUser(val userId: Int) : UserIntent
data object Refresh : UserIntent
}
// State — jednolity stan ekranu
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 — czysta funkcja
object UserReducer {
fun reduce(state: UserState, intent: UserIntent): UserState = when (intent) {
is UserIntent.LoadUser -> UserState.Loading
is UserIntent.Refresh -> UserState.Loading
}
}
// ViewModel z 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(/* poprzednie ID */)
}
}
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 wysyła 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 side effects — w MVI czysty Reducer nie może wykonywać żądań sieciowych. Middleware (również EffectHandler lub Bootstrapper) przetwarza Intent, wykonuje side effect i emituje nowy Intent z powrotem do cyklu. Biblioteki Orbit MVI i MVIKotlin zapewniają wbudowaną obsługę Middleware z testowalnymi efektami. Bez Middleware MVI degeneruje się do MVVM z dodatkową strukturą Intent i State.
MVIKotlin od Arkadii Ivanov — najpopularniejsza biblioteka MVI dla Kotlin Multiplatform. Obsługuje Android, iOS, web i JVM. Dostarcza komponenty: Store (ViewModel), Bootstrapper (efekty początkowe), Reducer, Middleware. Na październik 2025 biblioteka zebrała 2,5K gwiazdek na GitHub i jest używana w projektach komercyjnych, w tym aplikacjach dużych banków w Rosji. W IT Sectr używamy MVIKotlin do projektów cross-platform KMP ze wspólną logiką biznesową.
MVI na iOS jest implementowany bez Combine-ViewModel, przez cykl Intent → State. View wysyła Intent przez domknięcie, Reducer — czysta funkcja, State — struct z niezmiennymi polami. SwiftUI przerysowuje View przy zmianie State, co idealnie pasuje do cyklu MVI bez dodatkowych właściwości @Published. MVI na iOS jest szczególnie popularny w społeczności deweloperów SwiftUI, którzy przeszli z Redux (JavaScript).
// State — niezmienna struktura
struct UserState: Equatable {
var user: User?
var isLoading = false
var errorMessage: String?
}
// Intent — enum z intencjami
enum UserIntent {
case loadUser(id: Int)
case refresh
case userLoaded(User)
case loadFailed(Error)
}
// Reducer — czysta funkcja
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 — posiada stan i zarządza efektami
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 aktualizuje stan
state = userReducer(state: state, intent: intent)
// 2. Side effects (jeśli potrzebne)
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) — najpopularniejsza implementacja MVI dla iOS od Point-Free, zbudowana na SwiftUI i Combine. TCA dostarcza Store, Reducer, Effect i Environment. Na październik 2025 liczba gwiazdek na GitHub przekracza 13K — to de facto standard dla MVI na iOS. TCA jest używane w aplikacjach Starbucks, Airbnb (częściowo) i wielu projektach indie. W przeciwieństwie do własnoręcznie napisanego MVI, TCA rozwiązuje kwestie testowania, nawigacji i side effects od razu.
MVI vs MVVM na iOS — TCA/MVI zapewnia przewidywalność stanu, ale wymaga więcej kodu szablonowego (Reducer, State, Action). MVVM z @Published jest prostsze dla prostych ekranów. W IT Sectr używamy MVVM dla 80% ekranów i MVI (TCA) dla 20% złożonych — transakcje finansowe, formularze wieloetapowe, interfejsy drag-and-drop, gdzie błąd stanu może kosztować pieniądze użytkownika.
MVI i MVVM rozwiązują to samo zadanie — organizację warstwy Presentation — ale z różnym podejściem do zarządzania stanem. MVVM dopuszcza wiele reaktywnych źródeł (LiveData, @Published), co może prowadzić do niespójności. MVI gwarantuje dokładnie jeden stan w każdym momencie, co czyni go bardziej rygorystycznym i przewidywalnym, ale zwiększa objętość kodu.
| Kryterium | MVVM | MVI |
|---|---|---|
| Stan | Wiele LiveData/StateFlow | Pojedyncza sealed class State |
| Przepływ danych | Dwukierunkowy (View → ViewModel, LiveData → View) | Jednokierunkowy (Intent → Reducer → State → View) |
| Side effects | Bezpośrednio w ViewModel | Przez Middleware/EffectHandler |
| Testowanie | Testy jednostkowe ViewModel | Testy jednostkowe Reducer + Middleware |
| Kod szablonowy | Minimalny | Reducer + State + Intent + Middleware |
Kiedy wybrać MVI — ekrany, gdzie stan musi być ściśle deterministyczny: operacje finansowe, koszyk sklepu internetowego, formularze wieloetapowe z walidacją na każdym kroku. W tych scenariuszach koszt błędu stanu (np. pokazanie sumy koszyka bez jednego produktu z powodu wyścigu dwóch LiveData) przewyższa koszt dodatkowego kodu. W MVVM polegasz na dyscyplinie zespołu, w MVI — na architekturze.
Kiedy MVVM wystarcza — 80% standardowych ekranów: lista użytkowników, profil, ustawienia, kanał newsów. Tutaj pojedynczy stan jest nadmiarowy, a dodatkowa struktura MVI spowolni rozwój. W IT Sectr zasada jest taka: jeśli ekran ma 3+ możliwych stanów z przejściami (ładowanie → dane → błąd → spróbuj ponownie → ładowanie → dane) — MVI. Jeśli ekran ma 1-2 operacje asynchroniczne — MVVM.
Sealed State — najlepsza praktyka MVI. Stan jest definiowany jako sealed class/interface z wariantami Loading, Success(data), Error(message). To gwarantuje, że View nie trafi w niespójny stan — nie można wyświetlić danych przy loading=true, ponieważ Loading i Success to różne klasy. Wszystkie dane dotyczące stanu znajdują się wewnątrz wariantu sealed: Success zawiera użytkownika, Error — komunikat o błędzie.
Reducer musi pozostać czystą funkcją — bez wywołań API, BD, SharedPreferences. Czysta funkcja przyjmuje State i Intent, zwraca State. Efekty uboczne (sieć, BD, nawigacja, toasty) są obsługiwane w Middleware lub w Store.dispatch po wywołaniu Reducer. Jeśli Reducer jest zanieczyszczony efektami ubocznymi, MVI traci testowalność i przewidywalność — otrzymujesz MVVM z dodatkową strukturą bez zalet.
Typowe błędy — deklarowanie State jako data class z nullable polami zamiast sealed class: data class UserState(val user: User?, val isLoading: Boolean, val error: String?). To odpowiednik MVVM, a nie MVI — View musi sprawdzać kombinacje pól pod kątem poprawności. W podejściu sealed nieprawidłowe kombinacje (isLoading=true i user!=null) są niemożliwe na poziomie typów. Drugi błąd — umieszczanie logiki biznesowej w Intent (Intent.LoadUserBeforeXHours) zamiast tworzenia prostych komend Intent (Intent.LoadUser), a logikę biznesową — w Middleware.
Często zadawane pytania
MVI używa pojedynczej niezmiennej sealed klasy State i jednokierunkowego przepływu danych przez Reducer. MVVM dopuszcza wiele LiveData/StateFlow z dwukierunkowym powiązaniem. MVI gwarantuje spójność stanu na poziomie typów — nie można otrzymać loading=true i user=null jednocześnie. MVVM polega na dyscyplinie programisty.
Główne: MVIKotlin (Arkadii Ivanov, 2,5K gwiazdek, Kotlin Multiplatform), Orbit MVI (BabyJ, 1,3K gwiazdek), Mobius (Spotify, Kotlin/Java). MVIKotlin — najpopularniejszy dla Kotlin, Orbit — najprostszy do nauki. Wszystkie trzy obsługują testowalne Reducer i Middleware. Dla Jetpack Compose wystarczy napisać prosty MVI bez biblioteki przez sealed State + Reducer.
Nie — sealed Intent + sealed State + ViewModel + StateFlow dają działające MVI bez zależności. Biblioteki (MVIKotlin, Orbit, TCA) dodają Middleware, testowanie side effects i integrację z DI. Dla prostych projektów waga biblioteki jest nieuzasadniona. Dla złożonych projektów z 20+ ekranami biblioteka zwraca się poprzez strukturalne przetwarzanie efektów.
MVI doskonale nadaje się na iOS przez TCA (The Composable Architecture) — najpopularniejszą architekturę społeczności SwiftUI. TCA to w zasadzie MVI + Redux + Combine. Na iOS można zaimplementować MVI również bez TCA przez ObservableObject i czystą funkcję reducer. SwiftUI z immutable State idealnie pasuje do cyklu MVI.
Reducer testuje się testami jednostkowymi jako czystą funkcję: zadaje się początkowy State, wysyła Intent, sprawdza końcowy State. Middleware testuje się z mock-repozytorium: sprawdza się, że po LoadUser został wywołany getUser. ViewModel-test: wysłać Intent, sprawdzić StateFlow. MVI testuje się łatwiej niż MVVM, ponieważ Reducer to czysta funkcja bez ukrytych zależności.
Podsumowanie
Opracujemy aplikację mobilną pod klucz
IT Sectr tworzy aplikacje na iOS i Androida dla startupów i firm od 2017 roku. Doradzimy Ci i zaproponujemy najlepsze rozwiązanie.
Przeczytaj również