Section 16/171 menit
16. Real Use Cases
16. Real Use Cases
Use Case 1: Image Loader yang Aman, Dedup, dan Cancel-Friendly
swift
actor ImageLoader {
private var cache: [URL: UIImage] = [:]
private var inflight: [URL: Task<UIImage, Error>] = [:]
func image(for url: URL) async throws -> UIImage {
if let img = cache[url] { return img }
if let existing = inflight[url] {
return try await existing.value // dedup
}
let task = Task<UIImage, Error> {
let (data, _) = try await URLSession.shared.data(from: url)
guard let img = UIImage(data: data) else {
throw URLError(.cannotDecodeContentData)
}
return img
}
inflight[url] = task
defer { inflight[url] = nil }
let image = try await task.value
cache[url] = image
return image
}
func invalidate(_ url: URL) {
cache.removeValue(forKey: url)
}
}
@MainActor
final class ImageView: UIView {
private let loader: ImageLoader
private var currentTask: Task<Void, Never>?
private let imageView = UIImageView()
init(loader: ImageLoader) {
self.loader = loader
super.init(frame: .zero)
}
required init?(coder: NSCoder) { fatalError() }
func setImageURL(_ url: URL) {
currentTask?.cancel() // cancel sebelumnya saat URL berubah
imageView.image = nil
currentTask = Task { [weak self] in
guard let self else { return }
do {
let img = try await loader.image(for: url)
guard !Task.isCancelled else { return }
self.imageView.image = img
} catch is CancellationError {
// expected
} catch {
self.imageView.image = UIImage(named: "placeholder")
}
}
}
}
Keputusan desain:
ImageLoaderactor → state cache aman, dedup naturally.ImageView@MainActor, simpan task → cancel saat URL diganti.CancellationErrordipisah dari error lain.[weak self]untuk hindari leak.
Use Case 2: ViewModel Pencarian dengan Debounce-by-Cancellation
swift
@MainActor
@Observable
final class SearchViewModel {
var query: String = ""
var results: [SearchResult] = []
var isLoading: Bool = false
var errorMessage: String?
private let service: SearchService
private var searchTask: Task<Void, Never>?
init(service: SearchService) {
self.service = service
}
func updateQuery(_ newQuery: String) {
query = newQuery
searchTask?.cancel()
guard !newQuery.isEmpty else {
results = []
isLoading = false
return
}
isLoading = true
searchTask = Task { [weak self] in
do {
try await Task.sleep(for: .milliseconds(300)) // debounce
try Task.checkCancellation()
guard let self else { return }
let found = try await self.service.execute(query: newQuery)
try Task.checkCancellation()
self.results = found
self.errorMessage = nil
self.isLoading = false
} catch is CancellationError {
// diam — user kemungkinan ganti query
} catch {
self?.errorMessage = "Pencarian gagal: \(error.localizedDescription)"
self?.isLoading = false
}
}
}
deinit {
searchTask?.cancel()
}
}
actor SearchService {
func execute(query: String) async throws -> [SearchResult] {
let url = URL(string: "https://api.example.com/search?q=\(query)")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([SearchResult].self, from: data)
}
}
struct SearchResult: Sendable, Identifiable, Decodable {
let id: UUID
let title: String
}
Keputusan desain:
- Debounce via
Task.sleep+ cancel — tidak butuh timer external. - ViewModel
@MainActoragar property change langsung trigger SwiftUI update. - Service di actor sendiri → off-main untuk I/O.
- Cancellation didokumentasikan dengan check eksplisit di tiga titik.
Use Case 3: Repository SwiftData dengan Modal Actor
swift
@ModelActor
actor TaskRepository {
func allTasks() throws -> [TaskItem] {
let descriptor = FetchDescriptor<TaskItem>(
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
return try modelContext.fetch(descriptor)
}
func create(title: String) throws -> TaskItem.Snapshot {
let task = TaskItem(title: title, createdAt: .now, isDone: false)
modelContext.insert(task)
try modelContext.save()
return task.snapshot
}
func toggleDone(id: PersistentIdentifier) throws {
guard let task = modelContext.model(for: id) as? TaskItem else { return }
task.isDone.toggle()
try modelContext.save()
}
}
@Model
final class TaskItem {
var title: String
var createdAt: Date
var isDone: Bool
init(title: String, createdAt: Date, isDone: Bool) {
self.title = title
self.createdAt = createdAt
self.isDone = isDone
}
// Snapshot Sendable yang aman dikirim ke MainActor
var snapshot: Snapshot {
Snapshot(id: persistentModelID, title: title, isDone: isDone, createdAt: createdAt)
}
struct Snapshot: Sendable, Identifiable {
let id: PersistentIdentifier
let title: String
let isDone: Bool
let createdAt: Date
}
}
@MainActor
@Observable
final class TaskListViewModel {
var snapshots: [TaskItem.Snapshot] = []
private let repo: TaskRepository
init(repo: TaskRepository) { self.repo = repo }
func reload() async {
do {
let tasks = try await repo.allTasks()
// Transformasikan ke Sendable snapshot SEBELUM cross-isolation
self.snapshots = await Task.detached(priority: .userInitiated) {
tasks.map { $0.snapshot }
}.value
} catch {
// handle
}
}
func add(title: String) async {
do {
let snap = try await repo.create(title: title)
snapshots.insert(snap, at: 0)
} catch { }
}
}
Keputusan desain:
@ModelActormengisolasiModelContext(yang tidak Sendable) ke actor sendiri.- Snapshot Sendable melintasi boundary → ViewModel @MainActor aman.
- View hanya tahu Snapshot, bukan
TaskItem(non-Sendable).
Use Case 4: Background Sync dengan Task.detached + Priority
swift
actor SyncEngine {
private(set) var lastSyncedAt: Date?
private var currentSync: Task<Void, Error>?
func sync() async throws {
if let existing = currentSync {
return try await existing.value // dedup concurrent sync
}
let task = Task<Void, Error> {
try await performSync()
await self.markSynced()
}
currentSync = task
defer { Task { await self.clearCurrent() } }
try await task.value
}
private func performSync() async throws {
async let users = downloadUsers()
async let orders = downloadOrders()
async let products = downloadProducts()
_ = try await (users, orders, products)
}
private func markSynced() { lastSyncedAt = .now }
private func clearCurrent() { currentSync = nil }
private func downloadUsers() async throws { /* ... */ }
private func downloadOrders() async throws { /* ... */ }
private func downloadProducts() async throws { /* ... */ }
}
// App-level: trigger sync di background tapi tidak ikut cancel saat UI berubah
@MainActor
final class AppCoordinator {
private let sync: SyncEngine
init(sync: SyncEngine) { self.sync = sync }
func appWillEnterForeground() {
// Detached: sync tidak boleh cancel saat AppCoordinator method selesai
Task.detached(priority: .utility) { [sync] in
try? await sync.sync()
}
}
}
Keputusan desain:
SyncEngineactor → dedup concurrent sync request.async letdi dalam actor → paralel download di structured group.Task.detacheddi AppCoordinator karena lifecycle sync independen dari lifecycle method UI.- Priority
.utilityuntuk background sync.