Chuyển tới nội dung chính

Chọn background work theo yêu cầu thực thi

Bài này trả lời câu hỏi “Coroutines, Service hay WorkManager?” bằng một tính năng cụ thể: user chọn ảnh, app chuẩn bị ảnh rồi upload, kể cả khi màn hình đã đóng. Trước khi chọn API, cần xác định phần công việc nào được phép hủy và phần nào cần tiếp tục sau mất process.

Đọc trước: scope ownership, Service.

1. Tách công việc theo lifetime​

Trong cùng tính năng upload có nhiều công việc khác nhau:

Công việcLifetimeLựa chọn cần cân nhắc
Tạo preview nhỏ cho màn hình hiện tạiUI còn cần previewCoroutine theo owner phù hợp
Ghi yêu cầu upload và đường dẫn fileCần tồn tại sau màn hìnhPersistent storage
Thử upload khi có mạngCần tiếp tục qua process restartWorkManager, dựa trên dữ liệu persist
Truyền file do user chủ động yêu cầuUser chờ tiến độ/hoàn tấtKiểm tra API truyền dữ liệu do user khởi tạo hoặc cơ chế foreground phù hợp
Phát nhạc khi tắt màn hìnhPhiên playback người dùng nhận biếtMedia3 và service/session phù hợp

Không có một API thay thế mọi hàng. Coroutine là cơ chế thực thi; WorkManager là persistent scheduler; Service là component do Android quản lý. Một Worker vẫn có thể dùng coroutine, và một Service vẫn phải quản lý việc chuyển thread.

Nguồn lựa chọn: Background work.

2. WorkManager bảo đảm điều gì?​

WorkManager lưu thông tin lịch work và phối hợp với hệ thống. Nó phù hợp công việc persistent với constraints và retry; không hứa chạy ngay, hoàn tất trước một deadline hay hoàn tất trong mọi trạng thái thiết bị.

Phân biệt ba điều:

  1. Yêu cầu đã được lưu: không chỉ nằm trong một coroutine của màn hình.
  2. Worker được phép thực thi: còn phụ thuộc constraints, quota, Doze và chính sách hệ thống.
  3. Nghiệp vụ hoàn thành: còn phụ thuộc mạng, server, credential và sự đồng ý/hủy của người dùng.

Force-stop, work bị cancel, dữ liệu app bị xóa hoặc quyền truy cập file không còn là các trường hợp phải xử lý riêng. Không gọi một cơ chế là “guaranteed work” rồi bỏ qua các trạng thái này.

3. Persist input trước khi enqueue​

Payload lớn không nên nằm trong Data của WorkManager. Lưu file/payload ở nơi app có thể truy cập lâu dài, ghi bản ghi upload với ID ổn định, rồi truyền ID nhỏ vào Worker. Nếu nguồn là content URI, phải xét quyền URI có còn hợp lệ sau restart hay cần sao chép file vào storage do app quản lý.

val request = OneTimeWorkRequestBuilder<UploadWorker>()
.setInputData(workDataOf("uploadId" to uploadId))
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
30,
TimeUnit.SECONDS,
)
.build()

WorkManager.getInstance(context.applicationContext)
.enqueueUniqueWork("upload:$uploadId", ExistingWorkPolicy.KEEP, request)

Ví dụ dùng androidx.work và java.util.concurrent.TimeUnit; UploadWorker là worker của app. uploadId được tạo cho một thao tác logic, không tạo lại mỗi lần retry.

KEEP giúp không enqueue work cùng tên khi work trước còn chưa kết thúc. Nó không ngăn mọi request trùng sau khi work trước kết thúc, không khóa backend và không khiến file tự còn tồn tại. Trong app nhiều account, tên work và bản ghi phải phân biệt account.

Nguồn: Getting started, Managing work và unique work.

4. Thiết kế retry theo lỗi nghiệp vụ​

Giả sử repository expose uploadPending(id) và trả một sealed result do app định nghĩa. Thân doWork() của một CoroutineWorker đã được inject repository có thể có dạng:

override suspend fun doWork(): Result {
val uploadId = inputData.getString("uploadId") ?: return Result.failure()
return when (repository.uploadPending(uploadId)) {
UploadOutcome.Confirmed -> Result.success()
UploadOutcome.Retryable -> Result.retry()
UploadOutcome.Rejected -> Result.failure()
}
}

Đây là mẫu contract, không phải class Worker hoàn chỉnh. Repository chịu trách nhiệm kiểm tra account, đọc payload, phân loại lỗi dự kiến và ghi trạng thái local. Không catch cancellation thành Retryable; Worker bị dừng phải dừng được.

Kết quả quan sátChính sách cần quyết định
Mất kết nối/tạm không liên lạc được serverRetry với backoff, giới hạn theo nhu cầu
Rate limitTôn trọng tín hiệu server và chính sách retry
Credential hết hạnRefresh theo contract hoặc chờ user đăng nhập; tránh vòng lặp vô hạn
Nội dung bị từ chối vĩnh viễnFAILED với lý do để UI hướng dẫn sửa
Không biết server đã commit hay chưaReconcile/retry cùng idempotency key

CONNECTED không chứng minh server reachable. Wi-Fi cũng không đồng nghĩa unmetered. Nếu user chọn “chỉ trên mạng không tính dung lượng”, dùng UNMETERED theo đánh dấu của hệ thống và vẫn xử lý network error.

5. Khoảng hở sau remote commit​

Client gửi uploadId=U
Server tạo ảnh cho U
Response bị mất
Worker thấy timeout
Worker thử lại uploadId=U
Server trả cùng kết quả của U thay vì tạo ảnh thứ hai

Backoff chỉ giảm áp lực retry, không giải quyết trùng dữ liệu. Unique work chỉ quản lý lịch ở client, không khép được khoảng hở giữa remote commit và local acknowledgment. Backend phải có contract dedup/idempotency tương ứng; xem outbox và phục hồi.

6. Foreground Service cần được xét riêng​

Foreground Service không tự có worker thread và không được miễn ANR. Callback như onStartCommand() mặc định chạy trên main thread; CPU work vẫn cần dispatcher phù hợp cùng scope được cleanup.

Việc khởi chạy FGS từ background, service type, quyền liên quan và thời hạn hoạt động phụ thuộc phiên bản/target SDK. Ví dụ, ứng dụng target Android 12+ chịu hạn chế khởi chạy từ background với các ngoại lệ cụ thể; đây không phải lý do để cố khởi FGS mỗi lần nhận broadcast. Kiểm tra tài liệu theo use case và API level mà app hỗ trợ.

Với công việc dài, không suy luận rằng chuyển Worker sang foreground sẽ loại bỏ mọi quota. Long-running Worker vẫn có giới hạn phụ thuộc phiên bản; so sánh với API phù hợp cho truyền dữ liệu do user khởi tạo khi đó là yêu cầu sản phẩm.

Nguồn: FGS background start restrictions, Long-running workers, Doze.

7. Bài thực hành​

Tạo upload giả với khả năng “server đã commit nhưng client timeout”. Kiểm tra retry cùng ID chỉ tạo một ảnh; đóng màn hình không xóa input; mất file được báo lỗi có thể hiểu; logout không khiến dữ liệu account cũ bị gửi bằng session mới.

Câu hỏi tự kiểm: tại sao viewModelScope.launch(Dispatchers.IO) chưa đủ cho upload cần sống qua process death? Vì cả Job và dữ liệu chỉ trong RAM vẫn mất cùng process; dispatcher chỉ quyết định nơi chạy.

Bộ câu hỏi · Lộ trình Senior · Quiz.