I use a URLSession data task to download several JPG images from a backend. As the images are rather large in size (~500 KB) I want to cache the respective responses until they have expired (i.e. they have exceeded their max-age).
This is the code I use for downloading the images:
let request = URLRequest(url: url,
cachePolicy: .useProtocolCachePolicy,
timeoutInterval: 10.0)
let task = URLSession.shared.dataTask(with: request) { (data, _, error) in
// Error:
guard let imageData = data, error == nil, let image = UIImage(data: imageData) else {
DispatchQueue.main.async {
completion(nil)
}
return
}
// Success:
DispatchQueue.main.async {
completion(image)
}
}
task.resume()
Curiously, this works great with caching for all images except for one. For some reason, this particular image is always downloaded again – its response is not cached.
The only difference between the responses that I can spot is that the image whose corresponding response is not cached has the biggest file size. While all other images are < 500 kB, this particular image is slightly > 500 kB.
I've played around with the shared cache size and set it to a ridiculously high value, with no effect:
URLCache.shared = URLCache(memoryCapacity: 1000 * 1024 * 1024,
diskCapacity: 1000 * 1024 * 1024,
diskPath: nil)
I've checked that the Cache-Control header field is correctly set in the response:
Cache-Control: public, max-age=86400
and the Age header field is always below max-age, for example:
Age: 3526
What could be the reason for a single response not to be cached?
How can I fix this?
This is not an answer to the question why the shared URLSession does not cache the image and I'm still grateful for any hints or answers to that question.
However, after experimenting some time with my code I figured out that (for whatever reason) the response is always being cached when I use a custom URL session with a default configuration rather than the default shared URL session:
let urlSession = URLSession(configuration: .default)
So if I use:
let task = urlSession.dataTask(with: request) { ... }
instead of
let task = URLSession.shared.dataTask(with: request) { ... }
the caching works as expected – whatever black magic is responsible for that. 🤔
I found a little hint in the docs for URLSession.shared though:
When working with a shared session, you should generally avoid
customizing the cache, ...
In other words, if you’re doing anything with caches, cookies,
authentication, or custom networking protocols, you should probably be
using a default session instead of the shared session.
Related
I have an app which uses URLSession-based networking and URLCache for storing network requests on disk. I noticed that when the storage size of URLCache reaches the diskCapacity, the eviction strategy seems to be to remove all entries, which is a problem in my use case. So I decided to write an URLCache subclass which would provide a custom storage for cached responses and which would implement LRU eviction strategy with better control.
As URLCache's documentation states, subclassing for this purpose should be supported:
The URLCache class is meant to be used as-is, but you can subclass it when you have specific needs. For example, you might want to screen which responses are cached, or reimplement the storage mechanism for security or other reasons.
However, I ran into problems with trying to use this new URLCache subclass with URLSession networking.
I have a test resource which I fetch using HTTP GET. The response headers contain:
Cache-Control: public, max-age=30
Etag: <some-value>
When using the standard, non-subclassed URLCache, the first request loads the data from network as expected (verified with HTTP proxy). The second request doesn't go to the network, if done within first 30 seconds, as expected. Subsequent requests after 30 seconds cause conditional GETs with Etag, as expected.
When using a URLCache subclass, all requests load the data from network - max-age doesn't seem to matter, and no conditional GETs are made.
It seems that the URLCache does something special to the CachedURLResponse instances after they're loaded from its internal storage, and this something affects how URLSession handles the HTTP caching logic. What am I missing here?
I've written a very minimal URLCache subclass implementation to demonstrate this problem. This class stores and loads CachedURLResponse instances using NSKeyedArchiver / NSKeyedUnarchiver, and it supports only zero or one response. Note that there are no calls to super - this is by design, since I want to use my own storage.
class CustomURLCache: URLCache {
let cachedResponseFileURL = URL(filePath: NSTemporaryDirectory().appending("entry.data"))
// MARK: Internal storage
func read() -> CachedURLResponse? {
guard let data = try? Data(contentsOf: cachedResponseFileURL) else { return nil }
return try! NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as! CachedURLResponse
}
func store(_ cachedResponse: CachedURLResponse) {
try! (try! NSKeyedArchiver.archivedData(withRootObject: cachedResponse, requiringSecureCoding: false)).write(to: cachedResponseFileURL)
}
// MARK: URLCache Overrides
override func cachedResponse(for request: URLRequest) -> CachedURLResponse? {
read()
}
override func getCachedResponse(for dataTask: URLSessionDataTask, completionHandler: #escaping (CachedURLResponse?) -> Void) {
completionHandler(read())
}
override func storeCachedResponse(_ cachedResponse: CachedURLResponse, for request: URLRequest) {
store(cachedResponse)
}
override func storeCachedResponse(_ cachedResponse: CachedURLResponse, for dataTask: URLSessionDataTask) {
store(cachedResponse)
}
}
My test case:
func test() {
let useEvictingCache = false
let config = URLSessionConfiguration.default
if useEvictingCache {
config.urlCache = CustomURLCache()
} else {
config.urlCache = URLCache(memoryCapacity: 0, diskCapacity: 1024 * 1024 * 100)
}
self.urlSession = URLSession(configuration: config)
let url = URL(string: "https://example.com/my-test-resource")!
self.urlSession?.dataTask(with: URLRequest(url: url), completionHandler: { data, response, error in
if let data {
print("GOT DATA with \(data.count) bytes")
} else if let error {
print("GOT ERROR \(error)")
}
}).resume()
}
Tested on iOS 16.2.
Received a response for my question at Apple's Developer Forums from Quinn “The Eskimo”:
My experience is that subclassing Foundation’s URL loading system classes puts you on a path of pain [1]. If I were in your shoes, I’d do your custom caching above the Foundation URL loading system layer.
[1] Way back in the day the Foundation URL loading system was implemented in Objective-C and existed within the Foundation framework. In that world, subclasses mostly worked. Shortly thereafter — and I’m talking before the introduction of NSURLSession here — the core implementation changed languages and moved to CFNetwork. Since then, the classes you see in Foundation are basically thin wrappers around (private) CFNetwork types. That’s generally OK, except for the impact on subclassing.
So it sounds like one should read the URLCache documentation re: subclassing with a grain of salt.
I am stuck on how to fix something. I currently have an iPhone app that is literally Instagram but just horizontally and paging, where users see friends photos.
Now I have created a function that grabs images from firebase and puts them in an array. Now this works great along with performing a shared URLSession. I noticed my app was running high on memory usage so I added a URLcache, and set the limit on how large it can get, actually is sort of high now that I think about it. But I am still getting high memory (171mb)-, and that's only loading 4 images usage which makes it seem like I am not caching the data right.
I am still learning how to work with URLSessions and also caching so this also might contribute to a problem if I set it up wrong. Online people were saying use SDWebImage, but really, users won't scroll down or be able to scroll down fast because first paging's enabled and also it's horizontal. Here is some of my code, please tell me what you think I should do.
urlcache in
viewDidLoad() { //probably too high..
let memoryCapacity = 500 * 1024 * 1024
let diskCapacity = 500 * 1024 * 1024
let urlCache = URLCache(memoryCapacity: memoryCapacity, diskCapacity: diskCapacity, diskPath: "myDiskPath")
URLCache.shared = urlCache
}
// cellforrow
if posts.count != nil {
let pozt = posts[indexPath.row].pathToImage
let url = URL(string: pozt!)
URLSession.shared.dataTask(with: url!, completionHandler: { (data, response, error) in
if error != nil {
print(error?.localizedDescription as Any)
return
}
DispatchQueue.main.async {
cell.myImage.image = UIImage(data: data!)
self.loader.stopAnimating()
}
}).resume()
}
cell.myImage.image = UIImage(named: imaqes[0])
return cell
}
I think almost all programmers on swift using libraries to cache images.
I use KingFisher instead of SDWebImage. Its lightweight and simple to use.
To install:
Podfile:
pod 'Kingfisher'
In terminal:
pod install
In swift file:
import Kingfisher
In your case use it next way:
// cellforrow
if posts.count != nil {
let pozt = posts[indexPath.row].pathToImage
let url = URL(string: pozt!)
DispatchQueue.main.async {
cell.myImage.kf.setImage(with: url!) //Using kf for caching images
}
}
return cell
Maybe you should use .kf.setImage with comletion handler to remove loader. You should get the idea.
Hope it helps
In my iOS 9+ Swift 2.2 application, I'm downloading multiple images with NSURLSession's dataWithRequest method :
let session: NSURLSession = NSURLSession(configuration: self.configuration)
let request = NSURLRequest(URL: self.url)
let dataTask = session.dataTaskWithRequest(request) { (data, response, error) in
// Handle image
}
dataTask.resume()
session.finishTasksAndInvalidate()
The question is : How can I limit the image cache size? I can see that my application is using more and more disk space on my device. Is there any way to keep the image cache but to limit the size that my application can use? Is there an expiration by default?
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.
If I run the following code and let the app in background, the download is still continuing. Finally, when the download is finished, I can get the right callback.
let configuration = NSURLSessionConfiguration.backgroundSessionConfigurationWithIdentifier(SessionProperties.identifier)
let backgroundSession = NSURLSession(configuration: configuration, delegate: self.delegate, delegateQueue: nil)
let url = NSURLRequest(URL: NSURL(string: data[1])!)
let downloadTask = backgroundSession.downloadTaskWithRequest(url)
downloadTask.resume()
But I have a requirement, that is I have to judge what the server returns to me, if it is a json, I don't do the download, so I want to get the response header first, then if it needs to download, I change the data task to download task, so I did as the following code
let configuration = NSURLSessionConfiguration.backgroundSessionConfigurationWithIdentifier(SessionProperties.identifier)
let backgroundSession = NSURLSession(configuration: configuration, delegate: self.delegate, delegateQueue: nil)
let url = NSURLRequest(URL: NSURL(string: data[1])!)
//I change the downloadTaskWithRequest to dataTaskWithRequest
let downloadTask = backgroundSession.dataTaskWithRequest(url)
downloadTask.resume()
Then I can get the response header in the callback, and if it needs to download file, I can change the data task to download task, as following
func URLSession(session: NSURLSession, dataTask: NSURLSessionDataTask, didReceiveResponse response: NSURLResponse, completionHandler: (NSURLSessionResponseDisposition) -> Void) {
if let response = response as? NSHTTPURLResponse {
let contentType = response.allHeaderFields["Content-Type"] as! String
if contentType == "image/jpeg" {
//change the data task to download task
completionHandler(.BecomeDownload)
return
}
}
completionHandler(.Allow)
}
So far so good. When I run the app in the foreground, the effect is like what I thought. But after the app runs in background, the download is stoped, then when I open the app, the console says "Lost connection to background transfer service".
I thought Apple is so smart, he gives us many useful callbacks, but now, I didn't know where I am wrong, and I also see the source code about the AFNetworking and Alamofire, but I didn't find the referring thing.
I also think it is a common requirement, but I can't find any helpful information on the internet, it is too odd.
So hope you can help me out, thanks a billion.
Enable Background Mode in
Xcode->Target->Capabilities->On Background Mode and select the option Background Fetch.
The main issue I see is that you're calling the completionHandler twice. You need to return out of your content-type conditional like so:
if contentType == "image/jpeg" {
//change the data task to download task
completionHandler(.BecomeDownload)
return
}
Otherwise it appears that you are using the logic correctly. Hope that helps.
The problem is evident from your own answer. It's not a bug, you simply couldn't use data tasks for background transfers just download tasks.
Here is the correct full answer.
I have two UIImageView objects on my view stored inside an array called imgViews and I try to download images for them asynchronously with this code:
func showPic(positionIndex: Int){
let urlStr = "https://www.friendesque.com/arranged/userpics/amir/1"
let url = NSURL(string: urlStr)
let session = NSURLSession.sharedSession()
let task = session.dataTaskWithURL(url!, completionHandler: { (data, response, error) -> Void in
if error == nil {
self.imgViews[positionIndex].image = UIImage(data: data)
//self.imgViews[positionIndex].image = UIImage(named: "11665489_10154101221228009_2542754962143804380_n.jpg")
print("Loading Done...")
}
else{
print(error)
}
})
task.resume()
}
and inside my viewDidLoad(), I have
showPic(0)
When I run the code, I see "Loading Done..." immediately which means the picture has been loaded but it takes a very long time (about 1 min) for the UIImageView to actually change to the loaded picture. It's a very small picture (~15K) and it can't be a processing time problem.
I tried loading a resource image (the comment part of the code) instead of the downloaded picture but it's still slow.
I'm really confused. Why is swift so slow at working with images inside a block?
Perhaps when the data task returns it is on a background thread? You will need to switch to the main thread to change a UIImageView. Regardless I would use the UIImageView+AFNetworking category to achieve this. It's simple, well tested and lets you provide a placeholder image to display while it is downloading.
https://github.com/AFNetworking/AFNetworking/blob/master/UIKit%2BAFNetworking/UIImageView%2BAFNetworking.h
to use:
myImageView.setImageWithURL(url!)