Bảng Điều Khiển Ôn Tập Android

Kotlin • MVVM • Flow • REST API • Compose • Testing

Về Lộ trình kiến thức
P0 - Bắt buộc vững

MVVM + Jetpack

Trục chính của JD. Bạn cần vẽ và giải thích được luồng UI → ViewModel → Repository → data source, với UiState rõ ràng.

1MVVM giải quyết vấn đề gì

Hãy tưởng tượng cách làm KHÔNG có MVVM trước: bạn viết mọi thứ ngay trong Activity/Fragment — gọi API, tự parse dữ liệu, tự cập nhật từng ô trên màn hình, tất cả trong MỘT file. Ban đầu có vẻ nhanh, nhưng file đó phình to rất nhanh, rất khó đọc lại sau vài tuần, và gần như không thể viết test cho nó (vì test cần dựng cả một màn hình thật). Tệ hơn, như bạn đã học ở phần Android fundamentals: Activity có thể bị đập bỏ và dựng lại bất cứ lúc nào (xoay màn hình) — nên dữ liệu để ngay trong đó rất dễ mất.

MVVM giải quyết bằng cách chia công việc ra ba vai trò, mỗi vai trò chỉ làm ĐÚNG MỘT việc: UI chỉ có nhiệm vụ hiển thị đúng những gì được đưa cho nó và báo lại khi người dùng làm gì; ViewModel giữ dữ liệu hiện tại của màn hình và quyết định phải làm gì khi có hành động; Repository biết cách lấy dữ liệu thật (từ mạng hoặc máy) và đưa về cho ViewModel ở dạng sạch, dễ dùng.

Ghi nhớ

MVVM là viết tắt của Model - View - ViewModel. Nhìn ba vai trò vừa nêu (UI, ViewModel, Repository), bạn sẽ thấy thiếu hẳn chữ "Model" — nó không hề mất đi, chỉ là tài liệu Android không gọi thẳng tên đó. View = UI (Activity/Fragment/Composable), đúng nghĩa "View". ViewModel = ViewModel, giữ nguyên tên, không đổi. Model = TOÀN BỘ phần dữ liệu và nghiệp vụ không liên quan tới UI — gồm Repository, nơi dữ liệu thật nằm (Remote API/Local DB), và các class dữ liệu như Product, User. Tài liệu Android chỉ chia nhỏ "Model" ra thành Repository + nguồn dữ liệu để bạn dễ tổ chức code, thay vì gọi chung một chữ mơ hồ. Nói gọn: Model không phải một ô còn thiếu trong sơ đồ — nó CHÍNH LÀ Repository và mọi thứ bên dưới nó.

Có một nguyên tắc xuyên suốt cần nhớ: dữ liệu chỉ nên ĐI THEO MỘT CHIỀU. State (dữ liệu hiện tại) đi từ ViewModel xuống UI để hiển thị — UI chỉ đọc, không tự sửa. Hành động (event) đi từ UI lên ViewModel khi người dùng bấm/gõ gì đó — UI chỉ báo lại, không tự quyết định phải làm gì tiếp. Nhờ luồng một chiều này, tại bất kỳ lúc nào bạn cũng biết chắc dữ liệu đang ở đâu và ai vừa mới đổi nó, không phải đoán.

UI (Screen / Composable / Fragment)

Chỉ hiển thị và gửi hành động

Render đúng theo state đang có, không tự quyết định logic. Khi người dùng bấm gì, chỉ báo lại cho ViewModel.

gửi hành động (event)trả về state mới

ViewModel

Giữ state hiện tại, xử lý hành động

Nhận hành động từ UI, gọi Repository để lấy/đổi dữ liệu, rồi cập nhật state mới cho UI hiển thị.

Repository

Biết cách lấy dữ liệu ở đâu

Che giấu việc dữ liệu đến từ mạng hay từ máy — trả về đúng một dạng dữ liệu sạch cho ViewModel dùng.

Remote API / Local DB

Nơi dữ liệu thật sự nằm

Gọi mạng, đọc/ghi database — phần việc "bẩn tay" nhất, chỉ Repository được biết tới nó.

2ViewModel: nơi giữ dữ liệu, không bị mất khi xoay màn hình

ViewModel giữ dữ liệu hiện tại của màn hình (state) và xử lý các hành động người dùng gửi lên. Điều đặc biệt — và cũng là lý do ViewModel tồn tại — là nó KHÔNG bị đập bỏ khi Activity bị dựng lại lúc xoay màn hình (đúng như bạn đã học ở phần Android fundamentals). Vì vậy quy tắc đơn giản: dữ liệu nào cần giữ khi xoay màn hình, đặt trong ViewModel; đừng đặt trong biến của Activity/Fragment.

ViewModel đưa ra state, nhận hành động qua một hàm bình thường

class ProductViewModel(
    private val repository: ProductRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()

    // UI gọi hàm này khi người dùng kéo để làm mới — không tự gọi API
    fun onRefresh() = loadProducts()

    private fun loadProducts() { /* ... xem chi tiết ở phần Coroutines/Flow */ }
}

Lưu ý

ViewModel không được giữ Activity, View, hay bất kỳ thứ gì thuộc về một màn hình cụ thể — vì lý do y hệt như bài trước: Activity có thể bị đóng đi bất cứ lúc nào, còn ViewModel vẫn sống, nên nó sẽ giữ một thứ đã "chết" và gây leak. Nếu cần Context, dùng AndroidViewModel (chỉ giữ Application Context — loại Context sống lâu, không gắn với một màn hình).

3Repository: người trung gian giữa ViewModel và nơi dữ liệu thật sự nằm

Hãy nghĩ Repository như một nhân viên lễ tân: ViewModel chỉ cần hỏi "cho tôi danh sách sản phẩm", còn việc thực tế dữ liệu đó lấy từ đâu — gọi API qua mạng, đọc từ database lưu sẵn trên máy, hay kết hợp cả hai — là chuyện của Repository, ViewModel không cần biết và không nên biết.

Lợi ích cụ thể: nếu sau này bạn muốn thêm cache (lưu tạm để mở app lần sau đỡ phải tải lại), hoặc đổi hẳn thư viện gọi mạng, bạn chỉ cần sửa trong Repository — ViewModel không cần đổi một dòng nào, vì nó chỉ biết tới "cái interface" chứ không biết chi tiết bên trong.

Repository là một interface, để dễ đổi và dễ giả lập khi test

interface ProductRepository {
    suspend fun getProducts(): List<Product>
}

// bản thật: lấy từ API
class DefaultProductRepository(
    private val api: ProductApi
) : ProductRepository {
    override suspend fun getProducts() =
        api.getProducts().map { it.toDomain() }
}

// bản giả dùng khi viết test: trả sẵn một list có sẵn, không cần mạng thật
class FakeProductRepository(
    private val fake: List<Product>
) : ProductRepository {
    override suspend fun getProducts() = fake
}

Mẹo

Vì ProductViewModel ở phần trước chỉ cầm một ProductRepository (interface), lúc test bạn đưa vào FakeProductRepository thay cho bản thật — ViewModel không hề biết mình đang bị test, vẫn hoạt động y như thường. Đây chính là lý do "tách layer giúp dễ test" mà bạn sẽ gặp lại ở phần Testing.

4State và event: hai loại dữ liệu dễ bị nhầm với nhau

Hãy so sánh hai tình huống thật. Tình huống 1: màn hình danh sách sản phẩm đang hiển thị 20 sản phẩm. Bạn xoay màn hình, Activity dựng lại — bạn MUỐN 20 sản phẩm đó hiện lại y như cũ, không mất. Tình huống 2: bạn vừa đặt hàng thành công, app hiện một Snackbar "Đặt hàng thành công!". Ngay sau đó bạn xoay màn hình — bạn KHÔNG muốn Snackbar đó hiện lại lần nữa, vì việc đặt hàng chỉ xảy ra một lần, không phải liên tục.

Đây chính là khác biệt giữa hai loại dữ liệu UI. State là dữ liệu "hiện tại" — luôn cần được hiển thị lại mỗi khi màn hình được vẽ (dù vẽ lần đầu hay vẽ lại sau khi xoay). Event một lần là một hành động chỉ nên xảy ra đúng MỘT LẦN — hiện snackbar, điều hướng qua màn khác — dù màn hình có bị vẽ lại bao nhiêu lần sau đó cũng không được lặp lại nó.

Lỗi hay gặp: nếu bạn lưu luôn "cần hiện snackbar" là một phần của state (StateFlow), thì đúng như tình huống 1, mỗi lần vẽ lại màn hình (xoay máy) cái state đó bị đọc lại và snackbar hiện ra lần nữa — dù bạn chẳng đặt hàng thêm lần nào.

Đây chính là cách một người mới thường vô tình làm trước khi biết tới SharedFlow hay Channel: vẫn để "cần hiện snackbar" nằm trong state như bình thường, nhưng tự thêm một bước dọn dẹp — ngay sau khi UI xử lý xong, gọi một hàm để ViewModel đặt field đó về lại null.

Cách chưa tối ưu: nhét event vào state rồi tự dọn tay

data class UiState(
    val products: List<Product> = emptyList(),
    val orderSuccessMessage: String? = null,   // vẫn chỉ là 1 field trong state
)

// UI: hiện xong PHẢI TỰ NHỚ gọi hàm dọn, không thì bug ở tình huống 2 lại xảy ra
LaunchedEffect(state.orderSuccessMessage) {
    state.orderSuccessMessage?.let { message ->
        showSnackbar(message)
        viewModel.onSnackbarShown()   // reset lại về null trong ViewModel
    }
}

Lưu ý

Cách này vẫn chạy đúng NẾU bạn luôn nhớ gọi onSnackbarShown(). Nhưng đó chính là điểm chưa tối ưu: máy không tự lo việc "chỉ nhận một lần" giúp bạn, bạn phải tự nhớ dọn — quên một chỗ là dính lại đúng bug snackbar lặp lại. SharedFlow và Channel sinh ra để máy tự làm việc đó, không cần bạn tự nhớ.

Vậy SharedFlow và Channel khác nhau ở đâu? Cả hai đều đảm bảo "chỉ nhận một lần", nhưng khác nhau đúng một điểm: chuyện gì xảy ra nếu ViewModel gửi event vào ĐÚNG LÚC chưa có UI nào đang lắng nghe (ví dụ một khoảnh khắc rất ngắn lúc UI đang xoay màn hình, tạm ngừng collect).

  • Channel giống một hộp thư: event gửi vào sẽ NẰM CHỜ TRONG HỘP (có sức chứa giới hạn) cho tới khi có người tới nhận, dù lúc gửi chưa ai đứng đó cả. Channel không làm mất event.
  • SharedFlow ở cấu hình mặc định cho event một lần (replay = 0, không thêm buffer) giống việc hét lên giữa một căn phòng: nếu đúng lúc đó không ai đang lắng nghe, câu đó MẤT LUÔN, không được giữ lại cho ai nghe sau. ViewModel emit ngay lúc UI tạm ngừng collect là event biến mất, không báo lỗi gì cả.

Mẹo

Vì lý do này, Channel + receiveAsFlow() thường được chọn làm mặc định an toàn cho event một lần (navigate, snackbar) — đây cũng là lý do Google có riêng một bài viết chính thức so sánh hai cách này. Muốn dùng SharedFlow cho việc này vẫn được, nhưng nên thêm extraBufferCapacity (một khoang đệm dự phòng) để giảm rủi ro mất event.

Event một lần bằng Channel — cách an toàn, khuyên dùng mặc định

private val _events = Channel<UiEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()

fun onItemClick(id: String) {
    // gửi một lệnh "hãy điều hướng" — lệnh này chỉ được xử lý đúng 1 lần
    viewModelScope.launch { _events.send(UiEvent.NavigateToDetail(id)) }
}

Nếu muốn dùng SharedFlow: nhớ thêm buffer để giảm rủi ro mất event

private val _events = MutableSharedFlow<UiEvent>(
    extraBufferCapacity = 1,               // đệm thêm, không chỉ trông chờ replay
    onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val events = _events.asSharedFlow()

Ghi nhớ

Kiến thức bên lề — nếu gặp trong codebase cũ: trước khi Flow phổ biến trên Android, người ta hay dùng "Event wrapper" (bọc giá trị trong một class có cờ đánh dấu "đã xử lý chưa") kết hợp với LiveData — đôi khi được gọi là SingleLiveEvent. Cách đó vẫn đúng, chỉ là bạn phải tự viết thêm class bọc và tự kiểm tra cờ tay; SharedFlow/Channel làm sẵn việc đó cho bạn nên gọn hơn nhiều.

5Navigation và SavedStateHandle

Navigation component quản lý việc chuyển màn hình ở một nơi duy nhất: khai báo trước tất cả màn hình và đường đi giữa chúng (gọi là graph), lo việc truyền dữ liệu an toàn giữa hai màn hình (Safe Args — ví dụ truyền đúng id sản phẩm, không phải đoán kiểu dữ liệu), và tự quản lý việc bấm Back nên quay về đâu. Với Compose có bản riêng là Navigation Compose.

SavedStateHandle chính là phiên bản "dễ dùng hơn" của thứ bạn đã gặp ở phần Android fundamentals — onSaveInstanceState. Bạn không cần tự override hàm nào cả, chỉ cần khai báo SavedStateHandle trong constructor của ViewModel, và Android tự nối nó với đúng cơ chế lưu/khôi phục đó. Nhờ vậy dữ liệu trong SavedStateHandle bền hơn cả configuration change — nó còn sống sót qua cả việc app bị dọn hẳn (process death), khác với một biến bình thường trong ViewModel chỉ sống qua xoay màn hình.

Một công dụng rất thực tế: SavedStateHandle cũng là nơi bạn nhận lại đúng tham số mà Navigation đã truyền qua (ví dụ id của sản phẩm khi chuyển từ danh sách sang chi tiết).

Đọc tham số Navigation qua SavedStateHandle

class DetailViewModel(
    savedStateHandle: SavedStateHandle,
    repository: ProductRepository
) : ViewModel() {
    // id được Navigation truyền qua khi mở màn hình chi tiết
    private val productId: String = checkNotNull(savedStateHandle["id"])
    // ... dùng productId để gọi repository.getProduct(productId)
}

6MVI là gì, và có phải học không (không bắt buộc cho JD)

Ghi nhớ

Nói trước để khỏi lo: JD của job này chỉ yêu cầu MVVM, KHÔNG yêu cầu MVI. Phần này để ở cuối trang MVVM đúng chỗ của nó — ở mức "biết để nói chuyện rộng" nếu interviewer hỏi so sánh kiến trúc, không phải thứ bắt buộc áp dụng vào mini project của bạn. Cứ tự tin dùng MVVM thường; đọc mục này để trả lời được khi bị hỏi "MVI khác MVVM chỗ nào".

MVI (Model-View-Intent) KHÔNG phải một công nghệ hay thư viện riêng, cũng không phải một thứ đối lập với MVVM. Bạn vẫn dùng đúng ViewModel + StateFlow như mọi thứ ở trên trang này — MVI chỉ là một bộ QUY TẮC chặt hơn mà bạn tự đặt ra khi viết code bên trong ViewModel đó. Nói cách khác: MVVM là kiểu kiến trúc chung (UI - ViewModel - Repository) mà cả trang này đã nói, còn MVI là một cách LÀM CỤ THỂ, nghiêm ngặt hơn, để viết đúng phần ViewModel trong kiến trúc đó.

Để hiểu vì sao người ta nghĩ ra MVI, hãy tưởng tượng một tình huống rất thật. Màn hình danh sách sản phẩm của bạn cùng lúc phải lo nhiều việc: đang tải lại danh sách (loading), người dùng vừa bấm xoá một sản phẩm (đang gọi mạng để xoá), rồi mạng chập chờn báo lỗi. Nếu theo kiểu MVVM thoải mái, mỗi việc bạn để một luồng state riêng — isLoading, isDeleting, deleteError, danh sách sản phẩm — bốn thứ này cập nhật ĐỘC LẬP, mỗi cái đổi vào một thời điểm khác nhau.

Vấn đề nảy sinh ở những khoảnh khắc chúng "đá nhau": có lúc isDeleting đã bật true nhưng danh sách chưa kịp bỏ item đó ra, hoặc deleteError đã có nhưng isDeleting vẫn chưa tắt — và UI phải tự đoán trong mớ đó nên vẽ gì. Đây là loại bug rất khó tái hiện vì nó phụ thuộc thứ tự các luồng cập nhật. MVI sinh ra để chặn trước đúng loại bug này: nếu chỉ có MỘT state duy nhất, thì tại mọi thời điểm bốn thông tin đó luôn là một bức ảnh chụp nhất quán, không thể lệch pha nhau.

Ghi nhớ

Để ý: MVI thật ra chỉ là siết chặt đúng cái nguyên tắc bạn đã gặp ở đầu trang này — "dữ liệu chỉ đi MỘT CHIỀU" (state đi từ ViewModel xuống UI, hành động đi từ UI lên ViewModel). Tài liệu hay gọi nguyên tắc đó là unidirectional data flow (UDF). MVVM khuyến khích UDF nhưng không ép; MVI biến nó thành luật cứng, khép thành một vòng rõ ràng. Sơ đồ dưới đây là chính cái vòng đó:

Vòng một chiều — dữ liệu luôn chạy theo đúng một chiều kim đồng hồ

1

Intent

Người dùng làm gì đó

Bấm xoá, kéo làm mới... UI gói thành một Intent rõ ràng rồi gửi lên qua đúng một cửa: onIntent().

2

ViewModel + reducer

Tạo state mới từ state cũ

Nhận Intent, gọi Repository nếu cần, rồi copy state cũ và đổi vài field để ra state mới. Không sửa state kiểu nào khác.

3

State (một object duy nhất)

Bức ảnh chụp toàn màn hình

Tất cả những gì UI cần để vẽ nằm gọn trong một object bất biến. Không có state phụ rải rác nơi khác.

4

UI vẽ lại

Chỉ đọc state để hiển thị

collect state mới rồi vẽ đúng theo nó. Xong lại chờ hành động tiếp theo — quay về bước 1.

quay lại bước 1 — chờ hành động tiếp theo

So với sơ đồ MVVM ở đầu trang (một chồng tầng UI → ViewModel → Repository → data, dữ liệu đi lên xuống theo trục dọc), sơ đồ MVI là một VÒNG khép kín — nhấn mạnh điểm cốt lõi: mọi thứ chỉ chạy theo đúng một chiều, hết vòng lại quay về đầu, không có lối tắt. Ba điều MVI siết chặt chính là để giữ cái vòng đó luôn sạch: (1) chỉ MỘT state duy nhất, (2) mọi hành động đi qua MỘT cửa vào, (3) chỉ MỘT cách đổi state. Ba mục dưới đây lần lượt là ba điều đó; cuối cùng sẽ có phần UI khép lại vòng và một bảng so sánh gom lại.

7MVI (1/3) — dồn hết vào MỘT state duy nhất

Ở MVVM thông thường (như phần trên đã trình bày), bạn có một StateFlow<UiState> chính. Nhưng MVVM không cấm bạn thêm state phụ khi cần — ví dụ muốn có thêm cờ "đang xoá sản phẩm", nhiều người sẽ tiện tay thêm một MutableStateFlow riêng nữa. Làm vậy vài lần, ViewModel có thể có 3-4 luồng state khác nhau, và rất dễ xảy ra tình huống hai luồng "không khớp nhau" tại một thời điểm (ví dụ isLoading đã false nhưng isDeleting lại đang true mà UI không rõ nên ưu tiên hiển thị gì).

MVI đặt ra một luật CỨNG: toàn bộ những gì UI cần để vẽ màn hình phải nằm trong DUY NHẤT MỘT object bất biến, thường gọi là State. Không có state phụ nằm rải rác nơi khác. Muốn thêm thông tin gì, thêm field vào chính state đó — không được tạo thêm một luồng mới.

MVVM "lỏng" (nhiều luồng rải rác) so với MVI (một state duy nhất)

// Kiểu MVVM thoải mái: thêm luồng mới mỗi khi cần — dễ làm, dễ rối
class ProductViewModel(...) : ViewModel() {
    val uiState: StateFlow<UiState> = ...
    val isDeleting = MutableStateFlow(false)          // thêm tuỳ hứng
    val deleteError = MutableStateFlow<String?>(null)  // rồi thêm tiếp
}

// Kiểu MVI: mọi thứ dồn vào một state duy nhất, không có luồng phụ nào khác
data class ProductScreenState(
    val products: List<Product> = emptyList(),
    val isLoading: Boolean = false,
    val isDeleting: Boolean = false,
    val deleteError: String? = null,
)

class ProductViewModel(...) : ViewModel() {
    private val _state = MutableStateFlow(ProductScreenState())
    val state: StateFlow<ProductScreenState> = _state.asStateFlow()
}

Lưu ý

Đừng nhầm điểm khác biệt: ở phần MVVM trên, UiState là một sealed interface (Loading/Success/Error — mỗi lúc đúng một nhánh); ở đây ProductScreenState là data class phẳng (nhiều cờ cùng tồn tại). Việc chọn sealed hay data class chỉ là chọn KIỂU DỮ LIỆU cho state — cả hai kiểu đều dùng được ở cả MVVM lẫn MVI, KHÔNG phải điểm phân biệt hai kiến trúc. Điểm phân biệt thật sự chỉ là ba quy tắc: một state, một cửa vào, một cách đổi. (Thực tế màn hình có nhiều thứ chạy song song — vừa có list vừa đang xoá — thì data class phẳng gói gọn hơn sealed, nên ví dụ MVI hay dùng nó; nhưng đó là do nhu cầu, không phải luật MVI.)

8MVI (2/3) — Intent: một cửa vào duy nhất cho mọi hành động

Ở MVVM, UI thường gọi trực tiếp một hàm public riêng cho mỗi hành động — onRefresh(), onItemClick(id), onDeleteClick(id)... mỗi hành động một hàm khác nhau. MVI đi xa hơn: gói TẤT CẢ hành động có thể xảy ra thành một sealed class gọi là Intent, và ViewModel chỉ có DUY NHẤT một cửa vào để nhận nó — giống hệt cách sealed class giúp bạn không bỏ sót trạng thái mà bạn đã học ở phần Kotlin core, chỉ áp dụng cho hành động thay vì cho trạng thái.

Một điểm vào duy nhất cho mọi hành động

sealed interface ProductIntent {
    data object Refresh : ProductIntent
    data class ItemClicked(val id: String) : ProductIntent
    data class DeleteClicked(val id: String) : ProductIntent
}

class ProductViewModel(...) : ViewModel() {
    fun onIntent(intent: ProductIntent) {
        when (intent) {                              // không cần else
            ProductIntent.Refresh -> loadProducts()
            is ProductIntent.ItemClicked -> openDetail(intent.id)
            is ProductIntent.DeleteClicked -> deleteProduct(intent.id)
        }
    }
}

Mẹo

Lợi ích giống hệt lý do sealed class hợp cho UI state: khi thêm một hành động mới (một Intent mới), compiler sẽ nhắc bạn xử lý nó trong when — không có hành động nào bị âm thầm bỏ sót.

9MVI (3/3) — reducer: chỉ MỘT cách đổi state

Ở MVVM, sau khi Repository trả dữ liệu về, ViewModel thường sửa thẳng state (gán _uiState.value = ...) ở nhiều chỗ khác nhau trong nhiều hàm. MVI gom việc "tạo state mới" về một khuôn duy nhất: nhận state cũ, đổi vài field, trả ra state mới — luôn bằng copy(), không bao giờ sửa state kiểu khác. Nhiều tài liệu gọi bước này là reducer. Không bắt buộc phải tách hẳn thành một hàm tên reducer, quan trọng là mọi thay đổi đều đi qua đúng khuôn đó.

MVI: mọi thay đổi state đều là 'copy state cũ, đổi vài field' — không sửa lung tung

private fun deleteProduct(id: String) {
    // Mỗi lần đổi state đều là _state.update { copy(...) } — không có cách nào khác
    _state.update { it.copy(isDeleting = true, deleteError = null) }
    viewModelScope.launch {
        try {
            repository.delete(id)                 // xoá xong: bỏ id khỏi list
            _state.update { it.copy(isDeleting = false, products = it.products - id) }
        } catch (e: Exception) {                  // lỗi: ghi vào chính state đó
            _state.update { it.copy(isDeleting = false, deleteError = e.message) }
        }
    }
}

Ghi nhớ

Cách bắt lỗi bằng try/catch ở đây chỉ để ví dụ ngắn gọn. Trong dự án thật, Repository thường trả về một kiểu Result đã bọc sẵn thành công/thất bại (bạn sẽ gặp ở phần REST API) thay vì ném exception lên tận ViewModel — nhưng ý chính của reducer không đổi: dù kết quả là gì, state mới vẫn luôn sinh ra bằng một lần copy().

10MVI — phía UI: khép trọn vòng một chiều

Ba mục trên mới là phía ViewModel. Phải nhìn thêm phía UI thì cái "vòng một chiều" nhắc ở đầu mới thành hình thật. Phía UI trong MVI cực kỳ đơn giản, và đó là chủ đích: nó chỉ làm đúng hai việc — ĐỌC một state duy nhất để vẽ, và GỬI Intent lên khi người dùng làm gì. Không có việc thứ ba. UI không tự sửa state, không tự quyết định logic.

UI đọc 1 state để vẽ, gửi Intent khi có hành động — không làm gì khác

@Composable
fun ProductScreen(viewModel: ProductViewModel) {
    // ĐỌC: chỉ một state duy nhất, vẽ mọi thứ từ nó
    val state by viewModel.state.collectAsStateWithLifecycle()

    Column {
        if (state.isLoading) LoadingBar()
        state.deleteError?.let { ErrorBanner(it) }
        ProductList(
            products = state.products,
            // GỬI: mọi hành động đều đi qua onIntent, không gọi hàm riêng lẻ
            onRefresh = { viewModel.onIntent(ProductIntent.Refresh) },
            onDelete = { id -> viewModel.onIntent(ProductIntent.DeleteClicked(id)) },
        )
    }
}

Giờ ghép mọi thứ lại thành một kịch bản thật — người dùng bấm xoá một sản phẩm — để thấy dữ liệu chạy qua từng chặng ra sao, và vì sao đây đúng là thứ mà tình huống "list + đang xoá + lỗi mạng" ở đầu mục cần:

Kịch bản thật: bấm xoá một sản phẩm — dữ liệu chạy theo đúng một chiều

Người dùng

Bấm nút xoá một sản phẩm

Một hành động bình thường trên màn hình danh sách.

UI

Gửi ProductIntent.DeleteClicked lên

UI không tự xoá gì cả — chỉ gói hành động thành Intent rồi gọi onIntent().

ViewModel + reducer

Chạy reducer: state.copy(isDeleting = true)

Nhận Intent, tạo state mới với cờ đang-xoá bật lên. Bắt đầu gọi mạng để xoá.

State + StateFlow

StateFlow phát state mới

Bức ảnh state vừa đổi được đẩy ra cho ai đang lắng nghe.

UI

Collect state, vẽ lại — hiện spinner

UI thấy isDeleting = true nên hiển thị vòng xoay. Rồi CHỜ — chưa làm gì thêm.

Mạng trả kết quả → reducer chạy lần nữa, rẽ một trong hai nhánh:

Thành công

reducer: copy(isDeleting = false, products = products - id). UI vẽ lại: tắt spinner, item biến mất khỏi list.

Lỗi mạng

reducer: copy(isDeleting = false, deleteError = message). UI vẽ lại: tắt spinner, hiện banner lỗi.

Suốt vòng, mũi tên chỉ đi MỘT chiều — không có bước nào tự sửa state ngược. Ở mọi thời điểm UI vẽ từ đúng một bức ảnh state nhất quán.

Mẹo

So với phía UI của MVVM thường ở các mục trên: khác biệt DUY NHẤT bạn thấy là UI gọi viewModel.onIntent(SomeIntent) thay vì gọi thẳng viewModel.onRefresh() / viewModel.onDelete(id). Ngoài ra collectAsStateWithLifecycle, cách vẽ theo state... đều y hệt. Đây là bằng chứng cụ thể cho câu "MVI dùng chung nền với MVVM, chỉ khác quy tắc".

11MVI vs MVVM — so sánh gom lại và cách trả lời phỏng vấn

Ba quy tắc phía ViewModel cộng với phía UI vừa xem, tất cả trên cùng nền ViewModel + StateFlow của MVVM (không đổi công nghệ). Đặt cạnh nhau theo từng khía cạnh:

MVVM vs MVI — so cùng một nền ViewModel + StateFlow

Khía cạnhMVVM (thường)MVI
Nền tảng kỹ thuậtViewModel + StateFlow/LiveDataY HỆT — ViewModel + StateFlow (không phải công nghệ khác)
Số luồng stateMột chính, được phép thêm luồng phụ khi cầnĐúng MỘT state duy nhất, bất biến — cấm luồng phụ
UI gửi hành độngGọi trực tiếp nhiều hàm public: onRefresh(), onDeleteClick()...Một cửa vào duy nhất: onIntent(intent) với sealed Intent
Cách đổi stateGán _uiState.value = ... rải rác nhiều hàmLuôn 'copy state cũ → state mới' (reducer), tập trung
Rủi ro hay gặpHai luồng state không khớp nhau tại một thời điểmÉp hết vào một object nên khó lệch, bù lại code dài hơn
Chi phí viếtÍt boilerplate, nhanh với màn hình đơn giảnVerbose — phải viết State tổng + Intent, dư cho màn đơn giản
One-shot eventSharedFlow/Channel (như mục State và event ở trên)Y như vậy — event một lần vẫn tách khỏi state, không đổi
JD job nàyĐƯỢC yêu cầu — ưu tiên nắm chắcKhông yêu cầu — biết để so sánh là đủ

Cách hiểu gọn nhất để nhớ lâu: MVVM cho bạn tự do, MVI lấy bớt tự do đó để đổi lấy sự chắc chắn. Toàn bộ khác biệt của MVI gói trong ĐÚNG BA CÁI CẤM — nhớ ba dòng này là nhớ hết MVI:

Toàn bộ khác biệt của MVI — 3 cái cấm

1

Cấm nhiều luồng state

MVVM cho phép: thêm MutableStateFlow phụ tuỳ ý (isLoading, isDeleting... mỗi thứ một luồng).

MVI bắt: đúng MỘT state duy nhất, bất biến — mọi thứ là field trong đó.

2

Cấm nhiều cửa vào hành động

MVVM cho phép: mỗi hành động một hàm public riêng — onRefresh(), onDelete()...

MVI bắt: MỘT cửa vào duy nhất — onIntent(intent) với sealed Intent.

3

Cấm sửa state tuỳ tiện

MVVM cho phép: gán _uiState.value = ... ở bất kỳ đâu, trong nhiều hàm khác nhau.

MVI bắt: luôn copy() state cũ ra state mới (reducer) — không cách nào khác.

Nhớ đúng 3 cái cấm này là nhớ hết khác biệt của MVI so với MVVM.

Ba cái cấm này chính là ba dòng đầu của bảng so sánh phía trên, chỉ nói ngược lại cho dễ khắc vào đầu. Khi nào chúng đáng giá? Màn hình đơn giản: ba cái cấm chỉ làm bạn gõ nhiều hơn mà chẳng lợi gì, MVVM thường là đủ. Màn hình phức tạp nhiều trạng thái đan xen: chính ba cái cấm đó chặn trước loại bug "hai chỗ hiển thị mâu thuẫn" mà MVVM lỏng dễ mắc.

Lưu ý

Đừng nói sai trong phỏng vấn: MVI KHÔNG thay thế MVVM và KHÔNG cần thư viện riêng. Nói "em đổi từ MVVM sang MVI" là hiểu lệch — đúng hơn là "em vẫn dùng ViewModel + StateFlow, nhưng áp thêm quy tắc MVI". Cũng đừng nói MVI "tốt hơn" — nó chỉ đánh đổi tự do lấy tính chặt chẽ.

Mẹo

Câu trả lời an toàn nếu bị hỏi so sánh: "Em hiểu MVI là một cách làm chặt hơn MVVM trên cùng nền ViewModel + StateFlow — dồn hết vào một state bất biến duy nhất, gói mọi hành động thành Intent qua một cửa vào, và mọi thay đổi state đều là copy state cũ ra state mới. Nhờ vậy tránh được tình trạng nhiều luồng state rời rạc không khớp nhau. Đổi lại code dài hơn nên với màn hình đơn giản thì MVVM thường là đủ. Với job này JD yêu cầu MVVM nên em ưu tiên nắm chắc MVVM trước; MVI là kiến trúc em biết để áp dụng khi màn hình phức tạp hơn."

Checklist

Tick những gì bạn đã nắm chắc

0/7

Lỗi thường gặp

  • UI gọi thẳng API/DAO thay vì đi qua ViewModel + Repository.
  • Giữ Context/View trong ViewModel làm khó test và gây leak.
  • Dùng StateFlow cho one-shot event khiến snackbar/navigate lặp lại khi xoay màn hình.
  • Nói "đổi từ MVVM sang MVI" hoặc tưởng MVI cần thư viện riêng — thực ra MVI vẫn là ViewModel + StateFlow, chỉ siết thêm quy tắc.

Câu trả lời mẫu

Em tách UI khỏi business logic: UI chỉ observe state từ ViewModel và gửi event; ViewModel gọi Repository lấy data rồi expose `StateFlow<UiState>`. One-shot event như navigate/snackbar em dùng SharedFlow hoặc Channel để không bị phát lại.

Câu hỏi liên quan

Tự luyện trước khi trả lời