Mở đầu — vì sao bài này quan trọng
Hãy tưởng tượng bạn đang thanh toán đơn hàng trên app ShopeeFood. Vừa nhập xong mã OTP thì có cuộc gọi đến. Bạn nghe máy 30 giây, quay lại app — và màn hình trắng xóa, giỏ hàng trống, phải làm lại từ đầu. Đó không phải lỗi mạng, cũng không phải server chết. Đó là một lỗi lifecycle kinh điển: app không xử lý đúng khi bị đẩy xuống nền (background) rồi được gọi lại (foreground).
Với QA mobile, đây là một trong những khu vực dễ bị bỏ sót nhất nhưng lại gây bug nghiêm trọng nhất. Lý do là vì trên web, một trang gần như luôn "sống" cho tới khi bạn đóng tab. Nhưng trên điện thoại, hệ điều hành (iOS/Android) liên tục tạm dừng, đóng băng, thậm chí giết chết app của bạn để nhường tài nguyên (RAM, CPU, pin) cho tác vụ khác. App phải biết cách "chết đi sống lại" một cách mượt mà mà người dùng không nhận ra.
Nếu bạn không test kỹ vòng đời (lifecycle) của app, những bug sau sẽ lọt lưới: mất dữ liệu đang nhập dở, timer/đếm ngược sai, phiên đăng nhập bị reset, camera/mic không nhả ra, hoặc app crash ngay khi mở lại sau vài giờ. Bài này sẽ giúp bạn hiểu bản chất vòng đời và biến nó thành một checklist test có hệ thống.
Khái niệm cốt lõi
Vòng đời app là gì
"Lifecycle" là chuỗi các trạng thái mà app đi qua từ lúc khởi động đến lúc bị hủy, cùng với các sự kiện (events) mà hệ điều hành gửi tới app mỗi khi trạng thái thay đổi. App đăng ký lắng nghe các sự kiện này qua callback (hàm được hệ điều hành gọi tự động) để phản ứng: lưu dữ liệu, tạm dừng video, ngắt kết nối socket, v.v.
Điểm mấu chốt cần nhớ: người dùng không phải là người điều khiển vòng đời — hệ điều hành mới là chủ. Người dùng chỉ bấm Home, mở app khác, hay nhận cuộc gọi. Chính OS quyết định app của bạn sống hay chết.
Vòng đời iOS
Trên iOS, một app đi qua các trạng thái sau:
Not Running → Inactive → Active → Background → Suspended → (Terminated)
- Not Running: app chưa chạy hoặc đã bị hệ thống chấm dứt.
- Inactive: app ở foreground nhưng không nhận sự kiện (ví dụ đang có màn hình gọi điện đè lên, hoặc đang kéo Control Center xuống).
- Active: app ở foreground, nhận đầy đủ tương tác. Đây là trạng thái "bình thường".
- Background: app không hiển thị nhưng code vẫn chạy được trong thời gian ngắn (thường vài giây tới ~30 giây) để hoàn tất tác vụ.
- Suspended: app còn trong RAM nhưng bị "đóng băng", không chạy code. Khi RAM cần được giải phóng, hệ thống giết app ở trạng thái này mà không báo trước.
UIApplicationDelegate cũ và ScenePhase mới trong SwiftUI):applicationDidBecomeActive/.active: app vừa lên foreground và sẵn sàng. Đây là nơi khôi phục UI, resume timer, refresh dữ liệu.applicationWillResignActive/.inactive: app sắp mất focus. Nơi tạm dừng game, ẩn thông tin nhạy cảm.applicationDidEnterBackground/.background: app đã xuống nền. Nơi bắt buộc phải lưu state, vì sau đó app có thể bị giết bất cứ lúc nào.applicationWillEnterForeground: app sắp quay lại từ nền.applicationWillTerminate: app sắp bị đóng — lưu ý: callback này KHÔNG được gọi khi app bị giết từ trạng thái Suspended, nên đừng dựa vào nó để lưu dữ liệu.
Vòng đời Android
Android dùng khái niệm khác, xoay quanh Activity với các callback:
onCreate → onStart → onResume → [chạy] → onPause → onStop → onDestroy
onResume: app ở foreground, tương tác được (tương đương Active của iOS).onPause: một thứ gì đó đè một phần lên app (dialog, cuộc gọi). Nên nhẹ, nhanh.onStop: app không còn nhìn thấy (đã xuống nền hoàn toàn). Nơi lưu state.onDestroy: Activity bị hủy — do người dùng thoát hẳn, HOẶC do cấu hình thay đổi (xoay màn hình, đổi ngôn ngữ, chuyển sáng/tối). Đây là cái bẫy lớn: xoay màn hình = Activity bị destroy và tạo lại từ đầu.
ViewModel + onSaveInstanceState(Bundle) để giữ dữ liệu qua các lần recreate. QA cần biết điều này để test đúng.Process Death — kẻ giết người thầm lặng
Điểm chung nguy hiểm nhất trên cả hai nền tảng là process death (hệ điều hành giết tiến trình app khi app ở nền để lấy lại RAM). Khi người dùng quay lại, app khởi động lại tiến trình nhưng người dùng tưởng là quay lại chỗ cũ. Nếu app không khôi phục đúng, họ thấy màn hình trắng, mất dữ liệu, hoặc bị đá về trang đầu. Đây là bug rất khó tái hiện vì nó phụ thuộc vào tình trạng RAM của máy — và cũng vì thế mà QA phải chủ động ép nó xảy ra.
Tình huống thực tế
Tình huống 1 — Ví MoMo và OTP bốc hơi
Một team QA tại một ví điện tử ở TP.HCM (giả định gần với bối cảnh MoMo) gặp báo lỗi: khoảng 4% người dùng phàn nàn "nhập OTP xong bị văng ra". Điều tra ban đầu tưởng là lỗi SMS. Nhưng khi QA dựng lại kịch bản, họ phát hiện: người dùng nhận OTP qua SMS, kéo thanh thông báo xuống để đọc mã (app rơi vào Inactive), rồi trong lúc đó điện thoại RAM thấp và OS đẩy app xuống Background → Suspended → bị giết. Khi mở lại, app khởi động tiến trình mới, màn hình OTP mất sạch context giao dịch.
Bài học: bug này không thể tái hiện bằng thao tác thông thường trên máy RAM khỏe. QA phải chủ động ép process death (sẽ hướng dẫn ở dưới). Sau khi fix bằng cách lưu state giao dịch vào onSaveInstanceState (Android) và state restoration (iOS), tỉ lệ khiếu nại về 0. Con số 4% tưởng nhỏ nhưng với vài triệu giao dịch/tháng, đó là hàng chục nghìn giao dịch thất bại.
Tình huống 2 — App học tiếng Anh và timer chạy loạn
Một startup edtech làm app luyện thi (giả định như một sản phẩm nội địa kiểu ELSA) có tính năng làm bài thi 60 phút có đồng hồ đếm ngược. QA phát hiện: khi học viên bấm Home ra ngoài 5 phút rồi quay lại, đồng hồ vẫn hiển thị đúng như lúc bấm Home — tức là học viên "gian lận" được thêm thời gian, vì timer bị đóng băng theo Suspended chứ không tính theo thời gian thực.
Nguyên nhân: dev dùng một biến đếm giảm mỗi giây bằng timer nội bộ. Khi app vào background, timer ngừng chạy, và khi applicationDidBecomeActive được gọi lại, timer chỉ chạy tiếp mà không tính khoảng thời gian đã trôi qua.
Bài học: với mọi tính năng phụ thuộc thời gian (timer, phiên đăng nhập, khuyến mãi flash sale, đếm ngược OTP), QA phải test theo cặp: ghi lại mốc thời gian thực khi vào background, so với khi trở lại foreground. Fix đúng là lưu timestamp tuyệt đối lúc vào nền, rồi khi active lại thì tính elapsed = now - saved_timestamp. QA thêm hẳn một test case: "background 5 phút → foreground → đồng hồ phải giảm đúng 5 phút".
Tình huống 3 — App gọi xe và camera không nhả
Một app gọi xe/giao hàng ở Đông Nam Á (giả định kiểu Grab/Gojek) có tính năng tài xế chụp ảnh xác nhận giao hàng. QA phát hiện: nếu tài xế đang mở camera trong app mà có cuộc gọi đến (app → Inactive/Paused), sau khi cúp máy quay lại, camera đơ đen hoàn toàn và phải kill app mới dùng lại được. Nặng hơn, trên vài máy Android tầm trung, app crash.
Nguyên nhân: dev không giải phóng tài nguyên camera trong onPause/applicationWillResignActive, nên khi cuộc gọi (một tiến trình khác) chiếm camera, app mất quyền và không tự phục hồi khi được active lại.
Bài học: các tài nguyên phần cứng dùng chung (camera, mic, GPS, cảm biến, kết nối Bluetooth) phải được nhả khi vào nền và xin lại khi ra foreground. QA cần một checklist riêng cho từng loại tài nguyên, và luôn test kịch bản "bị gián đoạn bởi cuộc gọi/app khác" chứ không chỉ bấm Home.
Hướng dẫn từng bước
Đây là quy trình test lifecycle bạn có thể áp dụng cho bất kỳ màn hình quan trọng nào.
Bước 1 — Liệt kê các màn hình có state cần bảo toàn. Ưu tiên: form đang nhập dở, giỏ hàng, luồng thanh toán, màn hình OTP, bài thi có timer, màn hình đang phát video/nhạc, màn hình dùng camera/GPS.
Bước 2 — Test chuyển foreground/background cơ bản. Với mỗi màn hình: nhập dữ liệu dở → bấm Home → mở app khác vài giây → quay lại app. Kiểm tra dữ liệu và UI còn nguyên. Đây là mức nhẹ nhất (app thường chỉ vào Suspended, chưa bị giết).
Bước 3 — Ép process death (quan trọng nhất).
- Android: trên máy đã bật Developer Options, vào Settings → Developer options → "Don't keep activities" để mọi Activity bị hủy ngay khi rời foreground. Hoặc dùng lệnh ADB: đưa app xuống nền rồi chạy
adb shell am kill <package_name>. Khi quay lại app, đây chính là mô phỏng chính xác process death. - iOS: khó ép hơn. Cách chuẩn của dev là dừng process từ Xcode (Stop) khi app đang ở background, rồi mở lại app từ màn hình chính (không phải chạy lại từ Xcode). Ngoài ra dùng "Simulate Background Fetch" và State Restoration trong Xcode để kiểm tra khôi phục.
- Cuộc gọi đến / cuộc gọi Zalo, Messenger.
- Kéo Control Center / Notification Shade xuống.
- Báo thức, thông báo đẩy đè lên màn hình.
- Khóa màn hình rồi mở lại.
- Chuyển sang chế độ chia đôi màn hình (split-screen, đặc biệt trên máy tính bảng Android).
Bước 6 — Test cấu hình thay đổi (Android). Xoay màn hình, đổi ngôn ngữ hệ thống, đổi cỡ chữ, chuyển Dark/Light mode ngay khi đang nhập dữ liệu. Mỗi lần này Activity bị recreate — kiểm tra state có được giữ qua onSaveInstanceState không.
Bước 7 — Kiểm tra hành vi tài nguyên. Với camera/mic/GPS: vào nền → tài nguyên có được nhả không (kiểm tra chấm xanh/cam trên thanh trạng thái iOS, hoặc icon quyền trên Android)? Ra foreground → hoạt động lại bình thường không?
Bước 8 — Ghi log và tài nguyên. Dùng adb logcat (Android) hoặc Console/Instruments (iOS) để xác nhận các callback lifecycle được gọi đúng thứ tự, không có exception khi chuyển trạng thái.
Lỗi thường gặp & mẹo
- Chỉ test bằng cách bấm Home rồi mở lại ngay. Đây là mức nhẹ nhất và app hầu như luôn "sống", nên bug process death lọt lưới hoàn toàn. Luôn ép process death bằng "Don't keep activities" hoặc
adb shell am kill. - Test trên máy flagship RAM khỏe. iPhone 15 Pro hay Galaxy S24 có RAM lớn, hệ thống hiếm khi giết app ở nền. Người dùng thật đa số dùng máy tầm trung/giá rẻ (2-4GB RAM) — nơi process death xảy ra liên tục. Hãy test trên cả máy yếu.
- Dựa vào
applicationWillTerminateđể lưu dữ liệu (iOS). Callback này không được gọi khi app bị giết từ Suspended. Luôn lưu state trongapplicationDidEnterBackground. - Quên rằng xoay màn hình = recreate Activity (Android). Rất nhiều bug "mất dữ liệu form" thực chất là do xoay màn hình, không phải background. Test riêng kịch bản xoay.
- Không phân biệt Inactive và Background. Kéo Control Center chỉ đưa app vào Inactive (chưa mất state), còn bấm Home mới vào Background. Hai kịch bản có hành vi khác nhau, phải test tách bạch.
- Mẹo — làm "bảng ma trận trạng thái". Với mỗi màn hình quan trọng, lập bảng: cột là các loại gián đoạn (Home, cuộc gọi, khóa máy, xoay, process death, background 10 phút), hàng là các điểm cần kiểm (dữ liệu form, timer, tài nguyên, phiên đăng nhập). Điền pass/fail để không sót.
- Mẹo — bật "Background Process Limit = No background processes" trong Developer Options Android để ép app dễ bị giết hơn khi test.
- Mẹo — chú ý push notification và deep link khi app ở nền. Khi app đang Suspended mà người dùng bấm vào notification, app phải khôi phục và điều hướng đúng — đây là điểm giao thoa hay lỗi, cần test cùng lifecycle.
Bài tập thực hành
- Lập ma trận lifecycle cho một app quen thuộc. Chọn một app bạn hay dùng (ví dụ ShopeeFood, ZaloPay, hoặc một app nội bộ công ty). Chọn 3 màn hình quan trọng nhất và lập bảng ma trận trạng thái như mẹo ở trên. Điền thử kết quả bằng cách test tay.
- Ép process death và ghi lại kết quả. Trên một máy Android (thật hoặc giả lập), bật "Don't keep activities". Mở app, nhập dữ liệu vào một form, bấm Home, mở app khác, quay lại. Ghi lại: dữ liệu còn không? UI ở đúng màn hình không? So sánh với khi tắt "Don't keep activities".
- Test timer theo thời gian thực. Tìm một tính năng có đếm ngược (OTP, flash sale, hoặc bài thi). Ghi lại thời gian còn lại, đưa app xuống nền đúng 3 phút (dùng đồng hồ), quay lại và kiểm tra đồng hồ đã giảm đúng 3 phút chưa. Viết bug report nếu sai.
- Test tài nguyên camera/GPS. Mở một màn hình dùng camera hoặc bản đồ, đưa app xuống nền, quan sát chỉ báo quyền trên thanh trạng thái xem tài nguyên có được nhả không. Quay lại và kiểm tra hoạt động bình thường.
- Viết 5 test case chính thức theo định dạng của công ty bạn (tiêu đề, tiền điều kiện, các bước, kết quả mong đợi) cho kịch bản lifecycle, sẵn sàng đưa vào bộ test regression.
Tóm tắt
Vòng đời app là khu vực mà hệ điều hành, chứ không phải người dùng, nắm quyền điều khiển — và đó là lý do QA phải chủ động test nó. Trên iOS, app đi qua Not Running → Inactive → Active → Background → Suspended → Terminated, với applicationDidEnterBackground là nơi bắt buộc lưu state. Trên Android, chuỗi là onCreate → onStart → onResume → onPause → onStop → onDestroy, với onStop/onSaveInstanceState là điểm lưu dữ liệu và xoay màn hình là bẫy recreate.
Kẻ nguy hiểm nhất là process death: OS giết app ở nền để lấy RAM, và app phải khôi phục mà người dùng không nhận ra. Đừng test qua loa bằng cách bấm Home rồi mở lại — hãy ép process death bằng "Don't keep activities" hoặc adb shell am kill, test trên máy RAM thấp, test các gián đoạn thật (cuộc gọi, khóa máy, xoay màn hình), và test kết hợp với thời gian. Công cụ mạnh nhất của bạn là ma trận trạng thái cho từng màn hình quan trọng, đảm bảo dữ liệu, timer, tài nguyên phần cứng và phiên đăng nhập đều sống sót qua mọi biến động vòng đời.