Tạm biệt màn hình Loading với NetworkBoundResource Pattern
Chào anh em. Hơn 4 năm gõ code Android, kinh qua đủ các dự án lớn nhỏ, mình nhận ra có một cái "bệnh" mà rất nhiều anh em developer chúng ta hay mắc phải: Lạm dụng màn hình Loading.
Cứ mỗi lần mở app lên là bắt user nhìn cái vòng tròn xoay tít mù để chờ gọi API, trong khi dữ liệu cũ rích của ngày hôm qua vẫn đang nằm sờ sờ trong máy. Cách làm "gọi API -> lấy JSON -> ném thẳng lên UI" không chỉ đem lại trải nghiệm tệ hại (nhất là khi mạng chập chờn) mà còn phá vỡ cấu trúc của một ứng dụng tốt.
Hôm nay, mình muốn chia sẻ với anh em một pattern cực kỳ lợi hại mà các tech tier lớn đều dùng: NetworkBoundResource. Nó sẽ giúp app của bạn đạt được cảnh giới "Offline-First", mượt mà như một ứng dụng Native thực thụ.
Triết lý "Ví tiền và Cây ATM" - Chân ái của Single Source of Truth Để dễ hình dung nhất, anh em cứ tưởng tượng thế này. Đặc biệt khi làm mấy dự án đòi hỏi data phải real-time và chuẩn chỉ như app quản lý tài chính hay tracking dòng tiền, tính nhất quán của dữ liệu là sống còn.
Database (Room/Cache): Chính là chiếc ví tiền của bạn.
Network (API): Là cây ATM ngoài ngõ.
UI (Màn hình app): Là bạn, người đang cần tiền để đi chợ.
Nguyên tắc vàng ở đây là: Bạn CHỈ được phép lấy tiền từ VÍ ra tiêu. Cây ATM CHỈ có một nhiệm vụ duy nhất là bơm tiền vào VÍ.
Tuyệt đối không có chuyện rút tiền từ ATM rồi đem đi tiêu thẳng mà không đút vào ví. Dịch ra ngôn ngữ code: Database là Nguồn Sự Thật Duy Nhất (Single Source of Truth - SSOT). API gọi về không bao giờ được trả thẳng lên UI, mà phải lưu xuống Local Database. Sau đó, Database sẽ tự động "bắn" tín hiệu lên UI để render.
Hành trình 4 bước của luồng dữ liệu Thay vì bắt user chờ đợi, NetworkBoundResource xử lý cực kỳ khéo léo qua 4 nhịp:
Mở ví (Query): Vừa mở app, lập tức móc ví ra xem có đồng nào không (lấy data từ Local DB) và show ngay lên màn hình. User có luôn cái để xem, không phải chờ nửa giây nào.
Kiểm tra ví (Should Fetch): Xem xét xem data cũ chưa? Có cần chạy ra ATM rút thêm không?
Ra ATM (Fetch API): Nếu cần, app sẽ âm thầm chạy ra ATM gọi API ở dưới background. Trong lúc đó, user vẫn lướt app bình thường với data cũ, UI không hề bị block.
Cất tiền vào ví (Save Result): API trả về data mới, ta cất ngay vào Database. Nhờ cơ chế Reactive, Database tự động chớp nhoáng đẩy data mới nhất lên UI.
Xắn tay áo lên Code nào Chúng ta sẽ dùng Kotlin Coroutines và Flow để xử lý vụ này. Viết một hàm dùng chung, sau này anh em mang đi project nào xài cũng tiện.
Trước tiên, tạo cái "phong bì" để bọc data lại, giúp UI biết mình đang ở trạng thái nào:
Kotlin
sealed class Resource<T>(val data: T? = null, val message: String? = null) {
class Success<T>(data: T) : Resource<T>(data)
class Loading<T>(data: T? = null) : Resource<T>(data) // Vẫn có thể nhét data cũ vào đây
class Error<T>(message: String, data: T? = null) : Resource<T>(message, data)
}
Và đây, trái tim của kiến trúc - hàm networkBoundResource thần thánh.
Kotlin
import kotlinx.coroutines.flow.*
inline fun <ResultType, RequestType> networkBoundResource(
crossinline query: () -> Flow<ResultType>,
crossinline fetch: suspend () -> RequestType,
crossinline saveFetchResult: suspend (RequestType) -> Unit,
crossinline shouldFetch: (ResultType) -> Boolean = { true }
) = flow {
// Bước 1: Lấy data hiện có trong Local DB ra trước
val data = query().first()
val flow = if (shouldFetch(data)) {
// Bước 2: Báo cho UI biết là đang tải, nhưng vẫn quăng data cũ lên cho user xem tạm
emit(Resource.Loading(data))
try {
// Bước 3: Âm thầm đi gọi API
val result = fetch()
// Bước 4: Gọi thành công thì cất ngay vào Local DB
saveFetchResult(result)
// Lấy lại data từ DB (lúc này đã mới tinh) để đẩy lên UI. Đảm bảo chuẩn SSOT!
query().map { Resource.Success(it) }
} catch (throwable: Throwable) {
// Lỗi mạng hoặc server sập? Không sao, báo lỗi nhẹ nhàng và vẫn hiển thị data cũ.
query().map { Resource.Error(throwable.message ?: "Oops, lỗi kết nối!", it) }
}
} else {
// Data còn mới chán, không cần gọi API làm gì cho tốn pin.
query().map { Resource.Success(it) }
}
emitAll(flow)
}
Viết theo kiểu này, anh em sẽ tránh được những lỗi rất vô duyên như treo Main Thread gây ra lỗi ANR (Application Not Responding) ám ảnh, vì toàn bộ tác vụ fetch và lưu data đều diễn ra an toàn dưới background.
Đẩy lên UI thì sao? Ở tầng Repository, anh em cứ đóng gói nó lại thành một cái Flow. Giờ ở tầng UI, giả sử anh em đang dùng Jetpack Compose, hứng cái State này nhàn tênh:
Kotlin
// Trong ViewModel
val uiState = repository.getTransactions().stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = Resource.Loading()
)
Bên phía Compose, bạn chỉ việc check state: nếu là Loading thì quay cái progress bar nhỏ nhỏ trên góc, còn nội dung chính (list items) vẫn bind từ uiState.value.data để hiển thị mượt mà. User thậm chí không biết là app đang gọi API ở đằng sau.
Chốt lại Áp dụng NetworkBoundResource giúp app của bạn:
Luôn có dữ liệu để hiển thị: Đánh bay cảm giác giật lag, mang lại trải nghiệm "chạm là thấy".
Luồng dữ liệu một chiều rõ ràng: Tránh được vô số bug đồng bộ state, code dễ bảo trì và dễ scale cực kỳ.
Đừng bắt người dùng phải làm "kẻ ăn mày" ngồi đợi data từng giây nữa. Hãy xây dựng một kiến trúc mà app lúc nào cũng rủng rỉnh "tiền" trong ví nhé!
📚 Tài liệu tham khảo & Đọc thêm Anh em nào muốn mổ xẻ sâu hơn về pattern này hay xem cách các pháp sư Google viết code trong project thực tế thì nghía qua mấy link "gối đầu giường" này nhé:
Guide to app architecture (Android Developers): Nơi bắt nguồn của triết lý Single Source of Truth (SSOT). Tài liệu chính thức từ Google, bắt buộc phải đọc nếu muốn lên trình Architecture.
Now in Android (NiA) Open Source Project: Repo mã nguồn mở "chuẩn không cần chỉnh" của Google hiện nay. Anh em vào đây xem cách họ kết hợp Flow, Room và Jetpack Compose mượt như thế nào.
Kotlin Flows in Practice: Tài liệu chuẩn về cách xử lý luồng dữ liệu bất đồng bộ với Kotlin Flow – công cụ cốt lõi để xây dựng hàm networkBoundResource.
All rights reserved