diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 350dedb..ee0c1d4 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -9,7 +9,7 @@ concurrency: jobs: test: - runs-on: macos-latest + runs-on: macos-26 timeout-minutes: 30 env: CI: true diff --git a/AsyncImageView/AsyncImageLoader.swift b/AsyncImageView/AsyncImageLoader.swift index 68d4fba..636a5ac 100644 --- a/AsyncImageView/AsyncImageLoader.swift +++ b/AsyncImageView/AsyncImageLoader.swift @@ -27,11 +27,9 @@ Renderer.RenderResult == PlaceholderRenderer.RenderResult { requestsSignal: Signal, renderer: Renderer, placeholderRenderer: PlaceholderRenderer?, - uiScheduler: ReactiveSwift.Scheduler, - imageCreationScheduler: ReactiveSwift.Scheduler + uiScheduler: ReactiveSwift.Scheduler ) -> Signal { return requestsSignal.skipRepeats(==) - .observe(on: imageCreationScheduler) .flatMap(.latest) { data -> SignalProducer in let prefixSignal: SignalProducer = .init(value: nil) diff --git a/AsyncImageView/AsyncImageView.swift b/AsyncImageView/AsyncImageView.swift index b8907dd..623b962 100644 --- a/AsyncImageView/AsyncImageView.swift +++ b/AsyncImageView/AsyncImageView.swift @@ -59,8 +59,7 @@ open class AsyncImageView< requestsSignal: self.requestsSignal, renderer: renderer, placeholderRenderer: placeholderRenderer, - uiScheduler: uiScheduler, - imageCreationScheduler: imageCreationScheduler + uiScheduler: uiScheduler ) .observeValues { [weak self] result in self?.updateImage(result) @@ -77,13 +76,17 @@ open class AsyncImageView< open override var frame: CGRect { didSet { - self.requestNewImageIfReady() + if self.frame.size != oldValue.size { + self.requestNewImageIfReady() + } } } open override var bounds: CGRect { didSet { - self.requestNewImageIfReady() + if self.bounds.size != oldValue.size { + self.requestNewImageIfReady() + } } } diff --git a/AsyncImageView/AsyncSwiftUIImageView.swift b/AsyncImageView/AsyncSwiftUIImageView.swift index 4f17e2b..4f4efd5 100644 --- a/AsyncImageView/AsyncSwiftUIImageView.swift +++ b/AsyncImageView/AsyncSwiftUIImageView.swift @@ -20,23 +20,82 @@ public struct AsyncSwiftUIImageView< Renderer.Error == Never, PlaceholderRenderer.Data == Data, PlaceholderRenderer.Error == Never, -Renderer.RenderResult == PlaceholderRenderer.RenderResult { + Renderer.RenderResult == PlaceholderRenderer.RenderResult { private typealias ViewModel = AsyncSwiftUIImageViewModel - @State private var viewModel: ViewModel + @State private var viewModelReference: LazyReference + + private var viewModel: ViewModel { + self.viewModelReference.value + } + + public init( + renderer: Renderer, + placeholderRenderer: PlaceholderRenderer? = nil + ) { + self.init( + renderer: renderer, + placeholderRenderer: placeholderRenderer, + uiSchedulerFactory: { UIScheduler() }, + imageCreationSchedulerFactory: { QueueScheduler() } + ) + } + + public init( + renderer: Renderer, + placeholderRenderer: PlaceholderRenderer? = nil, + uiScheduler: ReactiveSwift.Scheduler + ) { + self.init( + renderer: renderer, + placeholderRenderer: placeholderRenderer, + uiSchedulerFactory: { uiScheduler }, + imageCreationSchedulerFactory: { QueueScheduler() } + ) + } + + public init( + renderer: Renderer, + placeholderRenderer: PlaceholderRenderer? = nil, + imageCreationScheduler: ReactiveSwift.Scheduler + ) { + self.init( + renderer: renderer, + placeholderRenderer: placeholderRenderer, + uiSchedulerFactory: { UIScheduler() }, + imageCreationSchedulerFactory: { imageCreationScheduler } + ) + } public init( renderer: Renderer, placeholderRenderer: PlaceholderRenderer? = nil, - uiScheduler: ReactiveSwift.Scheduler = UIScheduler(), - imageCreationScheduler: ReactiveSwift.Scheduler = QueueScheduler()) { - _viewModel = State( - initialValue: ViewModel( - renderer: renderer, - placeholderRenderer: placeholderRenderer, - uiScheduler: uiScheduler, - imageCreationScheduler: imageCreationScheduler - ) + uiScheduler: ReactiveSwift.Scheduler, + imageCreationScheduler: ReactiveSwift.Scheduler + ) { + self.init( + renderer: renderer, + placeholderRenderer: placeholderRenderer, + uiSchedulerFactory: { uiScheduler }, + imageCreationSchedulerFactory: { imageCreationScheduler } + ) + } + + internal init( + renderer: Renderer, + placeholderRenderer: PlaceholderRenderer?, + uiSchedulerFactory: @escaping () -> ReactiveSwift.Scheduler, + imageCreationSchedulerFactory: @escaping () -> ReactiveSwift.Scheduler + ) { + _viewModelReference = State( + initialValue: LazyReference { + ViewModel( + renderer: renderer, + placeholderRenderer: placeholderRenderer, + uiScheduler: uiSchedulerFactory(), + imageCreationScheduler: imageCreationSchedulerFactory() + ) + } ) } @@ -58,12 +117,11 @@ Renderer.RenderResult == PlaceholderRenderer.RenderResult { public var body: some View { ZStack { self.imageView - - Color.clear - .modifier(SizeModifier()) - .onPreferenceChange(ImageSizePreferenceKey.self) { imageSize in - self.size = imageSize - } + } + .onGeometryChange(for: CGSize.self) { geometry in + geometry.size + } action: { imageSize in + self.size = imageSize } .onAppear { self.viewModel.start() @@ -101,33 +159,25 @@ Renderer.RenderResult == PlaceholderRenderer.RenderResult { } } -public extension AsyncSwiftUIImageView { - func data(_ data: ImageViewData) -> Self { - var view = self - view.data = data +private final class LazyReference { + private let factory: () -> Value + private lazy var storedValue = self.factory() - return view + var value: Value { + self.storedValue } -} - -private struct ImageSizePreferenceKey: PreferenceKey { - typealias Value = CGSize - - static var defaultValue: CGSize = .zero - static func reduce(value: inout CGSize, nextValue: () -> CGSize) { - value = nextValue() + init(factory: @escaping () -> Value) { + self.factory = factory } } -private struct SizeModifier: ViewModifier { - func body(content: Content) -> some View { - content.background( - GeometryReader { geometry in - Color.clear - .preference(key: ImageSizePreferenceKey.self, value: geometry.size) - } - ) +public extension AsyncSwiftUIImageView { + func data(_ data: ImageViewData) -> Self { + var view = self + view.data = data + + return view } } @@ -178,8 +228,7 @@ private final class AsyncSwiftUIImageViewModel< requestsSignal: self.requestsSignal, renderer: self.renderer, placeholderRenderer: self.placeholderRenderer, - uiScheduler: self.uiScheduler, - imageCreationScheduler: self.imageCreationScheduler + uiScheduler: self.uiScheduler ) .observeValues { [weak self] result in self?.renderResult = result 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/AsyncImageView/Renderers/MulticastedRenderer.swift b/AsyncImageView/Renderers/MulticastedRenderer.swift index 99dcca8..a1b10c9 100644 --- a/AsyncImageView/Renderers/MulticastedRenderer.swift +++ b/AsyncImageView/Renderers/MulticastedRenderer.swift @@ -10,8 +10,45 @@ import UIKit import ReactiveSwift -// The initial value is `nil`. -private typealias ImageProperty = Property +private final class InFlightImage { + let result: Property + let observer: Signal.Observer + let disposable = SerialDisposable() + private let hasStarted = Atomic(false) + + init() { + let (signal, observer) = Signal.pipe() + self.result = Property(initial: nil, then: signal) + self.observer = observer + } + + deinit { + self.disposable.dispose() + } + + func startIfNeeded(_ action: () -> Void) { + let shouldStart = self.hasStarted.modify { hasStarted in + guard !hasStarted else { return false } + hasStarted = true + + return true + } + + if shouldStart { + action() + } + } +} + +private enum CachedImage { + case rendering(InFlightImage, latest: ImageResult?) + case completed(ImageResult) +} + +private enum CachedImageLookup { + case rendering(InFlightImage) + case completed(ImageResult) +} /// `RendererType` decorator which guarantees that images for a given `RenderDataType` /// are only rendered once, and multicasted to every observer. @@ -23,7 +60,7 @@ public final class MulticastedRenderer< Renderer.Data == Data, Renderer.Error == Never { private let renderer: Renderer - private let cache: Atomic<[Data: ImageProperty]> + private let cache: Atomic<[Data: CachedImage]> #if !os(watchOS) private let memoryWarningDisposable: Disposable @@ -45,36 +82,122 @@ public final class MulticastedRenderer< } public func renderImageWithData(_ data: Data) -> SignalProducer { - let property = getPropertyForData(data) + switch self.lookup(for: data) { + case let .completed(result): + return SignalProducer(value: result) - return property.producer - .filter { $0 != nil } // Skip initial `nil` value. - .map { $0! } - .take(first: 1) - } + case let .rendering(entry): + return self.producer(for: data, entry: entry) + } + } - private func getPropertyForData(_ data: Data) -> ImageProperty { - var result: ImageProperty! + private func lookup(for data: Data) -> CachedImageLookup { + return self.cache.modify { cache in + switch cache[data] { + case let .completed(result), let .rendering(_, latest: result?): + return .completed(result) - self.cache.modify { cache in - if let property = cache[data] { - result = property - } else { - result = ImageProperty( - initial: nil, - then: renderer.createProducerForRenderingData(data) - .map(Optional.init) - ) + case let .rendering(entry, latest: nil): + return .rendering(entry) + + case nil: + let entry = InFlightImage() + cache[data] = .rendering(entry, latest: nil) - cache[data] = result + return .rendering(entry) } } + } + + private func startRendering(_ data: Data, into entry: InFlightImage) { + entry.disposable.inner = self.renderer + .renderImageWithData(data) + .start { [weak self, weak entry] event in + guard let self, let entry else { return } + + if let result = event.value { + self.receive(result, for: data, entry: entry) + } else if event.isTerminating { + self.finishRendering(data, entry: entry) + } + } + } + + private func receive(_ result: Renderer.RenderResult, for data: Data, entry: InFlightImage) { + let cacheMiss = ImageResult( + image: result.image, + cacheHit: false, + shouldCache: result.shouldCache + ) + + guard result.shouldCache else { + self.remove(entry, for: data) + entry.observer.send(value: cacheMiss) + + return + } + + let cacheHit = ImageResult(image: result.image, cacheHit: true, shouldCache: true) + self.cache.modify { cache in + guard case let .rendering(current, _) = cache[data], current === entry else { return } + cache[data] = .rendering(entry, latest: cacheHit) + } + + entry.observer.send(value: cacheMiss) + entry.observer.send(value: cacheHit) + } + + private func finishRendering(_ data: Data, entry: InFlightImage) { + self.cache.modify { cache in + guard case let .rendering(current, latest) = cache[data], current === entry else { return } + + cache[data] = latest.map(CachedImage.completed) + } + entry.observer.sendCompleted() + } - return result + private func remove(_ entry: InFlightImage, for data: Data) { + self.cache.modify { cache in + guard case let .rendering(current, _) = cache[data], current === entry else { return } + cache[data] = nil + } + } + + private func producer(for data: Data, entry: InFlightImage) -> SignalProducer { + return SignalProducer { [self, entry] observer, lifetime in + // Keep async callbacks deliverable after cache eviction or a temporary wrapper. + // `Lifetime` releases this action on completion or cancellation. + lifetime.observeEnded { + _ = self + _ = entry + } + + let cachedResult = self.cache.withValue { cache -> ImageResult? in + switch cache[data] { + case let .completed(result), let .rendering(_, latest: result?): + return result + + case .rendering(_, latest: nil), nil: + return nil + } + } + + if let cachedResult { + lifetime += SignalProducer(value: cachedResult).start(observer) + } else { + lifetime += entry.result.producer + .compactMap { $0 } + .take(first: 1) + .start(observer) + entry.startIfNeeded { + self.startRendering(data, into: entry) + } + } + } } #if !os(watchOS) - private static func clearCacheOnMemoryWarning(_ cache: Atomic<[Data: ImageProperty]>) -> Disposable { + private static func clearCacheOnMemoryWarning(_ cache: Atomic<[Data: CachedImage]>) -> Disposable { return NotificationCenter.default .reactive.notifications(forName: UIApplication.didReceiveMemoryWarningNotification, object: nil) .observe(on: QueueScheduler()) @@ -91,15 +214,3 @@ extension RendererType where Error == Never { return MulticastedRenderer(renderer: self) } } - -extension RendererType { - fileprivate func createProducerForRenderingData(_ data: Data) -> SignalProducer { - return self.renderImageWithData(data) - .flatMap(.concat) { result in - return SignalProducer([ - ImageResult(image: result.image, cacheHit: false, shouldCache: result.shouldCache), - ImageResult(image: result.image, cacheHit: true, shouldCache: result.shouldCache) - ]) - } - } -} diff --git a/AsyncImageViewTests/AsyncImageSchedulerSpec.swift b/AsyncImageViewTests/AsyncImageSchedulerSpec.swift new file mode 100644 index 0000000..77eff4f --- /dev/null +++ b/AsyncImageViewTests/AsyncImageSchedulerSpec.swift @@ -0,0 +1,71 @@ +import Quick +import Nimble +import UIKit + +import ReactiveSwift + +import AsyncImageView + +class AsyncImageSchedulerSpec: QuickSpec { + override class func spec() { + describe("AsyncImageView scheduling") { + it("schedules image creation once per request") { + let imageCreationScheduler = CountingScheduler() + let renderer = SchedulerCheckingRenderer(scheduler: imageCreationScheduler) + let view = AsyncImageView< + TestRenderData, + TestData, + SchedulerCheckingRenderer, + SchedulerCheckingRenderer + >( + initialFrame: CGRect(origin: .zero, size: CGSize(width: 10, height: 10)), + renderer: renderer, + placeholderRenderer: nil, + uiScheduler: ImmediateScheduler(), + imageCreationScheduler: imageCreationScheduler + ) + let window = UIWindow() + window.addSubview(view) + imageCreationScheduler.reset() + + view.data = .a + + expect(imageCreationScheduler.scheduleCount.value) == 1 + expect(renderer.startedOnScheduler.value) == true + } + } + } +} + +private final class CountingScheduler: Scheduler { + let scheduleCount = Atomic(0) + let isExecuting = Atomic(false) + + func schedule(_ action: @escaping () -> Void) -> Disposable? { + self.scheduleCount.modify { $0 += 1 } + self.isExecuting.modify { $0 = true } + action() + self.isExecuting.modify { $0 = false } + + return nil + } + + func reset() { + self.scheduleCount.modify { $0 = 0 } + } +} + +private final class SchedulerCheckingRenderer: RendererType { + let startedOnScheduler = Atomic(false) + private let scheduler: CountingScheduler + + init(scheduler: CountingScheduler) { + self.scheduler = scheduler + } + + func renderImageWithData(_ data: TestRenderData) -> SignalProducer { + self.startedOnScheduler.modify { $0 = self.scheduler.isExecuting.value } + + return SignalProducer(value: UIImage()) + } +} diff --git a/AsyncImageViewTests/AsyncImageViewRequestSpec.swift b/AsyncImageViewTests/AsyncImageViewRequestSpec.swift new file mode 100644 index 0000000..a46c5e0 --- /dev/null +++ b/AsyncImageViewTests/AsyncImageViewRequestSpec.swift @@ -0,0 +1,84 @@ +import Quick +import Nimble +import UIKit + +import ReactiveSwift + +import AsyncImageView + +class AsyncImageViewRequestSpec: QuickSpec { + override class func spec() { + describe("AsyncImageView requests") { + var counter: Atomic! + var view: AsyncImageView< + RequestTestRenderData, + RequestTestViewData, + RequestTestRenderer, + RequestTestRenderer + >! + var window: UIWindow! + + beforeEach { + counter = Atomic(0) + view = AsyncImageView< + RequestTestRenderData, + RequestTestViewData, + RequestTestRenderer, + RequestTestRenderer + >( + initialFrame: CGRect(origin: .zero, size: CGSize(width: 10, height: 10)), + renderer: RequestTestRenderer(), + placeholderRenderer: nil, + uiScheduler: ImmediateScheduler(), + imageCreationScheduler: ImmediateScheduler() + ) + window = UIWindow() + window.addSubview(view) + view.data = RequestTestViewData(counter: counter) + } + + it("does not create render data for frame origin changes") { + for offset in 1...10 { + view.frame.origin.x = CGFloat(offset) + } + + expect(counter.value) == 1 + } + + it("does not create render data for bounds origin changes") { + for offset in 1...10 { + view.bounds.origin.x = CGFloat(offset) + } + + expect(counter.value) == 1 + } + + it("creates render data for size changes") { + view.frame.size = CGSize(width: 20, height: 20) + view.bounds.size = CGSize(width: 30, height: 30) + + expect(counter.value) == 3 + } + } + } +} + +private struct RequestTestViewData: ImageViewDataType { + let counter: Atomic + + func renderDataWithSize(_ size: CGSize) -> RequestTestRenderData { + self.counter.modify { $0 += 1 } + + return RequestTestRenderData(size: size) + } +} + +private struct RequestTestRenderData: RenderDataType { + let size: CGSize +} + +private final class RequestTestRenderer: RendererType { + func renderImageWithData(_ data: RequestTestRenderData) -> SignalProducer { + return SignalProducer(value: UIImage()) + } +} diff --git a/AsyncImageViewTests/AsyncSwiftUIImageViewSpec.swift b/AsyncImageViewTests/AsyncSwiftUIImageViewSpec.swift new file mode 100644 index 0000000..bfbe47c --- /dev/null +++ b/AsyncImageViewTests/AsyncSwiftUIImageViewSpec.swift @@ -0,0 +1,59 @@ +import Quick +import Nimble +import SwiftUI +import UIKit + +import ReactiveSwift + +import AsyncImageView + +class AsyncSwiftUIImageViewSpec: QuickSpec { + override class func spec() { + describe("AsyncSwiftUIImageView") { + it("requests the full proposed size") { + let renderer = SquareImageRenderer() + typealias ViewType = AsyncSwiftUIImageView< + TestRenderData, + TestData, + SquareImageRenderer, + SquareImageRenderer + > + let view = ViewType( + renderer: renderer, + placeholderRenderer: nil, + uiScheduler: ImmediateScheduler(), + imageCreationScheduler: ImmediateScheduler() + ) + .data(.a) + .frame(width: 300, height: 100) + let viewController = UIHostingController(rootView: view) + let window = UIWindow(frame: CGRect(origin: .zero, size: CGSize(width: 300, height: 100))) + window.rootViewController = viewController + window.makeKeyAndVisible() + viewController.view.frame = window.bounds + viewController.view.layoutIfNeeded() + + let expectedRequest = TestRenderData(data: .a, size: window.bounds.size) + expect(renderer.renderedImages.value).toEventually(equal([expectedRequest])) + + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) + viewController.view.layoutIfNeeded() + + expect(renderer.renderedImages.value) == [expectedRequest] + } + } + } +} + +private final class SquareImageRenderer: RendererType { + let renderedImages = Atomic<[TestRenderData]>([]) + + func renderImageWithData(_ data: TestRenderData) -> SignalProducer { + TestRenderer.rendererForSize(CGSize(width: 100, height: 100), scale: 1) + .asyncRenderer(ImmediateScheduler()) + .renderImageWithData(data) + .on(started: { + self.renderedImages.modify { $0.append(data) } + }) + } +} diff --git a/AsyncImageViewTests/AsyncSwiftUIImageViewStateSpec.swift b/AsyncImageViewTests/AsyncSwiftUIImageViewStateSpec.swift new file mode 100644 index 0000000..90d54d1 --- /dev/null +++ b/AsyncImageViewTests/AsyncSwiftUIImageViewStateSpec.swift @@ -0,0 +1,110 @@ +import Quick +import Nimble +import SwiftUI +import UIKit + +import ReactiveSwift + +@testable import AsyncImageView + +class AsyncSwiftUIImageViewStateSpec: QuickSpec { + override class func spec() { + describe("AsyncSwiftUIImageView state") { + it("preserves every scheduler initializer combination") { + let renderer = StateTestRenderer() + + _ = StateTestImageView(renderer: renderer) + _ = StateTestImageView(renderer: renderer, uiScheduler: ImmediateScheduler()) + _ = StateTestImageView(renderer: renderer, imageCreationScheduler: ImmediateScheduler()) + _ = StateTestImageView( + renderer: renderer, + uiScheduler: ImmediateScheduler(), + imageCreationScheduler: ImmediateScheduler() + ) + } + + it("creates its retained schedulers only once across parent updates") { + let schedulerFactory = SchedulerFactory() + let renderer = StateTestRenderer() + let initialView = StateTestContainer( + generation: 0, + renderer: renderer, + schedulerFactory: schedulerFactory + ) + let viewController = UIHostingController(rootView: initialView) + let window = UIWindow(frame: CGRect(origin: .zero, size: CGSize(width: 100, height: 100))) + window.rootViewController = viewController + window.makeKeyAndVisible() + viewController.view.frame = window.bounds + viewController.view.layoutIfNeeded() + + for generation in 1...100 { + viewController.rootView = StateTestContainer( + generation: generation, + renderer: renderer, + schedulerFactory: schedulerFactory + ) + viewController.view.layoutIfNeeded() + } + + expect(schedulerFactory.uiSchedulerCount.value) == 1 + expect(schedulerFactory.imageSchedulerCount.value) == 1 + } + } + } +} + +private struct StateTestContainer: View { + let generation: Int + let renderer: StateTestRenderer + let schedulerFactory: SchedulerFactory + + var body: some View { + StateTestImageView( + renderer: self.renderer, + placeholderRenderer: nil, + uiSchedulerFactory: self.schedulerFactory.makeUIScheduler, + imageCreationSchedulerFactory: self.schedulerFactory.makeImageScheduler + ) + .frame(width: 100, height: 100) + .accessibilityIdentifier("generation-\(self.generation)") + } +} + +private typealias StateTestImageView = AsyncSwiftUIImageView< + StateTestRenderData, + StateTestViewData, + StateTestRenderer, + StateTestRenderer +> + +private final class SchedulerFactory { + let uiSchedulerCount = Atomic(0) + let imageSchedulerCount = Atomic(0) + + func makeUIScheduler() -> ReactiveSwift.Scheduler { + self.uiSchedulerCount.modify { $0 += 1 } + return ImmediateScheduler() + } + + func makeImageScheduler() -> ReactiveSwift.Scheduler { + self.imageSchedulerCount.modify { $0 += 1 } + return ImmediateScheduler() + } +} + +private struct StateTestViewData: ImageViewDataType { + func renderDataWithSize(_ size: CGSize) -> StateTestRenderData { + StateTestRenderData(size: size) + } +} + +private struct StateTestRenderData: RenderDataType { + let size: CGSize +} + +private final class StateTestRenderer: RendererType { + func renderImageWithData(_ data: StateTestRenderData) -> SignalProducer { + SignalProducer(value: UIImage()) + } +} 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)) + } +} diff --git a/AsyncImageViewTests/MulticastedRendererSpec.swift b/AsyncImageViewTests/MulticastedRendererSpec.swift index e8007ce..5c4f5a8 100644 --- a/AsyncImageViewTests/MulticastedRendererSpec.swift +++ b/AsyncImageViewTests/MulticastedRendererSpec.swift @@ -127,15 +127,14 @@ class MulticastedRendererSpec: QuickSpec { expect(getCacheHitValue()) == false } - it("is a cache hit the second time the producer is fetched") { - let producer = getProducerForData(data, size) + it("is a cache hit after the initial producer completes") { + var initialResult: ImageResult? + getProducerForData(data, size).startWithValues { initialResult = $0 } scheduler.advance(by: interval) + expect(initialResult).toEventuallyNot(beNil()) - var result: ImageResult? - producer.startWithValues { result = $0 } - - expect(result).toEventuallyNot(beNil()) - expect(result?.cacheHit) == true + expect(initialResult?.cacheHit) == false + expect(getImageForData(data, size)?.cacheHit) == true } } } diff --git a/AsyncImageViewTests/MulticastedRendererTests.swift b/AsyncImageViewTests/MulticastedRendererTests.swift new file mode 100644 index 0000000..769b1ad --- /dev/null +++ b/AsyncImageViewTests/MulticastedRendererTests.swift @@ -0,0 +1,355 @@ +// +// MulticastedRendererTests.swift +// AsyncImageViewTests +// +// Created by Nacho Soto on 7/11/26. +// Copyright © 2026 Nacho Soto. All rights reserved. +// + +import Foundation +import Testing +import UIKit + +import ReactiveSwift + +@testable import AsyncImageView + +@Suite(.serialized) +struct MulticastedRendererTests { + @Test + func rendersDifferentKeysConcurrently() { + let source = BlockingRenderer() + let renderer = UncheckedSendable(source.multicasted()) + let group = DispatchGroup() + let queue = DispatchQueue(label: #function, attributes: .concurrent) + + for identifier in 0..<2 { + group.enter() + queue.async { + let data = MulticastRenderData(identifier: identifier) + _ = renderer.value.renderImageWithData(data).single() + group.leave() + } + } + + let firstStarted = source.started.wait(timeout: .now() + 2) + let secondStarted = source.started.wait(timeout: .now() + 2) + source.release.signal() + source.release.signal() + let completed = group.wait(timeout: .now() + 2) + + #expect(firstStarted == .success) + #expect(secondStarted == .success) + #expect(completed == .success) + } + + @Test + func preservesTheLatestValueFromAMultiValueRenderer() throws { + let firstImage = makeImage(size: CGSize(width: 16, height: 16)) + let secondImage = makeImage(size: CGSize(width: 32, height: 32)) + let source = MultiValueRenderer() + let renderer = source.multicasted() + let data = MulticastRenderData(identifier: 1) + var initial: ImageResult? + let disposable = renderer.renderImageWithData(data).startWithValues { initial = $0 } + defer { disposable.dispose() } + + source.observer.send(value: firstImage) + let firstHit = try #require(renderer.renderImageWithData(data).single()?.get()) + + source.observer.send(value: secondImage) + let secondHit = try #require(renderer.renderImageWithData(data).single()?.get()) + + source.observer.sendCompleted() + let completedHit = try #require(renderer.renderImageWithData(data).single()?.get()) + + #expect(source.renderCount == 1) + #expect(initial?.image === firstImage) + #expect(initial?.cacheHit == false) + #expect(firstHit.image === firstImage) + #expect(firstHit.cacheHit == true) + #expect(secondHit.image === secondImage) + #expect(secondHit.cacheHit == true) + #expect(completedHit.image === secondImage) + #expect(completedHit.cacheHit == true) + } + + @Test + func preservesTheInitialCacheMissFromASynchronousRenderer() throws { + let source = SynchronousRenderer() + let renderer = source.multicasted() + let data = MulticastRenderData(identifier: 1) + + let initial = try #require(renderer.renderImageWithData(data).single()?.get()) + let cached = try #require(renderer.renderImageWithData(data).single()?.get()) + + #expect(source.renderCount == 1) + #expect(initial.image === source.image) + #expect(initial.cacheHit == false) + #expect(cached.image === source.image) + #expect(cached.cacheHit == true) + } + + @Test + func sharesOneUpstreamRenderBetweenConcurrentWaiters() throws { + let image = makeImage() + let source = MultiValueRenderer() + let renderer = source.multicasted() + let data = MulticastRenderData(identifier: 1) + let firstProducer = renderer.renderImageWithData(data) + let secondProducer = renderer.renderImageWithData(data) + var first: ImageResult? + var second: ImageResult? + let firstDisposable = firstProducer.startWithValues { first = $0 } + let secondDisposable = secondProducer.startWithValues { second = $0 } + defer { + firstDisposable.dispose() + secondDisposable.dispose() + } + + source.observer.send(value: image) + source.observer.sendCompleted() + + let firstResult = try #require(first) + let secondResult = try #require(second) + #expect(source.renderCount == 1) + #expect(firstResult.image === image) + #expect(secondResult.image === image) + #expect(firstResult.cacheHit == false) + #expect(secondResult.cacheHit == false) + } + + @Test + func producerOutlivesATemporaryMulticastedRenderer() throws { + let image = makeImage() + let source = MultiValueRenderer() + let data = MulticastRenderData(identifier: 1) + let producer = source.multicasted().renderImageWithData(data) + var result: ImageResult? + let disposable = producer.startWithValues { result = $0 } + defer { disposable.dispose() } + + source.observer.send(value: image) + source.observer.sendCompleted() + + let rendered = try #require(result) + #expect(source.renderCount == 1) + #expect(rendered.image === image) + #expect(rendered.cacheHit == false) + } + + @Test + func preservesInFlightDeliveryAcrossAMemoryWarning() throws { + let image = makeImage() + let source = MultiValueRenderer() + let renderer = source.multicasted() + let data = MulticastRenderData(identifier: 1) + var first: ImageResult? + let firstDisposable = renderer.renderImageWithData(data).startWithValues { first = $0 } + var secondDisposable: Disposable? + defer { + firstDisposable.dispose() + secondDisposable?.dispose() + } + + #expect(source.renderCount == 1) + NotificationCenter.default.post(name: UIApplication.didReceiveMemoryWarningNotification, object: nil) + + for _ in 0..<100 where source.renderCount < 2 { + secondDisposable?.dispose() + secondDisposable = renderer.renderImageWithData(data).start() + if source.renderCount < 2 { + Thread.sleep(forTimeInterval: 0.01) + } + } + + #expect(source.renderCount == 2) + source.observer.send(value: image) + source.observer.sendCompleted() + + let rendered = try #require(first) + #expect(rendered.image === image) + #expect(rendered.cacheHit == false) + } + + @Test + func cancelsInFlightWorkWhenTheRendererIsReleased() { + let source = CancellableRenderer() + var renderer: MulticastedRenderer? = source.multicasted() + weak var weakRenderer = renderer + let disposable = renderer?.renderImageWithData(MulticastRenderData(identifier: 1)).start() + + #expect(source.started.value == true) + #expect(source.cancelled.value == false) + renderer = nil + #expect(weakRenderer != nil) + disposable?.dispose() + + #expect(source.cancelled.value == true) + #expect(weakRenderer == nil) + } + + @Test + func releasesTheRendererWhenAnActiveProducerCompletes() throws { + let image = makeImage() + let source = MultiValueRenderer() + var renderer: MulticastedRenderer? = source.multicasted() + weak var weakRenderer = renderer + var result: ImageResult? + let disposable = renderer? + .renderImageWithData(MulticastRenderData(identifier: 1)) + .startWithValues { result = $0 } + defer { disposable?.dispose() } + + renderer = nil + #expect(weakRenderer != nil) + source.observer.send(value: image) + + let rendered = try #require(result) + #expect(rendered.image === image) + #expect(weakRenderer == nil) + source.observer.sendCompleted() + } + + @Test + func retriesAfterANonCacheableResult() throws { + let source = RecoveringRenderer() + let renderer = source.multicasted() + let data = MulticastRenderData(identifier: 1) + + let failed = try #require(renderer.renderImageWithData(data).single()?.get()) + let recovered = try #require(renderer.renderImageWithData(data).single()?.get()) + let cached = try #require(renderer.renderImageWithData(data).single()?.get()) + + #expect(source.renderCount == 2) + #expect(failed.cacheHit == false) + #expect(failed.shouldCache == false) + #expect(recovered.shouldCache == true) + #expect(cached.cacheHit == true) + } + + @Test + func retriesAfterTheUpstreamCompletesWithoutAValue() throws { + let source = EmptyThenValueRenderer() + let renderer = source.multicasted() + let data = MulticastRenderData(identifier: 1) + + let empty = renderer.renderImageWithData(data).single() + let recovered = try #require(renderer.renderImageWithData(data).single()?.get()) + let cached = try #require(renderer.renderImageWithData(data).single()?.get()) + + #expect(empty == nil) + #expect(source.renderCount == 2) + #expect(recovered.image === source.image) + #expect(cached.image === source.image) + #expect(cached.cacheHit == true) + } +} + +private struct MulticastRenderData: RenderDataType, Sendable { + let identifier: Int + let size = CGSize(width: 8, height: 8) +} + +private struct UncheckedSendable: @unchecked Sendable { + let value: Value + + init(_ value: Value) { + self.value = value + } +} + +private final class BlockingRenderer: RendererType, @unchecked Sendable { + let started = DispatchSemaphore(value: 0) + let release = DispatchSemaphore(value: 0) + + private let image = makeImage() + + func renderImageWithData(_ data: MulticastRenderData) -> SignalProducer { + return SignalProducer { [image = self.image, release = self.release, started = self.started] observer, lifetime in + started.signal() + release.wait() + + guard !lifetime.hasEnded else { return } + + observer.send(value: image) + observer.sendCompleted() + } + } +} + +private final class MultiValueRenderer: RendererType { + let signal: Signal + let observer: Signal.Observer + private(set) var renderCount = 0 + + init() { + (self.signal, self.observer) = Signal.pipe() + } + + func renderImageWithData(_ data: MulticastRenderData) -> SignalProducer { + self.renderCount += 1 + + return self.signal.producer + } +} + +private final class SynchronousRenderer: RendererType { + let image = makeImage() + private(set) var renderCount = 0 + + func renderImageWithData(_ data: MulticastRenderData) -> SignalProducer { + self.renderCount += 1 + + return SignalProducer(value: self.image) + } +} + +private final class CancellableRenderer: RendererType { + let started = Atomic(false) + let cancelled = Atomic(false) + + func renderImageWithData(_ data: MulticastRenderData) -> SignalProducer { + return SignalProducer { [cancelled = self.cancelled, started = self.started] _, lifetime in + started.modify { $0 = true } + lifetime.observeEnded { + cancelled.modify { $0 = true } + } + } + } +} + +private final class RecoveringRenderer: RendererType { + private let image = makeImage() + private let scheduler = QueueScheduler(name: "MulticastedRendererTests.RecoveringRenderer") + private(set) var renderCount = 0 + + func renderImageWithData(_ data: MulticastRenderData) -> SignalProducer { + self.renderCount += 1 + let result = ImageResult( + image: self.image, + cacheHit: false, + shouldCache: self.renderCount > 1 + ) + + return SignalProducer(value: result) + .start(on: self.scheduler) + } +} + +private final class EmptyThenValueRenderer: RendererType { + let image = makeImage() + private(set) var renderCount = 0 + + func renderImageWithData(_ data: MulticastRenderData) -> SignalProducer { + self.renderCount += 1 + + return self.renderCount == 1 + ? SignalProducer.empty + : SignalProducer(value: self.image) + } +} + +private func makeImage(size: CGSize = CGSize(width: 8, height: 8)) -> UIImage { + return UIGraphicsImageRenderer(size: size).image { _ in } +}