objective-curlcachinguiimageviewnscache

How to cache images in Objective-C


I want to create a method to cache an image from an URL, I got the code in Swift since I had used it before, how can I do something similar to this in Objective-C:

import UIKit

let imageCache: NSCache = NSCache<AnyObject, AnyObject>()

extension UIImageView {
    
    func loadImageUsingCacheWithUrlString(urlString: String) {
        
        self.image = nil
        
        if let cachedImage = imageCache.object(forKey: urlString as AnyObject) as? UIImage {
            self.image = cachedImage
            return
        }
        
        let url = URL(string: urlString)
        if let data = try? Data(contentsOf: url!) {
            
            DispatchQueue.main.async(execute: {
                
                if let downloadedImage = UIImage(data: data) {
                    imageCache.setObject(downloadedImage, forKey: urlString as AnyObject)
                    
                    self.image = downloadedImage
                }
            })
        }
    }
    
}

Solution

  • Before you convert this, you might consider refactoring to make it asynchronous:

    1. One should never use Data(contentsOf:) for network requests because (a) it is synchronous and blocks the caller (which is a horrible UX, but also, in degenerate cases, can cause the watchdog process to kill your app); (b) if there is a problem, there’s no diagnostic information; and (c) it is not cancelable.

    2. Rather than updating image property when done, you should consider completion handler pattern, so caller knows when the request is done and the image is processed. This pattern avoids race conditions and lets you have concurrent image requests.

    3. When you use this asynchronous pattern, the URLSession runs its completion handlers on background queue. You should keep the processing of the image and updating of the cache on this background queue. Only the completion handler should be dispatched back to the main queue.

    4. I infer from your answer, that your intent was to use this code in a UIImageView extension. You really should put this code in a separate object (I created a ImageManager singleton) so that this cache is not only available to image views, but rather anywhere where you might need images. You might, for example, do some prefetching of images outside of the UIImageView. If this code is buried in the

    Thus, perhaps something like:

    final class ImageManager {
        static let shared = ImageManager()
    
        enum ImageFetchError: Error {
            case invalidURL
            case networkError(Data?, URLResponse?)
        }
    
        private let imageCache = NSCache<NSString, UIImage>()
    
        private init() { }
    
        @discardableResult
        func fetchImage(urlString: String, completion: @escaping (Result<UIImage, Error>) -> Void) -> URLSessionTask? {
            if let cachedImage = imageCache.object(forKey: urlString as NSString) {
                completion(.success(cachedImage))
                return nil
            }
    
            guard let url = URL(string: urlString) else {
                completion(.failure(ImageFetchError.invalidURL))
                return nil
            }
    
            let task = URLSession.shared.dataTask(with: url) { data, response, error in
                guard
                    error == nil,
                    let responseData = data,
                    let httpUrlResponse = response as? HTTPURLResponse,
                    200 ..< 300 ~= httpUrlResponse.statusCode,
                    let image = UIImage(data: responseData)
                else {
                    DispatchQueue.main.async {
                        completion(.failure(error ?? ImageFetchError.networkError(data, response)))
                    }
                    return
                }
    
                self.imageCache.setObject(image, forKey: urlString as NSString)
                DispatchQueue.main.async {
                    completion(.success(image))
                }
            }
    
            task.resume()
    
            return task
        }
    
    }
    

    And you'd call it like:

    ImageManager.shared.fetchImage(urlString: someUrl) { result in
        switch result {
        case .failure(let error): print(error)
        case .success(let image): // do something with image
        }
    }
    
    // but do not try to use `image` here, as it has not been fetched yet
    

    If you wanted to use this in a UIImageView extension, for example, you could save the URLSessionTask, so that you could cancel it if you requested another image before the prior one finished. (This is a very common scenario if using this in table views and the user scrolls very quickly, for example. You do not want to get backlogged in a ton of network requests.) We could

    extension UIImageView {
        private static var taskKey = 0
        private static var urlKey = 0
    
        private var currentTask: URLSessionTask? {
            get { objc_getAssociatedObject(self, &Self.taskKey) as? URLSessionTask }
            set { objc_setAssociatedObject(self, &Self.taskKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
        }
    
        private var currentURLString: String? {
            get { objc_getAssociatedObject(self, &Self.urlKey) as? String }
            set { objc_setAssociatedObject(self, &Self.urlKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
        }
    
        func setImage(with urlString: String) {
            if let oldTask = currentTask {
                currentTask = nil
                oldTask.cancel()
            }
    
            image = nil
    
            currentURLString = urlString
    
            let task = ImageManager.shared.fetchImage(urlString: urlString) { result in
                // only reset if the current value is for this url
    
                if urlString == self.currentURLString {
                    self.currentTask = nil
                    self.currentURLString = nil
                }
    
                // now use the image
    
                if case .success(let image) = result {
                    self.image = image
                }
            }
    
            currentTask = task
        }
    }
    

    There are tons of other things you might do in this UIImageView extension (e.g. placeholder images or the like), but by separating the UIImageView extension from the network layer, one keeps these different tasks in their own respective classes (in the spirit of the single responsibility principle).


    OK, with that behind us, let us look at the Objective-C rendition. For example, you might create an ImageManager singleton:

    //  ImageManager.h
    
    @import UIKit;
    
    NS_ASSUME_NONNULL_BEGIN
    
    typedef NS_ENUM(NSUInteger, ImageManagerError) {
        ImageManagerErrorInvalidURL,
        ImageManagerErrorNetworkError,
        ImageManagerErrorNotValidImage
    };
    
    @interface ImageManager : NSObject
    
    // if you make this singleton, mark normal instantiation methods as unavailable ...
    
    + (instancetype)alloc __attribute__((unavailable("alloc not available, call sharedImageManager instead")));
    - (instancetype)init __attribute__((unavailable("init not available, call sharedImageManager instead")));
    + (instancetype)new __attribute__((unavailable("new not available, call sharedImageManager instead")));
    - (instancetype)copy __attribute__((unavailable("copy not available, call sharedImageManager instead")));
    
    // ... and expose singleton access point
    
    @property (class, nonnull, readonly, strong) ImageManager *sharedImageManager;
    
    // provide fetch method
    
    - (NSURLSessionTask * _Nullable)fetchImageWithURLString:(NSString *)urlString completion:(void (^)(UIImage * _Nullable image, NSError * _Nullable error))completion;
    
    @end
    
    NS_ASSUME_NONNULL_END
    

    and then implement this singleton:

    //  ImageManager.m
    
    #import "ImageManager.h"
    
    @interface ImageManager()
    @property (nonatomic, strong) NSCache<NSString *, UIImage *> *imageCache;
    @end
    
    @implementation ImageManager
    
    + (instancetype)sharedImageManager {
        static dispatch_once_t onceToken;
        static ImageManager *shared;
        dispatch_once(&onceToken, ^{
            shared = [[self alloc] initPrivate];
        });
        return shared;
    }
    
    - (instancetype)initPrivate
    {
        self = [super init];
        if (self) {
            _imageCache = [[NSCache alloc] init];
        }
        return self;
    }
    
    - (NSURLSessionTask *)fetchImageWithURLString:(NSString *)urlString completion:(void (^)(UIImage *image, NSError *error))completion {
        UIImage *cachedImage = [self.imageCache objectForKey:urlString];
        if (cachedImage) {
            completion(cachedImage, nil);
            return nil;
        }
    
        NSURL *url = [NSURL URLWithString:urlString];
        if (!url) {
            NSError *error = [NSError errorWithDomain:[[NSBundle mainBundle] bundleIdentifier] code:ImageManagerErrorInvalidURL userInfo:nil];
            completion(nil, error);
            return nil;
        }
    
        NSURLSessionTask *task = [NSURLSession.sharedSession dataTaskWithURL:url completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
            if (error) {
                dispatch_async(dispatch_get_main_queue(), ^{
                    completion(nil, error);
                });
                return;
            }
    
            if (!data) {
                NSError *error = [NSError errorWithDomain:[[NSBundle mainBundle] bundleIdentifier] code:ImageManagerErrorNetworkError userInfo:nil];
                dispatch_async(dispatch_get_main_queue(), ^{
                    completion(nil, error);
                });
            }
    
            UIImage *image = [UIImage imageWithData:data];
            if (!image) {
                NSDictionary *userInfo = @{
                    @"data": data,
                    @"response": response ? response : [NSNull null]
                };
                NSError *error = [NSError errorWithDomain:[[NSBundle mainBundle] bundleIdentifier] code:ImageManagerErrorNotValidImage userInfo:userInfo];
                dispatch_async(dispatch_get_main_queue(), ^{
                    completion(nil, error);
                });
            }
    
            [self.imageCache setObject:image forKey:urlString];
            dispatch_async(dispatch_get_main_queue(), ^{
                completion(image, nil);
            });
        }];
    
        [task resume];
    
        return task;
    }
    
    @end
    

    And you'd call it like:

    [[ImageManager sharedImageManager] fetchImageWithURLString:urlString completion:^(UIImage * _Nullable image, NSError * _Nullable error) {
        if (error) {
            NSLog(@"%@", error);
            return;
        }
    
        // do something with `image` here ...
    }];
    
    // but not here, because the above runs asynchronously
    

    And, again, you could use this from within a UIImageView extension:

    #import <objc/runtime.h>
    
    @implementation UIImageView (Cache)
    
    - (void)setImage:(NSString *)urlString
    {
        NSURLSessionTask *oldTask = objc_getAssociatedObject(self, &taskKey);
        if (oldTask) {
            objc_setAssociatedObject(self, &taskKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
            [oldTask cancel];
        }
    
        image = nil
    
        objc_setAssociatedObject(self, &urlKey, urlString, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    
        NSURLSessionTask *task = [[ImageManager sharedImageManager] fetchImageWithURLString:urlString completion:^(UIImage * _Nullable image, NSError * _Nullable error) {
            NSString *currentURL = objc_getAssociatedObject(self, &urlKey);
            if ([currentURL isEqualToString:urlString]) {
                objc_setAssociatedObject(self, &urlKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
                objc_setAssociatedObject(self, &taskKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
            }
    
            if (image) {
                self.image = image;
            }
        }];
    
        objc_setAssociatedObject(self, &taskKey, task, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
    @end