MVI (Model-View-Intent) là một mẫu kiến trúc phản ứng dựa trên luồng dữ liệu đơn hướng và trạng thái bất biến. Không giống như MVVM, nơi ViewModel có thể có nhiều StateFlows, MVI định nghĩa một trạng thái duy nhất (State), các ý định bất biến (Intent) và một hàm rút gọn thuần túy (Reducer). MVI đảm bảo khả năng dự đoán trạng thái màn hình tại bất kỳ thời điểm nào. Mẫu này đã được phổ biến trong cộng đồng Android bởi các thư viện Mosby và Orbit. Tìm hiểu thêm trong MVIKotlin của Arkadii Ivanov.
Những Điểm Chính
MVI (Model-View-Intent) là một mẫu kiến trúc phản ứng được xây dựng trên các nguyên tắc của Redux và Cycle.js. Model là trạng thái màn hình bất biến, Intent là ý định của người dùng hoặc hệ thống, View đăng ký trạng thái và gửi Intents. Dữ liệu chảy theo một chu trình: người dùng tương tác với View → View tạo Intent → Intent được xử lý bởi Reducer → Reducer tạo trạng thái mới → View nhận trạng thái mới và hiển thị lại.
Sự khác biệt chính giữa MVI và MVVM là Nguồn Chân lý Duy nhất. Trong MVVM, một ViewModel có thể có nhiều LiveData/StateFlow (userState, loadingState, errorState), dẫn đến sự không nhất quán: loading=true và user=null đồng thời. Trong MVI, có chính xác một sealed class/interface State mô tả toàn bộ trạng thái màn hình. Tại bất kỳ thời điểm nào, trạng thái màn hình được xác định duy nhất — không thể có loading=true khi dữ liệu đã được tải. Tại IT Sectr, chúng tôi áp dụng MVI cho các màn hình có logic phức tạp — biểu mẫu đặt hàng, đăng ký nhiều bước, màn hình tài chính — nơi khả năng dự đoán trạng thái là rất quan trọng.
| Thành phần | Vai trò trong MVI | Ví dụ |
|---|---|---|
| Intent | Ý định của người dùng hoặc hệ thống | LoadUser, Refresh, SubmitForm |
| State | Trạng thái màn hình bất biến | sealed class UserState |
| Reducer | Hàm thuần túy: State + Intent → State | fun reduce(state, intent) -> state |
| Middleware | Xử lý hiệu ứng phụ | Yêu cầu mạng, ghi DB |
Chu trình MVI bao gồm năm bước: 1) View gửi Intent (ví dụ: LoadUser(42)); 2) Middleware (EffectHandler) thực hiện hiệu ứng phụ — một yêu cầu mạng; 3) Kết quả được trả về như một Intent mới vào hệ thống; 4) Reducer nhận trạng thái hiện tại và Intent, tạo trạng thái mới; 5) View nhận trạng thái mới và hiển thị lại. Mỗi bước đều có thể dự đoán và có thể kiểm tra độc lập.
MVI trên Android được triển khai bằng sealed classes cho Intent và State, ViewModel với logic MVI và Jetpack Compose để hiển thị phản ứng. ViewModel nhận Intent từ View, ủy thác hiệu ứng phụ cho Middleware, chạy Reducer và công bố trạng thái mới qua StateFlow. Jetpack Compose hiển thị lại UI khi trạng thái thay đổi — lý tưởng cho chu trình MVI.
// Intent — ý định người dùng
sealed interface UserIntent {
data class LoadUser(val userId: Int) : UserIntent
data object Refresh : UserIntent
}
// State — trạng thái màn hình duy nhất
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 — hàm thuần túy
object UserReducer {
fun reduce(state: UserState, intent: UserIntent): UserState = when (intent) {
is UserIntent.LoadUser -> UserState.Loading
is UserIntent.Refresh -> UserState.Loading
}
}
// ViewModel với 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 trước đó */)
}
}
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 gửi 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 và hiệu ứng phụ — trong MVI, một Reducer thuần túy không thể thực hiện yêu cầu mạng. Middleware (cũng được gọi là EffectHandler hoặc Bootstrapper) xử lý Intent, thực hiện hiệu ứng phụ và phát ra một Intent mới trở lại chu trình. Các thư viện Orbit MVI và MVIKotlin cung cấp hỗ trợ Middleware tích hợp với các hiệu ứng có thể kiểm tra. Nếu không có Middleware, MVI sẽ thoái hóa thành MVVM với cấu trúc Intent và State bổ sung.
MVIKotlin của Arkadii Ivanov là thư viện MVI phổ biến nhất cho Kotlin Multiplatform. Nó hỗ trợ Android, iOS, web và JVM. Nó cung cấp các thành phần: Store (ViewModel), Bootstrapper (hiệu ứng ban đầu), Reducer, Middleware. Tính đến tháng 10 năm 2025, thư viện đã đạt 2,5K sao trên GitHub và được sử dụng trong các dự án thương mại, bao gồm các ứng dụng của các ngân hàng lớn của Nga. Tại IT Sectr, chúng tôi sử dụng MVIKotlin cho các dự án KMP đa nền tảng với logic kinh doanh dùng chung.
MVI trên iOS được triển khai không cần Combine-ViewModel, thông qua chu trình Intent → State. View gửi Intent qua một closure, Reducer là một hàm thuần túy, và State là một struct với các trường bất biến. SwiftUI hiển thị lại View khi State thay đổi, hoàn toàn phù hợp với chu trình MVI mà không cần thêm thuộc tính @Published. MVI trên iOS đặc biệt phổ biến trong các nhà phát triển SwiftUI đã chuyển từ Redux (JavaScript).
// State — cấu trúc bất biến
struct UserState: Equatable {
var user: User?
var isLoading = false
var errorMessage: String?
}
// Intent — enum với ý định
enum UserIntent {
case loadUser(id: Int)
case refresh
case userLoaded(User)
case loadFailed(Error)
}
// Reducer — hàm thuần túy
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 — sở hữu trạng thái và quản lý hiệu ứng
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 cập nhật trạng thái
state = userReducer(state: state, intent: intent)
// 2. Side effects (nếu cần)
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) là triển khai MVI phổ biến nhất cho iOS của Point-Free, được xây dựng trên SwiftUI và Combine. TCA cung cấp Store, Reducer, Effect và Environment. Tính đến tháng 10 năm 2025, số sao GitHub của nó vượt quá 13K — đây là tiêu chuẩn thực tế cho MVI trên iOS. TCA được sử dụng trong các ứng dụng Starbucks, Airbnb (một phần) và nhiều dự án độc lập. Không giống như MVI tùy chỉnh, TCA giải quyết vấn đề kiểm thử, điều hướng và hiệu ứng phụ ngay từ đầu.
MVI so với MVVM trên iOS — TCA/MVI cung cấp khả năng dự đoán trạng thái nhưng yêu cầu nhiều mã boilerplate hơn (Reducer, State, Action). MVVM với @Published đơn giản hơn cho các màn hình cơ bản. Tại IT Sectr, chúng tôi sử dụng MVVM cho 80% màn hình và MVI (TCA) cho 20% màn hình phức tạp — giao dịch tài chính, biểu mẫu nhiều bước, giao diện kéo-thả — nơi lỗi trạng thái có thể khiến người dùng mất tiền.
MVI và MVVM giải quyết cùng một vấn đề — tổ chức lớp Trình bày — nhưng với các cách tiếp cận khác nhau để quản lý trạng thái. MVVM cho phép nhiều nguồn phản ứng (LiveData, @Published), có thể dẫn đến sự không nhất quán. MVI đảm bảo chính xác một trạng thái tại bất kỳ thời điểm nào, làm cho nó chặt chẽ hơn và dự đoán được hơn, nhưng làm tăng khối lượng mã.
| Tiêu chí | MVVM | MVI |
|---|---|---|
| Trạng thái | Nhiều LiveData/StateFlow | Sealed class State duy nhất |
| Luồng dữ liệu | Hai chiều (View → ViewModel, LiveData → View) | Một chiều (Intent → Reducer → State → View) |
| Hiệu ứng phụ | Trực tiếp trong ViewModel | Qua Middleware/EffectHandler |
| Kiểm thử | Kiểm thử đơn vị ViewModel | Kiểm thử đơn vị Reducer + Middleware |
| Mã boilerplate | Tối thiểu | Reducer + State + Intent + Middleware |
Khi nào chọn MVI — các màn hình nơi trạng thái phải hoàn toàn xác định: giao dịch tài chính, giỏ hàng, biểu mẫu nhiều bước với xác thực ở mỗi bước. Trong các kịch bản này, chi phí của lỗi trạng thái (ví dụ: hiển thị tổng giỏ hàng thiếu một mặt hàng do điều kiện tranh chấp giữa hai LiveData) lớn hơn chi phí của mã bổ sung. Trong MVVM, bạn dựa vào kỷ luật của nhóm; trong MVI, bạn dựa vào kiến trúc.
Khi nào MVVM đủ — 80% màn hình tiêu chuẩn: danh sách người dùng, hồ sơ, cài đặt, bảng tin. Ở đây, một trạng thái duy nhất là quá mức cần thiết và cấu trúc MVI bổ sung sẽ làm chậm quá trình phát triển. Tại IT Sectr, quy tắc là: nếu một màn hình có 3+ trạng thái khả dĩ với các chuyển đổi (tải → dữ liệu → lỗi → thử lại → tải → dữ liệu) — sử dụng MVI. Nếu một màn hình có 1-2 thao tác bất đồng bộ — sử dụng MVVM.
Sealed State — thực hành tốt nhất trong MVI. Trạng thái được định nghĩa là sealed class/interface với các biến thể: Loading, Success(data), Error(message). Điều này đảm bảo rằng View sẽ không rơi vào trạng thái không nhất quán — không thể hiển thị dữ liệu khi loading=true vì Loading và Success là các lớp khác nhau. Tất cả dữ liệu liên quan đến trạng thái nằm bên trong biến thể sealed: Success chứa người dùng, Error chứa thông báo lỗi.
Reducer phải duy trì là một hàm thuần túy — không gọi API, DB hoặc SharedPreferences. Một hàm thuần túy nhận State và Intent và trả về State. Các hiệu ứng phụ (mạng, DB, điều hướng, toast) được xử lý trong Middleware hoặc trong Store.dispatch sau khi gọi Reducer. Nếu Reducer bị ô nhiễm bởi các hiệu ứng phụ, MVI mất khả năng kiểm thử và dự đoán — bạn nhận được MVVM với cấu trúc bổ sung nhưng không có lợi ích.
Các lỗi thường gặp — khai báo State là data class với các trường nullable thay vì sealed class: data class UserState(val user: User?, val isLoading: Boolean, val error: String?). Điều này tương đương với MVVM, không phải MVI — View phải kiểm tra tổ hợp các trường để xác định tính hợp lệ. Trong cách tiếp cận sealed, các tổ hợp không hợp lệ (isLoading=true và user!=null) là không thể ở cấp độ kiểu. Lỗi thứ hai là đặt logic kinh doanh trong Intent (Intent.LoadUserBeforeXHours) thay vì tạo các Intent lệnh đơn giản (Intent.LoadUser) và đặt logic kinh doanh trong Middleware.
Câu hỏi Thường gặp
MVI sử dụng một lớp sealed State bất biến duy nhất và luồng dữ liệu một chiều thông qua Reducer. MVVM cho phép nhiều LiveData/StateFlow với ràng buộc hai chiều. MVI đảm bảo tính nhất quán của trạng thái ở cấp độ kiểu — không thể có loading=true và user=null đồng thời. MVVM dựa vào kỷ luật của nhà phát triển.
Các thư viện chính: MVIKotlin (Arkadii Ivanov, 2,5K sao, Kotlin Multiplatform), Orbit MVI (BabyJ, 1,3K sao), Mobius (Spotify, Kotlin/Java). MVIKotlin phổ biến nhất cho Kotlin, Orbit dễ học nhất. Cả ba đều hỗ trợ Reducer và Middleware có thể kiểm tra. Đối với Jetpack Compose, bạn có thể viết MVI đơn giản mà không cần thư viện bằng sealed State + Reducer.
Không — sealed Intent + sealed State + ViewModel + StateFlow tạo ra MVI hoạt động mà không cần phụ thuộc. Các thư viện (MVIKotlin, Orbit, TCA) thêm Middleware, kiểm thử hiệu ứng phụ và tích hợp DI. Đối với các dự án đơn giản, trọng lượng thư viện là không hợp lý. Đối với các dự án phức tạp với 20+ màn hình, thư viện tự bù đắp chi phí bằng cách xử lý hiệu ứng có cấu trúc.
MVI hoạt động tuyệt vời cho iOS thông qua TCA (The Composable Architecture) — kiến trúc phổ biến nhất trong cộng đồng SwiftUI. TCA về cơ bản là MVI + Redux + Combine. Trên iOS, bạn có thể triển khai MVI mà không cần TCA bằng ObservableObject và một hàm rút gọn thuần túy. SwiftUI với State bất biến hoàn toàn phù hợp với chu trình MVI.
Reducer được kiểm thử bằng kiểm thử đơn vị như một hàm thuần túy: đặt State ban đầu, gửi Intent, kiểm tra State kết quả. Middleware được kiểm thử với kho lưu trữ giả: xác minh rằng getUser đã được gọi sau LoadUser. Kiểm thử ViewModel: gửi Intent, kiểm tra StateFlow. MVI dễ kiểm thử hơn MVVM vì Reducer là một hàm thuần túy không có phụ thuộc ẩn.
Tổng kết
Chúng tôi sẽ phát triển ứng dụng di động chìa khóa trao tay
IT Sectr tạo các ứng dụng iOS và Android cho các công ty khởi nghiệp và doanh nghiệp từ năm 2017. Chúng tôi sẽ tư vấn và đề xuất giải pháp tốt nhất cho bạn.
Đọc thêm