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

Android fundamentals

Nền tảng để không bị hỏi xoáy: lifecycle, context và điều gì xảy ra khi cấu hình thay đổi.

1Vì sao Android không đơn giản như một app bình thường

Trên máy tính, app của bạn chạy liên tục cho tới khi bạn tự tắt nó. Trên điện thoại thì khác: hệ điều hành mới là bên quyết định khi nào màn hình app của bạn được tạo ra, khi nào bị dọn đi — kể cả khi bạn không hề bấm nút thoát. Lý do là điện thoại có RAM hạn chế và phải chạy nhiều app cùng lúc, nên Android có quyền "dọn dẹp" app đang không dùng để lấy chỗ cho việc khác.

Vì vậy, học lifecycle (vòng đời) không phải để thuộc tên hàm cho vui, mà để trả lời đúng một câu hỏi thực tế: "nếu màn hình này bị hệ thống dọn đi bất ngờ, dữ liệu của tôi có bị mất không, và tôi có đang giữ thứ gì mà lẽ ra phải bỏ đi không?"

2Activity lifecycle: chuỗi callback bạn gặp mỗi ngày khi dùng app

Cách dễ nhớ nhất là gắn mỗi callback với một khoảnh khắc thật, cụ thể — không cần ẩn dụ, vì đây đúng là những gì xảy ra khi bạn cầm điện thoại lên dùng app. Sơ đồ dưới đây lấy ví dụ app Product Catalog (app trong phần Mini Project của portal này): mở app xem danh sách sản phẩm, gõ tìm kiếm, bấm vào một sản phẩm để xem ảnh full-screen, rồi rời đi — mỗi ô là một callback, kèm mô tả đúng chuyện gì xảy ra tại đó. Mỗi callback cũng gắn với một "trạng thái" (state) cụ thể, và những trạng thái này (CREATED, STARTED, RESUMED, DESTROYED) chính là những giá trị bạn sẽ gặp lại khi dùng Lifecycle.State ở các phần sau (ví dụ repeatOnLifecycle(Lifecycle.State.STARTED)):

Trạng thái
Callback
CREATED
onCreate()

Bạn vừa mở app: màn hình đang dựng danh sách sản phẩm, bắt đầu gọi API tải dữ liệu — MÀN HÌNH CHƯA HIỆN GÌ CẢ, vẫn đang chuẩn bị.

STARTED
onStart()

Màn hình bắt đầu hiện ra (thấy danh sách hoặc vòng xoay loading), nhưng bạn CHƯA chạm/cuộn được, vì hệ thống chưa đưa app lên "hẳn".

RESUMED
onResume()

Giờ bạn chạm, cuộn, gõ vào ô tìm kiếm được — đây là lúc bạn THỰC SỰ đang tương tác với app.

đang hiển thị và tương tác được — vùng này có thể lặp lại nhiều lần

STARTED
onPause()

Bạn bấm vào một sản phẩm để xem ảnh full-screen (một màn hình mới che lên một phần), hoặc có cuộc gọi đến. Phải xử lý rất nhanh ở đây, đừng làm việc nặng.

CREATED
onStop()

Bạn bấm nút Home hoặc chuyển hẳn sang app khác — Product Catalog không còn hiển thị một chút nào trên màn hình nữa.

onSaveInstanceState() thường được gọi ngay trước onStop() ở bước này

DESTROYED
onDestroy()

Bạn bấm Back liên tục để thoát hẳn, vuốt xoá app khỏi danh sách app đang chạy gần đây, hoặc hệ thống tự dọn app để lấy lại RAM — dọn dẹp tài nguyên, huỷ listener ở đây.

Nếu mở lại app ngay sau onStop (chưa bị onDestroy): onRestart() rồi onStart() → onResume() — quay lại đúng vòng trên, không chạy lại onCreate().

Ghi nhớ

Nhìn cột bên trái sẽ thấy một quy luật: đi lên thì CREATED -> STARTED -> RESUMED, đi xuống thì RESUMED -> STARTED -> CREATED -> DESTROYED — cùng một dải trạng thái, chỉ khác chiều đi lên hay đi xuống. Đây không phải trùng hợp: đây chính là 5 trạng thái chuẩn mà Android dùng để mô tả vòng đời của MỌI thành phần có lifecycle, kể cả Fragment ở phần sau.

3Xoay màn hình: vì sao Activity "làm lại từ đầu" và ViewModel không

Khi bạn xoay điện thoại, đổi giao diện sáng/tối, hay đổi ngôn ngữ máy — những thứ này gọi chung là configuration change. Cách Android xử lý khá "triệt để": nó không cố gắng chỉnh sửa màn hình hiện tại, mà đập bỏ toàn bộ Activity rồi dựng lại một cái mới từ đầu, để áp đúng layout/ngôn ngữ mới. Giống như thay vì xoay lại vài món đồ trong phòng, người ta dọn sạch cả phòng và bày lại từ con số 0.

Hậu quả: nếu dữ liệu bạn đang có (ví dụ danh sách đã tải, ô nhập đang gõ) chỉ nằm trong biến của Activity, nó sẽ mất sạch mỗi lần xoay màn hình — vì Activity cũ đã bị đập bỏ. ViewModel được thiết kế để không bị đập bỏ cùng Activity — nó là "cái hộp" được giữ lại nguyên vẹn qua lần dựng lại đó. Đây chính là lý do ViewModel tồn tại: để bạn có một nơi đặt dữ liệu mà không lo mất khi xoay màn hình.

Mẹo

Ba mức độ "bền" của một chỗ lưu dữ liệu, từ yếu tới bền nhất: biến trong Activity (mất ngay khi xoay màn hình) → ViewModel (sống qua xoay màn hình, nhưng mất nếu app bị dọn hẳn) → SavedStateHandle / onSaveInstanceState (sống qua cả việc bị dọn hoàn toàn).

4Fragment khác Activity ở điểm nào

Trước hết cần phân biệt rõ hai thứ: Activity giống như CÁI CỬA SỔ của toàn app — nó mở lên khi bạn mở app, đóng lại khi bạn thoát app. Fragment là MỘT MIẾNG UI nằm BÊN TRONG cửa sổ đó — ví dụ miếng hiển thị danh sách sản phẩm, hoặc miếng hiển thị chi tiết một sản phẩm. Rất nhiều app chỉ dùng DUY NHẤT MỘT Activity trong suốt lúc chạy, và bên trong Activity đó, các Fragment được thay ra thay vào liên tục để hiện các màn hình khác nhau — mà không cần đóng/mở lại cửa sổ.

Đây là điểm khác cốt lõi: Activity lifecycle nói về việc CẢ CÁI CỬA SỔ xuất hiện hay biến mất. Fragment lifecycle nói về việc MỘT MIẾNG UI bên trong cửa sổ đó được thêm vào hay gỡ ra — trong lúc cửa sổ (Activity) vẫn đứng yên, không hề bị tạo lại hay đóng đi. Vì một Fragment có thể bị gỡ ra rồi lắp lại nhiều lần trong khi Activity chứa nó vẫn sống nguyên, nên nó cần một vòng đời chi tiết hơn để mô tả đủ các tình huống đó — đó là lý do Fragment có nhiều callback hơn Activity.

Đây là sơ đồ đầy đủ, chính xác theo tài liệu chính thức của Android — nhìn kỹ sẽ thấy có HAI cột trạng thái riêng: cột Fragment (bên trái) và cột View (bên phải). Cột View bắt đầu MUỘN HƠN (chỉ có từ sau onCreateView) và kết thúc SỚM HƠN (DESTROYED ngay ở onDestroyView, trước khi Fragment kết thúc ở onDestroy) — đây chính là bằng chứng trực quan nhất cho việc "Fragment có hai vòng đời tách biệt".

Trạng thái Fragment
Callback
Trạng thái View
CREATED
onCreate()

Fragment được tạo ra, nhưng CHƯA có phần hiển thị (View) nào — đừng đụng tới UI ở đây.

onCreateView()View: INITIALIZED

Bắt đầu dựng phần hiển thị (inflate layout) và trả về View đó. View vừa có, chưa gán dữ liệu.

onViewCreated()

View đã dựng xong hoàn chỉnh — đây là lúc nên gán dữ liệu và đăng ký lắng nghe (luôn dùng viewLifecycleOwner).

onViewStateRestored()View: CREATED

Trạng thái đã lưu trước đó (nếu có) được khôi phục lại vào View.

STARTED
onStart()View: STARTED

Phần hiển thị của Fragment sắp hiện ra cho người dùng thấy.

RESUMED
onResume()View: RESUMED

Người dùng chạm, cuộn được trên đúng Fragment này.

STARTED
onPause()View: STARTED

Có gì che một phần (mở Fragment khác lên trên, dialog bật lên...).

CREATED
onStop()

Fragment không còn hiển thị chút nào (bị Fragment khác che kín toàn bộ).

onSaveInstanceState()

Lưu vội một ít dữ liệu nhỏ, phòng trường hợp phải dựng lại View từ đầu.

onDestroyView()View: DESTROYED

Phần hiển thị (View) bị dọn hẳn — nhưng đối tượng Fragment vẫn còn sống, chỉ đang chờ trong back stack.

View mất ở đây — nhưng Fragment vẫn ở trạng thái CREATED, chưa bị huỷ

DESTROYED
onDestroy()

Đến lượt chính đối tượng Fragment cũng bị huỷ hẳn (nó không còn nằm trong back stack để lấy lại nữa).

Mẹo

So với Activity ở phần trên, Fragment dùng ĐÚNG 5 trạng thái quen thuộc (INITIALIZED, CREATED, STARTED, RESUMED, DESTROYED) — chỉ khác là nó có HAI dải trạng thái chạy song song (của Fragment và của View) thay vì một. Khi Fragment quay lại từ back stack (như ví dụ ProductListFragment dưới đây), dải bên trái (Fragment) không hề tụt xuống DESTROYED, còn dải bên phải (View) thì tụt xuống DESTROYED rồi bắt đầu lại từ INITIALIZED — đúng như phần ví dụ sắp nói tới.

Hãy bám theo một tình huống thật: bạn đang ở màn hình danh sách sản phẩm (ProductListFragment). Bạn bấm vào một sản phẩm để xem chi tiết. App KHÔNG mở một cửa sổ (Activity) mới — nó chỉ thay miếng UI đang hiển thị bên trong CÙNG MỘT Activity, và "cất" ProductListFragment vào một hàng chờ (gọi là back stack) để có thể lấy lại ngay khi bạn bấm Back.

Chuyện gì thực sự xảy ra khi chuyển từ danh sách sang chi tiết, rồi bấm Back

Bạn bấm vào 1 sản phẩm trong danh sách:
  -> ProductDetailFragment được tạo và hiện lên (onCreateView, onViewCreated...)
  -> ProductListFragment bị che đi, và PHẦN HIỂN THỊ của nó bị dọn: onDestroyView
     (nhưng đối tượng ProductListFragment thì KHÔNG bị huỷ — onDestroy KHÔNG
     được gọi, nó chỉ đang chờ trong back stack, chưa mất đi đâu cả)

Bạn bấm nút Back:
  -> ProductDetailFragment bị đóng hẳn: onDestroyView -> onDestroy -> onDetach
  -> ProductListFragment hiện lại — nhưng KHÔNG PHẢI một đối tượng mới được
     tạo ra, mà CHÍNH đối tượng cũ được dùng lại. Chỉ riêng phần hiển thị của
     nó được DỰNG LẠI TỪ ĐẦU: onCreateView chạy thêm một lần nữa.

Đây chính là ý nghĩa của "Fragment có hai vòng đời": đối tượng ProductListFragment (nơi chứa dữ liệu, biến, logic bạn viết) có thể tồn tại LÂU HƠN phần hiển thị (View) của nó. Có lúc phần hiển thị bị dọn đi rồi dựng lại thành một bản HOÀN TOÀN MỚI, nhưng đối tượng Fragment thì vẫn là MỘT, không đổi trong suốt quá trình đó.

Vì sao điều này thực sự gây rắc rối: giả sử trong ProductListFragment, bạn viết code lắng nghe (observe) dữ liệu mới nhất từ ViewModel ngay khi màn hình được tạo ra. Nếu bạn gắn việc lắng nghe đó theo vòng đời của CHÍNH FRAGMENT (this) thay vì theo vòng đời của PHẦN HIỂN THỊ (viewLifecycleOwner), chuyện sau sẽ xảy ra: lần đầu hiện màn hình, bạn tạo ra một "người lắng nghe". Bạn qua xem chi tiết sản phẩm rồi bấm Back quay lại — onCreateView chạy lại lần hai, và đoạn code lắng nghe đó CHẠY LẠI, tạo thêm một "người lắng nghe" thứ hai. Người lắng nghe đầu tiên thì chưa từng bị dừng, vì lý do khiến nó dừng (this bị huỷ) chưa hề xảy ra — Fragment vẫn còn sống nguyên như đã nói ở trên. Kết quả: có 2 người cùng lắng nghe 1 nguồn dữ liệu — mỗi khi có dữ liệu mới, code cập nhật UI chạy hai lần; nhẹ thì lãng phí, nặng thì một trong hai lần cố cập nhật lên một View đã không còn tồn tại và làm app crash.

Sai và đúng khi lắng nghe dữ liệu trong Fragment

// Sai: gắn theo vòng đời của chính Fragment — sống quá lâu, dễ lắng nghe trùng
lifecycleScope.launch {
    viewModel.uiState.collect { render(it) }
}

// Đúng: gắn theo vòng đời của PHẦN HIỂN THỊ hiện tại
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { render(it) }
    }
}

Mẹo

Vì sao dùng viewLifecycleOwner lại sửa được vấn đề: nó gắn đúng theo View đang tồn tại tại thời điểm đó, không gắn theo Fragment (sống lâu hơn). Mỗi lần View bị dọn (onDestroyView), việc lắng nghe gắn theo viewLifecycleOwner cũng tự động dừng theo — nên không bao giờ có chuyện hai lần lắng nghe chồng lên nhau. Quy tắc để nhớ: trong Fragment, mọi thứ liên quan tới hiển thị (observe, collect) đều gắn theo viewLifecycleOwner, không gắn theo this.

5Context: "cả toà nhà" hay "một căn phòng"

Context là thứ bạn cần để làm hầu hết mọi việc trong Android — hiển thị dialog, đọc resource, mở màn hình mới. Nhưng có hai loại Context, và nhầm loại là nguyên nhân memory leak phổ biến nhất.

  • Activity Context: giống như "một căn phòng cụ thể" — chỉ tồn tại trong lúc màn hình đó còn mở. Dùng khi cần hiển thị UI, dialog, hoặc lấy theme của đúng màn hình đó.
  • Application Context: giống như "cả toà nhà" — tồn tại suốt thời gian app còn chạy, không gắn với một màn hình cụ thể nào. Dùng cho thứ sống lâu như một object dùng chung toàn app, hoặc khởi tạo thư viện.

Lưu ý

Nếu bạn đưa Activity Context (một căn phòng) vào một object sống lâu như toà nhà (ví dụ một singleton), thì căn phòng đó sẽ không bao giờ được "dỡ đi" thật sự — dù người dùng đã đóng màn hình, hệ thống vẫn thấy có ai đó đang giữ nó, nên không giải phóng được. Đó chính là memory leak. Quy tắc đơn giản: thứ sống lâu thì chỉ nên cầm theo Application Context.

6Bị dọn hoàn toàn (process death) và cách hồi phục

Khi app của bạn ở dưới nền một thời gian, Android có thể dọn hẳn (kill) toàn bộ tiến trình app để lấy lại RAM cho việc khác — mạnh tay hơn nhiều so với chuyện xoay màn hình. Điều khó chịu là: khi người dùng mở lại app, họ mong thấy đúng màn hình như lúc họ rời đi, dù thực chất app đang khởi động lại từ đầu.

Vì ViewModel cũng bị xoá sạch trong trường hợp này (nó chỉ sống qua xoay màn hình, không sống qua bị dọn hẳn), bạn cần một chỗ lưu "bền" hơn: onSaveInstanceState (cách làm gốc, có từ lâu), SavedStateHandle (bản bọc gọn hơn, dùng trong ViewModel), hoặc rememberSaveable (bản dành cho Compose). Ba cái này thực chất dùng chung một cơ chế phía dưới — chỉ khác cách bạn cầm nó trên tay.

7onSaveInstanceState: tờ giấy nhớ đó thực chất là gì

Hãy tưởng tượng cụ thể hơn: ngay TRƯỚC KHI Android có thể huỷ Activity của bạn (mà không phải do bạn tự đóng), hệ thống đưa cho bạn một cái hộp nhỏ rỗng gọi là Bundle, và gọi hàm onSaveInstanceState(outState: Bundle) — đây là lúc bạn nhanh tay nhét vài thứ quan trọng vào hộp. Sau đó, nếu Activity thật sự bị tạo lại (do xoay màn hình, hoặc bị dọn rồi mở lại), cái hộp đó được trả về cho bạn ngay trong onCreate(savedInstanceState: Bundle?) — bạn mở hộp ra và khôi phục lại những gì đã lưu.

Lưu và đọc lại một giá trị đơn giản

override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
    outState.putString("search_query", currentQuery)   // nhét vào hộp
}

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    // mở hộp ra, nếu không có hộp (lần đầu mở app) thì dùng chuỗi rỗng
    currentQuery = savedInstanceState?.getString("search_query") ?: ""
}

Một điểm dễ hiểu lầm: hàm này KHÔNG được gọi khi người dùng TỰ Ý đóng màn hình — ví dụ bấm nút Back hoặc bạn tự gọi finish(). Lý do rất hợp lý: nếu người dùng cố tình rời khỏi màn hình đó, Android hiểu là bạn không cần quay lại trạng thái cũ nữa. Nó chỉ được gọi khi Activity có thể bị huỷ NGOÀI Ý MUỐN của người dùng — xoay màn hình, đổi ngôn ngữ, hoặc app bị đẩy xuống nền rồi có khả năng bị dọn.

Lưu ý

Cái hộp (Bundle) này rất nhỏ và không phải chỗ để chứa nhiều dữ liệu. Nó chỉ nên đựng những thứ gọn nhẹ: một chuỗi, một số, một id, vị trí đang cuộn tới — không phải cả một danh sách sản phẩm tải từ mạng về. Nhồi quá nhiều vào hộp này có thể khiến app crash (lỗi TransactionTooLargeException). Dữ liệu lớn nên để ở database hoặc tải lại từ mạng khi cần, không nhét vào đây.

Vậy SavedStateHandle khác gì? Về bản chất nó dùng đúng cái hộp này, nhưng Google đóng gói lại cho tiện hơn: bạn không cần tự override onSaveInstanceState/onCreate, mà chỉ cần đọc/ghi qua SavedStateHandle như một cái Map ngay trong ViewModel — mọi việc "nhét vào hộp" và "mở hộp ra đúng lúc" được làm tự động phía sau. Còn rememberSaveable trong Compose cũng là một lớp vỏ tương tự, dành riêng cho biến bạn khai báo trong composable.

Mẹo

Muốn tự kiểm tra app của mình có hồi phục đúng không, vào Developer Options trên điện thoại, bật 'Don't keep activities' — mỗi lần bạn rời màn hình, hệ thống sẽ dọn nó ngay như thể bị process death, để bạn thấy ngay app có giữ đúng trạng thái hay không.

Checklist

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

0/6

Lỗi thường gặp

  • Giữ reference tới Activity trong singleton/ViewModel gây memory leak.
  • Dùng Activity Context ở nơi cần Application Context (và ngược lại).
  • Quên state restoration khi process bị kill.

Câu trả lời mẫu

Khi xoay màn hình, Activity bị destroy và recreate, nhưng ViewModel sống qua configuration change nhờ được lưu ngoài Activity instance. Vì vậy em để UI state trong ViewModel thay vì trong Activity để không mất dữ liệu.

Câu hỏi liên quan

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