Bảng Điều Khiển Ôn Tập Android
Kotlin • MVVM • Flow • REST API • Compose • Testing
REST API
Gọi API, xử lý status code, map DTO và luôn có đủ loading/success/empty/error.
1REST API là gì: app và server nói chuyện với nhau kiểu gửi thư
Hãy tưởng tượng việc gọi API giống như gửi một lá thư có kèm form yêu cầu. Bạn (app) viết rõ: gửi tới đâu (URL, vd /products), yêu cầu loại gì (HTTP method — xin xem, tạo mới, sửa, hay xoá), kèm vài giấy tờ đi kèm (header, vd token đăng nhập), và đôi khi có cả nội dung chi tiết (body, vd thông tin đơn hàng muốn tạo). Server nhận thư, xử lý, rồi gửi thư trả lời gồm một mã kết quả (status code — thành công hay có vấn đề gì) và nội dung trả về (thường là JSON).
REST chỉ là một quy ước chung để hai bên hiểu nhau: mỗi loại dữ liệu (resource) như "sản phẩm", "người dùng" có một địa chỉ URL riêng, và bạn thao tác lên nó bằng đúng loại yêu cầu (method) phù hợp — thay vì mỗi API tự bịa một kiểu gọi khác nhau.
Trong app Android, bạn không tự tay soạn và gửi lá thư đó — Retrofit làm việc này giúp bạn: bạn chỉ cần khai báo trước "mẫu thư" (một interface mô tả từng endpoint), Retrofit sẽ tự soạn request thật, gửi đi qua OkHttp (thư viện lo phần kết nối mạng thật sự), rồi tự chuyển JSON trả về thành object Kotlin nhờ một converter (Moshi/Gson/kotlinx.serialization).
Khai báo "mẫu thư" bằng Retrofit — chỉ cần mô tả, không tự gửi tay
interface ProductApi {
@GET("products")
suspend fun getProducts(
@Query("page") page: Int = 1
): List<ProductDto>
@GET("products/{id}")
suspend fun getProduct(@Path("id") id: String): ProductDto
}Ghi nhớ
Từ khoá suspend ở đây chính là thứ bạn đã gặp ở phần Coroutines + Flow: nó cho phép Retrofit tự chạy request ở background mà không chặn Main thread, bạn chỉ cần gọi nó bên trong viewModelScope như một hàm bình thường, không cần tự tay chuyển thread hay dùng callback.
2HTTP method và status code: hỏi đúng cách, hiểu đúng câu trả lời
Mỗi HTTP method giống như một loại yêu cầu khác hẳn nhau về mặt Ý NGHĨA, không chỉ là tên gọi khác nhau — hiểu sai method sẽ dẫn tới thiết kế API sai (vd tạo đơn hàng bằng GET, rất dễ bị gọi lại nhầm và tạo trùng):
- GET: chỉ "hỏi xem có gì", không làm server thay đổi gì cả — gọi 10 lần cũng chỉ nhận lại đúng dữ liệu đó (idempotent), nên an toàn để cache hay gọi lại khi retry.
- POST: "tạo cái mới" — giống bấm nút Đặt hàng, mỗi lần bấm là một đơn hàng mới. Vì vậy gọi 2 lần do lỡ tay bấm 2 phát sẽ ra 2 bản ghi, không idempotent.
- PUT: thay nguyên cả tài nguyên bằng bản mới (giống nộp lại toàn bộ form); PATCH: chỉ sửa đúng phần muốn đổi (giống sửa một ô trong form đã nộp). PUT gọi lại nhiều lần vẫn ra kết quả như nhau (idempotent), PATCH thường cũng vậy.
- DELETE: xoá hẳn tài nguyên — xoá rồi thì xoá lần nữa cũng chỉ ra cùng một kết quả (đã không còn), nên vẫn idempotent.
Status code trả về được nhóm theo chữ số đầu, giống như phân loại thư trả lời: 2xx là "ổn, làm xong rồi" (200 OK lấy dữ liệu thành công, 201 Created vừa tạo xong, 204 No Content làm xong nhưng không có gì để trả về), 4xx là "chính app/người gọi sai" ở đâu đó, 5xx là "server tự nó có vấn đề", không phải lỗi của app.
- 400 Bad Request: request gửi sai định dạng hoặc thiếu field bắt buộc → cần kiểm tra lại body/tham số app đang gửi.
- 401 Unauthorized: chưa đăng nhập, hoặc token hết hạn → cần refresh token, hoặc đưa người dùng quay lại màn đăng nhập.
- 403 Forbidden: đã biết chính xác là ai rồi (đăng nhập hợp lệ), nhưng người này không có quyền làm việc đó → báo "không có quyền", đừng nhầm là lỗi đăng nhập rồi bắt đăng nhập lại vô ích.
- 404 Not Found: tài nguyên không tồn tại (vd sản phẩm đã bị xoá) → hiển thị trạng thái rỗng/không tìm thấy, không phải lỗi hệ thống.
- 409 Conflict, 422 Unprocessable: dữ liệu gửi lên không sai định dạng, nhưng mâu thuẫn với nghiệp vụ (vd đặt trùng một mã đã tồn tại).
- 500/502/503: server đang gặp sự cố → hiển thị thông báo lỗi kèm nút thử lại, đừng đổ lỗi ngược cho người dùng.
Mẹo
Câu hỏi rất hay gặp: '401 khác 403 thế nào?'. Cách nhớ dễ nhất: 401 là server hỏi ngược lại 'bạn là ai vậy?' (chưa xác định được danh tính, hoặc token hết hạn). 403 là server đã biết rõ bạn là ai, nhưng trả lời thẳng 'biết rồi, nhưng không được phép' (thiếu quyền, vd tài khoản thường cố vào trang chỉ dành cho admin).
3DTO và domain model: đừng để UI phải hiểu "ngôn ngữ riêng" của server
Server và app thường không cùng "gu" đặt tên hay định dạng dữ liệu. Ví dụ thật: server trả về giá sản phẩm dưới dạng price_cents = 19900000 (số tiền tính bằng phần nhỏ nhất, kiểu viết quen thuộc phía backend), nhưng UI cần hiển thị thẳng "199.000đ" đã format sẵn cho người dùng đọc. Nếu để y nguyên cấu trúc JSON đó chạy thẳng lên UI, coi như UI đang phải tự hiểu "ngôn ngữ riêng" của server.
DTO (Data Transfer Object) là class chỉ có nhiệm vụ phản ánh ĐÚNG những gì JSON của server trả về — kể cả tên field "xấu" (price_cents, image_url). Domain model (Product) là class app tự định nghĩa, đúng ý mình cần để hiển thị và xử lý nghiệp vụ. Việc map từ DTO sang domain model nên làm ngay tại Repository — nơi duy nhất "biết tiếng" của cả hai bên.
Tách DTO khỏi domain model và map ở Repository
data class ProductDto(
val id: String,
val name: String,
val price_cents: Long, // đúng theo JSON server, tên xấu cũng phải giữ nguyên
val image_url: String?,
val stock: Int
)
data class Product( // model app tự định nghĩa, đúng ý mình cần
val id: String,
val name: String,
val price: String, // đã format sẵn "199.000đ"
val imageUrl: String?,
val stock: Int // số lượng còn trong kho
)
fun ProductDto.toDomain() = Product(
id = id,
name = name,
price = formatVnd(price_cents),
imageUrl = image_url,
stock = stock
)Mẹo
Lợi ích rất thực tế: nếu một ngày server đổi price_cents thành một field khác, hoặc gộp/tách field, bạn chỉ cần sửa đúng một chỗ — hàm toDomain() này. UI và ViewModel không hề biết chuyện đó xảy ra, vì chúng chỉ làm việc với Product, không đụng tới ProductDto bao giờ.
4Xử lý kết quả và lỗi bằng UiState
Một lời gọi mạng thực tế chỉ có đúng 4 khả năng xảy ra: đang chờ (Loading), thành công và có dữ liệu (Success), thành công nhưng chẳng có gì để hiển thị (Empty — vd tìm kiếm ra 0 kết quả), hoặc thất bại (Error). Nếu không mô hình hoá rõ ràng, rất dễ quên vẽ một trong 4 trạng thái đó — ví dụ quên xử lý Empty, người dùng tìm kiếm ra 0 kết quả nhưng màn hình cứ trắng trơn không nói gì.
Cách chắc chắn không bỏ sót là khai báo cả 4 khả năng đó thành một sealed interface. Nhờ vậy, đúng như bạn đã gặp ở phần Kotlin core, compiler sẽ BẮT BUỘC bạn xử lý đủ khi vẽ UI (when không có else vẫn hợp lệ, vì đã liệt kê hết) — quên trạng thái nào là báo lỗi ngay lúc build, không phải để tới lúc chạy thật mới phát hiện.
Repository trả kết quả sạch, ViewModel map sang đủ 4 trạng thái
sealed interface ProductUiState {
data object Loading : ProductUiState
data class Success(val products: List<Product>) : ProductUiState
data object Empty : ProductUiState
data class Error(val message: String) : ProductUiState
}
class DefaultProductRepository(private val api: ProductApi) : ProductRepository {
override suspend fun getProducts(): List<Product> =
api.getProducts().map { it.toDomain() }
}
fun loadProducts() {
viewModelScope.launch {
_uiState.value = ProductUiState.Loading
try {
val products = repository.getProducts()
_uiState.value =
if (products.isEmpty()) ProductUiState.Empty
else ProductUiState.Success(products)
} catch (e: CancellationException) {
throw e // không nuốt cancellation, xem lại phần Coroutines + Flow
} catch (e: IOException) {
_uiState.value = ProductUiState.Error("Lỗi mạng, thử lại nhé")
} catch (e: HttpException) {
_uiState.value = ProductUiState.Error(mapHttpError(e.code()))
}
}
}Lưu ý
Đây chính là lỗi cancellation đã nói ở phần Coroutines + Flow, gặp lại trong bối cảnh gọi mạng: nếu catch chung Exception mà không tách riêng CancellationException, lúc người dùng rời màn hình giữa chừng (huỷ coroutine), bạn sẽ vô tình hiện UiState.Error dù thực ra chẳng có lỗi mạng nào cả.
Với mạng chậm hoặc chập chờn, nên đặt timeout ở OkHttp (đừng để app chờ vô thời hạn) và cân nhắc tự retry cho lỗi tạm thời (5xx, timeout — vì gọi lại có thể thành công). Với ô tìm kiếm, nhớ thêm debounce (đã học ở phần Coroutines + Flow) để không bắn API liên tục theo từng ký tự gõ.
5Interceptor: một trạm kiểm soát chung cho mọi request
Nếu không có interceptor, mỗi lần gọi API bạn phải tự tay đính token đăng nhập vào header, tự log, tự đo thời gian request — lặp lại y hệt ở hàng chục nơi trong ProductApi và các API khác. Interceptor của OkHttp giống một trạm kiểm soát đặt sẵn ở cổng ra vào: MỌI request đi qua đây trước khi thật sự ra ngoài mạng, và mọi response đi qua đây trước khi tới tay app — bạn chỉ cần viết luật kiểm soát MỘT LẦN ở trạm đó.
Interceptor tự gắn token vào mọi request, không cần sửa từng API
class AuthInterceptor(
private val tokenProvider: () -> String?
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder().apply {
tokenProvider()?.let { header("Authorization", "Bearer $it") }
}.build()
return chain.proceed(request)
}
}
val client = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor { tokenStore.accessToken })
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()Lưu ý
Trạm kiểm soát này thấy TOÀN BỘ nội dung request/response đi qua, kể cả token và dữ liệu nhạy cảm — nên tuyệt đối không bật log mức BODY (in cả nội dung ra Logcat) ở bản release, chỉ bật khi debug. Cũng đừng hardcode API key thẳng trong source code.
Mẹo
Muốn xem THẬT app đang gửi/nhận gì mà không cần tự thêm HttpLoggingInterceptor hay tự in log, dùng App Inspector trong Android Studio (mở khi app đang chạy debug) — nó hiện trực tiếp từng network request/response (header, body) và cả dữ liệu đang nằm trong database (Room), rất tiện để đối chiếu xem ProductApi có thật sự gửi đúng tham số, hay server trả về đúng như bạn nghĩ hay không.
Checklist
Tick những gì bạn đã nắm chắc
Lỗi thường gặp
- •Dùng DTO trực tiếp làm UI model, khiến UI phụ thuộc cấu trúc API.
- •Không phân biệt 401 (chưa auth) và 403 (không đủ quyền).
- •Bỏ qua empty state và error body khi API trả về khác 200.
Câu trả lời mẫu
Repository gọi API qua Retrofit, map DTO sang domain model rồi trả về `Result`/UiState. Em xử lý 401 bằng refresh token hoặc điều hướng login, 403 báo không đủ quyền, 5xx hiển thị lỗi kèm nút retry, và luôn có loading/empty/error state.
Câu hỏi liên quan