diff --git a/AsyncImageView/Caching.swift b/AsyncImageView/Caching.swift index 9583236..9378c70 100644 --- a/AsyncImageView/Caching.swift +++ b/AsyncImageView/Caching.swift @@ -97,6 +97,22 @@ public protocol NSDataConvertible { /// Encodes the receiver in `NSData`. Returns `nil` if failed. var data: Data? { get } + + /// Creates an instance from its disk-cache representation. + static func valueFromCacheData(_ data: Data) -> Self? + + /// Encodes the receiver for storage in `DiskCache`. Returns `nil` if failed. + var cacheData: Data? { get } +} + +public extension NSDataConvertible { + static func valueFromCacheData(_ data: Data) -> Self? { + Self(data: data) + } + + var cacheData: Data? { + self.data + } } /// Returns the directory where all `DiskCache` caches are stored @@ -129,7 +145,7 @@ public final class DiskCache: CacheType { public func valueForKey(_ key: K) -> V? { return withLock { (try? Data(contentsOf: self.filePathForKey(key))) } - .flatMap(V.init) + .flatMap(V.valueFromCacheData) } public func setValue(_ value: V?, forKey key: K) { @@ -138,7 +154,7 @@ public final class DiskCache: CacheType { self.withLock { self.guaranteeDirectoryExists(url.deletingLastPathComponent()) - if let data = value.flatMap({ $0.data }) { + if let data = value.flatMap({ $0.cacheData }) { // swiftlint:disable:next force_try try! data.write(to: url, options: .atomicWrite) } else if self.fileManager.fileExists(atPath: url.path) { diff --git a/AsyncImageView/Extensions.swift b/AsyncImageView/Extensions.swift index 3a3f0bc..b456e31 100644 --- a/AsyncImageView/Extensions.swift +++ b/AsyncImageView/Extensions.swift @@ -14,6 +14,14 @@ extension UIImage: NSDataConvertible { public var data: Data? { return self.pngData() } + + public static func valueFromCacheData(_ data: Data) -> Self? { + CachedImageSerialization.image(from: data) as? Self + } + + public var cacheData: Data? { + CachedImageSerialization.data(for: self) + } } extension ImageResult: NSDataConvertible { @@ -31,6 +39,50 @@ extension ImageResult: NSDataConvertible { public var data: Data? { return self.image.data } + + public static func valueFromCacheData(_ data: Data) -> ImageResult? { + UIImage.valueFromCacheData(data).map { image in + ImageResult(image: image, cacheHit: false) + } + } + + public var cacheData: Data? { + self.image.cacheData + } +} + +private enum CachedImageSerialization { + private static let currentVersion = 1 + + private struct Payload: Codable { + let version: Int + let pngData: Data + let scale: Double + } + + static func data(for image: UIImage) -> Data? { + guard let pngData = image.pngData() else { return nil } + + let payload = Payload( + version: self.currentVersion, + pngData: pngData, + scale: Double(image.scale) + ) + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + + return try? encoder.encode(payload) + } + + static func image(from data: Data) -> UIImage? { + guard let payload = try? PropertyListDecoder().decode(Payload.self, from: data), + payload.version == self.currentVersion, + payload.scale.isFinite, + payload.scale > 0, + let image = UIImage(data: payload.pngData, scale: CGFloat(payload.scale)) else { return nil } + + return image + } } extension Result { diff --git a/AsyncImageViewTests/DiskCacheImageScaleTests.swift b/AsyncImageViewTests/DiskCacheImageScaleTests.swift new file mode 100644 index 0000000..fa20f7c --- /dev/null +++ b/AsyncImageViewTests/DiskCacheImageScaleTests.swift @@ -0,0 +1,105 @@ +import Foundation +import Testing +import UIKit + +import ReactiveSwift + +@testable import AsyncImageView + +@Suite +struct DiskCacheImageScaleTests { + @Test + func preservesImageScaleAndDimensions() throws { + let cache = DiskCache(rootDirectory: temporaryDirectory()) + let image = makeImage(size: CGSize(width: 44, height: 44), scale: 2) + let key = ImageCacheKey(uniqueFilename: "image", size: image.size) + + cache.setValue(image, forKey: key) + let restored = try #require(cache.valueForKey(key)) + + #expect(restored.size == image.size) + #expect(restored.scale == image.scale) + #expect(restored.cgImage?.width == image.cgImage?.width) + #expect(restored.cgImage?.height == image.cgImage?.height) + } + + @Test + func preservesScaleThroughACachedRendererHit() throws { + let cache = DiskCache(rootDirectory: temporaryDirectory()) + let image = makeImage(size: CGSize(width: 52, height: 52), scale: 3) + let key = ImageCacheKey(uniqueFilename: "result", size: image.size) + let source = ScaleImageRenderer(image: image) + let renderer = source.withCache(cache) + + let rendered = try #require(renderer.renderImageWithData(key).single()?.get()) + let restored = try #require(renderer.renderImageWithData(key).single()?.get()) + + #expect(source.renderCount == 1) + #expect(rendered.cacheHit == false) + #expect(restored.cacheHit == true) + #expect(restored.image.size == image.size) + #expect(restored.image.scale == image.scale) + #expect(restored.image.cgImage?.width == image.cgImage?.width) + #expect(restored.image.cgImage?.height == image.cgImage?.height) + } + + @Test + func regeneratesLegacyRawPNGEntries() throws { + let directory = temporaryDirectory() + let cache = DiskCache(rootDirectory: directory) + let key = ImageCacheKey(uniqueFilename: "legacy", size: CGSize(width: 44, height: 44)) + let image = makeImage(size: CGSize(width: 44, height: 44), scale: 2) + let source = ScaleImageRenderer(image: image) + let renderer = source.withCache(cache) + let data = try #require(image.pngData()) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: directory.appendingPathComponent(key.uniqueFilename)) + + let regenerated = try #require(renderer.renderImageWithData(key).single()?.get()) + let cached = try #require(renderer.renderImageWithData(key).single()?.get()) + + #expect(source.renderCount == 1) + #expect(regenerated.cacheHit == false) + #expect(cached.cacheHit == true) + #expect(cached.image.size == image.size) + #expect(cached.image.scale == image.scale) + } + + private func temporaryDirectory() -> URL { + URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString, isDirectory: true) + } + + private func makeImage(size: CGSize, scale: CGFloat) -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = scale + + return UIGraphicsImageRenderer(size: size, format: format).image { context in + context.cgContext.setFillColor(UIColor.red.cgColor) + context.cgContext.fill(CGRect(origin: .zero, size: size)) + } + } +} + +private struct ImageCacheKey: DataFileType { + let uniqueFilename: String + let size: CGSize + let subdirectory: String? = nil +} + +extension ImageCacheKey: RenderDataType {} + +private final class ScaleImageRenderer: RendererType { + private let image: UIImage + private(set) var renderCount = 0 + + init(image: UIImage) { + self.image = image + } + + func renderImageWithData(_ data: ImageCacheKey) -> SignalProducer { + self.renderCount += 1 + + return SignalProducer(value: ImageResult(image: self.image, cacheHit: false)) + } +}