diff --git a/Example/MapleBacon Example.xcodeproj/project.pbxproj b/Example/MapleBacon Example.xcodeproj/project.pbxproj index 2b9cf27..c9a5436 100644 --- a/Example/MapleBacon Example.xcodeproj/project.pbxproj +++ b/Example/MapleBacon Example.xcodeproj/project.pbxproj @@ -17,6 +17,8 @@ F2CB22EE240A6953009FB183 /* images.plist in Resources */ = {isa = PBXBuildFile; fileRef = F2CB22ED240A6953009FB183 /* images.plist */; }; F2CB22F5240BB864009FB183 /* DownsamplingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2CB22F4240BB864009FB183 /* DownsamplingViewController.swift */; }; F2CB22F7240BB8DC009FB183 /* ImageCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2CB22F6240BB8DC009FB183 /* ImageCollectionViewCell.swift */; }; + F2D870D62416C63200946A0B /* PrefetchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2D870D52416C63200946A0B /* PrefetchViewController.swift */; }; + F2D870D92416C6AB00946A0B /* EntryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2D870D82416C6AB00946A0B /* EntryViewController.swift */; }; F2EEE58F240BCDA60002C9CA /* ImageTransformerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2EEE58E240BCDA60002C9CA /* ImageTransformerViewController.swift */; }; /* End PBXBuildFile section */ @@ -58,6 +60,8 @@ F2CB22ED240A6953009FB183 /* images.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = images.plist; sourceTree = ""; }; F2CB22F4240BB864009FB183 /* DownsamplingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownsamplingViewController.swift; sourceTree = ""; }; F2CB22F6240BB8DC009FB183 /* ImageCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCollectionViewCell.swift; sourceTree = ""; }; + F2D870D52416C63200946A0B /* PrefetchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefetchViewController.swift; sourceTree = ""; }; + F2D870D82416C6AB00946A0B /* EntryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntryViewController.swift; sourceTree = ""; }; F2EEE58E240BCDA60002C9CA /* ImageTransformerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageTransformerViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -122,6 +126,8 @@ F29D6184240595BA00E85F11 /* CollectionViewController.swift */, F2CB22F4240BB864009FB183 /* DownsamplingViewController.swift */, F2EEE58E240BCDA60002C9CA /* ImageTransformerViewController.swift */, + F2D870D52416C63200946A0B /* PrefetchViewController.swift */, + F2D870D82416C6AB00946A0B /* EntryViewController.swift */, ); path = Controllers; sourceTree = ""; @@ -236,8 +242,10 @@ F29D6185240595BA00E85F11 /* CollectionViewController.swift in Sources */, F29D6181240595BA00E85F11 /* AppDelegate.swift in Sources */, F2CB22EC240A68D1009FB183 /* UICollectionView+MapleBacon.swift in Sources */, + F2D870D92416C6AB00946A0B /* EntryViewController.swift in Sources */, F2EEE58F240BCDA60002C9CA /* ImageTransformerViewController.swift in Sources */, F29D6183240595BA00E85F11 /* SceneDelegate.swift in Sources */, + F2D870D62416C63200946A0B /* PrefetchViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -321,7 +329,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 13.2; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -375,7 +383,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 13.2; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; diff --git a/Example/MapleBacon Example.xcodeproj/xcshareddata/xcschemes/MapleBacon Example.xcscheme b/Example/MapleBacon Example.xcodeproj/xcshareddata/xcschemes/MapleBacon Example.xcscheme new file mode 100644 index 0000000..056702c --- /dev/null +++ b/Example/MapleBacon Example.xcodeproj/xcshareddata/xcschemes/MapleBacon Example.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/MapleBacon Example/Base.lproj/Main.storyboard b/Example/MapleBacon Example/Base.lproj/Main.storyboard index 82d898c..7d2b89c 100644 --- a/Example/MapleBacon Example/Base.lproj/Main.storyboard +++ b/Example/MapleBacon Example/Base.lproj/Main.storyboard @@ -1,8 +1,8 @@ - + - + @@ -10,7 +10,7 @@ - + @@ -75,7 +75,27 @@ - + + + + + + + + + + + + + + + @@ -86,7 +106,13 @@ - + + + + + + + @@ -255,7 +281,56 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/MapleBacon Example/Controllers/EntryViewController.swift b/Example/MapleBacon Example/Controllers/EntryViewController.swift new file mode 100644 index 0000000..16695b9 --- /dev/null +++ b/Example/MapleBacon Example/Controllers/EntryViewController.swift @@ -0,0 +1,14 @@ +// +// Copyright © 2020 Schnaub. All rights reserved. +// + +import MapleBacon +import UIKit + +final class EntryViewController: UITableViewController { + + @IBAction private func clearCache(_ sender: Any) { + MapleBacon.shared.clearCache(.all) + } + +} diff --git a/Example/MapleBacon Example/Controllers/ImageTransformerViewController.swift b/Example/MapleBacon Example/Controllers/ImageTransformerViewController.swift index f4cfdf8..d3d1867 100644 --- a/Example/MapleBacon Example/Controllers/ImageTransformerViewController.swift +++ b/Example/MapleBacon Example/Controllers/ImageTransformerViewController.swift @@ -8,7 +8,7 @@ import UIKit final class ImageTransformerViewController: UICollectionViewController { private var imageURLs: [URL] = [] - private var imageTransformer = SepiaImageTransformer() >>> VignetteImageTransformer() + private var imageTransformer = SepiaImageTransformer() override func viewDidLoad() { super.viewDidLoad() @@ -57,25 +57,3 @@ private class SepiaImageTransformer: ImageTransforming { } } - -private class VignetteImageTransformer: ImageTransforming { - - let identifier = "com.schnaub.VignetteImageTransformer" - - func transform(image: UIImage) -> UIImage? { - let filter = CIFilter(name: "CIVignette")! - - let ciImage = CIImage(image: image) - filter.setValue(ciImage, forKey: kCIInputImageKey) - - let context = CIContext() - guard let outputImage = filter.outputImage, - let cgImage = context.createCGImage(outputImage, from: outputImage.extent) else { - return image - } - - return UIImage(cgImage: cgImage) - } - -} - diff --git a/Example/MapleBacon Example/Controllers/PrefetchViewController.swift b/Example/MapleBacon Example/Controllers/PrefetchViewController.swift new file mode 100644 index 0000000..955b63b --- /dev/null +++ b/Example/MapleBacon Example/Controllers/PrefetchViewController.swift @@ -0,0 +1,46 @@ +// +// Copyright © 2020 Schnaub. All rights reserved. +// + +import MapleBacon +import UIKit + +final class PrefetchViewController: UICollectionViewController { + + private var imageURLs: [URL] = [] + + override func viewDidLoad() { + super.viewDidLoad() + + imageURLs = imageURLsFromBundle() + collectionView.prefetchDataSource = self + collectionView?.reloadData() + } + + private func imageURLsFromBundle() -> [URL] { + let file = Bundle.main.path(forResource: "images", ofType: "plist")! + let urls = NSArray(contentsOfFile: file) as! [String] + return urls.compactMap { URL(string: $0) } + } + + override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + imageURLs.count + } + + override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell: ImageCollectionViewCell = collectionView.dequeue(indexPath: indexPath) + let url = imageURLs[indexPath.item] + cell.imageView.setImage(with: url) + return cell + } + +} + +extension PrefetchViewController: UICollectionViewDataSourcePrefetching { + + func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) { + let urls = indexPaths.map { imageURLs[$0.item] } + MapleBacon.shared.hydrateCache(urls: urls) + } + +} diff --git a/MapleBacon.podspec b/MapleBacon.podspec index dc30a0a..c6f55ce 100644 --- a/MapleBacon.podspec +++ b/MapleBacon.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'MapleBacon' - s.version = '6.0.5' + s.version = '6.1.0' s.swift_version = '5.1' s.summary = 'A lightweight and fast image downloading library iOS.' diff --git a/MapleBacon.xcodeproj/project.pbxproj b/MapleBacon.xcodeproj/project.pbxproj index f0380b2..791d5ab 100644 --- a/MapleBacon.xcodeproj/project.pbxproj +++ b/MapleBacon.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 775455712418DF4100FF8F5F /* Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = 775455702418DF4100FF8F5F /* Download.swift */; }; + 775455732418E70B00FF8F5F /* Data+MapleBacon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 775455722418E70B00FF8F5F /* Data+MapleBacon.swift */; }; + 7778FD28240FAC6B007764C6 /* DispatchQueue+MapleBacon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7778FD27240FAC6B007764C6 /* DispatchQueue+MapleBacon.swift */; }; F2255E202404600D00193742 /* UIImageView+MapleBacon.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2255E1F2404600D00193742 /* UIImageView+MapleBacon.swift */; }; F2255E222404606E00193742 /* MapleBacon.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2255E212404606E00193742 /* MapleBacon.swift */; }; F2255E24240462D800193742 /* MapleBaconTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2255E23240462D800193742 /* MapleBaconTests.swift */; }; @@ -45,6 +48,9 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 775455702418DF4100FF8F5F /* Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Download.swift; sourceTree = ""; }; + 775455722418E70B00FF8F5F /* Data+MapleBacon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+MapleBacon.swift"; sourceTree = ""; }; + 7778FD27240FAC6B007764C6 /* DispatchQueue+MapleBacon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DispatchQueue+MapleBacon.swift"; sourceTree = ""; }; F2255E1F2404600D00193742 /* UIImageView+MapleBacon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImageView+MapleBacon.swift"; sourceTree = ""; }; F2255E212404606E00193742 /* MapleBacon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapleBacon.swift; sourceTree = ""; }; F2255E23240462D800193742 /* MapleBaconTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapleBaconTests.swift; sourceTree = ""; }; @@ -94,6 +100,18 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 775455742419208B00FF8F5F /* Cache */ = { + isa = PBXGroup; + children = ( + F27CBA0D2402B1FE006CD529 /* Cache.swift */, + F27CBA192402CC13006CD529 /* CacheClearOptions.swift */, + F27CBA1B2402CC28006CD529 /* CacheResult.swift */, + F247A25D23FF191C0083DB03 /* DiskCache.swift */, + F247A25923FF15980083DB03 /* MemoryCache.swift */, + ); + path = Cache; + sourceTree = ""; + }; F247A23423FF15410083DB03 = { isa = PBXGroup; children = ( @@ -144,12 +162,9 @@ F247A25823FF158E0083DB03 /* Core */ = { isa = PBXGroup; children = ( - F27CBA0D2402B1FE006CD529 /* Cache.swift */, - F27CBA192402CC13006CD529 /* CacheClearOptions.swift */, - F27CBA1B2402CC28006CD529 /* CacheResult.swift */, - F247A25D23FF191C0083DB03 /* DiskCache.swift */, + 775455742419208B00FF8F5F /* Cache */, F27CBA1D2402D9A8006CD529 /* Downloader.swift */, - F247A25923FF15980083DB03 /* MemoryCache.swift */, + 775455702418DF4100FF8F5F /* Download.swift */, F2255E212404606E00193742 /* MapleBacon.swift */, F2EEE582240BBC2E0002C9CA /* ImageTransforming.swift */, F2EEE58C240BC17F0002C9CA /* DisplayOptions.swift */, @@ -166,6 +181,8 @@ F2255E1F2404600D00193742 /* UIImageView+MapleBacon.swift */, F2EEE588240BBEF90002C9CA /* DownsamplingImageTransformer.swift */, F2EEE58A240BBFC00002C9CA /* CGSize+MapleBacon.swift */, + 7778FD27240FAC6B007764C6 /* DispatchQueue+MapleBacon.swift */, + 775455722418E70B00FF8F5F /* Data+MapleBacon.swift */, ); path = Extensions; sourceTree = ""; @@ -289,8 +306,11 @@ F2EEE58D240BC17F0002C9CA /* DisplayOptions.swift in Sources */, F27CBA0E2402B1FE006CD529 /* Cache.swift in Sources */, F27CBA1E2402D9A8006CD529 /* Downloader.swift in Sources */, + 775455732418E70B00FF8F5F /* Data+MapleBacon.swift in Sources */, F2EEE58B240BBFC00002C9CA /* CGSize+MapleBacon.swift in Sources */, + 775455712418DF4100FF8F5F /* Download.swift in Sources */, F2EEE583240BBC2E0002C9CA /* ImageTransforming.swift in Sources */, + 7778FD28240FAC6B007764C6 /* DispatchQueue+MapleBacon.swift in Sources */, F27CBA0C2402B147006CD529 /* TimePeriod.swift in Sources */, F27CBA1A2402CC13006CD529 /* CacheClearOptions.swift in Sources */, F27CBA1C2402CC28006CD529 /* CacheResult.swift in Sources */, diff --git a/MapleBacon.xcodeproj/xcshareddata/xcbaselines/F247A24623FF15410083DB03.xcbaseline/C1DF3A3C-F888-4084-9C7D-6D8EC38B7591.plist b/MapleBacon.xcodeproj/xcshareddata/xcbaselines/F247A24623FF15410083DB03.xcbaseline/C1DF3A3C-F888-4084-9C7D-6D8EC38B7591.plist new file mode 100644 index 0000000..170fd9f --- /dev/null +++ b/MapleBacon.xcodeproj/xcshareddata/xcbaselines/F247A24623FF15410083DB03.xcbaseline/C1DF3A3C-F888-4084-9C7D-6D8EC38B7591.plist @@ -0,0 +1,32 @@ + + + + + classNames + + DoubleKeyedContainerTests + + testAccessPerformance() + + com.apple.XCTPerformanceMetric_WallClockTime + + baselineAverage + 9.68e-05 + baselineIntegrationDisplayName + Local Baseline + + + testPerformanceExample() + + com.apple.XCTPerformanceMetric_WallClockTime + + baselineAverage + 5.4116e-05 + baselineIntegrationDisplayName + Local Baseline + + + + + + diff --git a/MapleBacon.xcodeproj/xcshareddata/xcbaselines/F247A24623FF15410083DB03.xcbaseline/Info.plist b/MapleBacon.xcodeproj/xcshareddata/xcbaselines/F247A24623FF15410083DB03.xcbaseline/Info.plist new file mode 100644 index 0000000..eb45cc9 --- /dev/null +++ b/MapleBacon.xcodeproj/xcshareddata/xcbaselines/F247A24623FF15410083DB03.xcbaseline/Info.plist @@ -0,0 +1,40 @@ + + + + + runDestinationsByUUID + + C1DF3A3C-F888-4084-9C7D-6D8EC38B7591 + + localComputer + + busSpeedInMHz + 100 + cpuCount + 1 + cpuKind + Dual-Core Intel Core i5 + cpuSpeedInMHz + 2900 + logicalCPUCoresPerPackage + 4 + modelCode + MacBookPro13,2 + physicalCPUCoresPerPackage + 2 + platformIdentifier + com.apple.platform.macosx + + targetArchitecture + x86_64 + targetDevice + + modelCode + iPhone12,5 + platformIdentifier + com.apple.platform.iphonesimulator + + + + + diff --git a/MapleBacon.xcodeproj/xcshareddata/xcschemes/MapleBacon.xcscheme b/MapleBacon.xcodeproj/xcshareddata/xcschemes/MapleBacon.xcscheme index 42c6907..224e7da 100644 --- a/MapleBacon.xcodeproj/xcshareddata/xcschemes/MapleBacon.xcscheme +++ b/MapleBacon.xcodeproj/xcshareddata/xcschemes/MapleBacon.xcscheme @@ -27,6 +27,7 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" + enableThreadSanitizer = "YES" codeCoverageEnabled = "YES"> where T.Result == T { } } - func clear(_ options: CacheClearOptions) { + func clear(_ options: CacheClearOptions, completion: ((Error?) -> Void)? = nil) { if options.contains(.memory) { memoryCache.clear() + if !options.contains(.disk) { + completion?(nil) + } } if options.contains(.disk) { - diskCache.clear() + diskCache.clear(completion) } } - private func convertToTargetType(_ data: Data, type: CacheType) -> Result, Error> { + func isCached(forKey key: String) throws -> Bool { + let safeKey = safeCacheKey(key) + if memoryCache.isCached(forKey: safeKey) { + return true + } + return try diskCache.isCached(forKey: safeKey) + } + + @objc private func cleanDiskOnNotification() { + clear(.disk) + } + +} + +private extension Cache { + + func convertToTargetType(_ data: Data, type: CacheType) -> Result, Error> { guard let targetType = T.convert(from: data) else { return .failure(CacheError.dataConversion) } return .success(.init(value: targetType, type: type)) } - private func safeCacheKey(_ key: String) -> String { + func safeCacheKey(_ key: String) -> String { #if canImport(CryptoKit) if #available(iOS 13.0, *) { return cryptoSafeCacheKey(key) @@ -92,13 +108,11 @@ final class Cache where T.Result == T { return key.components(separatedBy: CharacterSet(charactersIn: "()/")).joined(separator: "-") } - @objc private func cleanDiskOnNotification() { - clear(.disk) - } - } #if canImport(CryptoKit) +import CryptoKit + @available(iOS 13.0, *) private extension Cache { diff --git a/MapleBacon/Core/Cache/CacheClearOptions.swift b/MapleBacon/Core/Cache/CacheClearOptions.swift new file mode 100644 index 0000000..2dae12e --- /dev/null +++ b/MapleBacon/Core/Cache/CacheClearOptions.swift @@ -0,0 +1,18 @@ +// +// Copyright © 2020 Schnaub. All rights reserved. +// + +import Foundation + +public struct CacheClearOptions: OptionSet { + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public static let memory = CacheClearOptions(rawValue: 1 << 0) + public static let disk = CacheClearOptions(rawValue: 1 << 1) + + public static let all: CacheClearOptions = [.memory, .disk] +} diff --git a/MapleBacon/Core/CacheResult.swift b/MapleBacon/Core/Cache/CacheResult.swift similarity index 100% rename from MapleBacon/Core/CacheResult.swift rename to MapleBacon/Core/Cache/CacheResult.swift diff --git a/MapleBacon/Core/DiskCache.swift b/MapleBacon/Core/Cache/DiskCache.swift similarity index 84% rename from MapleBacon/Core/DiskCache.swift rename to MapleBacon/Core/Cache/DiskCache.swift index 794d33e..244715d 100644 --- a/MapleBacon/Core/DiskCache.swift +++ b/MapleBacon/Core/Cache/DiskCache.swift @@ -15,7 +15,7 @@ final class DiskCache { init(name: String) { let queueLabel = "\(Self.domain).\(name)" - self.diskQueue = DispatchQueue(label: queueLabel, qos: .background) + self.diskQueue = DispatchQueue(label: queueLabel) self.cacheName = "\(Self.domain).\(name)" } @@ -23,9 +23,7 @@ final class DiskCache { diskQueue.async { var diskError: Error? defer { - DispatchQueue.main.async { - completion?(diskError) - } + completion?(diskError) } do { try self.store(data: data, key: key) @@ -39,18 +37,14 @@ final class DiskCache { diskQueue.async { var diskError: Error? defer { - DispatchQueue.main.async { - if let error = diskError { - completion?(.failure(error)) - } + if let error = diskError { + completion?(.failure(error)) } } do { let url = try self.cacheDirectory().appendingPathComponent(key) let data = try FileManager.default.fileContents(at: url) - DispatchQueue.main.async { - completion?(.success(data)) - } + completion?(.success(data)) } catch { diskError = error } @@ -61,9 +55,7 @@ final class DiskCache { diskQueue.async { var diskError: Error? defer { - DispatchQueue.main.async { - completion?(diskError) - } + completion?(diskError) } do { let cacheDirectory = try self.cacheDirectory() @@ -78,9 +70,7 @@ final class DiskCache { diskQueue.async { var diskError: Error? defer { - DispatchQueue.main.async { - completion?(diskError) - } + completion?(diskError) } do { let expiredFiles = try self.expiredFileURLs() @@ -115,13 +105,22 @@ final class DiskCache { return expiredFileUrls } - private func store(data: Data, key: String) throws { + func isCached(forKey key: String) throws -> Bool { + let url = try self.cacheDirectory().appendingPathComponent(key) + return FileManager.default.fileExists(atPath: url.path) + } + +} + +private extension DiskCache { + + func store(data: Data, key: String) throws { let cacheDirectory = try self.cacheDirectory() let fileURL = cacheDirectory.appendingPathComponent(key) try data.write(to: fileURL) } - private func cacheDirectory() throws -> URL { + func cacheDirectory() throws -> URL { let fileManger = FileManager.default let folderURL = try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true) diff --git a/MapleBacon/Core/MemoryCache.swift b/MapleBacon/Core/Cache/MemoryCache.swift similarity index 84% rename from MapleBacon/Core/MemoryCache.swift rename to MapleBacon/Core/Cache/MemoryCache.swift index adc6d6a..f5d4353 100644 --- a/MapleBacon/Core/MemoryCache.swift +++ b/MapleBacon/Core/Cache/MemoryCache.swift @@ -25,23 +25,29 @@ final class MemoryCache { backingCache.name = name } + func isCached(forKey key: Key) -> Bool { + self[key] != nil + } + func clear() { backingCache.removeAllObjects() } - private func insert(_ value: Value, forKey key: Key) { +} + +private extension MemoryCache { + func insert(_ value: Value, forKey key: Key) { backingCache.setObject(Entry(value: value), forKey: WrappedKey(key: key)) } - private func value(forKey key: Key) -> Value? { + func value(forKey key: Key) -> Value? { let entry = backingCache.object(forKey: WrappedKey(key: key)) return entry?.value } - private func removeValue(forKey key: Key) { + func removeValue(forKey key: Key) { backingCache.removeObject(forKey: WrappedKey(key: key)) } - } extension MemoryCache { diff --git a/MapleBacon/Core/CacheClearOptions.swift b/MapleBacon/Core/CacheClearOptions.swift deleted file mode 100644 index 3aadc58..0000000 --- a/MapleBacon/Core/CacheClearOptions.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// Copyright © 2020 Schnaub. All rights reserved. -// - -import Foundation - -struct CacheClearOptions: OptionSet { - let rawValue: Int - - static let memory = CacheClearOptions(rawValue: 1 << 0) - static let disk = CacheClearOptions(rawValue: 1 << 1) - - static let all: CacheClearOptions = [.memory, .disk] -} diff --git a/MapleBacon/Core/Download.swift b/MapleBacon/Core/Download.swift new file mode 100644 index 0000000..3b43ab9 --- /dev/null +++ b/MapleBacon/Core/Download.swift @@ -0,0 +1,104 @@ +// +// Copyright © 2020 Schnaub. All rights reserved. +// + +import UIKit + +/// A download task – this wraps the internal download instance and can be used to cancel an inflight request +public struct DownloadTask { + + let download: Download + + public let cancelToken: CancelToken + + public func cancel() { + download.cancel(cancelToken: cancelToken) + } + +} + +final class Download { + + typealias Completion = (Result) -> Void + + let task: URLSessionDataTask + + var completions: [Completion] { + defer { + lock.unlock() + } + lock.lock() + return Array(tokenCompletions.values) + } + + private let lock = NSLock() + + private(set) var data = Data() + private var currentToken: CancelToken = 0 + private var backgroundTask: UIBackgroundTaskIdentifier = .invalid + private var tokenCompletions: [CancelToken: Completion] = [:] + + init(task: URLSessionDataTask) { + self.task = task + } + + deinit { + invalidateBackgroundTask() + } + + func addCompletion(_ completion: @escaping Completion) -> CancelToken { + defer { + currentToken += 1 + lock.unlock() + } + lock.lock() + tokenCompletions[currentToken] = completion + return currentToken + } + + func removeCompletion(for token: CancelToken) -> Completion? { + defer { + lock.unlock() + } + lock.lock() + guard let completion = tokenCompletions[token] else { + return nil + } + tokenCompletions[token] = nil + return completion + } + + func appendData(_ data: Data) { + self.data.append(data) + } + + func start() { + backgroundTask = UIApplication.shared.beginBackgroundTask { + self.invalidateBackgroundTask() + } + } + + func finish() { + invalidateBackgroundTask() + } + + func cancel(cancelToken: CancelToken) { + guard let completion = removeCompletion(for: cancelToken) else { + return + } + if tokenCompletions.isEmpty { + task.cancel() + } + completion(.failure(DownloaderError.canceled)) + } + +} + +private extension Download { + + func invalidateBackgroundTask() { + UIApplication.shared.endBackgroundTask(backgroundTask) + backgroundTask = .invalid + } + +} diff --git a/MapleBacon/Core/Downloader.swift b/MapleBacon/Core/Downloader.swift index 1d939fe..67a9ab0 100644 --- a/MapleBacon/Core/Downloader.swift +++ b/MapleBacon/Core/Downloader.swift @@ -2,40 +2,103 @@ // Copyright © 2020 Schnaub. All rights reserved. // -import Foundation +import UIKit enum DownloaderError: Error { case dataConversion + case canceled } final class Downloader { let session: URLSession - var task: URLSessionDataTask? + private let sessionDelegate: SessionDelegate + private let lock = NSLock() + + private var downloads: [URL: Download] = [:] + + fileprivate subscript(_ url: URL) -> Download? { + get { + defer { + lock.unlock() + } + lock.lock() + return downloads[url] + } + set { + defer { + lock.unlock() + } + lock.lock() + downloads[url] = newValue + } + } init(sessionConfiguration: URLSessionConfiguration = .default) { - self.session = URLSession(configuration: sessionConfiguration) + self.sessionDelegate = SessionDelegate() + self.session = URLSession(configuration: sessionConfiguration, delegate: sessionDelegate, delegateQueue: .main) + self.sessionDelegate.downloader = self } - func fetch(_ url: URL, completion: @escaping (Result) -> Void) { - let task = session.dataTask(with: url) { data, _, error in - DispatchQueue.main.async { - if let error = error { - completion(.failure(error)) - return - } - guard let data = data, let value = T.convert(from: data) else { - completion(.failure(DownloaderError.dataConversion)) - return - } - completion(.success(value)) - } + deinit { + session.invalidateAndCancel() + } + + func fetch(_ url: URL, completion: @escaping (Result) -> Void) -> DownloadTask { + if let download = self[url] { + let token = download.addCompletion(completion) + return DownloadTask(download: download, cancelToken: token) + } + + let task = session.dataTask(with: url) + let download = Download(task: task) + let token = download.addCompletion(completion) + download.start() + self[url] = download + task.resume() + + return DownloadTask(download: download, cancelToken: token) + } + +} + +private final class SessionDelegate: NSObject, URLSessionDataDelegate { + + weak var downloader: Downloader? + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + guard let url = dataTask.originalRequest?.url, let download = downloader?[url] else { + return + } + download.appendData(data) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let url = task.originalRequest?.url, let download = downloader?[url] else { + return } - defer { - task.resume() + + downloader?[url]?.completions.forEach { completion in + if let error = error { + completion(.failure(error)) + return + } + guard let value = T.convert(from: download.data) else { + completion(.failure(DownloaderError.dataConversion)) + return + } + completion(.success(value)) } - self.task = task + downloader?[url] = nil + download.finish() + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, + willCacheResponse proposedResponse: CachedURLResponse, + completionHandler: @escaping (CachedURLResponse?) -> Void) { + completionHandler(nil) } } + diff --git a/MapleBacon/Core/MapleBacon.swift b/MapleBacon/Core/MapleBacon.swift index 5448814..24781b9 100644 --- a/MapleBacon/Core/MapleBacon.swift +++ b/MapleBacon/Core/MapleBacon.swift @@ -8,10 +8,13 @@ public enum MapleBaconError: Error { case imageTransformingError } +public typealias CancelToken = Int + public final class MapleBacon { public typealias ImageCompletion = (Result) -> Void + /// The shared instance of MapleBacon public static let shared = MapleBacon() private static let queueLabel = "com.schnaub.MapleBacon.transformer" @@ -29,6 +32,10 @@ public final class MapleBacon { private let downloader: Downloader private let transformerQueue: DispatchQueue + /// Initialise a custom instance of MapleBacon + /// - Parameters: + /// - name: The name to give this instance. Internally this reflects a distinct cache region. + /// - sessionConfiguration: The URLSessionConfiguration to use. Uses `.default` when no parameter is supplied. public convenience init(name: String = "", sessionConfiguration: URLSessionConfiguration = .default) { self.init(cache: Cache(name: name), sessionConfiguration: sessionConfiguration) } @@ -36,33 +43,78 @@ public final class MapleBacon { init(cache: Cache, sessionConfiguration: URLSessionConfiguration) { self.cache = cache self.downloader = Downloader(sessionConfiguration: sessionConfiguration) - self.transformerQueue = DispatchQueue(label: Self.queueLabel) + self.transformerQueue = DispatchQueue(label: Self.queueLabel, attributes: .concurrent) } - public func image(with url: URL, imageTransformer: ImageTransforming? = nil, completion: @escaping ImageCompletion) { - fetchImageFromCache(with: url, imageTransformer: imageTransformer) { result in - switch result { - case .success(let image): - completion(.success(image)) - case .failure: - self.fetchImageFromNetworkAndCache(with: url, imageTransformer: imageTransformer, completion: completion) + /// Return an image for the passed URL. This will check the in-memory and disk cache and fetch the image over the network if nothing is in either cache yet. + /// After a successful download, the image will be stored in both caches. + /// - Parameters: + /// - url: The URL of the image + /// - imageTransformer: An optional image transformer + /// - completion: The completion to call with the image result + /// - Returns: An optional `DownloadTask` if needs to fetch the image over the network. The task can be used to cancel an inflight request + @discardableResult + public func image(with url: URL, imageTransformer: ImageTransforming? = nil, completion: @escaping ImageCompletion) -> DownloadTask? { + if (try? isCached(with: url, imageTransformer: imageTransformer)) == true { + fetchImageFromCache(with: url, imageTransformer: imageTransformer, completion: completion) + return nil + } + + return fetchImageFromNetworkAndCache(with: url, imageTransformer: imageTransformer, completion: completion) + } + + /// Hydrate the cache + /// - Parameter url: The URL to fetch + public func hydrateCache(url: URL) { + if (try? isCached(with: url, imageTransformer: nil)) == false { + _ = self.fetchImageFromNetworkAndCache(with: url, imageTransformer: nil, completion: { _ in }) + } + } + + /// Hydrate the cache + /// - Parameter urls: An array of URLs to fetch + public func hydrateCache(urls: [URL]) { + for url in urls { + if (try? isCached(with: url, imageTransformer: nil)) == false { + _ = self.fetchImageFromNetworkAndCache(with: url, imageTransformer: nil, completion: { _ in }) } } } - private func fetchImageFromCache(with url: URL, imageTransformer: ImageTransforming?, completion: @escaping ImageCompletion) { + /// Clear the cache + /// - Parameters: + /// - options: The `CacheClearOptions`. Clear either only memory, disk or both caches. + /// - completion: The completion to call after clearing the cache + public func clearCache(_ options: CacheClearOptions, completion: ((Error?) -> Void)? = nil) { + cache.clear(options, completion: completion) + } + +} + +private extension MapleBacon { + + func isCached(with url: URL, imageTransformer: ImageTransforming?) throws -> Bool { + let cacheKey = makeCacheKey(for: url, imageTransformer: imageTransformer) + return try cache.isCached(forKey: cacheKey) + } + + func fetchImageFromCache(with url: URL, imageTransformer: ImageTransforming?, completion: @escaping ImageCompletion) { let cacheKey = makeCacheKey(for: url, imageTransformer: imageTransformer) cache.value(forKey: cacheKey) { result in switch result { case .success(let cacheResult): - completion(.success(cacheResult.value)) + DispatchQueue.main.optionalAsync { + completion(.success(cacheResult.value)) + } case .failure(let error): - completion(.failure(error)) + DispatchQueue.main.optionalAsync { + completion(.failure(error)) + } } } } - private func fetchImageFromNetworkAndCache(with url: URL, imageTransformer: ImageTransforming?, completion: @escaping ImageCompletion) { + func fetchImageFromNetworkAndCache(with url: URL, imageTransformer: ImageTransforming?, completion: @escaping ImageCompletion) -> DownloadTask { fetchImageFromNetwork(with: url) { result in switch result { case .success(let image): @@ -71,47 +123,81 @@ public final class MapleBacon { self.transformImageAndCache(image, cacheKey: cacheKey, imageTransformer: transformer, completion: completion) } else { self.cache.store(value: image, forKey: url.absoluteString) - completion(.success(image)) + DispatchQueue.main.optionalAsync { + completion(.success(image)) + } } case .failure(let error): - completion(.failure(error)) + DispatchQueue.main.optionalAsync { + completion(.failure(error)) + } } } } - private func fetchImageFromNetwork(with url: URL, completion: @escaping ImageCompletion) { + func fetchImageFromNetwork(with url: URL, completion: @escaping ImageCompletion) -> DownloadTask { downloader.fetch(url, completion: completion) } - private func transformImageAndCache(_ image: UIImage, cacheKey: String, imageTransformer: ImageTransforming, completion: @escaping ImageCompletion) { + func transformImageAndCache(_ image: UIImage, cacheKey: String, imageTransformer: ImageTransforming, completion: @escaping ImageCompletion) { transformImage(image, imageTransformer: imageTransformer) { result in switch result { case .success(let image): self.cache.store(value: image, forKey: cacheKey) - completion(.success(image)) + DispatchQueue.main.optionalAsync { + completion(.success(image)) + } case .failure(let error): - completion(.failure(error)) + DispatchQueue.main.optionalAsync { + completion(.failure(error)) + } } } } - private func transformImage(_ image: UIImage, imageTransformer: ImageTransforming, completion: @escaping ImageCompletion) { + func transformImage(_ image: UIImage, imageTransformer: ImageTransforming, completion: @escaping ImageCompletion) { transformerQueue.async { - DispatchQueue.main.async { - guard let image = imageTransformer.transform(image: image) else { - completion(.failure(MapleBaconError.imageTransformingError)) - return - } - completion(.success(image)) + guard let image = imageTransformer.transform(image: image) else { + completion(.failure(MapleBaconError.imageTransformingError)) + return } + completion(.success(image)) } } - private func makeCacheKey(for url: URL, imageTransformer: ImageTransforming?) -> String { + func makeCacheKey(for url: URL, imageTransformer: ImageTransforming?) -> String { guard let imageTransformer = imageTransformer else { return url.absoluteString } return url.absoluteString + imageTransformer.identifier } +} + +#if canImport(Combine) +import Combine + +@available(iOS 13.0, *) +extension MapleBacon { + + /// The Combine way to fetch an image for the passed URL. This will check the in-memory and disk cache and fetch the image over the network if nothing is in either cache yet. + /// After a successful download, the image will be stored in both caches. + /// - Parameters: + /// - url: The URL of the image + /// - imageTransformer: An optional image transformer + /// - Returns: `AnyPublisher` + public func image(with url: URL, imageTransformer: ImageTransforming? = nil) -> AnyPublisher { + Future { resolve in + self.image(with: url, imageTransformer: imageTransformer) { result in + switch result { + case .success(let image): + resolve(.success(image)) + case .failure(let error): + resolve(.failure(error)) + } + } + }.eraseToAnyPublisher() + } } + +#endif diff --git a/MapleBacon/Extensions/Data+MapleBacon.swift b/MapleBacon/Extensions/Data+MapleBacon.swift new file mode 100644 index 0000000..5a92fd9 --- /dev/null +++ b/MapleBacon/Extensions/Data+MapleBacon.swift @@ -0,0 +1,26 @@ +// +// Copyright © 2020 Schnaub. All rights reserved. +// + +import Foundation + +extension Data { + + init(from inputStream: InputStream) { + self.init() + + defer { + inputStream.close() + } + inputStream.open() + + let bufferSize = 1024 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + while inputStream.hasBytesAvailable { + let read = inputStream.read(buffer, maxLength: bufferSize) + append(buffer, count: read) + } + buffer.deallocate() + } + +} diff --git a/MapleBacon/Extensions/DataConvertible.swift b/MapleBacon/Extensions/DataConvertible.swift index 76cb812..67825c3 100644 --- a/MapleBacon/Extensions/DataConvertible.swift +++ b/MapleBacon/Extensions/DataConvertible.swift @@ -4,7 +4,7 @@ import UIKit -protocol DataConvertible { +public protocol DataConvertible { associatedtype Result static func convert(from data: Data) -> Result? @@ -13,11 +13,11 @@ protocol DataConvertible { } extension Data: DataConvertible { - static func convert(from data: Data) -> Data? { + public static func convert(from data: Data) -> Data? { data } - func toData() -> Data { + public func toData() -> Data { self } } @@ -38,11 +38,11 @@ extension UIImage: DataConvertible { } } - static func convert(from data: Data) -> UIImage? { + public static func convert(from data: Data) -> UIImage? { UIImage(data: data, scale: UIScreen.main.scale) } - func toData() -> Data { + public func toData() -> Data { hasAlphaChannel ? pngData()! : jpegData(compressionQuality: 1)! } } diff --git a/MapleBacon/Extensions/DispatchQueue+MapleBacon.swift b/MapleBacon/Extensions/DispatchQueue+MapleBacon.swift new file mode 100644 index 0000000..747546d --- /dev/null +++ b/MapleBacon/Extensions/DispatchQueue+MapleBacon.swift @@ -0,0 +1,17 @@ +// +// Copyright © 2020 Schnaub. All rights reserved. +// + +import Foundation + +extension DispatchQueue { + func optionalAsync(_ block: @escaping () -> Void) { + if self === DispatchQueue.main && Thread.isMainThread { + block() + } else { + async { + block() + } + } + } +} diff --git a/MapleBacon/Extensions/FileManager.swift b/MapleBacon/Extensions/FileManager.swift index 5ef60b3..c69a08b 100644 --- a/MapleBacon/Extensions/FileManager.swift +++ b/MapleBacon/Extensions/FileManager.swift @@ -4,8 +4,20 @@ import Foundation +enum MapleBaconInputStreamError: Error { + case uninitializedInputStream + case emptyFile +} + extension FileManager { func fileContents(at url: URL) throws -> Data { - try Data(contentsOf: url, options: .mappedIfSafe) + guard let inputStream = InputStream(url: url) else { + throw MapleBaconInputStreamError.uninitializedInputStream + } + let data = Data(from: inputStream) + guard data.count > 0 else { + throw MapleBaconInputStreamError.emptyFile + } + return data } } diff --git a/MapleBacon/Extensions/TimePeriod.swift b/MapleBacon/Extensions/TimePeriod.swift index c073af3..accc1d1 100644 --- a/MapleBacon/Extensions/TimePeriod.swift +++ b/MapleBacon/Extensions/TimePeriod.swift @@ -25,6 +25,9 @@ enum TimePeriod { } extension Int { + var second: TimeInterval { + TimePeriod.seconds(self).timeInterval + } var seconds: TimeInterval { TimePeriod.seconds(self).timeInterval } diff --git a/MapleBacon/Extensions/UIImageView+MapleBacon.swift b/MapleBacon/Extensions/UIImageView+MapleBacon.swift index 9716bc6..c35ecb6 100644 --- a/MapleBacon/Extensions/UIImageView+MapleBacon.swift +++ b/MapleBacon/Extensions/UIImageView+MapleBacon.swift @@ -5,6 +5,7 @@ import UIKit private var baconImageUrlKey: UInt8 = 0 +private var downloadKey: UInt8 = 1 extension UIImageView { @@ -17,31 +18,58 @@ extension UIImageView { } } + private var downloadTask: DownloadTask? { + get { + objc_getAssociatedObject(self, &downloadKey) as? DownloadTask + } + set { + objc_setAssociatedObject(self, &downloadKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + /// Set remote image + /// - Parameters: + /// - url: The URL of the image + /// - placeholder: An optional placeholder image to set while fetching the remote image + /// - displayOptions: `DisplayOptions` + /// - imageTransformer: An optional image transformer + /// - completion: An optional completion to call when the image is set + /// - Returns: An optional `DownloadTask` if needs to fetch the image over the network. The task can be used to cancel an inflight request + @discardableResult public func setImage(with url: URL?, placeholder: UIImage? = nil, displayOptions: [DisplayOptions] = [], imageTransformer: ImageTransforming? = nil, - completion: (() -> Void)? = nil) { + completion: ((UIImage?) -> Void)? = nil) -> DownloadTask? { + cancelDownload() baconImageUrl = url + image = placeholder guard let url = url else { - return - } - - if let placeholder = placeholder { - image = placeholder + return nil } let transformer = makeTransformer(displayOptions: displayOptions, imageTransformer: imageTransformer) - MapleBacon.shared.image(with: url, imageTransformer: transformer) { [weak self] result in + let task = MapleBacon.shared.image(with: url, imageTransformer: transformer) { [weak self] result in + var resultImage: UIImage? defer { - completion?() + self?.baconImageUrl = nil + self?.downloadTask = nil + completion?(resultImage) } guard case let Result.success(image) = result, let self = self, url == self.baconImageUrl else { return } + resultImage = image self.image = image } + downloadTask = task + return task + } + + /// Cancel a running download + public func cancelDownload() { + downloadTask?.cancel() } private func makeTransformer(displayOptions: [DisplayOptions] = [], imageTransformer: ImageTransforming?) -> ImageTransforming? { diff --git a/MapleBaconTests/CacheTests.swift b/MapleBaconTests/CacheTests.swift index 21019df..545926a 100644 --- a/MapleBaconTests/CacheTests.swift +++ b/MapleBaconTests/CacheTests.swift @@ -11,14 +11,6 @@ final class CacheTests: XCTestCase { private let cache = Cache(name: CacheTests.cacheName) - override func tearDown() { - cache.clear(.all) - // Clearing the disk is an async operation so we should wait - wait(for: 2.seconds) - - super.tearDown() - } - func testStorage() { let expectation = self.expectation(description: #function) @@ -26,7 +18,9 @@ final class CacheTests: XCTestCase { cache.store(value: data, forKey: #function) { error in XCTAssertNil(error) - expectation.fulfill() + self.cache.clear(.all) { _ in + expectation.fulfill() + } } waitForExpectations(timeout: 5, handler: nil) @@ -46,7 +40,9 @@ final class CacheTests: XCTestCase { case .failure: XCTFail() } - expectation.fulfill() + self.cache.clear(.all) { _ in + expectation.fulfill() + } } } @@ -58,17 +54,19 @@ final class CacheTests: XCTestCase { let data = dummyData() - cache.store(value: data, forKey: #function) { _ in + cache.store(value: data, forKey: "test") { _ in self.cache.clear(.all) - self.cache.value(forKey: #function) { result in + self.cache.value(forKey: "test") { result in switch result { case .success: XCTFail() case .failure(let error): XCTAssertNotNil(error) } - expectation.fulfill() + self.cache.clear(.all) { _ in + expectation.fulfill() + } } } @@ -91,7 +89,9 @@ final class CacheTests: XCTestCase { case .failure: XCTFail() } - expectation.fulfill() + self.cache.clear(.all) { _ in + expectation.fulfill() + } } } @@ -118,7 +118,29 @@ final class CacheTests: XCTestCase { } } - expectation.fulfill() + self.cache.clear(.all) { _ in + expectation.fulfill() + } + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testIsCached() { + let expectation = self.expectation(description: #function) + + let data = dummyData() + + cache.store(value: data, forKey: #function) { _ in + XCTAssertTrue(try! self.cache.isCached(forKey: #function)) + + self.cache.clear(.memory) { _ in + XCTAssertTrue(try! self.cache.isCached(forKey: #function)) + + self.cache.clear(.all) { _ in + expectation.fulfill() + } } } diff --git a/MapleBaconTests/DiskCacheTests.swift b/MapleBaconTests/DiskCacheTests.swift index 5682581..84ddbde 100644 --- a/MapleBaconTests/DiskCacheTests.swift +++ b/MapleBaconTests/DiskCacheTests.swift @@ -9,22 +9,15 @@ final class DiskCacheTests: XCTestCase { private static let cacheName = "DiskCacheTests" - override func tearDown() { - let cache = DiskCache(name: Self.cacheName) - cache.clear() - // Clearing the disk is an async operation so we should wait - wait(for: 2.seconds) - - super.tearDown() - } - func testWrite() { let expectation = self.expectation(description: #function) let cache = DiskCache(name: Self.cacheName) cache.insert(dummyData(), forKey: "test") { error in XCTAssertNil(error) - expectation.fulfill() + cache.clear { _ in + expectation.fulfill() + } } waitForExpectations(timeout: 5, handler: nil) @@ -44,7 +37,9 @@ final class DiskCacheTests: XCTestCase { case .failure: XCTFail() } - expectation.fulfill() + cache.clear { _ in + expectation.fulfill() + } } } @@ -97,6 +92,22 @@ final class DiskCacheTests: XCTestCase { let expired = try! cache.expiredFileURLs() XCTAssertTrue(expired.isEmpty) + cache.clear { _ in + expectation.fulfill() + } + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testIsCached() { + let expectation = self.expectation(description: #function) + let cache = DiskCache(name: Self.cacheName) + + cache.insert(dummyData(), forKey: "test") { _ in + XCTAssertTrue(try! cache.isCached(forKey: "test")) + cache.clear { _ in expectation.fulfill() } } diff --git a/MapleBaconTests/DownloaderTests.swift b/MapleBaconTests/DownloaderTests.swift index eb8dcb5..e13fc03 100644 --- a/MapleBaconTests/DownloaderTests.swift +++ b/MapleBaconTests/DownloaderTests.swift @@ -16,7 +16,7 @@ final class DownloaderTests: XCTestCase { setupMockResponse(.data(dummyData())) - downloader.fetch(Self.url) { response in + _ = downloader.fetch(Self.url) { response in switch response { case .success(let data): XCTAssertNotNil(data) @@ -37,7 +37,7 @@ final class DownloaderTests: XCTestCase { setupMockResponse(.data(dummyData())) - downloader.fetch(Self.url) { response in + _ = downloader.fetch(Self.url) { response in switch response { case .success: XCTFail() @@ -58,7 +58,7 @@ final class DownloaderTests: XCTestCase { setupMockResponse(.error) - downloader.fetch(Self.url) { response in + _ = downloader.fetch(Self.url) { response in switch response { case .success: XCTFail() @@ -71,4 +71,58 @@ final class DownloaderTests: XCTestCase { waitForExpectations(timeout: 5, handler: nil) } + func testConcurrentDownloads() { + let configuration = MockURLProtocol.mockedURLSessionConfiguration() + let downloader = Downloader(sessionConfiguration: configuration) + + setupMockResponse(.data(dummyData())) + + let firstExpectation = expectation(description: "first") + _ = downloader.fetch(Self.url) { response in + switch response { + case .success(let data): + XCTAssertNotNil(data) + case .failure: + XCTFail() + } + firstExpectation.fulfill() + } + + let secondExpectation = expectation(description: "second") + _ = downloader.fetch(Self.url) { response in + switch response { + case .success(let data): + XCTAssertNotNil(data) + case .failure: + XCTFail() + } + secondExpectation.fulfill() + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCancel() { + let expectation = self.expectation(description: #function) + let configuration = MockURLProtocol.mockedURLSessionConfiguration() + let downloader = Downloader(sessionConfiguration: configuration) + + setupMockResponse(.error) + + let downloadTask = downloader.fetch(Self.url) { response in + switch response { + case .failure(let error as DownloaderError): + XCTAssertEqual(error, .canceled) + case .success, .failure: + XCTFail() + } + expectation.fulfill() + } + + XCTAssertNotNil(downloadTask) + downloadTask.cancel() + + waitForExpectations(timeout: 5, handler: nil) + } + } diff --git a/MapleBaconTests/MapleBaconTests.swift b/MapleBaconTests/MapleBaconTests.swift index 39f5ab3..845e9a6 100644 --- a/MapleBaconTests/MapleBaconTests.swift +++ b/MapleBaconTests/MapleBaconTests.swift @@ -2,6 +2,9 @@ // Copyright © 2020 Schnaub. All rights reserved. // +#if canImport(Combine) +import Combine +#endif @testable import MapleBacon import XCTest @@ -11,13 +14,8 @@ final class MapleBaconTests: XCTestCase { private let cache = Cache(name: "MapleBaconTests") - override func tearDown() { - cache.clear(.all) - // Clearing the disk is an async operation so we should wait - wait(for: 2.seconds) - - super.tearDown() - } + @available(iOS 13.0, *) + private lazy var subscriptions: Set = [] func testIntegration() { let expectation = self.expectation(description: #function) @@ -26,16 +24,19 @@ final class MapleBaconTests: XCTestCase { setupMockResponse(.data(makeImageData())) - mapleBacon.image(with: Self.url) { result in + let token = mapleBacon.image(with: Self.url) { result in switch result { case .success(let image): XCTAssertEqual(image.pngData(), makeImageData()) case .failure: XCTFail() } - expectation.fulfill() + mapleBacon.clearCache(.all) { _ in + expectation.fulfill() + } } + XCTAssertNotNil(token) waitForExpectations(timeout: 5, handler: nil) } @@ -53,7 +54,9 @@ final class MapleBaconTests: XCTestCase { case .failure(let error): XCTAssertNotNil(error) } - expectation.fulfill() + mapleBacon.clearCache(.all) { _ in + expectation.fulfill() + } } waitForExpectations(timeout: 5, handler: nil) @@ -75,10 +78,66 @@ final class MapleBaconTests: XCTestCase { case .failure: XCTFail() } - expectation.fulfill() + mapleBacon.clearCache(.all) { _ in + expectation.fulfill() + } + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testCancel() { + let expectation = self.expectation(description: #function) + let configuration = MockURLProtocol.mockedURLSessionConfiguration() + let mapleBacon = MapleBacon(cache: cache, sessionConfiguration: configuration) + + setupMockResponse(.error) + + let downloadTask = mapleBacon.image(with: Self.url) { result in + switch result { + case .failure(let error as DownloaderError): + XCTAssertEqual(error, .canceled) + case .success, .failure: + XCTFail() + } + mapleBacon.clearCache(.all) { _ in + expectation.fulfill() + } } + XCTAssertNotNil(downloadTask) + downloadTask?.cancel() + waitForExpectations(timeout: 5, handler: nil) } } + +#if canImport(Combine) + +@available(iOS 13.0, *) +extension MapleBaconTests { + + func testIntegrationPublisher() { + let expectation = self.expectation(description: #function) + let configuration = MockURLProtocol.mockedURLSessionConfiguration() + let mapleBacon = MapleBacon(cache: cache, sessionConfiguration: configuration) + + setupMockResponse(.data(makeImageData())) + + mapleBacon.image(with: Self.url) + .sink(receiveCompletion: { _ in + mapleBacon.clearCache(.all) { _ in + expectation.fulfill() + } + }, receiveValue: { image in + XCTAssertEqual(image.pngData(), makeImageData()) + }) + .store(in: &self.subscriptions) + + waitForExpectations(timeout: 5, handler: nil) + } + +} + +#endif diff --git a/MapleBaconTests/MemoryCacheTests.swift b/MapleBaconTests/MemoryCacheTests.swift index e820e47..9a79b65 100644 --- a/MapleBaconTests/MemoryCacheTests.swift +++ b/MapleBaconTests/MemoryCacheTests.swift @@ -46,4 +46,12 @@ final class MemoryCacheTests: XCTestCase { XCTAssertNil(cache["foo"]) } + func testIsCached() { + let cache = MemoryCache() + cache["foo"] = "bar" + + XCTAssertTrue(cache.isCached(forKey: "foo")) + XCTAssertFalse(cache.isCached(forKey: "bar")) + } + } diff --git a/MapleBaconTests/TestHelpers.swift b/MapleBaconTests/TestHelpers.swift index 9ffa62c..55909e0 100644 --- a/MapleBaconTests/TestHelpers.swift +++ b/MapleBaconTests/TestHelpers.swift @@ -4,7 +4,6 @@ import UIKit import MapleBacon -import XCTest enum MockResponse { case data(Data) @@ -42,13 +41,6 @@ func makeImageData() -> Data { makeImage().pngData()! } -extension XCTestCase { - func wait(for interval: TimeInterval) { - let date = Date(timeIntervalSinceNow: interval) - RunLoop.current.run(mode: RunLoop.Mode.default, before: date) - } -} - final class FirstDummyTransformer: ImageTransforming { let identifier = "com.schnaub.FirstDummyTransformer" diff --git a/README.md b/README.md index 7a81b49..24c10cb 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,6 @@ let chainedTransformer = SepiaImageTransformer() >>> DifferentTransformer() >>> (Keep in mind that if you are using Core Image it might not be optimal to chain individual transformers but rather create one transformer that applies multiple `CIFilter`s in one pass. See the [Core Image Programming Guide](https://developer.apple.com/library/content/documentation/GraphicsImaging/Conceptual/CoreImaging/ci_intro/ci_intro.html#//apple_ref/doc/uid/TP30001185).) - ### Caching MapleBacon will cache your images both in memory and on disk. Disk storage is automatically pruned after a week (taking into account the last access date as well) but you can control the maximum cache time yourself too: @@ -139,6 +138,19 @@ let oneDaySeconds: TimeInterval = 60 * 60 * 24 MapleBacon.default.maxCacheAgeSeconds = oneDaySeconds ``` +### Combine + +On iOS13 and above, you can use `Combine` to fetch images from MapleBacon + +```swift +MapleBacon.shared.image(with: url) + .receive(on: DispatchQueue.main) // Dispatch to the right queue if updating the UI + .sink(receiveValue: { image in + // Do something with your image + }) + .store(in: &subscriptions) // Hold on to and dispose your subscriptions +``` + ## Migrating from 5.x There is a small [migration guide](https://github.com/JanGorman/MapleBacon/wiki/Migration-Guide-Version-5.x-→-6.x) in the wiki when moving from the 5.x branch to 6.x