Section 15/212 menit

15. Swift Concurrency: withCheckedContinuation — Bridge Delegate

15. Swift Concurrency: withCheckedContinuation — Bridge Delegate

Problem

UIKit sangat bergantung pada pola delegate dan completion handler. Tidak semua API iOS sudah mendukung async/await. Kita perlu cara untuk menggunakan API lama ini dari dalam async function tanpa DispatchQueue.main.async atau nested callback.

Konsep

withCheckedContinuation dan withCheckedThrowingContinuation mengubah callback-based API menjadi async function yang bisa di-await. Execution dihentikan sementara di suspension point, lalu dilanjutkan ketika continuation.resume(...) dipanggil di callback. "Checked" berarti Swift melakukan runtime check — pastikan resume dipanggil tepat satu kali.

Scenario A: UIImagePickerController menjadi async

swift
// Simpan continuation sebagai property — harus @unchecked Sendable karena
// UIImagePickerControllerDelegate callback datang dari main thread,
// dan continuation sendiri adalah Sendable.
@MainActor
final class UserListViewController: UIViewController {
    // ...existing code...

    // Menyimpan continuation di antara method pickImage() dan delegate callback.
    // CheckedContinuation tidak boleh diakses dari banyak thread, tapi karena
    // kita di @MainActor, ini aman.
    private var imageContinuation: CheckedContinuation<UIImage?, Never>?

    // API lama (UIImagePickerController) dibungkus menjadi async.
    // Caller cukup: let image = await pickAvatarImage()
    func pickAvatarImage() async -> UIImage? {
        // withCheckedContinuation menangguhkan eksekusi di sini.
        // Eksekusi dilanjutkan ketika imageContinuation.resume() dipanggil.
        await withCheckedContinuation { continuation in
            self.imageContinuation = continuation

            let picker = UIImagePickerController()
            picker.sourceType = .photoLibrary
            picker.delegate = self
            present(picker, animated: true)
        }
    }
}

// MARK: - UIImagePickerControllerDelegate

extension UserListViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {

    func imagePickerController(
        _ picker: UIImagePickerController,
        didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
    ) {
        let image = info[.originalImage] as? UIImage
        dismiss(animated: true)

        // Resume continuation dengan hasil — eksekusi di 'await withCheckedContinuation'
        // dilanjutkan, dan 'image' menjadi return value dari pickAvatarImage().
        imageContinuation?.resume(returning: image)
        imageContinuation = nil    // WAJIB: nil-kan setelah resume untuk mencegah double-resume
    }

    func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
        dismiss(animated: true)

        // User cancel — resume dengan nil. Continuation HARUS di-resume
        // dalam semua code path, jika tidak caller akan hang selamanya.
        imageContinuation?.resume(returning: nil)
        imageContinuation = nil
    }
}

Penggunaan dari ViewController:

swift
// Dari action button "Ganti Avatar" — ini async yang bersih, tidak ada callback nesting.
@objc private func handleChangeAvatar() {
    Task {
        guard let image = await pickAvatarImage() else { return }
        // Lanjutkan dengan image yang dipilih user
        await interactor?.uploadAvatar(image: image)
    }
}

Scenario B: CLLocationManager menjadi async (throwing)

swift
import CoreLocation

// Wrapper CLLocationManager yang mengubah delegate pattern menjadi async.
// Menggunakan 'withCheckedThrowingContinuation' karena lokasi bisa gagal (denied, timeout).
final class LocationService: NSObject, CLLocationManagerDelegate {

    private let manager = CLLocationManager()
    private var locationContinuation: CheckedContinuation<CLLocationCoordinate2D, Error>?

    func requestCurrentLocation() async throws -> CLLocationCoordinate2D {
        try await withCheckedThrowingContinuation { continuation in
            self.locationContinuation = continuation
            manager.delegate = self
            manager.requestWhenInUseAuthorization()
            manager.requestLocation()    // Single location request, calls delegate once
        }
    }

    // MARK: - CLLocationManagerDelegate

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let location = locations.first else { return }
        locationContinuation?.resume(returning: location.coordinate)
        locationContinuation = nil
    }

    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        // Resume dengan error — caller akan catch ini.
        locationContinuation?.resume(throwing: error)
        locationContinuation = nil
    }
}

// Penggunaan di Interactor:
actor UserListInteractor: UserListBusinessLogic, UserListDataStore {
    private let locationService = LocationService()

    func fetchNearbyUsers() async {
        do {
            let coordinate = try await locationService.requestCurrentLocation()
            let users = try await worker.fetchUsers(near: coordinate)
            cachedUsers = users
            await presenter?.presentUsers(UserList.FetchUsers.Response(users: users, stats: nil, featuredUser: nil))
        } catch {
            await presenter?.presentError(error)
        }
    }
}

Scenario C: URLSession dengan Delegate menjadi async

swift
// Ketika butuh upload progress — URLSession.upload(for:from:) tidak memberikan progress.
// Kita harus pakai delegate, lalu bridge ke async.
actor UploadService: NSObject, URLSessionTaskDelegate {

    private var progressContinuation: AsyncStream<Double>.Continuation?

    func uploadWithProgress(data: Data, to url: URL) -> (
        progress: AsyncStream<Double>,
        result: Task<Data, Error>
    ) {
        let (stream, continuation) = AsyncStream.makeStream(of: Double.self)
        self.progressContinuation = continuation

        let task = Task {
            try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Data, Error>) in
                var request = URLRequest(url: url)
                request.httpMethod = "POST"
                request.httpBody = data

                let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
                session.uploadTask(with: request, from: data) { data, _, error in
                    if let error {
                        cont.resume(throwing: error)
                    } else {
                        cont.resume(returning: data ?? Data())
                    }
                    continuation.finish()
                }.resume()
            }
        }

        return (progress: stream, result: task)
    }

    // Delegate untuk progress — dipanggil oleh URLSession
    nonisolated func urlSession(
        _ session: URLSession,
        task: URLSessionTask,
        didSendBodyData bytesSent: Int64,
        totalBytesSent: Int64,
        totalBytesExpectedToSend: Int64
    ) {
        let progress = Double(totalBytesSent) / Double(totalBytesExpectedToSend)
        Task { await updateProgress(progress) }
    }

    private func updateProgress(_ progress: Double) {
        progressContinuation?.yield(progress)
    }
}

// Penggunaan:
func uploadAvatar() async {
    guard let imageData = UIImage(named: "avatar")?.jpegData(compressionQuality: 0.8) else { return }
    let uploadService = UploadService()
    let url = URL(string: "https://api.example.com/avatar")!

    let (progressStream, resultTask) = uploadService.uploadWithProgress(data: imageData, to: url)

    // Pantau progress di Task terpisah
    Task {
        for await progress in progressStream {
            await MainActor.run {
                print("Upload progress: \(Int(progress * 100))%")
                // Update progress bar di UI
            }
        }
    }

    // Tunggu hasil upload
    do {
        let responseData = try await resultTask.value
        print("Upload berhasil: \(responseData.count) bytes response")
    } catch {
        print("Upload gagal: \(error)")
    }
}

Aturan withCheckedContinuation:

Aturan Penjelasan
resume dipanggil tepat satu kali Kurang dari sekali → caller hang selamanya. Lebih dari sekali → crash (checked)
Nil-kan setelah resume Mencegah accidental double-resume di edge case
Gunakan withCheckedThrowingContinuation jika bisa error Memberikan error propagation yang bersih ke caller
Jangan simpan continuation terlalu lama Continuation menyimpan seluruh call stack — memory leak jika tidak pernah di-resume
Gunakan AsyncStream jika callback bisa dipanggil berkali-kali Continuation hanya untuk callback yang dipanggil tepat satu kali

Kapan Pakai withCheckedContinuation:

  • Menggunakan UIKit API yang masih callback-based (UIImagePickerController, PHPickerViewController)
  • Wrapping delegate API yang fire tepat satu kali (CLLocationManager single request, AVAudioPlayer)
  • Mengintegrasikan library third-party yang belum mendukung async/await

Kapan Tidak Pakai:

  • Callback bisa dipanggil berkali-kali → gunakan AsyncStream
  • API sudah punya versi async (URLSession.data(for:), PHPickerViewController dengan async) → gunakan langsung
  • Untuk Combine publisher → gunakan AsyncPublisher atau values property