downloading and caching images from url asynchronously
I know you found your problem and it was unrelated to the above code, yet I still have an observation. Specifically, your asynchronous requests will carry on, even if the cell (and therefore the image view) have been subsequently reused for another index path. This results in two problems:
If you quickly scroll to the 100th row, you are going to have to wait for the images for the first 99 rows to be retrieved before you see the images for the visible cells. This can result in really long delays before images start popping in.
If that cell for the 100th row was reused several times (e.g. for row 0, for row 9, for row 18, etc.), you may see the image appear to flicker from one image to the next until you get to the image retrieval for the 100th row.
Now, you might not immediately notice either of these are problems because they will only manifest themselves when the image retrieval has a hard time keeping up with the user's scrolling (the combination of slow network and fast scrolling). As an aside, you should always test your app using the network link conditioner, which can simulate poor connections, which makes it easier to manifest these bugs.
Anyway, the solution is to keep track of (a) the current URLSessionTask
associated with the last request; and (b) the current URL
being requested. You can then (a) when starting a new request, make sure to cancel any prior request; and (b) when updating the image view, make sure the URL associated with the image matches what the current URL is.
The trick, though, is when writing an extension, you cannot just add new stored properties. So you have to use the associated object API to associate these two new stored values with the UIImageView
object. I personally wrap this associated value API with a computed property, so that the code for retrieving the images does not get too buried with this sort of stuff. Anyway, that yields:
extension UIImageView {
private static var taskKey = 0
private static var urlKey = 0
private var currentTask: URLSessionTask? {
get { objc_getAssociatedObject(self, &UIImageView.taskKey) as? URLSessionTask }
set { objc_setAssociatedObject(self, &UIImageView.taskKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
}
private var currentURL: URL? {
get { objc_getAssociatedObject(self, &UIImageView.urlKey) as? URL }
set { objc_setAssociatedObject(self, &UIImageView.urlKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
}
func loadImageAsync(with urlString: String?, placeholder: UIImage? = nil) {
// cancel prior task, if any
weak var oldTask = currentTask
currentTask = nil
oldTask?.cancel()
// reset image view’s image
self.image = placeholder
// allow supplying of `nil` to remove old image and then return immediately
guard let urlString = urlString else { return }
// check cache
if let cachedImage = ImageCache.shared.image(forKey: urlString) {
self.image = cachedImage
return
}
// download
let url = URL(string: urlString)!
currentURL = url
let task = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
self?.currentTask = nil
// error handling
if let error = error {
// don't bother reporting cancelation errors
if (error as? URLError)?.code == .cancelled {
return
}
print(error)
return
}
guard let data = data, let downloadedImage = UIImage(data: data) else {
print("unable to extract image")
return
}
ImageCache.shared.save(image: downloadedImage, forKey: urlString)
if url == self?.currentURL {
DispatchQueue.main.async {
self?.image = downloadedImage
}
}
}
// save and start new task
currentTask = task
task.resume()
}
}
Also, note that you were referencing some imageCache
variable (a global?). I would suggest an image cache singleton, which, in addition to offering the basic caching mechanism, also observes memory warnings and purges itself in memory pressure situations:
class ImageCache {
private let cache = NSCache<NSString, UIImage>()
private var observer: NSObjectProtocol?
static let shared = ImageCache()
private init() {
// make sure to purge cache on memory pressure
observer = NotificationCenter.default.addObserver(
forName: UIApplication.didReceiveMemoryWarningNotification,
object: nil,
queue: nil
) { [weak self] notification in
self?.cache.removeAllObjects()
}
}
deinit {
NotificationCenter.default.removeObserver(observer!)
}
func image(forKey key: String) -> UIImage? {
return cache.object(forKey: key as NSString)
}
func save(image: UIImage, forKey key: String) {
cache.setObject(image, forKey: key as NSString)
}
}
A bigger, more architectural, observation: One really should decouple the image retrieval from the image view. Imagine you have a table where you have a dozen cells using the same image. Do you really want to retrieve the same image a dozen times just because the second image view scrolled into view before the first one finished its retrieval? No.
Also, what if you wanted to retrieve the image outside of the context of an image view? Perhaps a button? Or perhaps for some other reason, such as to download images to store in the user’s photos library. There are tons of possible image interactions above and beyond image views.
Bottom line, fetching images is not a method of an image view, but rather a generalized mechanism of which an image view would like to avail itself. An asynchronous image retrieval/caching mechanism should generally be incorporated in a separate “image manager” object. It can then detect redundant requests and be used from contexts other than an image view.
As you can see, the asynchronous retrieval and caching is starting to get a little more complicated, and this is why we generally advise considering established asynchronous image retrieval mechanisms like AlamofireImage or Kingfisher or SDWebImage. These guys have spent a lot of time tackling the above issues, and others, and are reasonably robust. But if you are going to “roll your own,” I would suggest something like the above at a bare minimum.
NSURLSession and image cache
NSURLSession uses shared NSURLCache to cache responses. If you want to limit disk/memory usage of a shared cache you should create a new cache and set it as a default one:
let URLCache = NSURLCache(memoryCapacity: 4 * 1024 * 1024, diskCapacity: 20 * 1024 * 1024, diskPath: nil)
NSURLCache.setSharedURLCache(URLCache)
You could find a little more about caching here.
Image downloading and caching issue
I think the problem is in your response handler, you are setting cache for url you are requesting, not for url from response, I modified your code a little bit, try, hope it will help you
func downloadImage(url: URL, imageView: UIImageView, placeholder: UIImage? = nil, row: Int) {
imageView.image = placeholder
imageView.cacheUrl = url.absoluteString + "\(row)"
if let cachedImage = imageCache.object(forKey: url.absoluteString as NSString) {
imageView.image = cachedImage
} else {
URLSession.shared.dataTask(with: url) { (data, response, error) in
guard
let response = response as? HTTPURLResponse,
let imageData = data,
let image = UIImage(data: imageData),
let cacheKey = response.url?.absoluteString,
let index = self.arrURLs.firstIndex(of: cacheKey)
else { return }
DispatchQueue.main.async {
if cacheKey + "\(index)" != imageView.cacheUrl { return }
imageView.image = image
self.imageCache.setObject(image, forKey: cacheKey as NSString)
}
}.resume()
}
}
And
var associateObjectValue: Int = 0
extension UIImageView {
fileprivate var cacheUrl: String? {
get {
return objc_getAssociatedObject(self, &associateObjectValue) as? String
}
set {
return objc_setAssociatedObject(self, &associateObjectValue, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN)
}
}
}
UPDATED:
URLSession cache only
That's a fairly unusual request. Normally, you're either writing code to operate in an offline mode (in which case you want to pull from the cache whether the cached results are still valid or not) or you are online (in which case you want to fetch new data if it isn't valid).
I would encourage you to really think long and hard about whether you really want to force cache validation if you aren't firing network requests.
That said, if you really want that behavior, there are two ways you can do it:
- Use
NSURLRequestReturnCacheDataDontLoad
and validate the age of the cached response yourself. - Perform the request in a custom session, use
NSURLRequestUseProtocolCachePolicy
, and in that session, install a customNSURLProtocol
subclass that overridesinitWithTask:cachedResponse:client:
andstartLoading
, and callsURLProtocol:didFailWithError:
on the provided client at the top of itsstartLoading
method.
The second approach is probably the best option, because you don't have to worry about knowing all the esoteric rules for cache validation. By making the actual load fail, the cache will work normally, but as soon as it actually would start making a network request, your custom protocol prevents that from happening. And because you'll register the protocol only in that specific session (via the protocolClasses
array on the session configuration), it won't break networking in other sessions.
Related Topics
Swiftui Set Position to Center of Different View
Reading Currently Playing Track in MACos Using Scriptingbridge Not Working
Swift Error Handling for Methods That Do Not Throw
Spacer Not Working with Form Inside a VStack
Global Function Sequence(State:Next:) and Type Inference
How to Install Xcode on an External Hard Drive Along with the iPhone Simulator.App
iOS 10 Imessage App Extension: How to Calculate the Height of the Extra Tall Navbar
Is There a Difference Between "Is" and Iskindofclass()
When to Use [Self] VS [Weak Self] in Swift Blocks
Grab Frames from Video Using Swift
Viewcontroller Slide Animation
Xcode Beta 6 "Type of Expression Is Ambiguous Without More Context" Navigationlink
Self' Captured by a Closure Before All Members Were Initialized
Declaration Is Only Valid at File Scope (Extension)