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

XML / Compose

JD chấp nhận XML hoặc Compose. Biết cả hai và làm được list/detail với đủ trạng thái.

1Hai cách dựng UI: tự tay ra lệnh (XML) và mô tả theo công thức (Compose)

JD chấp nhận cả XML lẫn Jetpack Compose, nên cần nắm cả hai — nhưng chúng đi theo hai triết lý gần như trái ngược nhau. Hình dung View system (XML) giống việc TỰ TAY ra lệnh từng bước: bạn khai báo trước layout bằng XML, rồi mỗi khi dữ liệu đổi (vd tải xong danh sách sản phẩm), bạn phải tự tìm đúng view đó (qua ViewBinding) và tự gọi lệnh cập nhật nó — quên gọi ở chỗ nào là chỗ đó không tự cập nhật.

Compose thì ngược lại, giống một CÔNG THỨC nấu ăn hơn là một chuỗi thao tác: bạn chỉ mô tả "màn hình này trông như thế nào NẾU có state này" — không tự tay gọi lệnh cập nhật gì cả. Khi state đổi, Compose tự biết phần nào của UI cần vẽ lại (gọi là recomposition) và tự vẽ lại đúng phần đó, bạn không phải nhớ tự cập nhật tay như XML.

2XML: layout phẳng, ViewBinding, và RecyclerView tái sử dụng ô thay vì vẽ lại cả danh sách

  • ConstraintLayout: giúp layout "phẳng" (đặt các view cạnh nhau bằng ràng buộc, không cần lồng hết layout này trong layout khác) — layout lồng nhiều tầng giống if lồng if lồng if, vừa khó đọc vừa khiến hệ thống phải đo đạc qua nhiều lớp hơn, chậm hơn.
  • ViewBinding: thay vì tự gõ findViewById(R.id.tvName) — dễ gõ nhầm id, và lỗi đó chỉ lộ ra khi CHẠY THẬT (crash) chứ compiler không biết trước — ViewBinding tự sinh sẵn một class chứa đúng các view đó, gõ sai là báo lỗi ngay lúc build.
  • RecyclerView: dùng cho danh sách sản phẩm dài hàng trăm dòng. Thay vì tạo mới một view cho MỖI dòng (rất tốn), nó tái sử dụng lại đúng vài view đang hiển thị trên màn hình, chỉ đổi nội dung bên trong khi cuộn tới dòng mới.

RecyclerView với ListAdapter + DiffUtil

class ProductAdapter : ListAdapter<Product, ProductVH>(DIFF) {
    override fun onCreateViewHolder(parent: ViewGroup, type: Int): ProductVH {
        val b = ItemProductBinding.inflate(
            LayoutInflater.from(parent.context), parent, false)
        return ProductVH(b)
    }
    override fun onBindViewHolder(holder: ProductVH, pos: Int) =
        holder.bind(getItem(pos))

    companion object {
        val DIFF = object : DiffUtil.ItemCallback<Product>() {
            override fun areItemsTheSame(a: Product, b: Product) = a.id == b.id
            override fun areContentsTheSame(a: Product, b: Product) = a == b
        }
    }
}

Mẹo

Tình huống thật: chỉ một sản phẩm trong 100 sản phẩm bị đổi giá (vd vừa giảm giá). Không có DiffUtil, cách cũ (notifyDataSetChanged) coi như hét lên "vẽ lại TOÀN BỘ 100 dòng", dù 99 dòng chẳng đổi gì. DiffUtil so sánh danh sách cũ và mới, tìm ra ĐÚNG dòng nào đổi (giống sửa đúng 1 ô trong bảng Excel thay vì tô lại cả bảng), rồi chỉ vẽ lại đúng dòng đó — mượt hơn hẳn.

3Compose: composable là công thức, đọc state nào thì "dính" vào state đó

Composable là một hàm gắn @Composable, việc của nó chỉ là MÔ TẢ UI trông ra sao — không phải một chuỗi lệnh chạy một lần rồi xong. Compose theo dõi: hàm này có ĐỌC state nào không? Nếu có, hễ state đó đổi, Compose sẽ tự gọi lại đúng hàm này để vẽ lại (recomposition) — bạn không cần tự gọi update gì cả.

Điều cần nhớ: Compose có thể gọi lại một composable NHIỀU LẦN, theo bất kỳ thứ tự nào, thậm chí BỎ QUA (skip) nếu nó thấy không cần vẽ lại. Vì vậy thân hàm composable phải "sạch" — không được làm việc phụ như tự gọi API hay tự tăng một biến đếm ngay trong đó, vì bạn không kiểm soát được nó sẽ chạy đúng bao nhiêu lần.

  • remember: nhớ một giá trị qua các lần recompose, nhưng vẫn mất khi Activity bị dựng lại lúc xoay màn hình (configuration change) — giống một biến bình thường trong Activity mà bạn đã học ở phần Android fundamentals.
  • rememberSaveable: cũng nhớ như remember, nhưng bền hơn — sống sót qua cả configuration change lẫn process death, vì bên dưới nó dùng đúng cơ chế Bundle/onSaveInstanceState đã học trước đó, chỉ là Compose đóng gói lại cho gọn.
  • mutableStateOf: tạo ra một state mà Compose "nhìn thấy" và theo dõi được — đọc nó trong một composable là tự động đăng ký nhận thông báo mỗi khi nó đổi.

4State hoisting: composable con không tự giữ gì, chỉ nhận và báo lại

Đây chính là nguyên tắc "state đi xuống, event đi lên" đã học ở phần MVVM, chỉ khác là áp dụng ở tầng nhỏ hơn — giữa hai composable cha/con thay vì giữa ViewModel và UI. State hoisting nghĩa là: đừng để composable con tự giữ state của riêng nó, mà đẩy state đó lên composable cha (hoặc lên tận ViewModel), rồi composable con chỉ nhận giá trị hiện tại (value) và một callback để báo lại khi có thay đổi.

Composable con làm vậy trở thành stateless (không tự giữ gì) — nó chỉ là một "khuôn hiển thị" thuần tuý. Lợi ích rất thực tế: bạn preview được nó với bất kỳ giá trị giả nào mà không cần dựng cả ViewModel, dễ viết test, và dễ tái sử dụng ở màn hình khác vì nó không dính cứng vào một nguồn dữ liệu cụ thể.

State ở ViewModel, SearchBar chỉ là composable con stateless

@Composable
fun SearchBar(query: String, onQueryChange: (String) -> Unit) {
    TextField(value = query, onValueChange = onQueryChange)
}

@Composable
fun SearchScreen(vm: SearchViewModel) {
    val state by vm.uiState.collectAsStateWithLifecycle()
    SearchBar(query = state.query, onQueryChange = vm::onQueryChange)
}

Ghi nhớ

Dễ nhầm state hoisting với "đọc state gần nơi dùng" ở mục sau, vì cả hai đều nói tới việc đưa state "lên cao" hoặc "xuống thấp". Thực ra đây là HAI TRỤC khác nhau, không mâu thuẫn: state hoisting trả lời state SỐNG Ở ĐÂU và ai được quyền sửa (luôn đẩy lên ViewModel/composable cha, để có một nguồn duy nhất). Đọc gần nơi dùng trả lời một câu hỏi khác hẳn: tại DÒNG CODE nào bạn TÁCH một giá trị cụ thể ra khỏi state to đó, ngay trước khi trao cho composable con — và việc tách này nên làm càng sát chỗ gọi composable con càng tốt, dù state vẫn hoisting ở trên cao như cũ.

Ví dụ cụ thể: ProductScreen hoisting đúng — state nằm ở trên, Header và ProductList không tự giữ gì. Nhưng nếu bạn truyền NGUYÊN CẢ object state xuống cho cả hai, thì dù Header chỉ đọc state.title bên trong, nó vẫn phải nhận tham số state — mà state là một object MỚI mỗi khi products đổi (data class sinh instance mới mỗi lần cập nhật). Compose thấy tham số state đổi (khác reference) nên buộc Header phải vẽ lại, dù title chưa hề đổi giá trị.

Cách A (truyền cả state) khiến Header vẽ lại oan — Cách B (tách tại chỗ gọi) thì không

// Cách A: hoisting đúng, nhưng Header vẫn nhận NGUYÊN state
// → products đổi là state là object mới → Header bị vẽ lại dù title không đổi
Header(state = state)
ProductList(state = state)

// Cách B: hoisting y hệt, chỉ khác ở chỗ tách giá trị NGAY tại call site
// → title vẫn là String y hệt lần trước → Compose skip Header, chỉ ProductList vẽ lại
Header(title = state.title)
ProductList(products = state.products)

Mẹo

Tóm gọn: state hoisting quyết định state đặt ở component/file nào và ai sửa. Đọc gần nơi dùng quyết định giá trị cụ thể được giải nén (destructure) ở dòng nào trong cây UI trước khi trao cho con — tách càng sát điểm truyền cho từng composable con, phạm vi recomposition càng nhỏ.

5Side-effect và collect state: việc phụ chỉ nên làm đúng lúc cần, không phải mỗi lần vẽ lại

Nhắc lại vấn đề ở mục trước: composable có thể bị gọi lại nhiều lần chỉ để vẽ lại UI, nên tuyệt đối không gọi API hay điều hướng thẳng trong thân hàm — làm vậy nghĩa là mỗi lần recompose lại vô tình gọi API thêm một lần. LaunchedEffect được sinh ra đúng để giải quyết việc này: nó chỉ chạy code bên trong khi "key" truyền vào thực sự thay đổi (hoặc lần đầu xuất hiện), không chạy lại mỗi lần composable vẽ lại vì lý do khác.

LaunchedEffect: tải dữ liệu một lần, và lắng nghe event điều hướng

LaunchedEffect(Unit) { vm.loadProducts() }   // Unit không đổi bao giờ → chỉ chạy đúng 1 lần khi vào màn

LaunchedEffect(Unit) {
    vm.events.collect { event ->
        when (event) {
            is UiEvent.NavigateToDetail -> navController.navigate("detail/" + event.id)
        }
    }
}

collectAsStateWithLifecycle() chính là cách an toàn để đọc StateFlow từ ViewModel vào Compose — đúng như bạn đã học ở phần Coroutines + Flow, nó tự dừng collect khi màn hình bị che đi và tự collect lại khi hiện ra, không cần tự viết repeatOnLifecycle tay như bên View system.

Lưu ý

Đọc state càng cao trong cây Compose (vd đọc thẳng ở composable ngoài cùng bao trọn cả màn hình) thì recomposition càng lan rộng — một thay đổi nhỏ (vd chỉ ô tìm kiếm gõ thêm 1 ký tự) có thể kéo theo cả cây con vẽ lại dù phần lớn chẳng liên quan. Nên đọc state càng gần nơi thực sự dùng nó càng tốt — giống việc chỉ báo động đúng tầng có cháy, không báo động cả toà nhà.

Checklist

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

0/6

Lỗi thường gặp

  • Gọi API trực tiếp trong Composable body (recompose nhiều lần).
  • Quên DiffUtil khiến RecyclerView refresh toàn bộ list.
  • Đọc state quá cao trong cây Compose làm recomposition lan rộng.

Câu trả lời mẫu

Em không gọi API trong composable body vì nó recompose nhiều lần. Em gửi event tới ViewModel hoặc dùng `LaunchedEffect` với key phù hợp; UI chỉ collect state và render. Với XML em dùng RecyclerView + ListAdapter + DiffUtil để update mượt.

Câu hỏi liên quan

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