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:

  • ImageLoader actor → state cache aman, dedup naturally.
  • ImageView @MainActor, simpan task → cancel saat URL diganti.
  • CancellationError dipisah 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 @MainActor agar 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:

  • @ModelActor mengisolasi ModelContext (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:

  • SyncEngine actor → dedup concurrent sync request.
  • async let di dalam actor → paralel download di structured group.
  • Task.detached di AppCoordinator karena lifecycle sync independen dari lifecycle method UI.
  • Priority .utility untuk background sync.