Section 11/211 menit

11. Swift Concurrency: Task Cancellation

11. Swift Concurrency: Task Cancellation

Problem

Ketika user membuka layar lalu langsung menutupnya sebelum fetch selesai, request network tetap berjalan di background — membuang bandwidth dan CPU. Di UIKit dengan delegate/completion handler, kita harus menyimpan referensi ke request dan membatalkannya secara manual. Dengan Swift Concurrency, cancellation bersifat cooperative dan terstruktur.

Konsep

  • Task adalah unit kerja yang bisa di-cancel via task.cancel()
  • Cancellation bersifat cooperative: fungsi async harus secara aktif mengecek pembatalan dengan try Task.checkCancellation() atau menunggu fungsi yang already-cancellable seperti URLSession.data
  • URLSession.data(for:) sudah mendukung cancellation otomatis — ketika Task di-cancel, ia melempar CancellationError
  • Penting: cancel tidak langsung menghentikan kode — ia hanya menandai Task sebagai "cancelled", dan kode harus kooperatif memeriksa tanda ini

Implementasi di Worker

swift
actor UserWorker {
    // ...existing code...

    func fetchUsers() async throws -> [User] {
        // Periksa sebelum mulai — jika Task sudah di-cancel bahkan sebelum request,
        // lempar CancellationError sekarang daripada memulai network request yang sia-sia.
        try Task.checkCancellation()

        let url = baseURL.appendingPathComponent("/users")
        let request = URLRequest(url: url)

        // URLSession.data sudah mendukung cancellation: jika Task di-cancel saat
        // menunggu response, ini otomatis melempar CancellationError.
        let (data, response) = try await session.data(for: request)

        // Setelah operasi berat (network), cek lagi — siapa tahu Task di-cancel
        // persis ketika response baru tiba. Tidak perlu memparse data yang tidak akan dipakai.
        try Task.checkCancellation()

        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            throw WorkerError.invalidResponse
        }

        let dtos = try JSONDecoder().decode([UserDTO].self, from: data)
        return dtos.map { dto in
            User(
                id: String(dto.id),
                name: dto.name,
                email: dto.email,
                avatarURL: URL(string: "https://i.pravatar.cc/150?u=\(dto.id)")
            )
        }
    }
}

Implementasi di Interactor

swift
actor UserListInteractor: UserListBusinessLogic, UserListDataStore {
    // ...existing code...

    func fetchUsers(request: UserList.FetchUsers.Request) async {
        guard !isLoading else { return }
        isLoading = true
        await presenter?.presentLoading(true)

        do {
            let users = try await worker.fetchUsers()
            // Jika Task di-cancel selama worker.fetchUsers(), CancellationError
            // akan di-catch di sini — kita bisa handle dengan bersih.
            cachedUsers = users
            await presenter?.presentUsers(UserList.FetchUsers.Response(users: users))
        } catch is CancellationError {
            // Cancellation bukan error yang perlu ditampilkan ke user.
            // Cukup matikan loading — view sudah tidak relevan.
            // Tidak perlu log atau alert.
        } catch {
            await presenter?.presentError(error)
        }

        isLoading = false
        await presenter?.presentLoading(false)
    }
}

Implementasi di ViewController

swift
@MainActor
final class UserListViewController: UIViewController {
    var interactor: (any UserListBusinessLogic)?
    var router: (any UserListRoutingLogic)?

    // Simpan Task reference untuk bisa di-cancel sewaktu-waktu.
    // 'Task<Void, Never>' karena kita handle error di dalam Task body.
    private var fetchTask: Task<Void, Never>?

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        fetchUsers()
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        // Cancel fetch jika view sedang menghilang — misalnya user tap back.
        // Task yang sudah cancel akan membersihkan dirinya sendiri.
        fetchTask?.cancel()
    }

    private func fetchUsers() {
        // Cancel request sebelumnya sebelum membuat yang baru.
        // Ini penting untuk pull-to-refresh: mencegah dua fetch berjalan bersamaan
        // dan response yang lebih lama menimpa response yang lebih baru.
        fetchTask?.cancel()

        fetchTask = Task {
            await interactor?.fetchUsers(request: UserList.FetchUsers.Request())
        }
    }

    private func handleRefresh() {
        fetchTask?.cancel()
        fetchTask = Task {
            await interactor?.fetchUsers(request: UserList.FetchUsers.Request())
        }
    }
}

Menggunakan withTaskCancellationHandler untuk Cleanup

Ketika ada resource eksternal (timer, koneksi) yang harus dibersihkan saat cancel:

swift
actor UserWorker {

    // Contoh: fetch dengan explicit cleanup saat cancel.
    // withTaskCancellationHandler memungkinkan kita mendaftarkan callback
    // yang dipanggil SEGERA saat Task di-cancel — bahkan jika Task sedang suspended.
    func fetchUsersWithCleanup() async throws -> [User] {
        var requestReference: URLSessionDataTask? = nil

        return try await withTaskCancellationHandler {
            // Body: kode yang dijalankan normal
            try await withCheckedThrowingContinuation { continuation in
                let task = session.dataTask(with: URLRequest(url: baseURL)) { data, _, error in
                    if let error {
                        continuation.resume(throwing: error)
                    } else if let data {
                        // parse...
                        continuation.resume(returning: []) // simplified
                    }
                }
                requestReference = task
                task.resume()
            }
        } onCancel: {
            // Handler ini dipanggil SEGERA saat Task.cancel() dipanggil,
            // bahkan jika body sedang suspended menunggu network.
            // Gunakan ini untuk membatalkan resource yang tidak bisa di-cancel secara async.
            requestReference?.cancel()
        }
    }
}

Kapan Pakai withTaskCancellationHandler:

  • Wrapping API lama yang punya cancel method sendiri (URLSessionDataTask.cancel(), AVAudioPlayer.stop())
  • Membersihkan subscription atau listener saat Task di-cancel
  • Menutup koneksi database atau socket