Skip to content

Instantly share code, notes, and snippets.

@janodev
Last active January 5, 2022 14:58
Show Gist options
  • Save janodev/5ba37518b353dde5a98652720305c37e to your computer and use it in GitHub Desktop.
Save janodev/5ba37518b353dde5a98652720305c37e to your computer and use it in GitHub Desktop.
Image cache from Apple’s Protect mutable state with Swift actors
import os
import UIKit
enum FetchError: Error {
case badResponse
case badImage
case badURL
}
/**
Image cache.
This is an actor so only one request is alive at a time,
even when there can be many ongoing but suspended at the suspension points (await).
*/
actor ImageDownloader
{
private let log = Logger(subsystem: "dev.jano", category: "ImageDownloader")
static let shared = ImageDownloader()
private enum CacheEntry {
case inProgress(Task<UIImage, Error>)
case ready(UIImage)
}
private var cache: [URL: CacheEntry] = [:]
func image(from urlString: String) async throws -> UIImage? {
guard let url = URL(string: urlString) else {
log.error("Ignoring request. URL is not valid: \(urlString)")
return nil
}
return try await image(from: url)
}
func image(from url: URL) async throws -> UIImage? {
if let cached = cache[url] {
// the cache contains images either downloaded or in progress
switch cached {
case .ready(let image):
return image // return image immediately
case .inProgress(let handle):
return try await handle.value // await the download and return the image
}
}
// create and store an image in progress
let handle = Task {
try await downloadImage(from: url)
}
cache[url] = .inProgress(handle)
do {
// await the download, store the image, return the image
let image = try await handle.value
cache[url] = .ready(image)
return image
} catch {
cache[url] = nil // remove the download in progress
throw error
}
}
private func downloadImage(from urlString: String) async throws -> UIImage {
guard let url = URL(string: urlString) else {
throw FetchError.badURL
}
return try await downloadImage(from: url)
}
private func downloadImage(from url: URL) async throws -> UIImage {
let request = URLRequest(url: url)
let (data, response) = try await URLSession.shared.data(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw FetchError.badResponse
}
guard let image = UIImage(data: data) else {
throw FetchError.badImage
}
return image
}
}
final class UIImageViewExtension
{
private let base: UIImageView
init(_ base: UIImageView) {
self.base = base
}
func setImage(url: URL, options: [UIImageViewExtensionOptions]) {
Task {
guard var image = try await ImageDownloader.shared.image(from: url) else {
return
}
var onSuccess: @MainActor () -> Void = {}
for option in options {
switch option {
case .discardUnless(let condition):
if !condition() { return }
case .aspectFitResizeOnViewport(let size):
let aspectSize = aspectSize(for: image, onViewport: size)
image = resize(image: image, newSize: aspectSize)
case .onSuccess(let action):
onSuccess = action
}
}
await MainActor.run { [image, onSuccess] in
base.image = image
onSuccess()
}
}
}
// ...
}
extension UIImageView {
var ext: UIImageViewExtension {
UIImageViewExtension(self)
}
}
// usage
Task {
imageView.ext.setImage(
stringURL: someURL,
options: [
.discardUnless(condition: { idBefore == self.id }),
.aspectFitResizeOnViewport(size: viewport),
.onSuccess(action: updateLayout)
]
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment