Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions AsyncImageView/Caching.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -129,7 +145,7 @@ public final class DiskCache<K: DataFileType, V: NSDataConvertible>: 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) {
Expand All @@ -138,7 +154,7 @@ public final class DiskCache<K: DataFileType, V: NSDataConvertible>: 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) {
Expand Down
52 changes: 52 additions & 0 deletions AsyncImageView/Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
105 changes: 105 additions & 0 deletions AsyncImageViewTests/DiskCacheImageScaleTests.swift
Original file line number Diff line number Diff line change
@@ -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<ImageCacheKey, UIImage>(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<ImageCacheKey, ImageResult>(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<ImageCacheKey, ImageResult>(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<ImageResult, Never> {
self.renderCount += 1

return SignalProducer(value: ImageResult(image: self.image, cacheHit: false))
}
}
Loading