MVI — istota wzorca Model-View-Intent w aplikacjach mobilnych

Autor: IT Sectr Opublikowano: 2026-02-16 Czas czytania: 10 min

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 (stan), View (widok), Intent (intencja użytkownika) — cykl reaktywny
  • Unidirectional data flow — dane płyną w jednym kierunku: Intent → Reducer → State → View
  • Immutable State — stan ekranu — niezmienny obiekt, odtwarzany przy każdej zmianie
  • Reducer — czysta funkcja, przyjmująca bieżący stan i Intent, zwracająca nowy stan
  • Side effects — efekty uboczne (sieć, BD) są obsługiwane oddzielnie od Reducer, przez Middleware

Czym jest MVI: istota wzorca Model-View-Intent

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.

KomponentRola w MVIPrzykład
IntentIntencja użytkownika lub systemuLoadUser, Refresh, SubmitForm
StateNiezmienny stan ekranusealed class UserState
ReducerCzysta funkcja: State + Intent → Statefun reduce(state, intent) -> state
MiddlewareObsł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 w Android: Intent, Reducer, State w Kotlin

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.

kotlin
// 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 w iOS: jednokierunkowy przepływ w Swift

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).

swift
// 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.

Porównanie MVI z MVVM: kiedy wybrać MVI

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.

KryteriumMVVMMVI
StanWiele LiveData/StateFlowPojedyncza sealed class State
Przepływ danychDwukierunkowy (View → ViewModel, LiveData → View)Jednokierunkowy (Intent → Reducer → State → View)
Side effectsBezpośrednio w ViewModelPrzez Middleware/EffectHandler
TestowanieTesty jednostkowe ViewModelTesty jednostkowe Reducer + Middleware
Kod szablonowyMinimalnyReducer + 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.

Najlepsze praktyki MVI i typowe błędy

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

Jaka jest główna różnica między MVI a MVVM?

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.

Jakie biblioteki MVI istnieją dla Android?

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.

Czy potrzebna jest osobna biblioteka do MVI?

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.

Czy MVI nadaje się na iOS, czy to tylko wzorzec Android?

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.

Jak testować 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

  • MVI (Model-View-Intent) — wzorzec reaktywny z jednokierunkowym przepływem i pojedynczym stanem
  • Sealed State — gwarantuje spójność na poziomie typów, wykluczając nieprawidłowe kombinacje
  • Reducer — czysta funkcja State + Intent → State, testowana bez mock-obiektów
  • Middleware — osobna warstwa dla side effects (sieć, BD, nawigacja)
  • MVI vs MVVM — MVI jest bardziej rygorystyczny i przewidywalny, MVVM prostszy i szybszy
  • Android — MVIKotlin lub Orbit dla złożonych ekranów; MVVM dla prostych
  • iOS — TCA (The Composable Architecture) — standard MVI na SwiftUI

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.

Omów projekt

Przeczytaj również