diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e56a2298..bc338ac58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,22 +10,22 @@ on: jobs: ios-latest: - name: Unit Tests (iOS 16.4, Xcode 14.3.1) - runs-on: macOS-13 + name: Unit Tests (iOS 17.4, Xcode 15.3) + runs-on: macOS-14 env: - DEVELOPER_DIR: /Applications/Xcode_14.3.1.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_15.3.app/Contents/Developer steps: - uses: actions/checkout@v2 - name: Run Tests run: | - Scripts/test.sh -s "Nuke" -d "OS=16.4,name=iPhone 14 Pro" - Scripts/test.sh -s "NukeUI" -d "OS=16.4,name=iPhone 14 Pro" - Scripts/test.sh -s "NukeExtensions" -d "OS=16.4,name=iPhone 14 Pro" + Scripts/test.sh -s "Nuke" -d "OS=17.4,name=iPhone 15 Pro" + Scripts/test.sh -s "NukeUI" -d "OS=17.4,name=iPhone 15 Pro" + Scripts/test.sh -s "NukeExtensions" -d "OS=17.4,name=iPhone 15 Pro" macos-latest: - name: Unit Tests (macOS, Xcode 14.3.1) - runs-on: macOS-13 - env: - DEVELOPER_DIR: /Applications/Xcode_14.3.1.app/Contents/Developer + name: Unit Tests (macOS, Xcode 15.3) + runs-on: macOS-14 + env: + DEVELOPER_DIR: /Applications/Xcode_15.3.app/Contents/Developer steps: - uses: actions/checkout@v2 - name: Run Tests @@ -34,17 +34,17 @@ jobs: Scripts/test.sh -s "NukeUI" -d "platform=macOS" Scripts/test.sh -s "NukeExtensions" -d "platform=macOS" tvos-latest: - name: Unit Tests (tvOS 16.4, Xcode 14.3.1) - runs-on: macOS-13 - env: - DEVELOPER_DIR: /Applications/Xcode_14.3.1.app/Contents/Developer + name: Unit Tests (tvOS 17.4, Xcode 15.3) + runs-on: macOS-14 + env: + DEVELOPER_DIR: /Applications/Xcode_15.3.app/Contents/Developer steps: - uses: actions/checkout@v2 - name: Run Tests run: | - Scripts/test.sh -s "Nuke" -d "OS=16.4,name=Apple TV" - Scripts/test.sh -s "NukeUI" -d "OS=16.4,name=Apple TV" - Scripts/test.sh -s "NukeExtensions" -d "OS=16.4,name=Apple TV" + Scripts/test.sh -s "Nuke" -d "OS=17.4,name=Apple TV" + Scripts/test.sh -s "NukeUI" -d "OS=17.4,name=Apple TV" + Scripts/test.sh -s "NukeExtensions" -d "OS=17.4,name=Apple TV" # There is a problem with watchOS runners where they often fail to launch on CI # # watchos-latest: @@ -59,27 +59,27 @@ jobs: # Scripts/test.sh -s "Nuke" -d "OS=9.1,name=Apple Watch Series 8 (45mm)" # Scripts/test.sh -s "NukeUI" -d "OS=9.1,name=Apple Watch Series 8 (45mm)" # Scripts/test.sh -s "Nuke Extensions" -d "OS=9.1,name=Apple Watch Series 8 (45mm)" - ios-xcode-14-1: - name: Unit Tests (iOS 16.1, Xcode 14.1) + ios-xcode-14-3-1: + name: Unit Tests (iOS 16.4, Xcode 14.3.1) runs-on: macOS-13 env: - DEVELOPER_DIR: /Applications/Xcode_14.1.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_14.3.1.app/Contents/Developer steps: - uses: actions/checkout@v2 - name: Run Tests run: | - Scripts/test.sh -s "Nuke" -d "OS=16.1,name=iPhone 14 Pro" - Scripts/test.sh -s "NukeUI" -d "OS=16.1,name=iPhone 14 Pro" - Scripts/test.sh -s "NukeExtensions" -d "OS=16.1,name=iPhone 14 Pro" + Scripts/test.sh -s "Nuke" -d "OS=16.4,name=iPhone 14 Pro" + Scripts/test.sh -s "NukeUI" -d "OS=16.4,name=iPhone 14 Pro" + Scripts/test.sh -s "NukeExtensions" -d "OS=16.4,name=iPhone 14 Pro" ios-thread-safety: name: Thread Safety Tests (TSan Enabled) - runs-on: macOS-13 - env: - DEVELOPER_DIR: /Applications/Xcode_14.3.1.app/Contents/Developer + runs-on: macOS-14 + env: + DEVELOPER_DIR: /Applications/Xcode_15.3.app/Contents/Developer steps: - uses: actions/checkout@v2 - name: Run Tests - run: Scripts/test.sh -s "Nuke Thread Safety Tests" -d "OS=16.4,name=iPhone 14 Pro" + run: Scripts/test.sh -s "Nuke Thread Safety Tests" -d "OS=17.4,name=iPhone 15 Pro" # ios-memory-management-tests: # name: Memory Management Tests # runs-on: macOS-13 diff --git a/Nuke.xcodeproj/project.pbxproj b/Nuke.xcodeproj/project.pbxproj index 73cb735a6..0b89ea742 100644 --- a/Nuke.xcodeproj/project.pbxproj +++ b/Nuke.xcodeproj/project.pbxproj @@ -154,7 +154,6 @@ 0C8D7BED1D9DC02B00D12EB7 /* Nuke.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0C9174901BAE99EE004A7905 /* Nuke.framework */; }; 0C8D7BF51D9DC07E00D12EB7 /* DataCachePeformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8D74201D9D6EEB0036349E /* DataCachePeformanceTests.swift */; }; 0C8DC723209B842600084AA6 /* cat.gif in Resources */ = {isa = PBXBuildFile; fileRef = 0C8DC722209B842600084AA6 /* cat.gif */; }; - 0C9165E626431942006B1D4F /* OperationTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9165E526431942006B1D4F /* OperationTask.swift */; }; 0C91B0EC2438E287007F9100 /* ResizeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C91B0EB2438E287007F9100 /* ResizeTests.swift */; }; 0C91B0EE2438E307007F9100 /* CircleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C91B0ED2438E307007F9100 /* CircleTests.swift */; }; 0C91B0F02438E352007F9100 /* RoundedCornersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C91B0EF2438E352007F9100 /* RoundedCornersTests.swift */; }; @@ -200,8 +199,8 @@ 0CB26807208F25C2004C83F4 /* DataCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB26806208F25C2004C83F4 /* DataCacheTests.swift */; }; 0CB2EFD22110F38600F7C63F /* ImagePipelineConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB2EFD12110F38600F7C63F /* ImagePipelineConfigurationTests.swift */; }; 0CB2EFD62110F52C00F7C63F /* RateLimiterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB2EFD52110F52C00F7C63F /* RateLimiterTests.swift */; }; - 0CB402D525B6569700F5A241 /* TaskFetchOriginalImageData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB402D425B6569700F5A241 /* TaskFetchOriginalImageData.swift */; }; - 0CB402DB25B656D200F5A241 /* TaskFetchDecodedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB402DA25B656D200F5A241 /* TaskFetchDecodedImage.swift */; }; + 0CB402D525B6569700F5A241 /* TaskFetchOriginalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB402D425B6569700F5A241 /* TaskFetchOriginalData.swift */; }; + 0CB402DB25B656D200F5A241 /* TaskFetchOriginalImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB402DA25B656D200F5A241 /* TaskFetchOriginalImage.swift */; }; 0CB4030125B6639200F5A241 /* TaskLoadImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB4030025B6639200F5A241 /* TaskLoadImage.swift */; }; 0CB6448928567DC300916267 /* MockImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C068A1BCA888800089D7F /* MockImageProcessor.swift */; }; 0CB6448A28567DC300916267 /* MockDataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C068C1BCA888800089D7F /* MockDataLoader.swift */; }; @@ -447,7 +446,6 @@ 0C8D7BDE1D9DBF1600D12EB7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 0C8D7BE81D9DC02B00D12EB7 /* Nuke Performance Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Nuke Performance Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 0C8DC722209B842600084AA6 /* cat.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = cat.gif; sourceTree = ""; }; - 0C9165E526431942006B1D4F /* OperationTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationTask.swift; sourceTree = ""; }; 0C9174901BAE99EE004A7905 /* Nuke.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Nuke.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0C91B0EB2438E287007F9100 /* ResizeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResizeTests.swift; sourceTree = ""; }; 0C91B0ED2438E307007F9100 /* CircleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircleTests.swift; sourceTree = ""; }; @@ -495,8 +493,8 @@ 0CB26806208F25C2004C83F4 /* DataCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCacheTests.swift; sourceTree = ""; }; 0CB2EFD12110F38600F7C63F /* ImagePipelineConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePipelineConfigurationTests.swift; sourceTree = ""; }; 0CB2EFD52110F52C00F7C63F /* RateLimiterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateLimiterTests.swift; sourceTree = ""; }; - 0CB402D425B6569700F5A241 /* TaskFetchOriginalImageData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskFetchOriginalImageData.swift; sourceTree = ""; }; - 0CB402DA25B656D200F5A241 /* TaskFetchDecodedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskFetchDecodedImage.swift; sourceTree = ""; }; + 0CB402D425B6569700F5A241 /* TaskFetchOriginalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskFetchOriginalData.swift; sourceTree = ""; }; + 0CB402DA25B656D200F5A241 /* TaskFetchOriginalImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskFetchOriginalImage.swift; sourceTree = ""; }; 0CB4030025B6639200F5A241 /* TaskLoadImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLoadImage.swift; sourceTree = ""; }; 0CB6449928567DE000916267 /* NukeExtensionsTestsHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NukeExtensionsTestsHelpers.swift; sourceTree = ""; }; 0CB6449B28567E5400916267 /* ImageViewLoadingOptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewLoadingOptionsTests.swift; sourceTree = ""; }; @@ -971,10 +969,9 @@ 0C2CD6EA25B67FB30017018F /* ImagePipelineTask.swift */, 0CB4030025B6639200F5A241 /* TaskLoadImage.swift */, 0C2A368A26437BF100F1D000 /* TaskLoadData.swift */, - 0CB402DA25B656D200F5A241 /* TaskFetchDecodedImage.swift */, - 0CB402D425B6569700F5A241 /* TaskFetchOriginalImageData.swift */, + 0CB402DA25B656D200F5A241 /* TaskFetchOriginalImage.swift */, + 0CB402D425B6569700F5A241 /* TaskFetchOriginalData.swift */, 0CE6202226543B6A00AAB8C3 /* TaskFetchWithPublisher.swift */, - 0C9165E526431942006B1D4F /* OperationTask.swift */, ); path = Tasks; sourceTree = ""; @@ -1731,12 +1728,11 @@ 0CA4ECD026E68FC000BAC8E5 /* DataCaching.swift in Sources */, 0CA4ECCA26E6868300BAC8E5 /* ImageProcessingOptions.swift in Sources */, 0C53C8B1263C968200E62D03 /* ImagePipelineDelegate.swift in Sources */, - 0C9165E626431942006B1D4F /* OperationTask.swift in Sources */, 0CA4ECBC26E6856300BAC8E5 /* ImageDecompression.swift in Sources */, 0CA4ECD326E68FDC00BAC8E5 /* ImageCaching.swift in Sources */, 0CA4ECC026E685C900BAC8E5 /* ImageProcessors+Anonymous.swift in Sources */, 0CA4ECC826E6864D00BAC8E5 /* ImageProcessors+RoundedCorners.swift in Sources */, - 0CB402DB25B656D200F5A241 /* TaskFetchDecodedImage.swift in Sources */, + 0CB402DB25B656D200F5A241 /* TaskFetchOriginalImage.swift in Sources */, 0C472F842654AD88007FC0F0 /* ImageRequestKeys.swift in Sources */, 0CE6202126542F7200AAB8C3 /* DataPublisher.swift in Sources */, 0CB0479A2856D9AC00DF9B6D /* Cache.swift in Sources */, @@ -1763,7 +1759,7 @@ 0CA4ECBE26E685A900BAC8E5 /* ImageProcessors+Circle.swift in Sources */, 0CE2D9BA2084FDDD00934B28 /* ImageDecoding.swift in Sources */, 0CC36A1925B8BC2500811018 /* RateLimiter.swift in Sources */, - 0CB402D525B6569700F5A241 /* TaskFetchOriginalImageData.swift in Sources */, + 0CB402D525B6569700F5A241 /* TaskFetchOriginalData.swift in Sources */, 0CA4ECAD26E683E300BAC8E5 /* ImageEncoders.swift in Sources */, 0CA4ECC626E6862A00BAC8E5 /* ImageProcessors+CoreImage.swift in Sources */, 0C2CD6EB25B67FB30017018F /* ImagePipelineTask.swift in Sources */, diff --git a/README.md b/README.md index 3dbee12bb..5f4e02fca 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,9 @@ Nuke supports [Swift Package Manager](https://www.swift.org/package-manager/), w ## Documentation -Nuke is easy to learn and use thanks to an extensive documentation. The best place to start is the [**Getting Started**](https://kean-docs.github.io/nuke/documentation/nuke/getting-started/) guide. +Nuke is easy to learn and use, thanks to its extensive documentation. The [**Getting Started**](https://kean-docs.github.io/nuke/documentation/nuke/getting-started/) guide is the best place to start learning how to use it. -Nuke package ships with four modules that you can install depending on your needs: +The package ships with four modules that you can install depending on your needs: |Module|Description| |--|--| @@ -45,7 +45,7 @@ Nuke package ships with four modules that you can install depending on your need |[**NukeExtensions**](https://kean-docs.github.io/nukeextensions/documentation/nukeextensions/)|The extensions for `UIImageView` (UIKit, AppKit)| |[**NukeVideo**](https://kean-docs.github.io/nukevideo/documentation/nukevideo/)|The components for decoding and playing short videos| -To see more usage examples, check out [**Nuke Demo**](https://github.com/kean/NukeDemo). +Check out [**Nuke Demo**](https://github.com/kean/NukeDemo) for more usage examples. > Upgrading from the previous version? Use a [**Migration Guide**](https://github.com/kean/Nuke/tree/master/Documentation/Migrations). diff --git a/Sources/Nuke/Caching/ImageCaching.swift b/Sources/Nuke/Caching/ImageCaching.swift index eaef111c6..37f408f29 100644 --- a/Sources/Nuke/Caching/ImageCaching.swift +++ b/Sources/Nuke/Caching/ImageCaching.swift @@ -24,7 +24,7 @@ public struct ImageCacheKey: Hashable, Sendable { // This is faster than using AnyHashable (and it shows in performance tests). enum Inner: Hashable, Sendable { case custom(String) - case `default`(CacheKey) + case `default`(MemoryCacheKey) } public init(key: String) { @@ -32,6 +32,6 @@ public struct ImageCacheKey: Hashable, Sendable { } public init(request: ImageRequest) { - self.key = .default(request.makeImageCacheKey()) + self.key = .default(MemoryCacheKey(request)) } } diff --git a/Sources/Nuke/Encoding/ImageEncoders+ImageIO.swift b/Sources/Nuke/Encoding/ImageEncoders+ImageIO.swift index b4f1aa5f7..89bfa172d 100644 --- a/Sources/Nuke/Encoding/ImageEncoders+ImageIO.swift +++ b/Sources/Nuke/Encoding/ImageEncoders+ImageIO.swift @@ -47,19 +47,21 @@ extension ImageEncoders { } public func encode(_ image: PlatformImage) -> Data? { - let data = NSMutableData() + guard let source = image.cgImage, + let data = CFDataCreateMutable(nil, 0), + let destination = CGImageDestinationCreateWithData(data, type.rawValue as CFString, 1, nil) else { + return nil + } var options: [CFString: Any] = [ kCGImageDestinationLossyCompressionQuality: compressionRatio ] #if canImport(UIKit) options[kCGImagePropertyOrientation] = CGImagePropertyOrientation(image.imageOrientation).rawValue #endif - guard let source = image.cgImage, - let destination = CGImageDestinationCreateWithData(data as CFMutableData, type.rawValue as CFString, 1, nil) else { - return nil - } CGImageDestinationAddImage(destination, source, options as CFDictionary) - CGImageDestinationFinalize(destination) + guard CGImageDestinationFinalize(destination) else { + return nil + } return data as Data } } diff --git a/Sources/Nuke/Internal/ImageRequestKeys.swift b/Sources/Nuke/Internal/ImageRequestKeys.swift index 85dd8dd6a..90046776b 100644 --- a/Sources/Nuke/Internal/ImageRequestKeys.swift +++ b/Sources/Nuke/Internal/ImageRequestKeys.swift @@ -4,98 +4,73 @@ import Foundation -extension ImageRequest { - - // MARK: - Cache Keys - - /// A key for processed image in memory cache. - func makeImageCacheKey() -> CacheKey { - CacheKey(self) - } - - /// A key for processed image data in disk cache. - func makeDataCacheKey() -> String { - "\(preferredImageId)\(thumbnail?.identifier ?? "")\(ImageProcessors.Composition(processors).identifier)" - } - - // MARK: - Load Keys - - /// A key for deduplicating operations for fetching the processed image. - func makeImageLoadKey() -> ImageLoadKey { - ImageLoadKey(self) - } - - /// A key for deduplicating operations for fetching the decoded image. - func makeDecodedImageLoadKey() -> DecodedImageLoadKey { - DecodedImageLoadKey(self) - } - - /// A key for deduplicating operations for fetching the original image. - func makeDataLoadKey() -> DataLoadKey { - DataLoadKey(self) - } -} - /// Uniquely identifies a cache processed image. -final class CacheKey: Hashable, Sendable { +final class MemoryCacheKey: Hashable, Sendable { // Using a reference type turned out to be significantly faster private let imageId: String? + private let scale: Float private let thumbnail: ImageRequest.ThumbnailOptions? private let processors: [any ImageProcessing] init(_ request: ImageRequest) { self.imageId = request.preferredImageId + self.scale = request.scale ?? 1 self.thumbnail = request.thumbnail self.processors = request.processors } func hash(into hasher: inout Hasher) { hasher.combine(imageId) + hasher.combine(scale) hasher.combine(thumbnail) hasher.combine(processors.count) } - static func == (lhs: CacheKey, rhs: CacheKey) -> Bool { - lhs.imageId == rhs.imageId && lhs.thumbnail == rhs.thumbnail && lhs.processors == rhs.processors + static func == (lhs: MemoryCacheKey, rhs: MemoryCacheKey) -> Bool { + lhs.imageId == rhs.imageId && lhs.scale == rhs.scale && lhs.thumbnail == rhs.thumbnail && lhs.processors == rhs.processors } } +// MARK: - Identifying Tasks + /// Uniquely identifies a task of retrieving the processed image. -final class ImageLoadKey: Hashable, Sendable { - let cacheKey: CacheKey - let options: ImageRequest.Options - let loadKey: DataLoadKey +final class TaskLoadImageKey: Hashable, Sendable { + private let loadKey: TaskFetchOriginalImageKey + private let options: ImageRequest.Options + private let processors: [any ImageProcessing] init(_ request: ImageRequest) { - self.cacheKey = CacheKey(request) + self.loadKey = TaskFetchOriginalImageKey(request) self.options = request.options - self.loadKey = DataLoadKey(request) + self.processors = request.processors } func hash(into hasher: inout Hasher) { - hasher.combine(cacheKey.hashValue) - hasher.combine(options.hashValue) hasher.combine(loadKey.hashValue) + hasher.combine(options.hashValue) + hasher.combine(processors.count) } - static func == (lhs: ImageLoadKey, rhs: ImageLoadKey) -> Bool { - lhs.cacheKey == rhs.cacheKey && lhs.options == rhs.options && lhs.loadKey == rhs.loadKey + static func == (lhs: TaskLoadImageKey, rhs: TaskLoadImageKey) -> Bool { + lhs.loadKey == rhs.loadKey && lhs.options == rhs.options && lhs.processors == rhs.processors } } -/// Uniquely identifies a task of retrieving the decoded image. -struct DecodedImageLoadKey: Hashable { - let dataLoadKey: DataLoadKey - let thumbnail: ImageRequest.ThumbnailOptions? +/// Uniquely identifies a task of retrieving the original image. +struct TaskFetchOriginalImageKey: Hashable { + private let dataLoadKey: TaskFetchOriginalDataKey + private let scale: Float + private let thumbnail: ImageRequest.ThumbnailOptions? init(_ request: ImageRequest) { - self.dataLoadKey = DataLoadKey(request) + self.dataLoadKey = TaskFetchOriginalDataKey(request) + self.scale = request.scale ?? 1 self.thumbnail = request.thumbnail } } -/// Uniquely identifies a task of retrieving the original image dataa. -struct DataLoadKey: Hashable { +/// Uniquely identifies a task of retrieving the original image data. +struct TaskFetchOriginalDataKey: Hashable { private let imageId: String? private let cachePolicy: URLRequest.CachePolicy private let allowsCellularAccess: Bool @@ -112,13 +87,3 @@ struct DataLoadKey: Hashable { } } } - -struct ImageProcessingKey: Equatable, Hashable { - let imageId: ObjectIdentifier - let processorId: AnyHashable - - init(image: ImageResponse, processor: any ImageProcessing) { - self.imageId = ObjectIdentifier(image.image) - self.processorId = processor.hashableIdentifier - } -} diff --git a/Sources/Nuke/Internal/Log.swift b/Sources/Nuke/Internal/Log.swift index 4c5bf977a..475b9a63b 100644 --- a/Sources/Nuke/Internal/Log.swift +++ b/Sources/Nuke/Internal/Log.swift @@ -5,13 +5,6 @@ import Foundation import os -func signpost(_ object: AnyObject, _ name: StaticString, _ type: OSSignpostType) { - guard ImagePipeline.Configuration.isSignpostLoggingEnabled else { return } - - let signpostId = OSSignpostID(log: log, object: object) - os_signpost(type, log: log, name: name, signpostID: signpostId) -} - func signpost(_ object: AnyObject, _ name: StaticString, _ type: OSSignpostType, _ message: @autoclosure () -> String) { guard ImagePipeline.Configuration.isSignpostLoggingEnabled else { return } @@ -20,19 +13,10 @@ func signpost(_ object: AnyObject, _ name: StaticString, _ type: OSSignpostType, } func signpost(_ name: StaticString, _ work: () throws -> T) rethrows -> T { - try signpost(name, "", work) -} - -func signpost(_ name: StaticString, _ message: @autoclosure () -> String, _ work: () throws -> T) rethrows -> T { guard ImagePipeline.Configuration.isSignpostLoggingEnabled else { return try work() } let signpostId = OSSignpostID(log: log) - let message = message() - if !message.isEmpty { - os_signpost(.begin, log: log, name: name, signpostID: signpostId, "%{public}s", message) - } else { - os_signpost(.begin, log: log, name: name, signpostID: signpostId) - } + os_signpost(.begin, log: log, name: name, signpostID: signpostId) let result = try work() os_signpost(.end, log: log, name: name, signpostID: signpostId) return result diff --git a/Sources/Nuke/Pipeline/ImagePipeline.swift b/Sources/Nuke/Pipeline/ImagePipeline.swift index 34af0cacf..1c3324fbc 100644 --- a/Sources/Nuke/Pipeline/ImagePipeline.swift +++ b/Sources/Nuke/Pipeline/ImagePipeline.swift @@ -34,11 +34,10 @@ public final class ImagePipeline: @unchecked Sendable { private var tasks = [ImageTask: TaskSubscription]() - private let tasksLoadData: TaskPool - private let tasksLoadImage: TaskPool - private let tasksFetchDecodedImage: TaskPool - private let tasksFetchOriginalImageData: TaskPool - private let tasksProcessImage: TaskPool + private let tasksLoadData: TaskPool + private let tasksLoadImage: TaskPool + private let tasksFetchOriginalImage: TaskPool + private let tasksFetchOriginalData: TaskPool // The queue on which the entire subsystem is synchronized. let queue = DispatchQueue(label: "com.github.kean.Nuke.ImagePipeline", qos: .userInitiated) @@ -77,9 +76,8 @@ public final class ImagePipeline: @unchecked Sendable { let isCoalescingEnabled = configuration.isTaskCoalescingEnabled self.tasksLoadData = TaskPool(isCoalescingEnabled) self.tasksLoadImage = TaskPool(isCoalescingEnabled) - self.tasksFetchDecodedImage = TaskPool(isCoalescingEnabled) - self.tasksFetchOriginalImageData = TaskPool(isCoalescingEnabled) - self.tasksProcessImage = TaskPool(isCoalescingEnabled) + self.tasksFetchOriginalImage = TaskPool(isCoalescingEnabled) + self.tasksFetchOriginalData = TaskPool(isCoalescingEnabled) self.lock = .allocate(capacity: 1) self.lock.initialize(to: os_unfair_lock()) @@ -503,12 +501,11 @@ public final class ImagePipeline: @unchecked Sendable { // // `loadImage()` call is represented by TaskLoadImage: // - // TaskLoadImage -> TaskFetchDecodedImage -> TaskFetchOriginalImageData - // -> TaskProcessImage + // TaskLoadImage -> TaskFetchOriginalImage -> TaskFetchOriginalData // // `loadData()` call is represented by TaskLoadData: // - // TaskLoadData -> TaskFetchOriginalImageData + // TaskLoadData -> TaskFetchOriginalData // // // Each task represents a resource or a piece of work required to produce the @@ -518,33 +515,27 @@ public final class ImagePipeline: @unchecked Sendable { // is created. The work is split between tasks to minimize any duplicated work. func makeTaskLoadImage(for request: ImageRequest) -> AsyncTask.Publisher { - tasksLoadImage.publisherForKey(request.makeImageLoadKey()) { + tasksLoadImage.publisherForKey(TaskLoadImageKey(request)) { TaskLoadImage(self, request) } } func makeTaskLoadData(for request: ImageRequest) -> AsyncTask<(Data, URLResponse?), Error>.Publisher { - tasksLoadData.publisherForKey(request.makeImageLoadKey()) { + tasksLoadData.publisherForKey(TaskLoadImageKey(request)) { TaskLoadData(self, request) } } - func makeTaskProcessImage(key: ImageProcessingKey, process: @escaping () throws -> ImageResponse) -> AsyncTask.Publisher { - tasksProcessImage.publisherForKey(key) { - OperationTask(self, configuration.imageProcessingQueue, process) + func makeTaskFetchOriginalImage(for request: ImageRequest) -> AsyncTask.Publisher { + tasksFetchOriginalImage.publisherForKey(TaskFetchOriginalImageKey(request)) { + TaskFetchOriginalImage(self, request) } } - func makeTaskFetchDecodedImage(for request: ImageRequest) -> AsyncTask.Publisher { - tasksFetchDecodedImage.publisherForKey(request.makeDecodedImageLoadKey()) { - TaskFetchDecodedImage(self, request) - } - } - - func makeTaskFetchOriginalImageData(for request: ImageRequest) -> AsyncTask<(Data, URLResponse?), Error>.Publisher { - tasksFetchOriginalImageData.publisherForKey(request.makeDataLoadKey()) { + func makeTaskFetchOriginalData(for request: ImageRequest) -> AsyncTask<(Data, URLResponse?), Error>.Publisher { + tasksFetchOriginalData.publisherForKey(TaskFetchOriginalDataKey(request)) { request.publisher == nil ? - TaskFetchOriginalImageData(self, request) : + TaskFetchOriginalData(self, request) : TaskFetchWithPublisher(self, request) } } diff --git a/Sources/Nuke/Pipeline/ImagePipelineCache.swift b/Sources/Nuke/Pipeline/ImagePipelineCache.swift index 895c4fbdf..8e3cc4ea6 100644 --- a/Sources/Nuke/Pipeline/ImagePipelineCache.swift +++ b/Sources/Nuke/Pipeline/ImagePipelineCache.swift @@ -200,7 +200,7 @@ extension ImagePipeline.Cache { if let customKey = pipeline.delegate.cacheKey(for: request, pipeline: pipeline) { return customKey } - return request.makeDataCacheKey() // Use the default key + return "\(request.preferredImageId)\(request.thumbnail?.identifier ?? "")\(ImageProcessors.Composition(request.processors).identifier)" } // MARK: Misc diff --git a/Sources/Nuke/Pipeline/ImagePipelineConfiguration.swift b/Sources/Nuke/Pipeline/ImagePipelineConfiguration.swift index 59e1dcdde..d13447df8 100644 --- a/Sources/Nuke/Pipeline/ImagePipelineConfiguration.swift +++ b/Sources/Nuke/Pipeline/ImagePipelineConfiguration.swift @@ -136,7 +136,8 @@ extension ImagePipeline { /// Data loading queue. Default maximum concurrent task count is 6. public var dataLoadingQueue = OperationQueue(maxConcurrentCount: 6) - /// Data caching queue. Default maximum concurrent task count is 2. + // Deprecated in Nuke 12.6 + @available(*, deprecated, message: "The pipeline now performs cache lookup on the internal queue, reducing the amount of context switching") public var dataCachingQueue = OperationQueue(maxConcurrentCount: 2) /// Image decoding queue. Default maximum concurrent task count is 1. diff --git a/Sources/Nuke/Prefetching/ImagePrefetcher.swift b/Sources/Nuke/Prefetching/ImagePrefetcher.swift index 88cb4ed60..a83bda1e1 100644 --- a/Sources/Nuke/Prefetching/ImagePrefetcher.swift +++ b/Sources/Nuke/Prefetching/ImagePrefetcher.swift @@ -53,7 +53,7 @@ public final class ImagePrefetcher: @unchecked Sendable { public var didComplete: (() -> Void)? private let pipeline: ImagePipeline - private var tasks = [ImageLoadKey: Task]() + private var tasks = [TaskLoadImageKey: Task]() private let destination: Destination private var _priority: ImageRequest.Priority = .low let queue = OperationQueue() // internal for testing @@ -122,7 +122,7 @@ public final class ImagePrefetcher: @unchecked Sendable { guard pipeline.cache[request] == nil else { return } - let key = request.makeImageLoadKey() + let key = TaskLoadImageKey(request) guard tasks[key] == nil else { return } @@ -189,7 +189,7 @@ public final class ImagePrefetcher: @unchecked Sendable { } private func _stopPrefetching(with request: ImageRequest) { - if let task = tasks.removeValue(forKey: request.makeImageLoadKey()) { + if let task = tasks.removeValue(forKey: TaskLoadImageKey(request)) { task.cancel() } } @@ -211,13 +211,13 @@ public final class ImagePrefetcher: @unchecked Sendable { } private final class Task: @unchecked Sendable { - let key: ImageLoadKey + let key: TaskLoadImageKey let request: ImageRequest weak var imageTask: ImageTask? weak var operation: Operation? var onCancelled: (() -> Void)? - init(request: ImageRequest, key: ImageLoadKey) { + init(request: ImageRequest, key: TaskLoadImageKey) { self.request = request self.key = key } diff --git a/Sources/Nuke/Processing/ImageDecompression.swift b/Sources/Nuke/Processing/ImageDecompression.swift index 83e2e771b..1e7e2e9fd 100644 --- a/Sources/Nuke/Processing/ImageDecompression.swift +++ b/Sources/Nuke/Processing/ImageDecompression.swift @@ -5,6 +5,18 @@ import Foundation enum ImageDecompression { + static func isDecompressionNeeded(for response: ImageResponse) -> Bool { + guard response.container.type != .png else { + // Attempting to decompress a `.png` image using + // `prepareForReuse` results in the following error: + // + // [Decompressor] Error -17102 decompressing image -- possibly corrupt + // + // It's also, in general, inefficient and unnecessary. + return false + } + return isDecompressionNeeded(for: response.image) ?? false + } static func decompress(image: PlatformImage, isUsingPrepareForDisplay: Bool = false) -> PlatformImage { image.decompressed(isUsingPrepareForDisplay: isUsingPrepareForDisplay) ?? image diff --git a/Sources/Nuke/Processing/ImageProcessors+CoreImage.swift b/Sources/Nuke/Processing/ImageProcessors+CoreImage.swift index 089bfc266..71cd73ea3 100644 --- a/Sources/Nuke/Processing/ImageProcessors+CoreImage.swift +++ b/Sources/Nuke/Processing/ImageProcessors+CoreImage.swift @@ -27,23 +27,36 @@ extension ImageProcessors { /// - [Core Image Programming Guide](https://developer.apple.com/library/ios/documentation/GraphicsImaging/Conceptual/CoreImaging/ci_intro/ci_intro.html) /// - [Core Image Filter Reference](https://developer.apple.com/library/prerelease/ios/documentation/GraphicsImaging/Reference/CoreImageFilterReference/index.html) public struct CoreImageFilter: ImageProcessing, CustomStringConvertible, @unchecked Sendable { - public let name: String - public let parameters: [String: Any] + let filter: Filter public let identifier: String + enum Filter { + case named(String, parameters: [String: Any]) + case custom(CIFilter) + } + + /// Initializes the processor with a name of the `CIFilter` and its parameters. + /// /// - parameter identifier: Uniquely identifies the processor. public init(name: String, parameters: [String: Any], identifier: String) { - self.name = name - self.parameters = parameters + self.filter = .named(name, parameters: parameters) self.identifier = identifier } + /// Initializes the processor with a name of the `CIFilter`. public init(name: String) { - self.name = name - self.parameters = [:] + self.filter = .named(name, parameters: [:]) self.identifier = "com.github.kean/nuke/core_image?name=\(name))" } + /// Initialize the processor with the given `CIFilter`. + /// + /// - parameter identifier: Uniquely identifies the processor. + public init(_ filter: CIFilter, identifier: String) { + self.filter = .custom(filter) + self.identifier = identifier + } + public func process(_ image: PlatformImage) -> PlatformImage? { try? _process(image) } @@ -53,7 +66,12 @@ extension ImageProcessors { } private func _process(_ image: PlatformImage) throws -> PlatformImage { - try CoreImageFilter.applyFilter(named: name, parameters: parameters, to: image) + switch filter { + case let .named(name, parameters): + return try CoreImageFilter.applyFilter(named: name, parameters: parameters, to: image) + case .custom(let filter): + return try CoreImageFilter.apply(filter: filter, to: image) + } } // MARK: - Apply Filter @@ -91,7 +109,12 @@ extension ImageProcessors { } public var description: String { - "CoreImageFilter(name: \(name), parameters: \(parameters))" + switch filter { + case let .named(name, parameters): + return "CoreImageFilter(name: \(name), parameters: \(parameters))" + case .custom(let filter): + return "CoreImageFilter(filter: \(filter))" + } } public enum Error: Swift.Error, CustomStringConvertible { diff --git a/Sources/Nuke/Processing/ImageProcessors.swift b/Sources/Nuke/Processing/ImageProcessors.swift index db1f6c5d0..796a505ac 100644 --- a/Sources/Nuke/Processing/ImageProcessors.swift +++ b/Sources/Nuke/Processing/ImageProcessors.swift @@ -101,6 +101,10 @@ extension ImageProcessing where Self == ImageProcessors.CoreImageFilter { public static func coreImageFilter(name: String) -> ImageProcessors.CoreImageFilter { ImageProcessors.CoreImageFilter(name: name) } + + public static func coreImageFilter(_ filter: CIFilter, identifier: String) -> ImageProcessors.CoreImageFilter { + ImageProcessors.CoreImageFilter(filter, identifier: identifier) + } } extension ImageProcessing where Self == ImageProcessors.GaussianBlur { diff --git a/Sources/Nuke/Tasks/AsyncTask.swift b/Sources/Nuke/Tasks/AsyncTask.swift index 5d55151da..f70cf6dbd 100644 --- a/Sources/Nuke/Tasks/AsyncTask.swift +++ b/Sources/Nuke/Tasks/AsyncTask.swift @@ -50,7 +50,6 @@ class AsyncTask: AsyncTaskSubscriptionDelegate guard oldValue != priority else { return } operation?.queuePriority = priority.queuePriority dependency?.setPriority(priority) - dependency2?.setPriority(priority) } } @@ -64,14 +63,6 @@ class AsyncTask: AsyncTaskSubscriptionDelegate } } - // The tasks only ever need up to 2 dependencies and this code is much faster - // than creating an array. - var dependency2: TaskSubscription? { - didSet { - dependency2?.setPriority(priority) - } - } - weak var operation: Foundation.Operation? { didSet { guard priority != .normal else { return } @@ -88,7 +79,7 @@ class AsyncTask: AsyncTaskSubscriptionDelegate // MARK: - Managing Observers /// - notes: Returns `nil` if the task was disposed. - private func subscribe(priority: TaskPriority = .normal, subscriber: AnyObject? = nil, _ closure: @escaping (Event) -> Void) -> TaskSubscription? { + private func subscribe(priority: TaskPriority = .normal, subscriber: AnyObject, _ closure: @escaping (Event) -> Void) -> TaskSubscription? { guard !isDisposed else { return nil } let subscriptionKey = nextSubscriptionKey @@ -111,7 +102,6 @@ class AsyncTask: AsyncTaskSubscriptionDelegate // The task may have been completed synchronously by `starter`. guard !isDisposed else { return nil } - return subscription } @@ -194,7 +184,6 @@ class AsyncTask: AsyncTaskSubscriptionDelegate if reason == .cancelled { operation?.cancel() dependency?.unsubscribe() - dependency2?.unsubscribe() onCancelled?() } onDisposed?() @@ -234,7 +223,7 @@ extension AsyncTask { /// Attaches the subscriber to the task. /// - notes: Returns `nil` if the task is already disposed. - func subscribe(priority: TaskPriority = .normal, subscriber: AnyObject? = nil, _ closure: @escaping (Event) -> Void) -> TaskSubscription? { + func subscribe(priority: TaskPriority = .normal, subscriber: AnyObject, _ closure: @escaping (Event) -> Void) -> TaskSubscription? { task.subscribe(priority: priority, subscriber: subscriber, closure) } diff --git a/Sources/Nuke/Tasks/ImagePipelineTask.swift b/Sources/Nuke/Tasks/ImagePipelineTask.swift index 2068a283a..b6dc6707e 100644 --- a/Sources/Nuke/Tasks/ImagePipelineTask.swift +++ b/Sources/Nuke/Tasks/ImagePipelineTask.swift @@ -36,3 +36,26 @@ extension ImagePipelineTask: ImageTaskSubscribers { } } } + +extension ImagePipelineTask { + /// Decodes the data on the dedicated queue and calls the completion + /// on the pipeline's internal queue. + func decode(_ context: ImageDecodingContext, decoder: any ImageDecoding, _ completion: @escaping (Result) -> Void) { + @Sendable func decode() -> Result { + signpost(context.isCompleted ? "DecodeImageData" : "DecodeProgressiveImageData") { + Result { try decoder.decode(context) } + .mapError { .decodingFailed(decoder: decoder, context: context, error: $0) } + } + } + guard decoder.isAsynchronous else { + return completion(decode()) + } + operation = pipeline.configuration.imageDecodingQueue.add { [weak self] in + guard let self else { return } + let response = decode() + self.pipeline.queue.async { + completion(response) + } + } + } +} diff --git a/Sources/Nuke/Tasks/OperationTask.swift b/Sources/Nuke/Tasks/OperationTask.swift deleted file mode 100644 index fd3da5331..000000000 --- a/Sources/Nuke/Tasks/OperationTask.swift +++ /dev/null @@ -1,35 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). - -import Foundation - -/// A one-shot task for performing a single () -> T function. -final class OperationTask: AsyncTask { - private let pipeline: ImagePipeline - private let queue: OperationQueue - private let process: () throws -> T - - init(_ pipeline: ImagePipeline, _ queue: OperationQueue, _ process: @escaping () throws -> T) { - self.pipeline = pipeline - self.queue = queue - self.process = process - } - - override func start() { - operation = queue.add { [weak self] in - guard let self else { return } - let result = Result(catching: { try self.process() }) - self.pipeline.queue.async { - switch result { - case .success(let value): - self.send(value: value, isCompleted: true) - case .failure(let error): - self.send(error: error) - } - } - } - } - - struct Error: Swift.Error {} -} diff --git a/Sources/Nuke/Tasks/TaskFetchOriginalImageData.swift b/Sources/Nuke/Tasks/TaskFetchOriginalData.swift similarity index 94% rename from Sources/Nuke/Tasks/TaskFetchOriginalImageData.swift rename to Sources/Nuke/Tasks/TaskFetchOriginalData.swift index c4e1dc37d..0f59103e6 100644 --- a/Sources/Nuke/Tasks/TaskFetchOriginalImageData.swift +++ b/Sources/Nuke/Tasks/TaskFetchOriginalData.swift @@ -6,7 +6,7 @@ import Foundation /// Fetches original image from the data loader (`DataLoading`) and stores it /// in the disk cache (`DataCaching`). -final class TaskFetchOriginalImageData: ImagePipelineTask<(Data, URLResponse?)> { +final class TaskFetchOriginalData: ImagePipelineTask<(Data, URLResponse?)> { private var urlResponse: URLResponse? private var resumableData: ResumableData? private var resumedDataCount: Int64 = 0 @@ -159,6 +159,7 @@ final class TaskFetchOriginalImageData: ImagePipelineTask<(Data, URLResponse?)> extension ImagePipelineTask where Value == (Data, URLResponse?) { func storeDataInCacheIfNeeded(_ data: Data) { + let request = makeSanitizedRequest() guard let dataCache = pipeline.delegate.dataCache(for: request, pipeline: pipeline), shouldStoreDataInDiskCache() else { return } @@ -170,7 +171,17 @@ extension ImagePipelineTask where Value == (Data, URLResponse?) { } } + /// Returns a request that doesn't contain any information non-related + /// to data loading. + private func makeSanitizedRequest() -> ImageRequest { + var request = request + request.processors = [] + request.userInfo[.thumbnailKey] = nil + return request + } + private func shouldStoreDataInDiskCache() -> Bool { + let imageTasks = imageTasks guard imageTasks.contains(where: { !$0.request.options.contains(.disableDiskCacheWrites) }) else { return false } diff --git a/Sources/Nuke/Tasks/TaskFetchDecodedImage.swift b/Sources/Nuke/Tasks/TaskFetchOriginalImage.swift similarity index 59% rename from Sources/Nuke/Tasks/TaskFetchDecodedImage.swift rename to Sources/Nuke/Tasks/TaskFetchOriginalImage.swift index 04f4fecbe..927bd967c 100644 --- a/Sources/Nuke/Tasks/TaskFetchDecodedImage.swift +++ b/Sources/Nuke/Tasks/TaskFetchOriginalImage.swift @@ -5,16 +5,16 @@ import Foundation /// Receives data from ``TaskLoadImageData`` and decodes it as it arrives. -final class TaskFetchDecodedImage: ImagePipelineTask { +final class TaskFetchOriginalImage: ImagePipelineTask { private var decoder: (any ImageDecoding)? override func start() { - dependency = pipeline.makeTaskFetchOriginalImageData(for: request).subscribe(self) { [weak self] in + dependency = pipeline.makeTaskFetchOriginalData(for: request).subscribe(self) { [weak self] in self?.didReceiveData($0.0, urlResponse: $0.1, isCompleted: $1) } } - /// Receiving data from `OriginalDataTask`. + /// Receiving data from `TaskFetchOriginalData`. private func didReceiveData(_ data: Data, urlResponse: URLResponse?, isCompleted: Bool) { guard isCompleted || pipeline.configuration.isProgressiveDecodingEnabled else { return @@ -38,35 +38,20 @@ final class TaskFetchDecodedImage: ImagePipelineTask { return } - // Fast-track default decoders, most work is already done during - // initialization anyway. - @Sendable func decode() -> Result { - signpost("DecodeImageData", isCompleted ? "FinalImage" : "ProgressiveImage") { - Result(catching: { try decoder.decode(context) }) - } - } - - if !decoder.isAsynchronous { - didFinishDecoding(decoder: decoder, context: context, result: decode()) - } else { - operation = pipeline.configuration.imageDecodingQueue.add { [weak self] in - guard let self else { return } - - let result = decode() - self.pipeline.queue.async { - self.didFinishDecoding(decoder: decoder, context: context, result: result) - } - } + decode(context, decoder: decoder) { [weak self] in + self?.didFinishDecoding(context: context, result: $0) } } - private func didFinishDecoding(decoder: any ImageDecoding, context: ImageDecodingContext, result: Result) { + private func didFinishDecoding(context: ImageDecodingContext, result: Result) { + operation = nil + switch result { case .success(let response): send(value: response, isCompleted: context.isCompleted) case .failure(let error): if context.isCompleted { - send(error: .decodingFailed(decoder: decoder, context: context, error: error)) + send(error: error) } } } diff --git a/Sources/Nuke/Tasks/TaskLoadData.swift b/Sources/Nuke/Tasks/TaskLoadData.swift index e7e113ccd..ec77f24b5 100644 --- a/Sources/Nuke/Tasks/TaskLoadData.swift +++ b/Sources/Nuke/Tasks/TaskLoadData.swift @@ -7,26 +7,10 @@ import Foundation /// Wrapper for tasks created by `loadData` calls. final class TaskLoadData: ImagePipelineTask<(Data, URLResponse?)> { override func start() { - guard let dataCache = pipeline.delegate.dataCache(for: request, pipeline: pipeline), - !request.options.contains(.disableDiskCacheReads) else { - loadData() - return - } - operation = pipeline.configuration.dataCachingQueue.add { [weak self] in - self?.getCachedData(dataCache: dataCache) - } - } - - private func getCachedData(dataCache: any DataCaching) { - let data = signpost("ReadCachedImageData") { - pipeline.cache.cachedData(for: request) - } - pipeline.queue.async { - if let data { - self.send(value: (data, nil), isCompleted: true) - } else { - self.loadData() - } + if let data = pipeline.cache.cachedData(for: request) { + self.send(value: (data, nil), isCompleted: true) + } else { + self.loadData() } } @@ -36,7 +20,7 @@ final class TaskLoadData: ImagePipelineTask<(Data, URLResponse?)> { } let request = self.request.withProcessors([]) - dependency = pipeline.makeTaskFetchOriginalImageData(for: request).subscribe(self) { [weak self] in + dependency = pipeline.makeTaskFetchOriginalData(for: request).subscribe(self) { [weak self] in self?.didReceiveData($0.0, urlResponse: $0.1, isCompleted: $1) } } diff --git a/Sources/Nuke/Tasks/TaskLoadImage.swift b/Sources/Nuke/Tasks/TaskLoadImage.swift index 56fc61dc7..beb170a14 100644 --- a/Sources/Nuke/Tasks/TaskLoadImage.swift +++ b/Sources/Nuke/Tasks/TaskLoadImage.swift @@ -11,74 +11,35 @@ import Foundation /// scenarios in which coalescing can kick in). final class TaskLoadImage: ImagePipelineTask { override func start() { - // Memory cache lookup - if let image = pipeline.cache[request] { - let response = ImageResponse(container: image, request: request, cacheType: .memory) - send(value: response, isCompleted: !image.isPreview) - if !image.isPreview { - return // Already got the result! + if let container = pipeline.cache[request] { + let response = ImageResponse(container: container, request: request, cacheType: .memory) + send(value: response, isCompleted: !container.isPreview) + if !container.isPreview { + return // The final image is loaded } } - - // Disk cache lookup - if let dataCache = pipeline.delegate.dataCache(for: request, pipeline: pipeline), - !request.options.contains(.disableDiskCacheReads) { - operation = pipeline.configuration.dataCachingQueue.add { [weak self] in - self?.getCachedData(dataCache: dataCache) - } - return - } - - // Fetch image - fetchImage() - } - - // MARK: Disk Cache Lookup - - private func getCachedData(dataCache: any DataCaching) { - let data = signpost("ReadCachedProcessedImageData") { - pipeline.cache.cachedData(for: request) - } - pipeline.queue.async { - if let data { - self.didReceiveCachedData(data) - } else { - self.fetchImage() - } + if let data = pipeline.cache.cachedData(for: request) { + decodeCachedData(data) + } else { + fetchImage() } } - private func didReceiveCachedData(_ data: Data) { - guard !isDisposed else { return } - + private func decodeCachedData(_ data: Data) { let context = ImageDecodingContext(request: request, data: data, isCompleted: true, urlResponse: nil, cacheType: .disk) guard let decoder = pipeline.delegate.imageDecoder(for: context, pipeline: pipeline) else { // This shouldn't happen in practice unless encoder/decoder pair // for data cache is misconfigured. return fetchImage() } - - @Sendable func decode() -> ImageResponse? { - signpost("DecodeCachedProcessedImageData") { - try? decoder.decode(context) - } - } - if !decoder.isAsynchronous { - didDecodeCachedData(decode()) - } else { - operation = pipeline.configuration.imageDecodingQueue.add { [weak self] in - guard let self else { return } - let response = decode() - self.pipeline.queue.async { - self.didDecodeCachedData(response) - } - } + decode(context, decoder: decoder) { [weak self] in + self?.didFinishDecoding(with: try? $0.get()) } } - private func didDecodeCachedData(_ response: ImageResponse?) { + private func didFinishDecoding(with response: ImageResponse?) { if let response { - decompressImage(response, isCompleted: true, isFromDiskCache: true) + didReceiveResponse(response, isCompleted: true) } else { fetchImage() } @@ -87,102 +48,61 @@ final class TaskLoadImage: ImagePipelineTask { // MARK: Fetch Image private func fetchImage() { - // Memory cache lookup for intermediate images. - // For example, for processors ["p1", "p2"], check only ["p1"]. - // Then apply the remaining processors. - // - // We are not performing data cache lookup for intermediate requests - // for now (because it's not free), but maybe adding an option would be worth it. - // You can emulate this behavior by manually creating intermediate requests. - if request.processors.count > 1 { - var processors = request.processors - var remaining: [any ImageProcessing] = [] - if let last = processors.popLast() { - remaining.append(last) - } - while !processors.isEmpty { - if let image = pipeline.cache[request.withProcessors(processors)] { - let response = ImageResponse(container: image, request: request, cacheType: .memory) - process(response, isCompleted: !image.isPreview, processors: remaining) - if !image.isPreview { - return // Nothing left to do, just apply the processors - } else { - break - } - } - if let last = processors.popLast() { - remaining.append(last) - } - } + guard !request.options.contains(.returnCacheDataDontLoad) else { + return send(error: .dataMissingInCache) } - - let processors: [any ImageProcessing] = request.processors.reversed() - // The only remaining choice is to fetch the image - if request.options.contains(.returnCacheDataDontLoad) { - send(error: .dataMissingInCache) - } else if request.processors.isEmpty { - dependency = pipeline.makeTaskFetchDecodedImage(for: request).subscribe(self) { [weak self] in - self?.process($0, isCompleted: $1, processors: processors) + if let processor = request.processors.last { + let request = request.withProcessors(request.processors.dropLast()) + dependency = pipeline.makeTaskLoadImage(for: request).subscribe(self) { [weak self] in + self?.process($0, isCompleted: $1, processor: processor) } } else { - let request = self.request.withProcessors([]) - dependency = pipeline.makeTaskLoadImage(for: request).subscribe(self) { [weak self] in - self?.process($0, isCompleted: $1, processors: processors) + dependency = pipeline.makeTaskFetchOriginalImage(for: request).subscribe(self) { [weak self] in + self?.didReceiveResponse($0, isCompleted: $1) } } } // MARK: Processing - /// - parameter processors: Remaining processors to by applied - private func process(_ response: ImageResponse, isCompleted: Bool, processors: [any ImageProcessing]) { + private func process(_ response: ImageResponse, isCompleted: Bool, processor: any ImageProcessing) { if isCompleted { - dependency2?.unsubscribe() // Cancel any potential pending progressive processing tasks - } else if dependency2 != nil { - return // Back pressure - already processing another progressive image - } - - _process(response, isCompleted: isCompleted, processors: processors) - } - - /// - parameter processors: Remaining processors to by applied - private func _process(_ response: ImageResponse, isCompleted: Bool, processors: [any ImageProcessing]) { - guard let processor = processors.last else { - self.decompressImage(response, isCompleted: isCompleted) - return + operation?.cancel() // Cancel any potential pending progressive + } else if operation != nil { + return // Back pressure - already processing another progressive image } - - let key = ImageProcessingKey(image: response, processor: processor) let context = ImageProcessingContext(request: request, response: response, isCompleted: isCompleted) - dependency2 = pipeline.makeTaskProcessImage(key: key, process: { - try signpost("ProcessImage", isCompleted ? "FinalImage" : "ProgressiveImage") { - var response = response - response.container = try processor.process(response.container, context: context) - return response - } - }).subscribe(priority: priority) { [weak self] event in + operation = pipeline.configuration.imageProcessingQueue.add { [weak self] in guard let self else { return } - if event.isCompleted { - self.dependency2 = nil - } - switch event { - case .value(let response, _): - self._process(response, isCompleted: isCompleted, processors: processors.dropLast()) - case .error(let error): - if isCompleted { - self.send(error: .processingFailed(processor: processor, context: context, error: error)) + let result = signpost(isCompleted ? "ProcessImage" : "ProcessProgressiveImage") { + Result { + var response = response + response.container = try processor.process(response.container, context: context) + return response } - case .progress: - break // Do nothing (Not reported by OperationTask) + } + self.pipeline.queue.async { + self.didFinishProcessing(result: result, processor: processor, context: context) + } + } + } + + private func didFinishProcessing(result: Result, processor: any ImageProcessing, context: ImageProcessingContext) { + switch result { + case .success(let response): + didReceiveResponse(response, isCompleted: context.isCompleted) + case .failure(let error): + if context.isCompleted { + send(error: .processingFailed(processor: processor, context: context, error: error)) } } } // MARK: Decompression - private func decompressImage(_ response: ImageResponse, isCompleted: Bool, isFromDiskCache: Bool = false) { + private func didReceiveResponse(_ response: ImageResponse, isCompleted: Bool) { guard isDecompressionNeeded(for: response) else { - storeImageInCaches(response, isFromDiskCache: isFromDiskCache) + storeImageInCaches(response) send(value: response, isCompleted: isCompleted) return } @@ -198,42 +118,38 @@ final class TaskLoadImage: ImagePipelineTask { operation = pipeline.configuration.imageDecompressingQueue.add { [weak self] in guard let self else { return } - let response = signpost("DecompressImage", isCompleted ? "FinalImage" : "ProgressiveImage") { + let response = signpost(isCompleted ? "DecompressImage" : "DecompressProgressiveImage") { self.pipeline.delegate.decompress(response: response, request: self.request, pipeline: self.pipeline) } self.pipeline.queue.async { - self.storeImageInCaches(response, isFromDiskCache: isFromDiskCache) + self.storeImageInCaches(response) self.send(value: response, isCompleted: isCompleted) } } } private func isDecompressionNeeded(for response: ImageResponse) -> Bool { - (ImageDecompression.isDecompressionNeeded(for: response.image) ?? false) && + ImageDecompression.isDecompressionNeeded(for: response) && !request.options.contains(.skipDecompression) && + hasDirectSubscribers && pipeline.delegate.shouldDecompress(response: response, for: request, pipeline: pipeline) } // MARK: Caching - private func storeImageInCaches(_ response: ImageResponse, isFromDiskCache: Bool) { - guard subscribers.contains(where: { $0 is ImageTask }) else { - return // Only store for direct requests + private func storeImageInCaches(_ response: ImageResponse) { + guard hasDirectSubscribers else { + return } - // Memory cache (ImageCaching) pipeline.cache[request] = response.container - // Disk cache (DataCaching) - if !isFromDiskCache { + if shouldStoreResponseInDataCache(response) { storeImageInDataCache(response) } } private func storeImageInDataCache(_ response: ImageResponse) { - guard !response.container.isPreview else { - return - } - guard let dataCache = pipeline.delegate.dataCache(for: request, pipeline: pipeline), shouldStoreImageInDiskCache() else { + guard let dataCache = pipeline.delegate.dataCache(for: request, pipeline: pipeline) else { return } let context = ImageEncodingContext(request: request, image: response.image, urlResponse: response.urlResponse) @@ -246,7 +162,7 @@ final class TaskLoadImage: ImagePipelineTask { } guard let data = encodedData else { return } pipeline.delegate.willCache(data: data, image: response.container, for: request, pipeline: pipeline) { - guard let data = $0 else { return } + guard let data = $0, !data.isEmpty else { return } // Important! Storing directly ignoring `ImageRequest.Options`. dataCache.storeData(data, for: key) // This is instant, writes are async } @@ -256,20 +172,26 @@ final class TaskLoadImage: ImagePipelineTask { } } - private func shouldStoreImageInDiskCache() -> Bool { - guard !(request.url?.isLocalResource ?? false) else { + private func shouldStoreResponseInDataCache(_ response: ImageResponse) -> Bool { + guard !response.container.isPreview, + !(response.cacheType == .disk), + !(request.url?.isLocalResource ?? false) else { return false } - let isProcessed = !request.processors.isEmpty + let isProcessed = !request.processors.isEmpty || request.thumbnail != nil switch pipeline.configuration.dataCachePolicy { case .automatic: return isProcessed case .storeOriginalData: return false case .storeEncodedImages: - return isProcessed || imageTasks.contains { $0.request.processors.isEmpty } + return true case .storeAll: return isProcessed } } + + private var hasDirectSubscribers: Bool { + subscribers.contains { $0 is ImageTask } + } } diff --git a/Sources/NukeUI/Internal.swift b/Sources/NukeUI/Internal.swift index e440e5d23..8d5b51fd8 100644 --- a/Sources/NukeUI/Internal.swift +++ b/Sources/NukeUI/Internal.swift @@ -30,7 +30,7 @@ extension _PlatformBaseView { @discardableResult func pinToSuperview() -> [NSLayoutConstraint] { guard let superview else { return [] } - + translatesAutoresizingMaskIntoConstraints = false let constraints = [ topAnchor.constraint(equalTo: superview.topAnchor), @@ -45,7 +45,7 @@ extension _PlatformBaseView { @discardableResult func centerInSuperview() -> [NSLayoutConstraint] { guard let superview else { return [] } - + translatesAutoresizingMaskIntoConstraints = false let constraints = [ centerXAnchor.constraint(equalTo: superview.centerXAnchor), diff --git a/Sources/NukeUI/LazyImage.swift b/Sources/NukeUI/LazyImage.swift index 3d4f8d3f5..1a5e68763 100644 --- a/Sources/NukeUI/LazyImage.swift +++ b/Sources/NukeUI/LazyImage.swift @@ -111,7 +111,7 @@ public struct LazyImage: View { /// Gets called when the request is started. public func onStart(_ closure: @escaping (ImageTask) -> Void) -> Self { - map { $0.viewModel.onStart = closure } + map { $0.onStart = closure } } /// Override the behavior on disappear. By default, the view is reset. diff --git a/Tests/Helpers.swift b/Tests/Helpers.swift index f3f411de6..2953c910a 100644 --- a/Tests/Helpers.swift +++ b/Tests/Helpers.swift @@ -33,7 +33,7 @@ enum Test { return try! ImageDecoders.Default().decode(data) } - static let url = URL(string: "http://test.com")! + static let url = URL(string: "http://test.com/example.jpeg")! static let data: Data = Test.data(name: "fixture", extension: "jpeg") diff --git a/Tests/NukeTests/DataLoaderTests.swift b/Tests/NukeTests/DataLoaderTests.swift index 4913ab5d3..fb34ca625 100644 --- a/Tests/NukeTests/DataLoaderTests.swift +++ b/Tests/NukeTests/DataLoaderTests.swift @@ -59,7 +59,7 @@ class DataLoaderTests: XCTestCase { expectation.fulfill() } }) - wait(for: [expectation], timeout: 2.0) + wait(for: [expectation], timeout: 5) // THEN XCTAssertEqual(delegate.recordedMetrics.count, 1) diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineAsyncAwaitTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineAsyncAwaitTests.swift index f39f96700..75cf11901 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineAsyncAwaitTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineAsyncAwaitTests.swift @@ -124,11 +124,12 @@ class ImagePipelineAsyncAwaitTests: XCTestCase, @unchecked Sendable { func testCancelAsyncImageTask() async throws { dataLoader.queue.isSuspended = true + pipeline.queue.suspend() let task = pipeline.imageTask(with: Test.url) - observer = NotificationCenter.default.addObserver(forName: MockDataLoader.DidStartTask, object: dataLoader, queue: OperationQueue()) { _ in task.cancel() } + pipeline.queue.resume() var caughtError: Error? do { diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineCoalescingTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineCoalescingTests.swift index 2abc6d709..3416ccfb7 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineCoalescingTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineCoalescingTests.swift @@ -127,6 +127,36 @@ class ImagePipelineCoalescingTests: XCTestCase { } } + // MARK: - Scale + +#if !os(macOS) + func testOverridingImageScale() throws { + dataLoader.queue.isSuspended = true + + // GIVEN requests with the same URLs but one accesses thumbnail + let request1 = ImageRequest(url: Test.url, userInfo: [.scaleKey: 2]) + let request2 = ImageRequest(url: Test.url, userInfo: [.scaleKey: 3]) + + // WHEN loading images for those requests + expect(pipeline).toLoadImage(with: request1) { result in + // THEN + guard let image = result.value?.image else { return XCTFail() } + XCTAssertEqual(image.scale, 2) + } + expect(pipeline).toLoadImage(with: request2) { result in + // THEN + guard let image = result.value?.image else { return XCTFail() } + XCTAssertEqual(image.scale, 3) + } + + dataLoader.queue.isSuspended = false + + wait() + + XCTAssertEqual(self.dataLoader.createdTaskCount, 1) + } +#endif + // MARK: - Thumbnail func testDeduplicationGivenSameURLButDifferentThumbnailOptions() { @@ -156,6 +186,34 @@ class ImagePipelineCoalescingTests: XCTestCase { } } + func testDeduplicationGivenSameURLButDifferentThumbnailOptionsReversed() { + dataLoader.queue.isSuspended = true + + // GIVEN requests with the same URLs but one accesses thumbnail + // (in this test, order is reversed) + let request1 = ImageRequest(url: Test.url) + let request2 = ImageRequest(url: Test.url, userInfo: [.thumbnailKey: ImageRequest.ThumbnailOptions(maxPixelSize: 400)]) + + // WHEN loading images for those requests + expect(pipeline).toLoadImage(with: request1) { result in + // THEN + guard let image = result.value?.image else { return XCTFail() } + XCTAssertEqual(image.sizeInPixels, CGSize(width: 640.0, height: 480.0)) + } + expect(pipeline).toLoadImage(with: request2) { result in + // THEN + guard let image = result.value?.image else { return XCTFail() } + XCTAssertEqual(image.sizeInPixels, CGSize(width: 400, height: 300)) + } + + dataLoader.queue.isSuspended = false + + wait { _ in + // THEN the image data is fetched once + XCTAssertEqual(self.dataLoader.createdTaskCount, 1) + } + } + // MARK: - Processing func testProcessorsAreDeduplicated() { @@ -539,7 +597,7 @@ class ImagePipelineProcessingDeduplicationTests: XCTestCase { } } - func testWhenApplingMultipleImageProcessorsIntermediateDataCacheResultsAreNotUsed() { + func testWhenApplingMultipleImageProcessorsIntermediateDataCacheResultsAreUsed() { // Given let dataCache = MockDataCache() dataCache.store[Test.url.absoluteString + "12"] = Test.data @@ -555,13 +613,12 @@ class ImagePipelineProcessingDeduplicationTests: XCTestCase { guard let image = result.value?.image else { return XCTFail("Expected image to be loaded successfully") } - XCTAssertEqual(image.nk_test_processorIDs, ["1", "2", "3"], "Expected only the last processor to be applied") + XCTAssertEqual(image.nk_test_processorIDs, ["3"], "Expected only the last processor to be applied") } - // Then we don't expect any intermediate results to be stored in data cache wait { _ in - XCTAssertEqual(self.dataLoader.createdTaskCount, 1, "Expected no data task to be performed") - XCTAssertEqual(factory.numberOfProcessorsApplied, 3, "Expected only one processor to be applied") + XCTAssertEqual(self.dataLoader.createdTaskCount, 0, "Expected no data task to be performed") + XCTAssertEqual(factory.numberOfProcessorsApplied, 1, "Expected only one processor to be applied") } } diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineDataCacheTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineDataCacheTests.swift index 0c075807c..a2c042c1e 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineDataCacheTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineDataCacheTests.swift @@ -46,31 +46,80 @@ class ImagePipelineDataCachingTests: XCTestCase { } } - func testGeneratedThumbnailDataIsStoredIncache() { - // When + func testThumbnailOptionsDataCacheStoresOriginalDataByDefault() throws { + // GIVEN + pipeline = pipeline.reconfigured { + $0.dataCachePolicy = .storeOriginalData + $0.imageCache = MockImageCache() + $0.debugIsSyncImageEncoding = true + } + + // WHEN let request = ImageRequest(url: Test.url, userInfo: [.thumbnailKey: ImageRequest.ThumbnailOptions(size: CGSize(width: 400, height: 400), unit: .pixels, contentMode: .aspectFit)]) expect(pipeline).toLoadImage(with: request) - - // Then - wait { _ in - XCTAssertFalse(self.dataCache.store.isEmpty) - - XCTAssertNotNil(self.pipeline.cache.cachedData(for: request)) - - guard let container = self.pipeline.cache.cachedImage(for: request, caches: [.disk]) else { - return XCTFail() - } - XCTAssertEqual(container.image.sizeInPixels, CGSize(width: 400, height: 300)) - - XCTAssertNil(self.pipeline.cache.cachedData(for: ImageRequest(url: Test.url))) + + // THEN + wait() + + do { // Check memory cache + // Image does not exists for the original image + XCTAssertNil(pipeline.cache.cachedImage(for: ImageRequest(url: Test.url), caches: [.memory])) + + // Image exists for thumbnail + let thumbnail = try XCTUnwrap(pipeline.cache.cachedImage(for: request, caches: [.memory])) + XCTAssertEqual(thumbnail.image.sizeInPixels, CGSize(width: 400, height: 300)) + } + + do { // Check disk cache + // Data exists for the original image + let original = try XCTUnwrap(pipeline.cache.cachedImage(for: ImageRequest(url: Test.url), caches: [.disk])) + XCTAssertEqual(original.image.sizeInPixels, CGSize(width: 640, height: 480)) + + // Data does not exist for thumbnail + XCTAssertNil(pipeline.cache.cachedData(for: request)) } } - + + func testThumbnailOptionsDataCacheStoresOriginalDataWithStoreAllPolicy() throws { + // GIVEN + pipeline = pipeline.reconfigured { + $0.dataCachePolicy = .storeAll + $0.imageCache = MockImageCache() + $0.debugIsSyncImageEncoding = true + } + + // WHEN + let request = ImageRequest(url: Test.url, userInfo: [.thumbnailKey: ImageRequest.ThumbnailOptions(size: CGSize(width: 400, height: 400), unit: .pixels, contentMode: .aspectFit)]) + expect(pipeline).toLoadImage(with: request) + + // THEN + wait() + + do { // Check memory cache + // Image does not exists for the original image + XCTAssertNil(pipeline.cache.cachedImage(for: ImageRequest(url: Test.url), caches: [.memory])) + + // Image exists for thumbnail + let thumbnail = try XCTUnwrap(pipeline.cache.cachedImage(for: request, caches: [.memory])) + XCTAssertEqual(thumbnail.image.sizeInPixels, CGSize(width: 400, height: 300)) + } + + do { // Check disk cache + // Data exists for the original image + let original = try XCTUnwrap(pipeline.cache.cachedImage(for: ImageRequest(url: Test.url), caches: [.disk])) + XCTAssertEqual(original.image.sizeInPixels, CGSize(width: 640, height: 480)) + + // Data exists for thumbnail + let thumbnail = try XCTUnwrap(pipeline.cache.cachedImage(for: request, caches: [.disk])) + XCTAssertEqual(thumbnail.image.sizeInPixels, CGSize(width: 400, height: 300)) + } + } + // MARK: - Updating Priority func testPriorityUpdated() { // Given - let queue = pipeline.configuration.dataCachingQueue + let queue = pipeline.configuration.dataLoadingQueue queue.isSuspended = true let request = Test.request @@ -95,7 +144,7 @@ class ImagePipelineDataCachingTests: XCTestCase { func testOperationCancelled() { // Given - let queue = pipeline.configuration.dataCachingQueue + let queue = pipeline.configuration.dataLoadingQueue queue.isSuspended = true let observer = self.expect(queue).toEnqueueOperationsWithCount(1) let task = pipeline.loadImage(with: Test.request) { _ in } @@ -668,4 +717,19 @@ class ImagePipelineDataCachePolicyTests: XCTestCase { XCTAssertNil(self.dataCache.cachedData(for: Test.url.absoluteString + "1"), "Expected processed image data to not be stored") } } + + // MARK: Integration with Thumbnail Feature + + func testOriginalDataStoredWhenThumbnailRequested() { + // GIVEN + let options = ImageRequest.ThumbnailOptions(maxPixelSize: 400) + let request = ImageRequest(url: Test.url, userInfo: [.thumbnailKey: options]) + + // WHEN + expect(pipeline).toLoadImage(with: request) + wait() + + // THEN + XCTAssertTrue(dataCache.containsData(for: "http://test.com/example.jpeg")) + } } diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineImageCacheTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineImageCacheTests.swift index 6aa2bb2e7..558066857 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineImageCacheTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineImageCacheTests.swift @@ -304,7 +304,7 @@ class ImagePipelineCacheLayerPriorityTests: XCTestCase { XCTAssertEqual(imageCache.writeCount, 2) // Processed + original XCTAssertNotNil(imageCache[originalRequest]) XCTAssertNotNil(imageCache[request]) - XCTAssertEqual(dataCache.readCount, 1) // Processed + XCTAssertEqual(dataCache.readCount, 2) // "1", "2" XCTAssertEqual(dataCache.writeCount, 1) // Initial XCTAssertEqual(dataLoader.createdTaskCount, 0) } @@ -323,7 +323,7 @@ class ImagePipelineCacheLayerPriorityTests: XCTestCase { XCTAssertEqual(imageCache.readCount, 3) // Processed + intermediate + original XCTAssertEqual(imageCache.writeCount, 1) // Processed XCTAssertNotNil(imageCache[request]) - XCTAssertEqual(dataCache.readCount, 2) // Processed + original + XCTAssertEqual(dataCache.readCount, 3) // "1" + "2" + original XCTAssertEqual(dataCache.writeCount, 1) // Initial XCTAssertEqual(dataLoader.createdTaskCount, 0) } @@ -387,7 +387,7 @@ class ImagePipelineCacheLayerPriorityTests: XCTestCase { XCTAssertEqual(imageCache.readCount, 3) // Processed + intermediate + original XCTAssertEqual(imageCache.writeCount, 1) // Processed XCTAssertNotNil(imageCache[request]) - XCTAssertEqual(dataCache.readCount, 2) // Processed + original + XCTAssertEqual(dataCache.readCount, 3) // "1" + "2" + original XCTAssertEqual(dataCache.writeCount, 0) XCTAssertEqual(dataLoader.createdTaskCount, 1) } diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineLoadDataTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineLoadDataTests.swift index 86b342697..624535739 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineLoadDataTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineLoadDataTests.swift @@ -116,7 +116,7 @@ class ImagePipelineLoadDataTests: XCTestCase { } // WHEN - let record = expect(pipeline).toLoadData(with: ImageRequest(url: URL(string: "http://example.com/invalid url"))) + let record = expect(pipeline).toLoadData(with: ImageRequest(url: URL(string: ""))) wait() // THEN diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineProcessorTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineProcessorTests.swift index a3d5c2042..99ca391c9 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineProcessorTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineProcessorTests.swift @@ -75,4 +75,22 @@ class ImagePipelineProcessorTests: XCTestCase { } wait() } + + // MARK: - Decompression + +#if !os(macOS) + func testDecompressionSkippedIfProcessorsAreApplied() { + // Given + let request = ImageRequest(url: Test.url, processors: [ImageProcessors.Anonymous(id: "1", { image in + XCTAssertTrue(ImageDecompression.isDecompressionNeeded(for: image) == true) + return image + })]) + + // When + expect(pipeline).toLoadImage(with: request) { result in + // Then + } + wait() + } +#endif } diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineTests.swift index 05d3ff981..a870b510d 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineTests.swift @@ -368,25 +368,25 @@ class ImagePipelineTests: XCTestCase { func testCacheKeyForRequest() { let request = Test.request - XCTAssertEqual(pipeline.cache.makeDataCacheKey(for: request), "http://test.com") + XCTAssertEqual(pipeline.cache.makeDataCacheKey(for: request), "http://test.com/example.jpeg") } func testCacheKeyForRequestWithProcessors() { var request = Test.request request.processors = [ImageProcessors.Anonymous(id: "1", { $0 })] - XCTAssertEqual(pipeline.cache.makeDataCacheKey(for: request), "http://test.com1") + XCTAssertEqual(pipeline.cache.makeDataCacheKey(for: request), "http://test.com/example.jpeg1") } func testCacheKeyForRequestWithThumbnail() { let options = ImageRequest.ThumbnailOptions(maxPixelSize: 400) let request = ImageRequest(url: Test.url, userInfo: [.thumbnailKey: options]) - XCTAssertEqual(pipeline.cache.makeDataCacheKey(for: request), "http://test.comcom.github/kean/nuke/thumbnail?maxPixelSize=400.0,options=truetruetruetrue") + XCTAssertEqual(pipeline.cache.makeDataCacheKey(for: request), "http://test.com/example.jpegcom.github/kean/nuke/thumbnail?maxPixelSize=400.0,options=truetruetruetrue") } func testCacheKeyForRequestWithThumbnailFlexibleSize() { let options = ImageRequest.ThumbnailOptions(size: CGSize(width: 400, height: 400), unit: .pixels, contentMode: .aspectFit) let request = ImageRequest(url: Test.url, userInfo: [.thumbnailKey: options]) - XCTAssertEqual(pipeline.cache.makeDataCacheKey(for: request), "http://test.comcom.github/kean/nuke/thumbnail?width=400.0,height=400.0,contentMode=.aspectFit,options=truetruetruetrue") + XCTAssertEqual(pipeline.cache.makeDataCacheKey(for: request), "http://test.com/example.jpegcom.github/kean/nuke/thumbnail?width=400.0,height=400.0,contentMode=.aspectFit,options=truetruetruetrue") } // MARK: - Invalidate @@ -598,8 +598,8 @@ class ImagePipelineTests: XCTestCase { } // WHEN - for _ in 0...100 { - expect(pipeline).toFailRequest(ImageRequest(url: URL(string: "http://example.com/invalid url"))) + for _ in 0...10 { + expect(pipeline).toFailRequest(ImageRequest(url: URL(string: ""))) wait() } } diff --git a/Tests/NukeTests/ImageProcessorsTests/CoreImageFilterTests.swift b/Tests/NukeTests/ImageProcessorsTests/CoreImageFilterTests.swift index cf24a9935..9d8d6ee3a 100644 --- a/Tests/NukeTests/ImageProcessorsTests/CoreImageFilterTests.swift +++ b/Tests/NukeTests/ImageProcessorsTests/CoreImageFilterTests.swift @@ -102,6 +102,19 @@ class ImageProcessorsCoreImageFilterTests: XCTestCase { // THEN XCTAssertEqual("\(processor)", "CoreImageFilter(name: CISepiaTone, parameters: [\"inputIntensity\": 0.5])") } + + func testApplyCustomFilter() throws { + // GIVEN + let input = Test.image(named: "fixture-tiny.jpeg") + let filter = try XCTUnwrap(CIFilter(name: "CISepiaTone", parameters: nil)) + let processor = ImageProcessors.CoreImageFilter(filter, identifier: "test") + + // WHEN + let output = try XCTUnwrap(processor.process(input)) + + // THEN + XCTAssertNotNil(output) + } } #endif diff --git a/Tests/NukeTests/ImageRequestTests.swift b/Tests/NukeTests/ImageRequestTests.swift index 7e519576b..48e65302c 100644 --- a/Tests/NukeTests/ImageRequestTests.swift +++ b/Tests/NukeTests/ImageRequestTests.swift @@ -64,86 +64,86 @@ class ImageRequestTests: XCTestCase { class ImageRequestCacheKeyTests: XCTestCase { func testDefaults() { let request = Test.request - AssertHashableEqual(CacheKey(request), CacheKey(request)) // equal to itself + AssertHashableEqual(MemoryCacheKey(request), MemoryCacheKey(request)) // equal to itself } func testRequestsWithTheSameURLsAreEquivalent() { - let request1 = ImageRequest(url: Test.url) - let request2 = ImageRequest(url: Test.url) - AssertHashableEqual(CacheKey(request1), CacheKey(request2)) + let lhs = ImageRequest(url: Test.url) + let rhs = ImageRequest(url: Test.url) + AssertHashableEqual(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) } func testRequestsWithDefaultURLRequestAndURLAreEquivalent() { - let request1 = ImageRequest(url: Test.url) - let request2 = ImageRequest(urlRequest: URLRequest(url: Test.url)) - AssertHashableEqual(CacheKey(request1), CacheKey(request2)) + let lhs = ImageRequest(url: Test.url) + let rhs = ImageRequest(urlRequest: URLRequest(url: Test.url)) + AssertHashableEqual(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) } func testRequestsWithDifferentURLsAreNotEquivalent() { - let request1 = ImageRequest(url: URL(string: "http://test.com/1.png")) - let request2 = ImageRequest(url: URL(string: "http://test.com/2.png")) - XCTAssertNotEqual(CacheKey(request1), CacheKey(request2)) + let lhs = ImageRequest(url: URL(string: "http://test.com/1.png")) + let rhs = ImageRequest(url: URL(string: "http://test.com/2.png")) + XCTAssertNotEqual(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) } func testRequestsWithTheSameProcessorsAreEquivalent() { - let request1 = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")]) - let request2 = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")]) - AssertHashableEqual(CacheKey(request1), CacheKey(request2)) + let lhs = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")]) + let rhs = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")]) + AssertHashableEqual(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) } func testRequestsWithDifferentProcessorsAreNotEquivalent() { - let request1 = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")]) - let request2 = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "2")]) - XCTAssertNotEqual(CacheKey(request1), CacheKey(request2)) + let lhs = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")]) + let rhs = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "2")]) + XCTAssertNotEqual(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) } func testURLRequestParametersAreIgnored() { - let request1 = ImageRequest(urlRequest: URLRequest(url: Test.url, cachePolicy: .reloadRevalidatingCacheData, timeoutInterval: 50)) - let request2 = ImageRequest(urlRequest: URLRequest(url: Test.url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 0)) - AssertHashableEqual(CacheKey(request1), CacheKey(request2)) + let lhs = ImageRequest(urlRequest: URLRequest(url: Test.url, cachePolicy: .reloadRevalidatingCacheData, timeoutInterval: 50)) + let rhs = ImageRequest(urlRequest: URLRequest(url: Test.url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 0)) + AssertHashableEqual(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) } func testSettingDefaultProcessorManually() { - let request1 = ImageRequest(url: Test.url) - let request2 = ImageRequest(url: Test.url, processors: request1.processors) - AssertHashableEqual(CacheKey(request1), CacheKey(request2)) + let lhs = ImageRequest(url: Test.url) + let rhs = ImageRequest(url: Test.url, processors: lhs.processors) + AssertHashableEqual(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) } } class ImageRequestLoadKeyTests: XCTestCase { func testDefaults() { let request = ImageRequest(url: Test.url) - AssertHashableEqual(request.makeDataLoadKey(), request.makeDataLoadKey()) + AssertHashableEqual(TaskFetchOriginalDataKey(request), TaskFetchOriginalDataKey(request)) } func testRequestsWithTheSameURLsAreEquivalent() { - let request1 = ImageRequest(url: Test.url) - let request2 = ImageRequest(url: Test.url) - AssertHashableEqual(request1.makeDataLoadKey(), request2.makeDataLoadKey()) + let lhs = ImageRequest(url: Test.url) + let rhs = ImageRequest(url: Test.url) + AssertHashableEqual(TaskFetchOriginalDataKey(lhs), TaskFetchOriginalDataKey(rhs)) } func testRequestsWithDifferentURLsAreNotEquivalent() { - let request1 = ImageRequest(url: URL(string: "http://test.com/1.png")) - let request2 = ImageRequest(url: URL(string: "http://test.com/2.png")) - XCTAssertNotEqual(request1.makeDataLoadKey(), request2.makeDataLoadKey()) + let lhs = ImageRequest(url: URL(string: "http://test.com/1.png")) + let rhs = ImageRequest(url: URL(string: "http://test.com/2.png")) + XCTAssertNotEqual(TaskFetchOriginalDataKey(lhs), TaskFetchOriginalDataKey(rhs)) } func testRequestsWithTheSameProcessorsAreEquivalent() { - let request1 = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")]) - let request2 = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")]) - AssertHashableEqual(request1.makeDataLoadKey(), request2.makeDataLoadKey()) + let lhs = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")]) + let rhs = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")]) + AssertHashableEqual(TaskFetchOriginalDataKey(lhs), TaskFetchOriginalDataKey(rhs)) } func testRequestsWithDifferentProcessorsAreEquivalent() { - let request1 = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")]) - let request2 = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "2")]) - AssertHashableEqual(request1.makeDataLoadKey(), request2.makeDataLoadKey()) + let lhs = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")]) + let rhs = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "2")]) + AssertHashableEqual(TaskFetchOriginalDataKey(lhs), TaskFetchOriginalDataKey(rhs)) } func testRequestWithDifferentURLRequestParametersAreNotEquivalent() { - let request1 = ImageRequest(urlRequest: URLRequest(url: Test.url, cachePolicy: .reloadRevalidatingCacheData, timeoutInterval: 50)) - let request2 = ImageRequest(urlRequest: URLRequest(url: Test.url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 0)) - XCTAssertNotEqual(request1.makeDataLoadKey(), request2.makeDataLoadKey()) + let lhs = ImageRequest(urlRequest: URLRequest(url: Test.url, cachePolicy: .reloadRevalidatingCacheData, timeoutInterval: 50)) + let rhs = ImageRequest(urlRequest: URLRequest(url: Test.url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 0)) + XCTAssertNotEqual(TaskFetchOriginalDataKey(lhs), TaskFetchOriginalDataKey(rhs)) } func testMockImageProcessorCorrectlyImplementsIdentifiers() { @@ -159,37 +159,37 @@ class ImageRequestImageIdTests: XCTestCase { func testThatCacheKeyUsesAbsoluteURLByDefault() { let lhs = ImageRequest(url: Test.url) let rhs = ImageRequest(url: Test.url.appendingPathComponent("?token=1")) - XCTAssertNotEqual(lhs.makeImageCacheKey(), rhs.makeImageCacheKey()) + XCTAssertNotEqual(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) } func testThatCacheKeyUsesFilteredURLWhenSet() { let lhs = ImageRequest(url: Test.url, userInfo: [.imageIdKey: Test.url.absoluteString]) let rhs = ImageRequest(url: Test.url.appendingPathComponent("?token=1"), userInfo: [.imageIdKey: Test.url.absoluteString]) - AssertHashableEqual(lhs.makeImageCacheKey(), rhs.makeImageCacheKey()) + AssertHashableEqual(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) } func testThatCacheKeyForProcessedImageDataUsesAbsoluteURLByDefault() { let lhs = ImageRequest(url: Test.url) let rhs = ImageRequest(url: Test.url.appendingPathComponent("?token=1")) - XCTAssertNotEqual(lhs.makeImageCacheKey(), rhs.makeImageCacheKey()) + XCTAssertNotEqual(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) } func testThatCacheKeyForProcessedImageDataUsesFilteredURLWhenSet() { let lhs = ImageRequest(url: Test.url, userInfo: [.imageIdKey: Test.url.absoluteString]) let rhs = ImageRequest(url: Test.url.appendingPathComponent("?token=1"), userInfo: [.imageIdKey: Test.url.absoluteString]) - AssertHashableEqual(lhs.makeImageCacheKey(), rhs.makeImageCacheKey()) + AssertHashableEqual(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) } func testThatLoadKeyForProcessedImageDoesntUseFilteredURL() { let lhs = ImageRequest(url: Test.url, userInfo: [.imageIdKey: Test.url.absoluteString]) let rhs = ImageRequest(url: Test.url.appendingPathComponent("?token=1"), userInfo: [.imageIdKey: Test.url.absoluteString]) - XCTAssertNotEqual(lhs.makeImageLoadKey(), rhs.makeImageLoadKey()) + XCTAssertNotEqual(TaskLoadImageKey(lhs), TaskLoadImageKey(rhs)) } func testThatLoadKeyForOriginalImageDoesntUseFilteredURL() { let lhs = ImageRequest(url: Test.url, userInfo: [.imageIdKey: Test.url.absoluteString]) let rhs = ImageRequest(url: Test.url.appendingPathComponent("?token=1"), userInfo: [.imageIdKey: Test.url.absoluteString]) - XCTAssertNotEqual(lhs.makeDataLoadKey(), rhs.makeDataLoadKey()) + XCTAssertNotEqual(TaskFetchOriginalDataKey(lhs), TaskFetchOriginalDataKey(rhs)) } } diff --git a/Tests/NukeTests/TaskTests.swift b/Tests/NukeTests/TaskTests.swift index 5186e7f20..003e1fcb8 100644 --- a/Tests/NukeTests/TaskTests.swift +++ b/Tests/NukeTests/TaskTests.swift @@ -466,6 +466,6 @@ private final class SimpleTask: AsyncTask { extension AsyncTask { func subscribe(priority: TaskPriority = .normal, _ observer: @escaping (Event) -> Void) -> TaskSubscription? { - publisher.subscribe(priority: priority, observer) + publisher.subscribe(priority: priority, subscriber: "" as AnyObject, observer) } } diff --git a/Tests/NukeUITests/FetchImageTests.swift b/Tests/NukeUITests/FetchImageTests.swift index 6adf1b0b4..a0264a89d 100644 --- a/Tests/NukeUITests/FetchImageTests.swift +++ b/Tests/NukeUITests/FetchImageTests.swift @@ -77,7 +77,7 @@ class FetchImageTests: XCTestCase { } func testPriorityUpdated() { - let queue = pipeline.configuration.dataCachingQueue + let queue = pipeline.configuration.dataLoadingQueue queue.isSuspended = true let observer = self.expect(queue).toEnqueueOperationsWithCount(1) @@ -92,7 +92,7 @@ class FetchImageTests: XCTestCase { } func testPriorityUpdatedDynamically() { - let queue = pipeline.configuration.dataCachingQueue + let queue = pipeline.configuration.dataLoadingQueue queue.isSuspended = true let observer = self.expect(queue).toEnqueueOperationsWithCount(1) diff --git a/Tests/XCTestCaseExtensions.swift b/Tests/XCTestCaseExtensions.swift index 461a020ef..f710fcaf0 100644 --- a/Tests/XCTestCaseExtensions.swift +++ b/Tests/XCTestCaseExtensions.swift @@ -12,7 +12,7 @@ extension XCTestCase { return self.expectation(forNotification: name, object: object, handler: handler) } - func wait(_ timeout: TimeInterval = 4, handler: XCWaitCompletionHandler? = nil) { + func wait(_ timeout: TimeInterval = 5, handler: XCWaitCompletionHandler? = nil) { self.waitForExpectations(timeout: timeout, handler: handler) } }