Image loading/caching off the main thread

451 Views Asked by At

I am writing a custom image fetcher to fetch the images needed for my collection view. Below is my image fetcher logic

class ImageFetcher {

    /// Thread safe cache that stores `UIImage`s against corresponding URL's
    private var cache = Synchronised([URL: UIImage]())

    /// Inflight Requests holder which we can use to cancel the requests if needed
    /// Thread safe
    private var inFlightRequests = Synchronised([UUID: URLSessionDataTask]())
    
    
    func fetchImage(using url: URL, completion: @escaping (Result<UIImage, Error>) -> Void) -> UUID? {
        /// If the image is present in cache return it
        if let image = cache.value[url] {
            completion(.success(image))
        }
        
        let uuid = UUID()
        
        let dataTask = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            guard let self = self else { return }
            defer {
                self.inFlightRequests.value.removeValue(forKey:uuid )
            }
            
            if let data = data, let image = UIImage(data: data) {
                self.cache.value[url] = image
                
                DispatchQueue.main.async {
                    completion(.success(image))
                }
                return
            }
            
            guard let error = error else {
                // no error , no data
                // trigger some special error
                return
            }
            
            
            // Task cancelled do not send error code
            guard (error as NSError).code == NSURLErrorCancelled else {
                completion(.failure(error))
                return
            }
        }
        
        dataTask.resume()
        
        self.inFlightRequests.value[uuid] = dataTask
        
        return uuid
    }
    
    func cancelLoad(_ uuid: UUID) {
        self.inFlightRequests.value[uuid]?.cancel()
        self.inFlightRequests.value.removeValue(forKey: uuid)
    }
}

This is a block of code that provides the thread safety needed to access the cache

/// Use to make a struct thread safe
public class Synchronised<T> {
    private var _value: T
    
    private let queue = DispatchQueue(label: "com.sync", qos: .userInitiated, attributes: .concurrent)
    
    public init(_ value: T) {
        _value = value
    }
    
    public var value: T {
        get {
            return queue.sync { _value }
        }
        set { queue.async(flags: .barrier) { self._value = newValue }}
    }
}

I am not seeing the desired scroll performance and I anticipate that is because my main thread is getting blocked when I try to access the cache(queue.sync { _value }). I am calling the fetchImage method from the cellForRowAt method of the collectionView and I can't seem to find a way to dispatch it off the main thread because I would need the request's UUID so I would be able to cancel the request if needed. Any suggestions on how to get this off the main thread or are there any suggestions to architect this in a better way?

1

There are 1 best solutions below

4
On BEST ANSWER

I do not believe that your scroll performance is related to fetchImage. While there are modest performance issues in Synchronized, it likely is not enough to explain your issues. That having been said, there are several issue here, but blocking the main queue does not appear to be one of them.

The more likely culprit might be retrieving assets that are larger than the image view (e.g. large asset in small image view requires resizing which can block the main thread) or some mistake in the fetching logic. When you say “not seeing desired scroll performance”, is it stuttering or just slow? The nature of the “scroll performance” problem will dictate the solution.


A few unrelated observations:

  1. Synchronised, used with a dictionary, is not thread-safe. Yes, the getter and setter for value is synchronized, but not the subsequent manipulation of that dictionary. It is also very inefficient (though, not likely sufficiently inefficient to explain the problems you are having).

    I would suggest not synchronizing the retrieval and setting of the whole dictionary, but rather make a synchronized dictionary type:

    public class SynchronisedDictionary<Key: Hashable, Value> {
        private var _value: [Key: Value]
    
        private let queue = DispatchQueue(label: "com.sync", qos: .userInitiated, attributes: .concurrent)
    
        public init(_ value: [Key: Value] = [:]) {
            _value = value
        }
    
        // you don't need/want this
        //
        // public var value: [Key: Value] {
        //     get { queue.sync { _value } }
        //     set { queue.async(flags: .barrier) { self._value = newValue } }
        // }
    
        subscript(key: Key) -> Value? {
            get { queue.sync { _value[key] } }
            set { queue.async(flags: .barrier) { self._value[key] = newValue } }
        }
    
        var count: Int { queue.sync { _value.count } }
    }
    

    In my tests, in release build this was about 20 times faster. Plus it is thread-safe.

    But, the idea is that you should not expose the underlying dictionary, but rather just expose whatever interface you need for the synchronization type to manage the dictionary. You will likely want to add additional methods to the above (e.g. removeAll or whatever), but the above should be sufficient for your immediate purposes. And you should be able to do things like:

    var dictionary = SynchronizedDictionary<String, UIImage>()
    
    dictionary["foo"] = image
    imageView.image = dictionary["foo"]
    print(dictionary.count)
    

    Alternatively, you could just dispatch all updates to the dictionary to the main queue (see point 4 below), then you don't need this synchronized dictionary type at all.

  2. You might consider using NSCache, instead of your own dictionary, to hold the images. You want to make sure that you respond to memory pressure (emptying the cache) or some fixed total cost limit. Plus, NSCache is already thread-safe.

  3. In fetchImage, you have several paths of execution where you do not call the completion handler. As a matter of convention, you will want to ensure that the completion handler is always called. E.g. what if the caller started a spinner before fetching the image, and stopping it in the completion handler? If you might not call the completion handler, then the spinner might never stop, either.

  4. Similarly, where you do call the completion handler, you do not always dispatch it back to the main queue. I would either always dispatch back to the main queue (relieving the caller from having to do so) or just call the completion handler from the current queue, but only dispatching some of them to the main queue is an invitation for confusion.


FWIW, you can create Unit Tests target and demonstrate the difference between the original Synchronised and the SynchronisedDictionary, by testing a massively concurrent modification of the dictionary with concurrentPerform:

// this is not thread-safe if T is mutable

public class Synchronised<T> {
    private var _value: T

    private let queue = DispatchQueue(label: "com.sync", qos: .userInitiated, attributes: .concurrent)

    public init(_ value: T) {
        _value = value
    }

    public var value: T {
        get { queue.sync { _value } }
        set { queue.async(flags: .barrier) { self._value = newValue }}
    }
}

// this is thread-safe dictionary ... assuming `Value` is not mutable reference type

public class SynchronisedDictionary<Key: Hashable, Value> {
    private var _value: [Key: Value]

    private let queue = DispatchQueue(label: "com.sync", qos: .userInitiated, attributes: .concurrent)

    public init(_ value: [Key: Value] = [:]) {
        _value = value
    }

    subscript(key: Key) -> Value? {
        get { queue.sync { _value[key] } }
        set { queue.async(flags: .barrier) { self._value[key] = newValue } }
    }

    var count: Int { queue.sync { _value.count } }
}

class SynchronisedTests: XCTestCase {
    let iterations = 10_000

    func testSynchronised() throws {
        let dictionary = Synchronised([String: Int]())

        DispatchQueue.concurrentPerform(iterations: iterations) { i in
            let key = "\(i)"
            dictionary.value[key] = i
        }

        XCTAssertEqual(iterations, dictionary.value.count)  //  XCTAssertEqual failed: ("10000") is not equal to ("834")
    }

    func testSynchronisedDictionary() throws {
        let dictionary = SynchronisedDictionary<String, Int>()

        DispatchQueue.concurrentPerform(iterations: iterations) { i in
            let key = "\(i)"
            dictionary[key] = i
        }

        XCTAssertEqual(iterations, dictionary.count)        // success
    }
}