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

Performance

Có quy trình đo đạc và tối ưu thay vì đoán mò: main thread, list, ảnh và recomposition.

1Nguyên tắc số 1: đừng bắt Main thread làm việc nặng

Hãy hình dung Main thread như MỘT nhân viên duy nhất đứng ở quầy, vừa phải vẽ lại màn hình vừa phải phản hồi mọi cú chạm của người dùng. Để mắt nhìn thấy chuyển động mượt (60 khung hình/giây), nhân viên đó phải xong phần việc của MỖI khung hình trong khoảng 16 mili-giây — nhanh hơn một cái chớp mắt rất nhiều.

Nếu bạn giao cho nhân viên đó một việc nặng — gọi mạng, đọc database, decode một tấm ảnh lớn, parse một JSON khổng lồ — ngay trên Main thread, nhân viên đó bị "kẹt" ở việc nặng đó và không kịp vẽ khung hình tiếp theo đúng hạn. Kẹt nhẹ thì màn hình giật (jank, người dùng thấy cuộn không mượt); kẹt quá lâu (khoảng 5 giây với sự kiện chạm) thì hệ thống coi như app đã "đứng hình" và tự hỏi người dùng có muốn đóng app không — đây chính là ANR.

Cách giải quyết đã học ở phần Coroutines + Flow: việc nặng nào cũng nên đẩy sang đúng làn phù hợp — Dispatchers.IO cho việc ngồi chờ (mạng, đọc/ghi file, DB), Dispatchers.Default cho việc tính toán nặng CPU — chỉ để lại cho Main thread đúng một việc: cập nhật UI.

Sai: chặn Main thread — Đúng: đẩy việc nặng sang Dispatchers.IO

// SAI: đọc file cấu hình lớn ngay trên Main — nhân viên ở quầy phải dừng vẽ để đi đọc file
fun onScreenOpen() {
    val config = File(path).readText()   // block Main thread
    render(config)
}

// ĐÚNG: nhường việc đọc file cho làn IO, Main thread chỉ nhận kết quả rồi vẽ
viewModelScope.launch {
    val config = withContext(Dispatchers.IO) { File(path).readText() }
    render(config)
}

2ANR: khi nhân viên duy nhất đó "biến mất" quá lâu

ANR (Application Not Responding) là hậu quả trực tiếp của phần trước, chỉ khác ở MỨC ĐỘ nghiêm trọng: Main thread bị chặn đủ lâu (khoảng 5 giây khi đang có sự kiện chạm chờ xử lý) khiến hệ thống kết luận app không còn phản hồi. Ba thủ phạm quen mặt nhất: tự ý gọi Room/đọc file/gọi mạng một cách ĐỒNG BỘ (không suspend) ngay trên Main; tự decode một bitmap độ phân giải lớn trực tiếp; hoặc một vòng lặp tính toán quá nặng không nhường CPU cho việc vẽ UI.

  • Không bao giờ tự truy vấn Room/đọc file/gọi mạng đồng bộ trên Main — luôn qua coroutine + dispatcher phù hợp (IO/Default), đúng như phần Coroutines + Flow.
  • Không tự decode bitmap lớn trên Main — dùng thư viện ảnh (Coil/Glide), chúng đã tự làm việc decode ở background giúp bạn.
  • Việc tính toán nặng nên chia nhỏ hoặc chuyển hẳn sang Dispatchers.Default, không cố nhồi hết vào một vòng lặp chạy liền một mạch trên Main.

3Danh sách dài: tái sử dụng ô thay vì vẽ lại từ đầu

Một danh sách sản phẩm vài trăm dòng, nếu vẽ mới từng dòng mỗi khi cuộn hay mỗi khi có gì đó đổi, sẽ ngốn rất nhiều thời gian vẽ (giống hệt lỗi "vẽ lại toàn bộ 100 dòng" đã nói ở phần XML/Compose). Cả RecyclerView lẫn Compose LazyColumn đều được thiết kế để TÁI SỬ DỤNG, không phải vẽ lại từ đầu:

  • RecyclerView: dùng ListAdapter + DiffUtil (đã học ở phần XML/Compose) để chỉ cập nhật đúng dòng thay đổi; tránh layout lồng nhiều tầng bên trong mỗi item; giữ kích thước item ổn định để hệ thống không phải đo lại nhiều lần khi cuộn.
  • Tải ảnh qua Coil/Glide — hai thư viện này tự có cache, tự decode off-thread; không tự giữ bitmap lớn trong biến của mình vì nó chiếm bộ nhớ ngay cả khi item đã cuộn ra khỏi màn hình.
  • Danh sách RẤT lớn (hàng nghìn dòng trở lên): dùng Paging để tải theo trang, không tải hết một lần rồi giữ tất cả trong bộ nhớ.
  • Compose LazyColumn: luôn truyền key ổn định cho từng item (vd id sản phẩm) — nhờ vậy khi danh sách đổi thứ tự hoặc chèn/xoá, Compose biết chính xác "đây vẫn là item cũ, chỉ đổi vị trí" thay vì hiểu nhầm là item hoàn toàn mới rồi vẽ lại từ đầu.

4Compose: thu hẹp phạm vi recomposition

Nối tiếp phần XML/Compose: recomposition càng lan rộng thì càng tốn công vẽ lại những phần lẽ ra không cần đổi. Có hai cách thu hẹp phạm vi đó — một là tổ chức lại code (đọc state gần nơi dùng, tách composable nhỏ), hai là giúp Compose TỰ TIN hơn khi quyết định có cần vẽ lại hay không.

Compose chỉ dám bỏ qua (skip) việc vẽ lại một composable nếu nó chắc chắn tham số truyền vào KHÔNG ĐỔI so với lần trước. Nếu tham số là một class mà Compose không chắc có immutable hay không (vd một class thường chứa var, hoặc một List có thể bị sửa ngầm ở nơi khác), Compose đành phải vẽ lại cho chắc — dù thực chất dữ liệu giống hệt lần trước. Đây là lý do nên ưu tiên data class chỉ chứa val, và các kiểu Compose biết chắc là ổn định (String, Int, các kiểu immutable của Kotlin).

Một tình huống thực tế khác: giá trị offset khi cuộn danh sách đổi liên tục theo TỪNG PIXEL, nhưng bạn chỉ cần biết một điều đơn giản hơn — "đã cuộn qua khỏi 200px hay chưa" để quyết định có hiện nút "lên đầu trang" hay không. Nếu đọc thẳng offset thô, composable đó sẽ recompose theo từng pixel cuộn — rất lãng phí.

derivedStateOf: chỉ recompose khi giá trị DẪN XUẤT thật sự đổi, không phải mỗi lần offset nhích 1px

val showScrollToTop by remember {
    derivedStateOf { listState.firstVisibleItemScrollOffset > 200 }
}
// showScrollToTop chỉ đổi giá trị (true/false) đúng lúc vượt ngưỡng — không đổi theo từng pixel

Mẹo

Cũng nên tránh tạo mới một object hay lambda ngay trong thân composable khi không cần thiết (vd Modifier hay lambda click tạo lại mỗi lần vẽ) — Compose so sánh bằng tham chiếu, object mới tinh mỗi lần khiến nó nghĩ tham số đã đổi và vẽ lại dù nội dung thực chất giống hệt.

5Memory leak: giữ một thứ đã "chết" mà không hay biết

Memory leak xảy ra khi một object đáng lẽ phải được dọn (garbage collected) lại không thể dọn được, vì có gì đó vẫn đang giữ tham chiếu tới nó — giống việc giữ mãi một tấm danh thiếp của người đã nghỉ việc, khiến người đó vẫn "còn tồn tại" trong hệ thống dù thực ra đã rời đi từ lâu. Ba nguồn quen thuộc nhất: một object sống lâu (singleton, ViewModel) giữ tham chiếu tới Activity/View/Context của một màn hình đã đóng; một listener đăng ký rồi quên gỡ; hoặc một coroutine/handler vẫn tiếp tục chạy dù màn hình đã không còn.

  • Chỉ giữ Application Context (sống suốt vòng đời app) ở những nơi sống lâu — không giữ Context của một Activity/Fragment cụ thể, đúng như đã học ở phần Android fundamentals.
  • Luôn gỡ listener đã đăng ký khi không còn cần (thường ở đúng callback vòng đời tương ứng lúc đăng ký).
  • Luôn chạy coroutine trong viewModelScope/lifecycleScope, và collect Flow gắn với viewLifecycleOwner (Fragment) — đúng như phần Coroutines + Flow — để chúng tự huỷ theo đúng vòng đời, không phải tự bạn nhớ huỷ tay.

Quy trình đúng khi tối ưu hiệu năng là ĐO trước, sửa sau — đừng đoán mò rồi tối ưu nhầm chỗ không phải là bottleneck thật, vừa mất thời gian vừa có thể làm code phức tạp thêm mà không ích gì.

Android Studio Profiler chính là bộ dụng cụ đo đó — nhưng nó không phải MỘT cái đồng hồ chung chung, mà là 7 công cụ nhỏ, mỗi cái soi đúng MỘT khía cạnh khác nhau. Biết chọn đúng công cụ cho đúng nghi ngờ (nghi ngờ leak thì khác với nghi ngờ jank) sẽ nhanh hơn hẳn việc mở đại một cái rồi đoán:

7 tool con trong Android Studio Profiler

ToolViệc nó làmKhi nào dùng
Analyze Memory UsageChụp Heap Dump — một "bức ảnh" toàn bộ object đang tồn tại trong bộ nhớ tại đúng thời điểm bấm chụp.Xác nhận nghi ngờ leak, vd soi xem có còn giữ Activity của màn hình đã đóng lâu rồi không.
View Live TelemetryBảng đồng hồ theo dõi CPU/RAM/network theo thời gian thực.Thấy ngay lúc nào xuất hiện đỉnh bất thường trong lúc đang thao tác thử trên app.
Capture System ActivitiesGhi System Trace — toàn bộ hoạt động hệ thống trong một khoảng thời gian (mọi thread, mọi khung hình).Soi vì sao một đoạn cuộn màn hình bị giật/jank.
Java/Kotlin AllocationsTheo dõi từng object Kotlin/Java được cấp phát mới trong một khoảng thời gian.Nghi ngờ có chỗ tạo quá nhiều object không cần thiết (vd lambda/object mới mỗi lần recompose, đã nói ở mục trước).
Java/Kotlin Method RecordingGhi lại thời gian thực thi của từng method.Tìm chính xác method nào đang "ăn" CPU nhiều nhất.
Callstack SampleLấy MẪU callstack theo chu kỳ thay vì ghi đầy đủ mọi lời gọi method.Kém chi tiết hơn Method Recording nhưng ít ảnh hưởng hiệu năng thật hơn — hợp khi đo lâu hoặc trên máy yếu.
Native AllocationsTheo dõi bộ nhớ cấp phát ở tầng C/C++/JNI.Chỉ cần tới khi app có tích hợp thư viện native.

Ghi nhớ

Ngoài Profiler, còn có App Inspector (cũng trong Android Studio) — cho phép soi trực tiếp network request/response THẬT app đang gửi và dữ liệu đang nằm trong database (Room), ngay trong lúc app đang chạy, không cần tự viết thêm log hay HttpLoggingInterceptor. Bổ ích nhất khi debug API (xem phần REST API) hoặc kiểm tra dữ liệu Room có đúng như mong đợi không.

Ghi nhớ

Ngoài Profiler và App Inspector, còn có Layout Inspector (soi cây UI thực tế đang vẽ trên máy), LeakCanary (tự động phát hiện và báo leak ngay khi đang chạy debug, không cần tự đi tìm bằng Heap Dump), và Perfetto/systrace khi cần soi kỹ hơn nữa nguyên nhân giật khung hình (jank) ở tầng hệ thống.

Checklist

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

0/6

Lỗi thường gặp

  • Decode ảnh lớn trên main thread gây ANR/jank.
  • Giữ reference gây leak (listener, Context, static).
  • Tạo object/lambda mới liên tục trong recomposition.

Câu trả lời mẫu

Khi list scroll lag, em mở Profiler xem frame/CPU, kiểm tra layout lồng nhau, DiffUtil, và việc load ảnh. Với mạng chậm em thêm loading state, timeout và debounce cho search. Nguyên tắc là không block main thread.

Câu hỏi liên quan

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