Загрузка/кэширование изображения вне основного потока

Я пишу пользовательский сборщик изображений для получения изображений, необходимых для моего представления коллекции. Ниже приведена логика моего сборщика изображений.

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)
    }
}

Это блок кода, который обеспечивает безопасность потоков, необходимую для доступа к кешу.

/// 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 }}
    }
}

Я не вижу желаемой производительности прокрутки, и я предполагаю, что это связано с тем, что мой основной поток блокируется, когда я пытаюсь получить доступ к кешу (queue.sync { _value }). Я вызываю метод fetchImage из метода cellForRowAt collectionView и не могу найти способ отправить его из основного потока, потому что мне понадобится UUID запроса, чтобы я мог отменить запрос, если это необходимо. Любые предложения о том, как убрать это из основного потока, или есть какие-либо предложения, чтобы лучше спроектировать это?


person Kiran    schedule 06.12.2020    source источник


Ответы (1)


Я не верю, что ваша производительность прокрутки связана с fetchImage. Хотя в Synchronized есть небольшие проблемы с производительностью, этого, вероятно, недостаточно, чтобы объяснить ваши проблемы. При этом здесь есть несколько проблем, но блокировка основной очереди, похоже, не является одной из них.

Более вероятным виновником может быть извлечение ресурсов, которые больше, чем представление изображения (например, большой ресурс в представлении маленького изображения требует изменения размера, что может заблокировать основной поток) или какая-то ошибка в логике выборки. Когда вы говорите «не вижу желаемой производительности прокрутки», это заикание или просто медленное? Природа проблемы «производительности прокрутки» будет диктовать решение.


Несколько несвязанных наблюдений:

  1. Synchronised, используемый со словарем, не является потокобезопасным. Да, геттер и сеттер для value синхронизируются, но не последующие манипуляции с этим словарем. Это также очень неэффективно (хотя вряд ли достаточно неэффективно, чтобы объяснить проблемы, которые у вас возникают).

    Я бы предложил не синхронизировать поиск и настройку всего словаря, а создать тип синхронизированного словаря:

    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 } }
    }
    

    В моих тестах в релизной сборке это было примерно в 20 раз быстрее. Кроме того, он потокобезопасен.

    Но идея состоит в том, что вы не должны открывать базовый словарь, а просто открывать любой интерфейс, необходимый для типа синхронизации для управления словарем. Вы, вероятно, захотите добавить дополнительные методы к вышеперечисленному (например, removeAll или что-то еще), но вышеперечисленного должно быть достаточно для ваших непосредственных целей. И вы должны уметь делать такие вещи, как:

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

    В качестве альтернативы вы можете просто отправить все обновления словаря в основную очередь (см. пункт 4 ниже), тогда вам вообще не нужен этот тип синхронизированного словаря.

  2. Вы можете рассмотреть возможность использования NSCache вместо собственного словаря для хранения изображений. Вы хотите убедиться, что вы реагируете на нехватку памяти (очистка кеша) или некоторый фиксированный предел общей стоимости. Кроме того, NSCache уже является потокобезопасным.

  3. В fetchImage у вас есть несколько путей выполнения, где вы не вызываете обработчик завершения. По соглашению вам нужно убедиться, что обработчик завершения всегда вызывается. Например. что, если вызывающая сторона запустит счетчик перед получением изображения и остановит его в обработчике завершения? Если вы не можете вызвать обработчик завершения, то счетчик также может никогда не остановиться.

  4. Точно так же, когда вы вызываете обработчик завершения, вы не всегда отправляете его обратно в основную очередь. Я бы либо всегда отправлял обратно в основную очередь (избавляя вызывающего от необходимости делать это), либо просто вызывал обработчик завершения из текущей очереди, но только отправка некоторых из них в основную очередь вызывает путаницу.


FWIW, вы можете создать цель модульных тестов и продемонстрировать разницу между исходным Synchronised и SynchronisedDictionary, протестировав массовую параллельную модификацию словаря с 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
    }
}
person Rob    schedule 07.12.2020
comment
Спасибо за ответ. Проблема, которую я видел, заключается в заикании, и, как вы подозревали, проблема действительно заключается в извлечении ресурсов, которые больше, чем размер представления изображения. Получение правильного размера актива устранило проблему. Да, я также рассматриваю возможность использования NSCache для кэширования. Однако я не понимаю, как Synchronised в сочетании со словарем не является потокобезопасным. - person Kiran; 08.12.2020
comment
Yes, the getter and setter for value is synchronized, but not the subsequent manipulation of that dictionary. Что вы подразумеваете под последующими манипуляциями со словарем и как «Синхронизированный словарь» решает проблему? - person Kiran; 08.12.2020
comment
В оригинале вы синхронизируете геттер и сеттер value, но вы манипулируете словарем (с индексом) вне механизма синхронизации. SynchronisationDictionary решает эту проблему, никогда не раскрывая базовый Swift Dictionary, а предоставляя свой собственный синхронизированный оператор индекса. Это также более эффективно, потому что типы значений Dictionary не передаются и не копируются. Короче говоря, при попытке синхронизировать изменяемые типы никогда не раскрывайте базовый объект, а вместо этого предоставляйте свои собственные методы/операторы для выполнения мутации. - person Rob; 08.12.2020
comment
Спасибо за объяснение @Rob. Ты лучший! - person Kiran; 08.12.2020