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

Coroutines + Flow

Phần xác suất bị hỏi cao nhất sau MVVM: xử lý async đúng scope, dispatcher và lifecycle.

1Vì sao cần coroutine: đừng bắt máy đứng im chờ mạng

Hãy tưởng tượng ProductViewModel gọi thẳng repository.getProducts() theo kiểu thông thường (không async): dòng lệnh gọi mạng đó có thể mất vài giây, và trong lúc chờ, cả app treo cứng — không vuốt được, không bấm được gì, vì tất cả code UI trên Android đều chạy trên đúng MỘT thread gọi là Main thread. Main thread bận vẽ UI và phản hồi cử chỉ; nếu nó bị giữ lại để "ngồi chờ" mạng trả lời, hệ thống sẽ nghĩ app bị đứng và hiện hộp thoại ANR (App Not Responding).

Coroutine giải quyết việc này mà không cần bạn tự quản lý thread thủ công. Bạn đánh dấu một hàm là suspend (có thể tạm dừng), và khi hàm đó gọi tới việc chờ lâu (mạng, DB), nó CHỈ tạm nhường chỗ chứ không chiếm giữ Main thread — xong việc, nó quay lại đúng chỗ đang dang dở, code đọc từ trên xuống dưới như bình thường, không có callback lồng nhau rối mắt.

Coroutine không tự chạy lang thang — nó luôn sống trong một CoroutineScope, giống như một nhân viên luôn thuộc về một phòng ban cụ thể. Khi phòng ban đó đóng cửa (scope bị huỷ), mọi nhân viên (coroutine con) bên trong cũng phải nghỉ theo — đây gọi là structured concurrency: không có coroutine nào chạy lạc, mồ côi, không ai quản.

  • viewModelScope: đúng như bạn đã biết ở phần MVVM — đây là "phòng ban" gắn liền với một ViewModel, tự đóng cửa (huỷ hết coroutine con) ngay khi ViewModel bị clear. Dùng cho hầu hết việc trong ViewModel.
  • lifecycleScope: tương tự nhưng gắn với vòng đời Activity/Fragment thay vì ViewModel.
  • launch: giao việc rồi không cần lấy kết quả trả về ngay (fire-and-forget), trả về một Job để có thể huỷ sau. async: giao việc và CẦN lấy kết quả, trả về Deferred, gọi await() để lấy khi cần.

launch (tuần tự) vs async (chạy song song rồi gộp lại)

// Cách 1: làm lần lượt — chờ xong việc này mới làm việc kia
viewModelScope.launch {
    val user = repo.getUser()      // đợi xong
    val posts = repo.getPosts()    // rồi mới chạy
}

// Cách 2: hai việc không phụ thuộc nhau — cho chạy song song, xong cả hai mới gộp
viewModelScope.launch {
    val user = async { repo.getUser() }
    val posts = async { repo.getPosts() }
    render(user.await(), posts.await())
}

Mẹo

Quy tắc chọn nhanh: hai việc phụ thuộc nhau (cần kết quả việc trước để làm việc sau) → launch tuần tự. Hai việc độc lập, càng xong sớm càng tốt (vd tải avatar user và tải feed cùng lúc) → async song song.

2Dispatcher: chọn đúng "làn" để chạy việc

Một coroutine chạy được, nhưng chạy Ở ĐÂU (thread/nhóm thread nào) là do Dispatcher quyết định. Cứ hình dung có ba làn đường riêng biệt, mỗi làn hợp với một loại việc — đi lộn làn là kẹt xe (treo UI) hoặc phí tài nguyên.

  • Dispatchers.Main: làn duy nhất được phép chạm vào UI. Chỉ dùng để cập nhật màn hình — không bao giờ làm việc nặng ở đây.
  • Dispatchers.IO: làn dành cho việc phải NGỒI CHỜ (đợi mạng trả lời, đợi ổ đĩa đọc/ghi xong, đợi database) — nhiều "nhân viên" rảnh cùng lúc để không ai phải chờ ai.
  • Dispatchers.Default: làn dành cho việc TÍNH TOÁN nặng, chiếm CPU thật sự (parse file JSON khổng lồ, sắp xếp danh sách lớn, xử lý ảnh) — khác hẳn với việc chỉ ngồi chờ.

Mẹo

Trong ProductRepository, nếu bạn gọi Retrofit hoặc Room, KHÔNG cần tự bọc withContext(Dispatchers.IO) — hai thư viện này đã tự chuyển sang đúng làn phù hợp bên trong chúng rồi. Bạn chỉ cần tự withContext(IO/Default) khi TỰ TAY viết một đoạn code blocking hoặc nặng CPU mà không dùng thư viện nào lo sẵn việc đó.

3Huỷ coroutine: máy chỉ dừng khi được "hợp tác"

Tình huống thật: người dùng đang ở màn ProductDetail, ViewModel bắt đầu tải dữ liệu (viewModelScope.launch { ... }), nhưng người dùng bấm Back ngay lập tức. ViewModel bị clear, viewModelScope bị huỷ — vậy request đang tải dở có tự dừng theo không?

Có, nhưng với một điều kiện: việc huỷ trong coroutine là cooperative (hợp tác), không phải "rút phích cắm" ngay lập tức. Khi scope bị huỷ, coroutine con nhận một tín hiệu gọi là CancellationException — nhưng nó chỉ THỰC SỰ dừng lại ở lần tiếp theo code có "dừng để kiểm tra tín hiệu" đó, thường là mỗi khi gọi một suspend function của thư viện Kotlin (delay, các hàm mạng...). Nếu bạn đang chạy một vòng lặp thuần tính toán không gọi suspend function nào cả, nó sẽ không tự dừng — bạn phải tự kiểm tra isActive trong vòng lặp đó.

Lưu ý

Lỗi rất hay gặp: viết catch (e: Exception) để bắt lỗi mạng, nhưng CancellationException CŨNG là một Exception nên vô tình bị bắt luôn — coroutine tưởng là "lỗi" và bạn lỡ tay hiện UiState.Error("Có lỗi xảy ra") ngay khi người dùng chỉ đơn giản là bấm Back, không có lỗi mạng nào thật cả. Luôn rethrow CancellationException trước, rồi mới catch Exception còn lại.

Catch đúng cách trong ProductViewModel

try {
    val data = repository.getProducts()
    _uiState.value = UiState.Success(data)
} catch (e: CancellationException) {
    throw e                       // để coroutine huỷ bình thường, không coi là lỗi
} catch (e: Exception) {
    _uiState.value = UiState.Error(e.message ?: "Không tải được dữ liệu")
}

4Flow, StateFlow, SharedFlow: khi dữ liệu chảy liên tục theo thời gian

Một suspend function bình thường (như repository.getProducts() ở trên) giống như đặt một câu hỏi và nhận đúng một câu trả lời rồi xong. Nhưng có những dữ liệu không dừng ở một lần — ví dụ ô tìm kiếm: mỗi lần người dùng gõ thêm một chữ, bạn lại muốn có một kết quả mới. Đây là lúc cần Flow: một luồng có thể phát ra NHIỀU giá trị theo thời gian, không chỉ một lần.

Flow là cold — giống một cuốn băng ghi sẵn, không tự chạy, chỉ thật sự chạy khi có người bấm "nghe" (collect). Có người collect thì nó chạy cho người đó, chưa ai collect thì nó nằm im, không tốn gì cả. Flow còn có cả một bộ "phép biến đổi" mạnh — map, filter, combine, debounce, flatMapLatest... — để lọc, ghép, hay chặn bớt dữ liệu trước khi nó tới UI.

  • StateFlow: giống một tấm bảng luôn treo sẵn giá trị MỚI NHẤT — ai nhìn vào lúc nào cũng thấy có gì đó (bắt buộc phải có initial value), không bao giờ trống. Rất hợp làm UI state, vì UI luôn cần có gì đó để vẽ ngay khi vừa mở màn hình.
  • SharedFlow: linh hoạt hơn — bạn tự cấu hình xem người mới tham gia có được xem lại giá trị cũ hay không (replay), và có buffer đệm hay không. Mặc định (replay = 0, không buffer) thì không giữ lại gì cả — hợp cho việc chỉ cần xảy ra một lần (điều hướng, hiện snackbar), y như bạn đã học ở phần MVVM.
  • stateIn / shareIn: cách biến một Flow cold (chỉ chạy khi được collect) thành StateFlow/SharedFlow hot gắn với một scope cụ thể, để nhiều nơi có thể cùng "nhìn vào" một nguồn mà không cần mỗi nơi tự chạy lại từ đầu.

Tìm kiếm sản phẩm: gõ tới đâu, tự tìm tới đó

val results: StateFlow<UiState> = queryFlow
    .debounce(300)                       // đợi người dùng gõ xong ~300ms mới tìm, đỡ tìm tới tấp từng chữ
    .distinctUntilChanged()              // gõ ra chữ giống lần trước thì khỏi tìm lại
    .flatMapLatest { query ->            // có từ khoá mới → huỷ luôn kết quả tìm cũ đang chờ
        flow { emit(repository.search(query)) }
    }
    .map { UiState.Success(it) }
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState.Loading)

Ghi nhớ

flatMapLatest ở đây quan trọng vì lý do thực tế: nếu người dùng gõ "áo" rồi gõ tiếp "áo thun" trước khi kết quả "áo" kịp về, bạn không muốn kết quả "áo" (cũ, chậm hơn) lại hiện lên SAU kết quả "áo thun" (mới) chỉ vì nó về trễ. flatMapLatest tự huỷ query cũ ngay khi có query mới, đảm bảo UI luôn khớp với từ khoá mới nhất.

5Collect đúng vòng đời: đừng để đài phát khi tivi đã tắt

Giả sử ProductListScreen đang collect uiState để vẽ danh sách. Người dùng chuyển sang app khác, màn hình bị che đi (không hiển thị nữa) nhưng Activity chưa hẳn bị đóng. Nếu bạn collect Flow một cách "vô tư" không gắn gì với vòng đời, coroutine collect đó vẫn tiếp tục chạy dù màn hình chẳng ai nhìn thấy — vừa lãng phí pin/tài nguyên, vừa có thể cố cập nhật lên một UI đã không còn ở trên màn hình.

Cách sửa là buộc việc collect phải đi kèm vòng đời màn hình: chỉ collect khi màn hình đang thực sự hiển thị, tự dừng khi bị che đi, tự bắt đầu lại khi hiện ra trở lại.

Compose và View system (Fragment/Activity)

// Compose: một dòng lo hết, tự dừng/tự collect lại theo vòng đời
val state by viewModel.uiState.collectAsStateWithLifecycle()

// Fragment (View system): tự viết vòng lặp repeatOnLifecycle
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { render(it) }
    }
}

Lưu ý

Trong Fragment, luôn dùng viewLifecycleOwner (không phải this) khi collect/observe. Lý do giống hệt phần Android fundamentals: View của Fragment có thể bị huỷ và tạo lại (ví dụ khi đưa vào back stack) sớm hơn chính Fragment — dùng nhầm this dễ giữ tham chiếu tới một View đã chết, gây leak hoặc crash.

Checklist

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

0/6

Lỗi thường gặp

  • Dùng `GlobalScope` thay vì `viewModelScope` gây leak coroutine.
  • Collect Flow không lifecycle-aware (thiếu `repeatOnLifecycle`/`collectAsStateWithLifecycle`).
  • Chạy IO trên `Dispatchers.Main` làm block UI.

Câu trả lời mẫu

suspend function trả về một giá trị, còn Flow là luồng emit nhiều giá trị. StateFlow là hot stream giữ giá trị mới nhất cho UI state, SharedFlow hợp cho event. Em gọi API trong `viewModelScope` với `Dispatchers.IO` và collect bằng `collectAsStateWithLifecycle` để không leak.

Câu hỏi liên quan

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