Mở đầu — vì sao bài này quan trọng
Có một loại lỗi mà bạn gần như không bao giờ bắt được trong 5 phút test đầu tiên, nhưng nó lại là nguyên nhân số một khiến app của bạn bị crash trên điện thoại của người dùng thật: memory leak (rò rỉ bộ nhớ). App chạy mượt trên máy bạn — con iPhone 15 Pro 8GB RAM hay con Samsung S24 12GB RAM — nhưng đến tay bác tài xế công nghệ đang dùng con máy Android 3GB RAM đời 2021 thì cứ dùng được 15-20 phút là app tự động thoát ra, mất luôn giỏ hàng, mất luôn tin nhắn đang gõ dở.
Đây không phải chuyện lý thuyết. Thị trường Việt Nam có một đặc thù rất rõ: theo dữ liệu phân mảnh thiết bị mà nhiều team QA trong nước ghi nhận, hơn 40% người dùng vẫn đang dùng máy có RAM 3-4GB. Trên những máy này, hệ điều hành cực kỳ "hung dữ" trong việc thu hồi bộ nhớ. Một app chiếm giữ residual memory (bộ nhớ còn sót lại sau khi lẽ ra phải được giải phóng) vượt quá 200MB là ứng cử viên số một để bị Android LowMemoryKiller hoặc iOS Jetsam "tiễn" ra khỏi RAM.
Là một Mobile QA, nhiệm vụ của bạn trong bài này là học cách phát hiện memory leak trước khi nó phát hiện ra người dùng của bạn. Đây là kỹ năng phân biệt giữa một tester chỉ biết bấm nút và một QA engineer thực thụ.
Khái niệm cốt lõi
Memory leak là gì và khác gì với "app ăn nhiều RAM"?
Nhiều bạn hay nhầm hai khái niệm này. Một app ăn nhiều RAM chưa chắc đã leak — ví dụ app xem phim buộc phải giữ nhiều frame video trong bộ nhớ, đó là hành vi bình thường và bộ nhớ đó sẽ được trả lại khi bạn thoát màn hình.
Memory leak là khi bạn rời khỏi một màn hình, đóng một tính năng, nhưng bộ nhớ mà nó chiếm dụng không được giải phóng. Về mặt kỹ thuật, đó là những object lẽ ra phải bị "dọn dẹp" (garbage collected trên Android/Java-Kotlin, hoặc released trên iOS/Swift) nhưng vẫn còn ai đó "níu kéo" chúng bằng một reference. Vì còn tham chiếu nên hệ thống nghĩ rằng object vẫn đang được dùng, và không dám thu hồi.
Dấu hiệu nhận biết kinh điển: bạn lặp lại một hành động (vào màn hình A → thoát ra → vào lại) 10 lần, và biểu đồ bộ nhớ đi lên theo bậc thang mà không bao giờ đi xuống lại mức ban đầu. Đó chính là leak.
Vì sao leak dẫn tới OOM crash?
OOM là viết tắt của Out Of Memory. Khi bộ nhớ của app cứ tích tụ dần theo thời gian sử dụng (do leak), đến một ngưỡng nhất định hệ điều hành sẽ không cấp thêm bộ nhớ nữa. Trên iOS, tiến trình Jetsam sẽ chấm dứt app; trên Android, app bị kill vì vượt heap limit hoặc bị hệ thống thu hồi. Với người dùng, đó đơn giản là "app tự thoát" — một trải nghiệm cực kỳ tệ, và họ hiếm khi báo bug đúng cách. Họ chỉ để lại 1 sao trên App Store với dòng chữ "app hay bị văng".
Các dạng leak phổ biến bạn cần thuộc lòng
- Trên Android: giữ tham chiếu tới
Activity/Contexttrong một object sống lâu (static field, singleton, listener không gỡ).Handlerkhông được remove callback.Bitmapkhông recycle.Cursor/InputStreamkhông close. Coroutine/RxJava subscription không hủy khi màn hình đóng. - Trên iOS: retain cycle (chu trình tham chiếu) — kinh điển nhất là closure capture
selfmà không dùng[weak self], hoặc hai object giữstrongreference lẫn nhau. Delegate khai báostrongthay vìweak.NotificationCenterobserver không remove.Timerkhông invalidate.
Ba chỉ số bạn phải đọc được
- RSS (Resident Set Size) — lượng RAM vật lý app đang thực sự chiếm. Đây là con số hệ điều hành nhìn vào khi quyết định có kill app hay không.
- Heap size — vùng bộ nhớ động cho object của app. Trên Android xem qua Java heap; trên iOS xem qua Allocations.
- Retained memory / leaked objects — số object đáng lẽ phải chết nhưng vẫn sống.
Tình huống thực tế
Ví dụ 1: App gọi xe và cái Handler chết người
Một startup gọi xe ở TP.HCM (tạm gọi là GoRide) nhận được hàng loạt review 1 sao với nội dung "đang đặt xe thì app văng". Team dev không tái hiện được vì họ test trên Pixel 7. Bạn — QA — lấy một con Redmi 9A (2GB RAM) làm máy test, mở màn hình bản đồ theo dõi tài xế, rồi thao tác vào-ra màn hình chi tiết chuyến đi liên tục 30 lần.
Kết quả trên Android Profiler: RSS tăng từ 180MB lên 420MB và không tụt xuống. Bạn chụp một heap dump (ảnh chụp toàn bộ bộ nhớ tại một thời điểm) và tìm thấy 30 instance của MapActivity vẫn còn sống — trong khi lẽ ra chỉ được có 1. Nguyên nhân: một LocationListener được đăng ký với GPS service nhưng không bao giờ được gỡ trong onDestroy(). Mỗi lần mở màn hình lại tạo một Activity mới bị "ghim" lại.
Bài học: Leak trên máy RAM thấp biểu hiện thành crash thật, còn trên máy flagship thì "ẩn mình". Luôn test memory trên thiết bị đại diện đúng phân khúc người dùng của bạn.
Ví dụ 2: Sàn TMĐT và closure "quên weak self"
Một team iOS của một sàn thương mại điện tử lớn (bối cảnh Đông Nam Á, tạm gọi ShopFast) phát hiện màn hình chi tiết sản phẩm ngày càng chậm và app crash sau khi user lướt qua 40-50 sản phẩm. QA dùng Xcode Instruments — công cụ Leaks và thấy đồ thị leak nhảy vọt mỗi khi mở ProductDetailViewController.
Đào sâu bằng công cụ Allocations với chế độ "Generations" (chụp thế hệ bộ nhớ), họ xác định mỗi ViewController không bao giờ được deallocate. Thủ phạm: một closure xử lý callback ảnh có dạng imageLoader.load { self.imageView.image = $0 } — closure này capture self theo strong reference, tạo retain cycle. Fix bằng cách đổi thành { [weak self] in self?.imageView.image = $0 }. Sau khi fix, số instance của ViewController luôn về 0 sau khi thoát màn hình.
Bài học: Trên iOS, phần lớn leak nằm ở closure và delegate. Một QA giỏi cần biết dùng Instruments để chỉ đích danh dòng code, giúp dev fix nhanh thay vì chỉ báo "app chậm".
Ví dụ 3: App tin tức và cái vòng lặp soak test
Một app đọc báo ở Việt Nam bị người dùng phàn nàn "để mở qua đêm sáng ra là văng". QA thiết kế một soak test: dùng script Appium tự động cuộn feed tin liên tục trong 4 tiếng, mỗi 5 phút ghi lại RSS bằng adb shell dumpsys meminfo. Đồ thị cho thấy bộ nhớ tăng tuyến tính từ 150MB lên 380MB sau 3 tiếng — leak tích lũy chậm nhưng chắc chắn. Nguyên nhân là mỗi ảnh thumbnail tải về không được giải phóng khỏi cache đúng cách.
Bài học: Có những leak chậm chỉ lộ ra sau nhiều giờ. Test 5 phút không đủ; cần kịch bản soak/endurance để bắt loại leak "âm ỉ".
Hướng dẫn từng bước
Đây là quy trình chuẩn để test memory leak cho một luồng tính năng (feature flow):
Bước 1 — Chọn thiết bị đại diện. Đừng chỉ test máy flagship. Với thị trường Việt Nam, hãy có ít nhất một máy Android RAM 2-3GB (Redmi, Realme đời cũ) làm "canh gác". Đây là nơi leak lộ diện sớm nhất.
Bước 2 — Thiết lập baseline (mức nền). Mở app, đứng yên ở màn hình chính, ghi lại RSS/heap ban đầu. Trên Android dùng Android Studio Profiler (Memory) hoặc lệnh adb shell dumpsys meminfo <package_name>. Trên iOS dùng Xcode → Debug Navigator → Memory, hoặc Instruments.
Bước 3 — Xác định "hành động lặp". Chọn một thao tác vào-ra rõ ràng: vào màn hình detail → back → lặp lại. Đây là mẫu test kinh điển gọi là "repeat and observe".
Bước 4 — Lặp 10-20 lần và quan sát đồ thị. Sau mỗi vòng, ép garbage collection (trên Android Profiler có nút "force GC" hình thùng rác) rồi đọc lại con số. Nếu bộ nhớ trở về gần baseline sau GC → tốt. Nếu nó tăng bậc thang không giảm → nghi ngờ leak.
Bước 5 — Chụp heap dump / snapshot. Trên Android: nhấn "Dump Java heap", tìm class Activity/Fragment/ViewModel có số instance nhiều bất thường. Trên iOS: dùng Instruments Leaks hoặc "Debug Memory Graph" (nút hình 3 node trong Xcode) để xem object nào còn giữ reference.
Bước 6 — Dùng công cụ chuyên dụng. Với Android, tích hợp LeakCanary (thư viện dev tích hợp vào bản debug) — nó tự động phát hiện và thông báo leak kèm chuỗi reference gây leak, cực kỳ hữu ích để đính kèm vào bug report.
Bước 7 — Chạy soak test. Với các luồng dài (feed, chat, bản đồ), tự động hóa thao tác lặp trong 1-4 tiếng, log bộ nhớ định kỳ, vẽ đồ thị. Đồ thị đi lên tuyến tính = leak.
Bước 8 — Viết bug report có sức nặng. Đính kèm: đồ thị memory, số instance object rò rỉ, chuỗi reference (từ LeakCanary/Instruments), thiết bị + RAM, số lần lặp để tái hiện. Bug report như vậy dev không thể "không tái hiện được".
Lỗi thường gặp & mẹo
- Nhầm "dùng nhiều RAM" với "leak". Luôn kiểm bằng phép thử: sau khi GC, bộ nhớ có về baseline không? Nếu có, đó không phải leak.
- Quên force GC trước khi đọc số. Nếu không ép GC, bạn có thể thấy bộ nhớ cao chỉ vì rác chưa được dọn — dẫn tới báo nhầm.
- Chỉ test trên máy xịn. Đây là sai lầm phổ biến nhất ở VN. Máy flagship che giấu leak. Luôn có một máy RAM thấp trong bộ test.
- Test quá ngắn. Leak chậm cần soak test. Đừng kết luận "không leak" sau 5 phút.
- Bỏ qua điều kiện xoay màn hình (rotation) trên Android. Xoay ngang-dọc liên tục là "máy phát hiện leak" tuyệt vời vì nó buộc Activity tạo lại — nếu Activity cũ không chết, bạn sẽ thấy instance tăng.
- Mẹo — dùng LeakCanary trong CI: Yêu cầu dev bật LeakCanary ở bản debug và cho chạy qua các test tự động; leak sẽ được ghi lại log ngay trong pipeline.
- Mẹo — ngưỡng cảnh báo: Đặt quy ước nội bộ, ví dụ "residual memory sau khi thoát mọi màn hình phụ không được vượt quá 20% so với baseline". Có con số cụ thể giúp bug được ưu tiên xử lý.
Bài tập thực hành
- Bài tập repeat-and-observe: Chọn một app bất kỳ trên máy Android (có thể là app bạn đang test hoặc app mẫu). Mở Android Studio Profiler, thiết lập baseline, rồi vào-ra một màn hình detail 15 lần, ép GC sau mỗi lần. Vẽ tay đồ thị bộ nhớ theo từng vòng và kết luận có leak hay không.
- Bài tập heap dump: Nếu phát hiện nghi ngờ leak ở bài 1, chụp heap dump và tìm class nào có số instance bất thường (nhiều hơn 1 Activity của cùng một màn hình đã đóng). Ghi lại tên class và chuỗi reference.
- Bài tập thiết kế soak test: Viết một kịch bản (bằng lời hoặc pseudo-Appium) mô tả cách bạn sẽ tự động cuộn feed một app tin tức trong 2 tiếng và log RSS mỗi 5 phút. Nêu rõ tiêu chí pass/fail dựa trên hình dạng đồ thị.
- Bài tập viết bug report: Dựa trên Ví dụ 1 (GoRide), hãy viết một bug report hoàn chỉnh gồm: tiêu đề, bước tái hiện, thiết bị/RAM, bằng chứng (đồ thị + số instance), mức độ nghiêm trọng và đề xuất nguyên nhân.
Tóm tắt
Memory leak là kẻ giết app thầm lặng, đặc biệt nguy hiểm với thị trường Việt Nam nơi phần lớn người dùng dùng máy RAM 3-4GB và app vượt residual memory 200MB dễ bị hệ điều hành kill, dẫn tới OOM crash. Là Mobile QA, bạn cần phân biệt "dùng nhiều RAM" (bình thường) với "leak" (bộ nhớ không được trả lại sau khi thoát màn hình). Quy trình cốt lõi là: thiết lập baseline → lặp hành động vào-ra 10-20 lần → force GC → quan sát đồ thị; nếu bộ nhớ tăng bậc thang không giảm thì chụp heap dump/memory graph để chỉ đích danh object rò rỉ. Dùng LeakCanary (Android) và Xcode Instruments Leaks/Allocations (iOS) để bắt và định vị leak. Nhớ ba nguyên tắc sống còn: luôn test trên máy RAM thấp, luôn chạy soak test cho luồng dài, và luôn viết bug report kèm bằng chứng đồ thị + chuỗi reference để dev không thể chối "không tái hiện được".