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:),PHPickerViewControllerdengan async) → gunakan langsung - Untuk Combine publisher → gunakan
AsyncPublisheratauvaluesproperty