-
-
Save janodev/5ba37518b353dde5a98652720305c37e to your computer and use it in GitHub Desktop.
Image cache from Apple’s Protect mutable state with Swift actors
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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