MVI (Model-View-Intent) — ένα αντιδραστικό αρχιτεκτονικό πρότυπο που βασίζεται στη μονόδρομη ροή δεδομένων και σε αμετάβλητη κατάσταση. Σε αντίθεση με το MVVM, όπου το ViewModel μπορεί να έχει πολλά StateFlow, το MVI ορίζει μία ενιαία κατάσταση (State), αμετάβλητες προθέσεις (Intent) και μια καθαρή συνάρτηση αναγωγής (Reducer). Το MVI εγγυάται την προβλεψιμότητα της κατάστασης της οθόνης ανά πάσα στιγμή. Το πρότυπο διαδόθηκε στην κοινότητα Android από τις βιβλιοθήκες Mosby και Orbit. Περισσότερα — στο MVIKotlin από τον Arkadii Ivanov.
Κύρια
MVI (Model-View-Intent) — ένα αντιδραστικό αρχιτεκτονικό πρότυπο χτισμένο στις αρχές των Redux και Cycle.js. Model — η αμετάβλητη κατάσταση οθόνης, Intent — η πρόθεση του χρήστη ή του συστήματος, View — εγγραφή στην κατάσταση και αποστολή Intent. Τα δεδομένα κινούνται σε έναν κύκλο: ο χρήστης αλληλεπιδρά με το View → το View δημιουργεί ένα Intent → το Intent επεξεργάζεται από τον Reducer → ο Reducer δημιουργεί μια νέα κατάσταση → το View λαμβάνει τη νέα κατάσταση και ανασχεδιάζεται.
Η κύρια διαφορά του MVI από το MVVM — μία μοναδική πηγή αλήθειας (Single Source of Truth). Στο MVVM, το ViewModel μπορεί να έχει πολλαπλά LiveData/StateFlow (userState, loadingState, errorState), που οδηγεί σε ασυνέπεια: loading=true και user=null ταυτόχρονα. Στο MVI υπάρχει ακριβώς μία sealed class/interface State που περιγράφει ολόκληρη την κατάσταση της οθόνης. Ανά πάσα στιγμή, η κατάσταση οθόνης είναι μοναδικά καθορισμένη — είναι αδύνατο να λάβετε loading=true όταν τα δεδομένα έχουν ήδη φορτωθεί. Στην IT Sectr εφαρμόζουμε MVI για οθόνες με σύνθετη λογική — φόρμες παραγγελίας, εγγραφές πολλαπλών βημάτων, οικονομικές οθόνες — όπου η προβλεψιμότητα της κατάστασης είναι κρίσιμη.
| Στοιχείο | Ρόλος στο MVI | Παράδειγμα |
|---|---|---|
| Intent | Πρόθεση χρήστη ή συστήματος | LoadUser, Refresh, SubmitForm |
| State | Αμετάβλητη κατάσταση οθόνης | sealed class UserState |
| Reducer | Καθαρή συνάρτηση: State + Intent → State | fun reduce(state, intent) -> state |
| Middleware | Επεξεργασία παρενεργειών | Αίτημα δικτύου, εγγραφή σε ΒΔ |
Κύκλος MVI αποτελείται από πέντε βήματα: 1) Το View στέλνει ένα Intent (π.χ. LoadUser(42)); 2) Το Middleware (EffectHandler) εκτελεί την παρενέργεια — αίτημα δικτύου; 3) Το αποτέλεσμα επιστρέφει ως νέο Intent στο σύστημα; 4) Ο Reducer λαμβάνει την τρέχουσα κατάσταση και το Intent, δημιουργεί νέα κατάσταση; 5) Το View λαμβάνει τη νέα κατάσταση και ανασχεδιάζεται. Κάθε βήμα είναι προβλέψιμο και δοκιμάζεται μεμονωμένα.
MVI στο Android υλοποιείται μέσω sealed-κλάσεων για Intent και State, ViewModel με λογική MVI και Jetpack Compose για αντιδραστική προβολή. Το ViewModel λαμβάνει Intent από το View, αναθέτει παρενέργειες στο Middleware, εκτελεί τον Reducer και δημοσιεύει τη νέα κατάσταση μέσω StateFlow. Το Jetpack Compose ανασχεδιάζει το UI όταν αλλάζει το state — ιδανικό για τον κύκλο MVI.
// Intent — προθέσεις χρήστη
sealed interface UserIntent {
data class LoadUser(val userId: Int) : UserIntent
data object Refresh : UserIntent
}
// State — ενιαία κατάσταση οθόνης
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 — καθαρή συνάρτηση
object UserReducer {
fun reduce(state: UserState, intent: UserIntent): UserState = when (intent) {
is UserIntent.LoadUser -> UserState.Loading
is UserIntent.Refresh -> UserState.Loading
}
}
// ViewModel με 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 */)
}
}
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 στέλνει 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 και παρενέργειες — στο MVI, ο καθαρός Reducer δεν μπορεί να εκτελεί αιτήματα δικτύου. Το Middleware (επίσης EffectHandler ή Bootstrapper) επεξεργάζεται το Intent, εκτελεί την παρενέργεια και εκπέμπει ένα νέο Intent πίσω στον κύκλο. Οι βιβλιοθήκες Orbit MVI και MVIKotlin παρέχουν ενσωματωμένη υποστήριξη για Middleware με δοκιμάσιμα εφέ. Χωρίς Middleware, το MVI εκφυλίζεται σε MVVM με πρόσθετη δομή Intent και State.
MVIKotlin από τον Arkadii Ivanov — η πιο δημοφιλής βιβλιοθήκη MVI για Kotlin Multiplatform. Υποστηρίζει Android, iOS, web και JVM. Παρέχει στοιχεία: Store (ViewModel), Bootstrapper (αρχικά εφέ), Reducer, Middleware. Μέχρι τον Οκτώβριο του 2025, η βιβλιοθήκη έχει συγκεντρώσει 2,5K αστέρια στο GitHub και χρησιμοποιείται σε εμπορικά έργα, συμπεριλαμβανομένων εφαρμογών μεγάλων ρωσικών τραπεζών. Στην IT Sectr χρησιμοποιούμε το MVIKotlin για cross-platform έργα KMP με κοινή επιχειρηματική λογική.
MVI στο iOS υλοποιείται χωρίς Combine-ViewModel, μέσω του κύκλου Intent → State. Το View στέλνει Intent μέσω κλεισίματος (closure), ο Reducer — καθαρή συνάρτηση, το State — struct με αμετάβλητα πεδία. Το SwiftUI ανασχεδιάζει το View όταν αλλάζει το State, που ταιριάζει απόλυτα στον κύκλο MVI χωρίς πρόσθετες ιδιότητες @Published. Το MVI στο iOS είναι ιδιαίτερα δημοφιλές στην κοινότητα προγραμματιστών SwiftUI που μεταπήδησαν από το Redux (JavaScript).
// State — αμετάβλητη δομή
struct UserState: Equatable {
var user: User?
var isLoading = false
var errorMessage: String?
}
// Intent — enum με προθέσεις
enum UserIntent {
case loadUser(id: Int)
case refresh
case userLoaded(User)
case loadFailed(Error)
}
// Reducer — καθαρή συνάρτηση
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 — κατέχει την κατάσταση και διαχειρίζεται εφέ
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 ενημερώνει την κατάσταση
state = userReducer(state: state, intent: intent)
// 2. Side effects (αν χρειάζεται)
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) — η πιο δημοφιλής υλοποίηση MVI για iOS από την Point-Free, χτισμένη σε SwiftUI και Combine. Η TCA παρέχει Store, Reducer, Effect και Environment. Μέχρι τον Οκτώβριο του 2025, ο αριθμός αστεριών στο GitHub ξεπερνά τα 13K — αυτό είναι το de facto πρότυπο για MVI στο iOS. Η TCA χρησιμοποιείται σε εφαρμογές των Starbucks, Airbnb (μερικώς) και πολλά indie έργα. Σε αντίθεση με το χειροποίητο MVI, η TCA λύνει θέματα δοκιμών, πλοήγησης και παρενεργειών έτοιμα.
MVI εναντίον MVVM στο iOS — TCA/MVI παρέχει προβλεψιμότητα κατάστασης, αλλά απαιτεί περισσότερο κώδικα προτύπου (Reducer, State, Action). Το MVVM με @Published είναι απλούστερο για απλές οθόνες. Εμείς στην IT Sectr χρησιμοποιούμε MVVM για το 80% των οθονών και MVI (TCA) για το 20% των σύνθετων — οικονομικές συναλλαγές, φόρμες πολλαπλών βημάτων, διεπαφές drag-and-drop, όπου ένα σφάλμα κατάστασης μπορεί να κοστίσει χρήματα στον χρήστη.
MVI και MVVM λύνουν την ίδια εργασία — την οργάνωση του επιπέδου Presentation — αλλά με διαφορετικές προσεγγίσεις στη διαχείριση κατάστασης. Το MVVM επιτρέπει πολλαπλές αντιδραστικές πηγές (LiveData, @Published), που μπορεί να οδηγήσει σε ασυνέπεια. Το MVI εγγυάται ακριβώς μία κατάσταση κάθε στιγμή, καθιστώντας το πιο αυστηρό και προβλέψιμο, αλλά αυξάνει τον όγκο κώδικα.
| Κριτήριο | MVVM | MVI |
|---|---|---|
| Κατάσταση | Πολλαπλά LiveData/StateFlow | Μία sealed class State |
| Ροή δεδομένων | Αμφίδρομη (View → ViewModel, LiveData → View) | Μονόδρομη (Intent → Reducer → State → View) |
| Παρενέργειες | Απευθείας στο ViewModel | Μέσω Middleware/EffectHandler |
| Δοκιμές | Unit tests ViewModel | Unit tests Reducer + Middleware |
| Κώδικας προτύπου | Ελάχιστος | Reducer + State + Intent + Middleware |
Πότε να επιλέξετε MVI — οθόνες όπου η κατάσταση πρέπει να είναι αυστηρά ντετερμινιστική: οικονομικές λειτουργίες, καλάθι ηλεκτρονικού καταστήματος, φόρμες πολλαπλών βημάτων με επικύρωση σε κάθε βήμα. Σε αυτά τα σενάρια, το κόστος ενός σφάλματος κατάστασης (π.χ. εμφάνιση ποσού καλαθιού χωρίς ένα προϊόν λόγω ανταγωνισμού δύο LiveData) είναι υψηλότερο από το κόστος του πρόσθετου κώδικα. Στο MVVM βασίζεστε στην πειθαρχία της ομάδας, στο MVI — στην αρχιτεκτονική.
Πότε το MVVM είναι αρκετό — 80% των τυπικών οθονών: λίστα χρηστών, προφίλ, ρυθμίσεις, ροή ειδήσεων. Εδώ η μοναδική κατάσταση είναι περιττή και η πρόσθετη δομή MVI θα επιβραδύνει την ανάπτυξη. Στην IT Sectr ο κανόνας είναι: αν η οθόνη έχει 3+ πιθανές καταστάσεις με μεταβάσεις (φόρτωση → δεδομένα → σφάλμα → επανάληψη → φόρτωση → δεδομένα) — MVI. Αν η οθόνη έχει 1-2 ασύγχρονες λειτουργίες — MVVM.
Sealed State — βέλτιστη πρακτική του MVI. Η κατάσταση ορίζεται ως sealed class/interface με παραλλαγές Loading, Success(data), Error(message). Αυτό εγγυάται ότι το View δεν θα περιέλθει σε ασυνεπή κατάσταση — δεν μπορούν να εμφανιστούν δεδομένα με loading=true, επειδή τα Loading και Success είναι διαφορετικές κλάσεις. Όλα τα δεδομένα που σχετίζονται με την κατάσταση βρίσκονται εντός της sealed παραλλαγής: το Success περιέχει τον χρήστη, το Error — το μήνυμα σφάλματος.
Ο Reducer πρέπει να παραμείνει καθαρή συνάρτηση — χωρίς κλήσεις API, ΒΔ, SharedPreferences. Η καθαρή συνάρτηση δέχεται State και Intent, επιστρέφει State. Οι παρενέργειες (δίκτυο, ΒΔ, πλοήγηση, toast) επεξεργάζονται στο Middleware ή στο Store.dispatch μετά την κλήση του Reducer. Αν ο Reducer μολυνθεί με παρενέργειες, το MVI χάνει τη δυνατότητα δοκιμής και προβλεψιμότητας — παίρνετε MVVM με πρόσθετη δομή χωρίς πλεονεκτήματα.
Τυπικά λάθη — δήλωση του State ως data class με nullable πεδία αντί για sealed class: data class UserState(val user: User?, val isLoading: Boolean, val error: String?). Αυτό είναι το ισοδύναμο του MVVM, όχι MVI — το View πρέπει να ελέγχει συνδυασμούς πεδίων για εγκυρότητα. Στην προσέγγιση sealed, οι μη έγκυροι συνδυασμοί (isLoading=true και user!=null) είναι αδύνατοι σε επίπεδο τύπου. Δεύτερο λάθος — τοποθέτηση επιχειρηματικής λογικής στο Intent (Intent.LoadUserBeforeXHours) αντί να δημιουργείτε απλά εντολικά Intent (Intent.LoadUser) και την επιχειρηματική λογική — στο Middleware.
Συχνές Ερωτήσεις
Το MVI χρησιμοποιεί μία μοναδική αμετάβλητη sealed κλάση State και μονόδρομη ροή δεδομένων μέσω Reducer. Το MVVM επιτρέπει πολλαπλά LiveData/StateFlow με αμφίδρομη σύνδεση. Το MVI εγγυάται συνέπεια κατάστασης σε επίπεδο τύπου — είναι αδύνατο να λάβετε loading=true και user=null ταυτόχρονα. Το MVVM βασίζεται στην πειθαρχία του προγραμματιστή.
Κύριες: MVIKotlin (Arkadii Ivanov, 2,5K αστέρια, Kotlin Multiplatform), Orbit MVI (BabyJ, 1,3K αστέρια), Mobius (Spotify, Kotlin/Java). Το MVIKotlin είναι το πιο δημοφιλές για Kotlin, το Orbit το πιο απλό για εκμάθηση. Και οι τρεις υποστηρίζουν δοκιμάσιμο Reducer και Middleware. Για Jetpack Compose αρκεί να γράψετε ένα απλό MVI χωρίς βιβλιοθήκη μέσω sealed State + Reducer.
Όχι — sealed Intent + sealed State + ViewModel + StateFlow δίνουν ένα λειτουργικό MVI χωρίς εξαρτήσεις. Οι βιβλιοθήκες (MVIKotlin, Orbit, TCA) προσθέτουν Middleware, δοκιμές παρενεργειών και ενσωμάτωση με DI. Για απλά έργα, το βάρος της βιβλιοθήκης είναι αδικαιολόγητο. Για σύνθετα έργα με 20+ οθόνες, η βιβλιοθήκη αποδίδει με δομημένη επεξεργασία εφέ.
Το MVI είναι εξαιρετικά κατάλληλο για iOS μέσω TCA (The Composable Architecture) — της πιο δημοφιλούς αρχιτεκτονικής της κοινότητας SwiftUI. Η TCA είναι στην πραγματικότητα MVI + Redux + Combine. Στο iOS, το MVI μπορεί να υλοποιηθεί και χωρίς TCA μέσω ObservableObject και καθαρής συνάρτησης reducer. Το SwiftUI με αμετάβλητο State ταιριάζει απόλυτα στον κύκλο MVI.
Ο Reducer δοκιμάζεται με unit tests ως καθαρή συνάρτηση: δίνεται αρχικό State, στέλνεται Intent, ελέγχεται το τελικό State. Το Middleware δοκιμάζεται με mock-αποθετήριο: ελέγχεται ότι μετά το LoadUser κλήθηκε το getUser. Δοκιμή ViewModel: στείλτε Intent, ελέγξτε το StateFlow. Το MVI δοκιμάζεται ευκολότερα από το MVVM, επειδή ο Reducer είναι καθαρή συνάρτηση χωρίς κρυφές εξαρτήσεις.
Σύνοψη
Θα αναπτύξουμε μια εφαρμογή για κινητά έτοιμη για χρήση
Η IT Sectr δημιουργεί εφαρμογές iOS και Android για νεοφυείς επιχειρήσεις και επιχειρήσεις από το 2017. Θα σας συμβουλεύσουμε και θα προτείνουμε την καλύτερη λύση.
Διαβάστε επίσης