Skip to content

Commit

Permalink
Create URLDataCache and DataDownloader (#164)
Browse files Browse the repository at this point in the history
### Summary
The ImageDownloader and Cache works great but only supports images that are supported by UIImage/NSImage for the OS version of the device. If the Image doesn't support the data type, the data is lost and not cached, and we are unable to fully utilize the cache if we support other image types from our application.

`URLDataCache` and `DataDownloader` removes the Image requirement from downloading the data needed while maintaining the functionality of `ImageDownloader` and ImageCache.

### Implementation
In order to reduce duplicated code, most of the existing code from `URLImageCache.swift` and `AutoPurgingURLImageCache.swift` and has been replaced with `NSData` in place of `UIImage` and `NSImage`. This allows for more flexibility in the data types being returned from dynamic URL types that we might not always have control over.

`NSData` was chosen over `Data` in order to maintain the use of `NSCache` which requires the use of a class [NSData], instead of a struct [Data]. Since these are interoperable, I felt this wouldn't be an issue. 

### Test Plan
- Ensure Images are still downloaded, cached, and retrieved properly. This can be done using the tests or in your application. I have used Proxyman to determine that multiple requests were not being made by the application to request subsequent Images from the web. In prior versions these requests would continue to be made because once `UIImage`/`NSImage` is instantiated from `Data`, the cached data is lost.
- [New functionality] Ensure unsupported image types from the web are being stored properly. This can be done using the tests or in your application. I have used Proxyman to determine that multiple requests were not being made by the application to request subsequent Images from the web.
  • Loading branch information
anthony-lipscomb-dev authored Aug 6, 2021
1 parent f070673 commit 44611d6
Show file tree
Hide file tree
Showing 9 changed files with 580 additions and 62 deletions.
100 changes: 100 additions & 0 deletions Sources/Conduit/Networking/Data/AutoPurgingURLDataCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//
// AutoPurgingURLDataCache.swift
//
//
// Created by Anthony Lipscomb on 8/2/21.
//

import Foundation

public struct AutoPurgingURLDataCache: URLDataCache {

private let cache: NSCache<NSString, NSData>
private let serialQueue = DispatchQueue(
label: "com.mindbodyonline.Conduit.AutoPurgingDataCache-\(UUID().uuidString)"
)

/// Initializes an AutoPurgingURLDataCache with the desired memory capacity
///
/// - Parameters:
/// - memoryCapacity: The desired cache capacity before data eviction. Defaults to 60MB.
///
/// - Important: The system will evict data based on different constraints within the system environment.
/// It is possible for the memory capacity to be surpassed and for the system to purge data at a later time.
public init(memoryCapacity: Int = 1_024 * 1_024 * 60) {
cache = NSCache()
cache.totalCostLimit = memoryCapacity
}

/// Attempts to retrieve a cached data for the given request
///
/// - Parameters:
/// - request: The request for the data
/// - Returns: The cached data or nil of none exists
public func data(for request: URLRequest) -> NSData? {
guard let identifier = cacheIdentifier(for: request) else {
return nil
}

var data: NSData?
serialQueue.sync {
data = cache.object(forKey: identifier as NSString)
}
return data
}

/// Attempts to build a cache identifier for the given request
///
/// - Parameters:
/// - request: The request for the data
/// - Returns: An identifier for the cached data
public func cacheIdentifier(for request: URLRequest) -> String? {
return request.url?.absoluteString
}

/// Attempts to cache data for a given request
///
/// - Parameters:
/// - data: The data to be cached
/// - request: The original request for the data
/// - Returns: Boolean describing if the operation was successful
@discardableResult
public func cache(data: NSData, for request: URLRequest) -> Bool {
guard let identifier = cacheIdentifier(for: request) else {
return false
}

let totalBytes = numberOfBytes(in: data)
serialQueue.sync {
cache.setObject(data, forKey: identifier as NSString, cost: totalBytes)
}
return true
}

/// Attempts to remove an data from the cache for a given request
/// - Parameters:
/// - request: The original request for the
/// - Returns: Boolean describing if the operation was successful
@discardableResult
public func removeData(for request: URLRequest) -> Bool {
guard let identifier = cacheIdentifier(for: request) else {
return false
}

serialQueue.sync {
cache.removeObject(forKey: identifier as NSString)
}
return true
}

/// Purges all data from the cache
public func purge() {
serialQueue.sync {
cache.removeAllObjects()
}
}

private func numberOfBytes(in data: NSData) -> Int {
return data.count
}
}
137 changes: 137 additions & 0 deletions Sources/Conduit/Networking/Data/DataDownloader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
//
// DataDownloader.swift
// Conduit
//
// Created by Anthony Lipscomb on 8/2/21.
// Copyright © 2021 MINDBODY. All rights reserved.
//

import Foundation

/// Represents an error that occurred within an DataDownloader
/// - invalidRequest: An invalid request was supplied, most likely with an empty URL
public enum DataDownloaderError: Error {
case invalidRequest
}

public protocol DataDownloaderType {
func downloadData(for request: URLRequest, completion: @escaping (DataDownloader.Response) -> Void) -> SessionTaskProxyType?
}

/// Utilizes Conduit to download and safely cache/retrieve
/// data across multiple threads
public final class DataDownloader: DataDownloaderType {

/// Represents a network or cached data response
public struct Response {
/// The resulting data
public let data: NSData?
/// The error that occurred from transport or cache retrieval
public let error: Error?
/// The URL response, if a download occurred
public let urlResponse: HTTPURLResponse?
/// Signifies if the data was retrieved directly from the cache
public let isFromCache: Bool

public init(data: NSData?, error: Error?, urlResponse: HTTPURLResponse?, isFromCache: Bool) {
self.data = data
self.error = error
self.urlResponse = urlResponse
self.isFromCache = isFromCache
}
}

/// A closure that fires upon data fetch success/failure
public typealias CompletionHandler = (Response) -> Void

private var cache: URLDataCache
private let sessionClient: URLSessionClientType
private var sessionProxyMap: [String: SessionTaskProxyType] = [:]
private var completionHandlerMap: [String: [CompletionHandler]] = [:]
private let completionQueue: OperationQueue?
private let serialQueue = DispatchQueue(
label: "com.mindbodyonline.Conduit.DataDownloader-\(UUID().uuidString)"
)

/// Initializes a new DataDownloader
/// - Parameters:
/// - cache: The data cache in which to store downloaded data
/// - sessionClient: The URLSessionClient to be used to download data
/// - completionQueue: An optional operation queue for completion callback
public init(cache: URLDataCache,
sessionClient: URLSessionClientType = URLSessionClient(),
completionQueue: OperationQueue? = nil) {
self.cache = cache
self.sessionClient = sessionClient
self.completionQueue = completionQueue
}

/// Downloads data or retrieves it from the cache if previously downloaded.
/// - Parameters:
/// - request: The request for the data
/// - Returns: A concrete SessionTaskProxyType
@discardableResult
public func downloadData(for request: URLRequest, completion: @escaping CompletionHandler) -> SessionTaskProxyType? {
var proxy: SessionTaskProxyType?
let completionQueue = self.completionQueue ?? .current ?? .main

serialQueue.sync { [weak self] in
guard let `self` = self else {
return
}

if let data = self.cache.data(for: request) {
let response = Response(data: data, error: nil, urlResponse: nil, isFromCache: true)
completion(response)
return
}

guard let cacheIdentifier = self.cache.cacheIdentifier(for: request) else {
let response = Response(data: nil,
error: DataDownloaderError.invalidRequest,
urlResponse: nil,
isFromCache: false)
completion(response)
return
}

self.register(completionHandler: completion, for: cacheIdentifier)

if let sessionTaskProxy = self.sessionProxyMap[cacheIdentifier] {
proxy = sessionTaskProxy
return
}

proxy = self.sessionClient.begin(request: request) { data, response, error in
if let data = data {
_ = self.cache.cache(data: data as NSData, for: request)
}

let response = Response(data: data as NSData?, error: error, urlResponse: response, isFromCache: false)

func execute(handler: @escaping CompletionHandler) {
completionQueue.addOperation {
handler(response)
}
}

self.serialQueue.async {
self.sessionProxyMap[cacheIdentifier] = nil
self.completionHandlerMap[cacheIdentifier]?.forEach(execute)
self.completionHandlerMap[cacheIdentifier] = nil
}
}

self.sessionProxyMap[cacheIdentifier] = proxy
}

return proxy
}

private func register(completionHandler: @escaping CompletionHandler, for cacheIdentifier: String) {
var handlers = completionHandlerMap[cacheIdentifier] ?? []
handlers.append(completionHandler)
completionHandlerMap[cacheIdentifier] = handlers
}

}
43 changes: 43 additions & 0 deletions Sources/Conduit/Networking/Data/URLDataCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// URLDataCache.swift
// Conduit
//
// Created by Anthony Lipscomb on 8/2/21.
// Copyright © 2021 MINDBODY. All rights reserved.
//

import Foundation

/// Caches data keyed off of URLRequests
public protocol URLDataCache {

/// Attempts to retrieve cached data for the given request
///
/// - Parameters:
/// - request: The request for the data
/// - Returns: The cached data or nil of none exists
func data(for request: URLRequest) -> NSData?

/// Attempts to build a cache identifier for the given request
///
/// - Parameters:
/// - request: The request for the data
/// - Returns: An identifier for the cached data
func cacheIdentifier(for request: URLRequest) -> String?

/// Attempts to cache data for a given request
///
/// - Parameters:
/// - data: The data to be cached
/// - request: The original request for the data
mutating func cache(data: NSData, for request: URLRequest) -> Bool

/// Attempts to remove data from the cache for a given request
/// - Parameters:
/// - request: The original request for the data
mutating func removeData(for request: URLRequest) -> Bool

/// Purges all data from the cache
mutating func purge()

}
Loading

0 comments on commit 44611d6

Please sign in to comment.