Tối ưu hóa Networking trong Android với NetworkBoundResource Pattern
Triết lý "Ví Tiền và Cây ATM" - Tạm biệt màn hình Loading!
Chào mọi người, mình đã code Android được 2 năm rồi nhưng đây mới là bài viết đầu tiên của mình trên Viblo. 👋
Gần đây mình được giao làm phần Networking cho dự án mới và may mắn được anh Tech Lead cho tham khảo source code app của một công ty V (giấu tên). Thấy Architecture của họ hay quá mà chưa có bài tiếng Việt nào, nên mình quyết định viết bài chia sẻ lại cho mọi người.
Không nói về code khô khan vội, hãy bắt đầu bằng câu chuyện... đi chợ mua xôi sáng nay của mình.
Câu chuyện là thế này: Mình đang xếp hàng mua xôi, bụng đói meo. Đến lượt mình, mình rút ví ra trả tiền cái rụp, cầm gói xôi đi làm. Nhanh, gọn, lẹ! Nhưng hãy tưởng tượng, nếu quy trình mua xôi nó diễn ra thế này:
- Mình gọi cô bán xôi.
- Mình bắt buộc phải chạy ra cây ATM cách đó 500m.
- Xếp hàng chờ rút tiền.
- Chạy về đưa tiền cho cô bán xôi.
- Mới được lấy xôi ăn.
Nghe vô lý đúng không? Tại sao phải ra ATM trong khi trong ví vẫn còn tiền? Thế nhưng, buồn thay là rất nhiều anh em Developer chúng ta đang bắt người dùng phải "chạy ra ATM" mỗi lần mở App, bằng cách show cái vòng Loading xoay tít mù khơi để chờ gọi API, trong khi dữ liệu cũ vẫn còn nằm sờ sờ trong máy.
Bài viết này sẽ giới thiệu pattern NetworkBoundResource với triết lý "Ví tiền và cây ATM", giúp App của bạn chạy mượt như cách bạn rút ví trả tiền mua xôi vậy! 🚀
1. Core Concept: Ví Tiền là Chân Ái (Single Source of Truth)
Trong kiến trúc này, chúng ta tuân thủ nguyên tắc vàng: Single Source of Truth (SSOT).
Hãy hình dung:
- Database (Room/Cache) chính là cái VÍ TIỀN của bạn.
- Network (API) chính là CÂY ATM.
- UI (Màn hình) là BẠN (người tiêu tiền).
Quy tắc hoạt động cực kỳ đơn giản:
"Bạn chỉ được lấy tiền từ VÍ để tiêu. Cây ATM chỉ có nhiệm vụ bơm tiền vào VÍ."
Tuyệt đối không có chuyện rút tiền từ ATM rồi đưa thẳng cho người bán hàng (UI). Tiền phải vào ví đã, rồi muốn làm gì thì làm.
2. The Flow: Hành trình của tờ tiền (4 Bước)
Để hiện thực hóa triết lý trên, NetworkBoundResource sẽ hoạt động theo 4 bước, mình gọi là quy trình "Tiêu tiền thông minh":
Bước 1: Mở ví kiểm tra (loadFromDb)
User mở màn hình lên. Việc đầu tiên App làm là "mở ví" (query DB).
- Hành động: Show ngay dữ liệu đang có trong DB lên UI.
- Kết quả: User thấy nội dung ngay lập tức (dù có thể là tin tức của... hôm qua). Nhưng thà đọc tin hôm qua còn hơn ngồi nhìn màn hình trắng, đúng không?
Bước 2: Có cần rút thêm không? (shouldFetch)
Liếc nhìn vào ví.
- Nếu tiền còn mới (Data mới fetch 5 phút trước) -> Thôi, khỏi ra ATM, dùng luôn cho lẹ.
- Nếu tiền rách, hoặc ví rỗng tuếch -> OK, bắt buộc phải ra ATM (Gọi API).
Bước 3: Ra cây ATM rút tiền (createCall)
App chạy ngầm ra gọi API (Background thread).
- Lúc này, ở nhà (UI) User vẫn đang xem dữ liệu cũ (từ Bước 1). App vẫn mượt, vẫn lướt được, không hề bị block.
Bước 4: Nhét tiền vào ví (saveCallResult)
Đây là bước quan trọng nhất!
- ATM nhả tiền (API trả về JSON).
- Bạn KHÔNG cầm cục JSON đó đẩy thẳng lên UI.
- Bạn LƯU cục đó vào Database (Room).
- Vì Room Database hỗ trợ
LiveDatahoặcFlow, ngay khi data được lưu xuống, nó sẽ tự động bắn tín hiệu (emit) data mới nhất lên UI.
3. Let's Code: Triển khai với Kotlin Flow
Lý thuyết thế đủ rồi, giờ xắn tay vào code nhé. Chúng ta sẽ sử dụng Kotlin Flow để xử lý luồng dữ liệu này cho "modern".
3.1. Wrapper Class: Cái phong bao lì xì (Resource)
Để UI biết được tình trạng tiền nong (đang rút, rút xong, hay ATM nuốt thẻ), ta bọc data vào một Sealed Class:
// Resource.kt
sealed class Resource<T>(val data: T? = null, val message: String? = null) {
// Tiền về, ví đầy!
class Success<T>(data: T) : Resource<T>(data)
// Đang đếm tiền... (có thể đưa tạm tiền cũ ra dùng)
class Loading<T>(data: T? = null) : Resource<T>(data)
// ATM bảo trì, nhưng vẫn trả về tiền cũ trong ví (nếu có)
class Error<T>(message: String, data: T? = null) : Resource<T>(message, data)
}
3.2. Hàm NetworkBoundResource thần thánh
Đây là trái tim của cả bài viết. Hàm này sẽ trả về một Flow<Resource<ResultType>>.
// NetworkBoundResource.kt
import kotlinx.coroutines.flow.*
inline fun <ResultType, RequestType> networkBoundResource(
// 1. Hàm lấy data từ DB (Mở ví)
crossinline query: () -> Flow<ResultType>,
// 2. Hàm gọi API (Ra ATM)
crossinline fetch: suspend () -> RequestType,
// 3. Hàm lưu data vào DB (Nhét tiền vào ví)
crossinline saveFetchResult: suspend (RequestType) -> Unit,
// 4. Logic check xem có cần fetch không (Có cần rút tiền không?)
crossinline shouldFetch: (ResultType) -> Boolean = { true }
) = flow {
// B1: Lấy data trong ví ra xem trước
val data = query().first()
// B2: Check xem có cần ra ATM không?
val flow = if (shouldFetch(data)) {
// Báo UI là "Đang tải nha", nhưng đưa tạm data cũ cho xem
emit(Resource.Loading(data))
try {
// B3: Gọi API
val result = fetch()
// B4: Lưu vào DB
saveFetchResult(result)
// Query lại từ DB để đảm bảo SSOT (Lúc này DB đã có data mới)
query().map { Resource.Success(it) }
} catch (throwable: Throwable) {
// Lỗi thì báo lỗi, nhưng vẫn trả về data cũ trong ví
query().map { Resource.Error(throwable.message ?: "Unknown Error", it) }
}
} else {
// Không cần fetch, dùng luôn data trong ví
query().map { Resource.Success(it) }
}
// Emit tất cả ra ngoài
emitAll(flow)
}
3.3. Áp dụng vào Repository
Giả sử bạn làm app xem danh sách Phim.
// MovieRepository.kt
fun getMovies(): Flow<Resource<List<Movie>>> {
return networkBoundResource(
query = { movieDao.getAllMovies() },
fetch = { apiService.getMovies() },
saveFetchResult = { movies ->
db.withTransaction {
movieDao.deleteAll()
movieDao.insertAll(movies)
}
},
shouldFetch = { movies ->
// Ví dụ: Nếu DB rỗng hoặc data cũ quá 5 phút thì mới fetch
movies.isEmpty() || isDataStale(System.currentTimeMillis())
}
)
}
Và ở ViewModel, bạn chỉ cần collect cái Flow này là xong!
// MovieViewModel.kt
val movies = repository.getMovies().asLiveData()
// Hoặc collect trong LifeCycleScope nếu dùng Compose/Fragment
4. Kết luận
Việc áp dụng NetworkBoundResource giúp App của bạn đạt được cảnh giới "Offline-First":
- Mất mạng? Không sao, vẫn mở ví (DB) ra xem tiền cũ được.
- Trải nghiệm mượt mà: Không còn cảnh màn hình trắng xóa chờ loading.
- Code Clean: Tách bạch rõ ràng trách nhiệm.
Đừng biến App của bạn thành kẻ "lúc nào cũng túng thiếu" phải chạy vạy ra ATM. Hãy biến nó thành một "đại gia" luôn rủng rỉnh tiền trong ví nhé!
Tham khảo: Google Architecture Components, ProAndroidDev.
All rights reserved