diff --git a/AsyncImageView/Renderers/CacheRenderer.swift b/AsyncImageView/Renderers/CacheRenderer.swift index 4c20fb7..e7a7ccf 100644 --- a/AsyncImageView/Renderers/CacheRenderer.swift +++ b/AsyncImageView/Renderers/CacheRenderer.swift @@ -32,7 +32,7 @@ public final class CacheRenderer< public func renderImageWithData(_ data: Renderer.Data) -> SignalProducer { return SignalProducer { [cache = self.cache] in Result( - cache.valueForKey(data)?.image.asCacheHit, + cache.valueForKey(data)?.asCacheHit, failWith: CacheRendererError.imageNotFound ) } @@ -41,9 +41,11 @@ public final class CacheRenderer< return renderer .renderImageWithData(data) .on(value: { [cache = self.cache] result in - cache.setValue(result, forKey: data) + if result.shouldCache { + cache.setValue(result, forKey: data) + } }) - .map { $0.image.asCacheMiss } + .map { $0.asCacheMiss } } } } @@ -86,18 +88,20 @@ private enum CacheRendererError: Error { case imageNotFound } -private extension UIImage { +private extension RenderResultType { var asCacheHit: ImageResult { return ImageResult( - image: self, - cacheHit: true + image: self.image, + cacheHit: true, + shouldCache: self.shouldCache ) } var asCacheMiss: ImageResult { return ImageResult( - image: self, - cacheHit: false + image: self.image, + cacheHit: false, + shouldCache: self.shouldCache ) } } diff --git a/AsyncImageView/Renderers/FallbackRenderer.swift b/AsyncImageView/Renderers/FallbackRenderer.swift index 4e717ac..343e0e1 100644 --- a/AsyncImageView/Renderers/FallbackRenderer.swift +++ b/AsyncImageView/Renderers/FallbackRenderer.swift @@ -60,7 +60,8 @@ extension RenderResultType { fileprivate var asResult: ImageResult { return ImageResult( image: self.image, - cacheHit: self.cacheHit + cacheHit: self.cacheHit, + shouldCache: self.shouldCache ) } } diff --git a/AsyncImageView/Renderers/ImageInflaterRenderer.swift b/AsyncImageView/Renderers/ImageInflaterRenderer.swift index 0c350fe..626385a 100644 --- a/AsyncImageView/Renderers/ImageInflaterRenderer.swift +++ b/AsyncImageView/Renderers/ImageInflaterRenderer.swift @@ -21,30 +21,53 @@ public final class ImageInflaterRenderer: RendererType { private let screenScale: CGFloat private let opaque: Bool private let contentMode: ContentMode + private let bitmapContextFactory: UIImage.BitmapContextFactory - public init( + public convenience init( renderer: Renderer, screenScale: CGFloat, opaque: Bool, contentMode: ContentMode = .defaultMode ) { + self.init( + renderer: renderer, + screenScale: screenScale, + opaque: opaque, + contentMode: contentMode, + bitmapContextFactory: UIImage.makeBitmapContext + ) + } + + internal init( + renderer: Renderer, + screenScale: CGFloat, + opaque: Bool, + contentMode: ContentMode = .defaultMode, + bitmapContextFactory: @escaping UIImage.BitmapContextFactory + ) { self.renderer = renderer self.screenScale = screenScale self.opaque = opaque self.contentMode = contentMode + self.bitmapContextFactory = bitmapContextFactory } public func renderImageWithData(_ data: Data) -> SignalProducer { return self.renderer.renderImageWithData(data) - .map { [screenScale = self.screenScale, opaque = self.opaque, contentMode = self.contentMode] result in - let inflatedImage = result.image.inflate( + .map { [screenScale = self.screenScale, opaque = self.opaque, contentMode = self.contentMode, bitmapContextFactory = self.bitmapContextFactory] result in + let inflationResult = result.image.inflate( withSize: data.size, scale: screenScale, opaque: opaque, - contentMode: contentMode + contentMode: contentMode, + bitmapContextFactory: bitmapContextFactory ) - return ImageResult(image: inflatedImage, cacheHit: result.cacheHit) + return ImageResult( + image: inflationResult.image, + cacheHit: result.cacheHit, + shouldCache: result.shouldCache && inflationResult.didProcess + ) } .start(on: QueueScheduler()) } @@ -77,18 +100,43 @@ extension UIImage { _ colorSpace: CGColorSpace, _ bitmapInfo: UInt32 ) -> CGContext? + internal typealias BitmapImageFactory = (_ context: CGContext) -> CGImage? - internal func inflate( - withSize size: CGSize, - scale: CGFloat, - opaque: Bool, - contentMode: ImageInflaterRendererContentMode - ) -> UIImage { + internal static func makeBitmapContext( + width: Int, + height: Int, + bytesPerRow: Int, + colorSpace: CGColorSpace, + bitmapInfo: UInt32 + ) -> CGContext? { + return CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: bitmapInfo + ) + } + + internal static func makeBitmapImage(_ context: CGContext) -> CGImage? { + return context.makeImage() + } + + internal func inflate( + withSize size: CGSize, + scale: CGFloat, + opaque: Bool, + contentMode: ImageInflaterRendererContentMode, + bitmapContextFactory: BitmapContextFactory = UIImage.makeBitmapContext + ) -> BitmapContextProcessingResult { return self.processImageWithBitmapContext( withSize: size, scale: scale, opaque: opaque, contentMode: contentMode, + bitmapContextFactory: bitmapContextFactory, renderingBlock: { _, _, _, imageDrawing in imageDrawing() } @@ -100,19 +148,10 @@ extension UIImage { scale: CGFloat, opaque: Bool, contentMode: ImageInflaterRendererContentMode, - bitmapContextFactory: BitmapContextFactory = { width, height, bytesPerRow, colorSpace, bitmapInfo in - CGContext( - data: nil, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: bytesPerRow, - space: colorSpace, - bitmapInfo: bitmapInfo - ) - }, + bitmapContextFactory: BitmapContextFactory = UIImage.makeBitmapContext, + bitmapImageFactory: BitmapImageFactory = UIImage.makeBitmapImage, renderingBlock: (_ image: UIImage, _ context: CGContext, _ contextSize: CGSize, _ imageDrawing: () -> Void) -> Void) - -> UIImage { + -> BitmapContextProcessingResult { precondition(size.width > 0 && size.height > 0, "Invalid size: \(size.width)x\(size.height)") let colorSpace = CGColorSpaceCreateDeviceRGB() @@ -131,7 +170,7 @@ extension UIImage { colorSpace, bitmapInfo ) else { - return self + return BitmapContextProcessingResult(image: self, didProcess: false) } renderingBlock( @@ -149,14 +188,26 @@ extension UIImage { } ) - return UIImage( - cgImage: bitmapContext.makeImage()!, - scale: scale, - orientation: self.imageOrientation + guard let processedImage = bitmapImageFactory(bitmapContext) else { + return BitmapContextProcessingResult(image: self, didProcess: false) + } + + return BitmapContextProcessingResult( + image: UIImage( + cgImage: processedImage, + scale: scale, + orientation: self.imageOrientation + ), + didProcess: true ) } } +internal struct BitmapContextProcessingResult { + let image: UIImage + let didProcess: Bool +} + extension RendererType { public func inflatedWithScale( _ screenScale: CGFloat, diff --git a/AsyncImageView/Renderers/ImageProcessingRenderer.swift b/AsyncImageView/Renderers/ImageProcessingRenderer.swift index c00b07e..45114b7 100644 --- a/AsyncImageView/Renderers/ImageProcessingRenderer.swift +++ b/AsyncImageView/Renderers/ImageProcessingRenderer.swift @@ -13,50 +13,81 @@ import ReactiveSwift /// `RendererType` decorator that allows rendering a new image derived from the original one. public final class ImageProcessingRenderer: RendererType { public typealias Block = (_ image: UIImage, _ context: CGContext, _ contextSize: CGSize, _ data: Renderer.Data, _ imageDrawingBlock: () -> Void) -> Void + public typealias Data = Renderer.Data + public typealias Error = Renderer.Error + public typealias RenderResult = ImageResult private let renderer: Renderer private let scale: CGFloat private let opaque: Bool - private let contentMode: ImageInflaterRendererContentMode + private let contentMode: ImageInflaterRendererContentMode private let renderingBlock: Block + private let bitmapImageFactory: UIImage.BitmapImageFactory private let schedulerCreator: () -> Scheduler - public init( + public convenience init( renderer: Renderer, scale: CGFloat, opaque: Bool, contentMode: ImageInflaterRendererContentMode = .defaultMode, renderingBlock: @escaping Block, schedulerCreator: @escaping () -> Scheduler = { QueueScheduler() }) { - self.renderer = renderer - self.scale = scale - self.opaque = opaque - self.contentMode = contentMode - self.renderingBlock = renderingBlock + self.init( + renderer: renderer, + scale: scale, + opaque: opaque, + contentMode: contentMode, + renderingBlock: renderingBlock, + bitmapImageFactory: UIImage.makeBitmapImage, + schedulerCreator: schedulerCreator + ) + } - self.schedulerCreator = schedulerCreator + internal init( + renderer: Renderer, + scale: CGFloat, + opaque: Bool, + contentMode: ImageInflaterRendererContentMode = .defaultMode, + renderingBlock: @escaping Block, + bitmapImageFactory: @escaping UIImage.BitmapImageFactory, + schedulerCreator: @escaping () -> Scheduler = { QueueScheduler() } + ) { + self.renderer = renderer + self.scale = scale + self.opaque = opaque + self.contentMode = contentMode + self.renderingBlock = renderingBlock + self.bitmapImageFactory = bitmapImageFactory + + self.schedulerCreator = schedulerCreator } - public func renderImageWithData(_ data: Renderer.Data) -> SignalProducer { + public func renderImageWithData(_ data: Data) -> SignalProducer { return self.renderer.renderImageWithData(data) .observe(on: self.schedulerCreator()) - .map { $0.image } - .map { [scale = self.scale, opaque = self.opaque, block = self.renderingBlock, contentMode = self.contentMode] image in - image.processImageWithBitmapContext( - withSize: data.size, - scale: scale, + .map { [scale = self.scale, opaque = self.opaque, block = self.renderingBlock, contentMode = self.contentMode, bitmapImageFactory = self.bitmapImageFactory] result in + let processingResult = result.image.processImageWithBitmapContext( + withSize: data.size, + scale: scale, opaque: opaque, contentMode: contentMode, - renderingBlock: { image, context, contextSize, imageDrawingBlock in - block( - image, - context, - contextSize, - data, - imageDrawingBlock - ) - } + bitmapImageFactory: bitmapImageFactory, + renderingBlock: { image, context, contextSize, imageDrawingBlock in + block( + image, + context, + contextSize, + data, + imageDrawingBlock + ) + } + ) + + return ImageResult( + image: processingResult.image, + cacheHit: result.cacheHit, + shouldCache: result.shouldCache && processingResult.didProcess ) } } diff --git a/AsyncImageView/Renderers/MulticastedRenderer.swift b/AsyncImageView/Renderers/MulticastedRenderer.swift index 5de27ff..99dcca8 100644 --- a/AsyncImageView/Renderers/MulticastedRenderer.swift +++ b/AsyncImageView/Renderers/MulticastedRenderer.swift @@ -96,9 +96,9 @@ extension RendererType { fileprivate func createProducerForRenderingData(_ data: Data) -> SignalProducer { return self.renderImageWithData(data) .flatMap(.concat) { result in - return SignalProducer([ - ImageResult(image: result.image, cacheHit: false), - ImageResult(image: result.image, cacheHit: true) + return SignalProducer([ + ImageResult(image: result.image, cacheHit: false, shouldCache: result.shouldCache), + ImageResult(image: result.image, cacheHit: true, shouldCache: result.shouldCache) ]) } } diff --git a/AsyncImageView/Renderers/Renderer.swift b/AsyncImageView/Renderers/Renderer.swift index db15091..d45addf 100644 --- a/AsyncImageView/Renderers/Renderer.swift +++ b/AsyncImageView/Renderers/Renderer.swift @@ -20,16 +20,34 @@ public protocol RenderResultType { var cacheHit: Bool { get } } +internal extension RenderResultType { + var shouldCache: Bool { + return (self as? CachePolicyProviding)?.shouldCache ?? true + } +} + public struct ImageResult: RenderResultType { public let image: UIImage public let cacheHit: Bool + internal let shouldCache: Bool public init(image: UIImage, cacheHit: Bool) { + self.init(image: image, cacheHit: cacheHit, shouldCache: true) + } + + internal init(image: UIImage, cacheHit: Bool, shouldCache: Bool) { self.image = image self.cacheHit = cacheHit + self.shouldCache = shouldCache } } +private protocol CachePolicyProviding { + var shouldCache: Bool { get } +} + +extension ImageResult: CachePolicyProviding {} + extension UIImage: RenderResultType { public var image: UIImage { return self diff --git a/AsyncImageViewTests/CacheRendererSpec.swift b/AsyncImageViewTests/CacheRendererSpec.swift new file mode 100644 index 0000000..6a6ba2d --- /dev/null +++ b/AsyncImageViewTests/CacheRendererSpec.swift @@ -0,0 +1,114 @@ +// +// CacheRendererSpec.swift +// AsyncImageView +// +// Created by Nacho Soto on 7/10/26. +// Copyright © 2026 Nacho Soto. All rights reserved. +// + +import Quick +import Nimble +import UIKit + +@testable import AsyncImageView + +class CacheRendererSpec: QuickSpec { + override class func spec() { + describe("CacheRenderer") { + it("does not cache the original image after inflation fails") { + let data = TestRenderData( + data: .a, + size: CGSize(width: 20, height: 30) + ) + let sourceRenderer = TestRenderer() + let cache = InMemoryCache(cacheName: #function) + var shouldFailContextCreation = true + + let renderer = ImageInflaterRenderer( + renderer: sourceRenderer, + screenScale: 2, + opaque: false, + bitmapContextFactory: { width, height, bytesPerRow, colorSpace, bitmapInfo in + guard !shouldFailContextCreation else { return nil } + + return UIImage.makeBitmapContext( + width: width, + height: height, + bytesPerRow: bytesPerRow, + colorSpace: colorSpace, + bitmapInfo: bitmapInfo + ) + } + ) + .withCache(cache) + + let fallbackResult = renderer.renderImageWithData(data).single()?.get() + + expect(fallbackResult?.image.scale) == data.data.rawValue + expect(fallbackResult?.shouldCache) == false + expect(cache.valueForKey(data)).to(beNil()) + expect(sourceRenderer.renderedImages.value.count) == 1 + + shouldFailContextCreation = false + + let inflatedResult = renderer.renderImageWithData(data).single()?.get() + + expect(inflatedResult?.image.scale) == 2 + expect(inflatedResult?.shouldCache) == true + expect(cache.valueForKey(data)).toNot(beNil()) + expect(sourceRenderer.renderedImages.value.count) == 2 + + let cachedResult = renderer.renderImageWithData(data).single()?.get() + + expect(cachedResult?.cacheHit) == true + expect(sourceRenderer.renderedImages.value.count) == 2 + } + + it("does not cache the original image after processing output creation fails") { + let data = TestRenderData( + data: .a, + size: CGSize(width: 20, height: 30) + ) + let sourceRenderer = TestRenderer() + let cache = InMemoryCache(cacheName: #function) + var shouldFailImageCreation = true + + let renderer = ImageProcessingRenderer( + renderer: sourceRenderer, + scale: 2, + opaque: false, + renderingBlock: { _, _, _, _, imageDrawing in + imageDrawing() + }, + bitmapImageFactory: { context in + guard !shouldFailImageCreation else { return nil } + + return context.makeImage() + } + ) + .withCache(cache) + + let fallbackResult = renderer.renderImageWithData(data).single()?.get() + + expect(fallbackResult?.image.scale) == data.data.rawValue + expect(fallbackResult?.shouldCache) == false + expect(cache.valueForKey(data)).to(beNil()) + expect(sourceRenderer.renderedImages.value.count) == 1 + + shouldFailImageCreation = false + + let processedResult = renderer.renderImageWithData(data).single()?.get() + + expect(processedResult?.image.scale) == 2 + expect(processedResult?.shouldCache) == true + expect(cache.valueForKey(data)).toNot(beNil()) + expect(sourceRenderer.renderedImages.value.count) == 2 + + let cachedResult = renderer.renderImageWithData(data).single()?.get() + + expect(cachedResult?.cacheHit) == true + expect(sourceRenderer.renderedImages.value.count) == 2 + } + } + } +} diff --git a/AsyncImageViewTests/ImageInflaterRendererSpec.swift b/AsyncImageViewTests/ImageInflaterRendererSpec.swift index 8169c1c..f794db1 100644 --- a/AsyncImageViewTests/ImageInflaterRendererSpec.swift +++ b/AsyncImageViewTests/ImageInflaterRendererSpec.swift @@ -39,7 +39,8 @@ class ImageInflaterRendererSpec: QuickSpec { expect(didAttemptContextCreation) == true expect(didRender) == false - expect(result).to(beIdenticalTo(image)) + expect(result.image).to(beIdenticalTo(image)) + expect(result.didProcess) == false } }