OperationQueue을 이용한 UICollectionView 성능 개선

UIImageView를 extension을 해서 UICollectionViewDelegate의 collectionView(_:cellForItemAt:)이 호출되는 시점에서 cell의 UIImageView에서 이미지를 비동기를 가져오는 작업을 수행(Cell이 이미지를 가져오는 작업을 수행)할 경우 다음과 같은 문제가 발생할 수 있습니다.

  1. 셀 재사용으로 인한 이미지 비동기 작업 중첩
  2. View가 이미지 작업에 관한 책임을 가지게 되는 문제

셀 재사용으로 인한 이미지 비동기 작업 중첩

이미지를 가져오는 작업이 완료되기 전에 셀의 재사용으로 인해 이미지를 가져오는 비동기 작업을 계속해서 수행하게 될 경우 셀의 이미지가 중첩된 이미지를 가져오는 비동기 작업이 완료되는 순서대로 바뀌는 문제입니다.

View가 이미지 작업에 관한 책임을 가지게 되는 문제

위 문제를 해결하기 위해, collectionView(_:didEndDisplaying)이 호출되는 경우에 이미지를 가져오는 작업을 cancel을 하려고 한다고 가정을 한다고 해봅니다. 현재 이미지를 가져오는 책임은 Cell에 있으니, 작업을 cancel 할 수 있는 dispatchWorkItem 또는 operation을 Cell에서 가지고 있어야 합니다. 

class CollectionViewCell: UICollectionViewCell {
    var fetchImageTask: DispatchWorkItem?
    
    func cancelFetchImageTask() {
        fetchImageTask?.cancel()
    }
}

func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
        guard let cell = cellcollectionView.dequeueReusableCell(withReuseIdentifier: CollectionViewCell.identifier, for: indexPath) as? CollectionViewCell else { fatalError() }
        cell.cancelFetchImageTask()
    }

이런 식으로 말이죠. 이렇게 될 경우 CollectionViewCell의 재사용성이 떨어질 뿐만 아니라, 추후에 작업과 관련된 코드가 Cell에 계속해서 추가가 될 가능성이 있습니다. 저는 UIView나 Cell에는 UI와 관련된 코드만 있는 것을 좋아해서 이러한 문제점을 해결하기 위해 이미지를 가지고 오는 기능을 분리방식으로 문제를 해결했습니다.

 

Operation 구현

작업을 cancel을 할 수 있게 하는 방식에는 여러 가지가 존재합니다. 대표적인 방법으로는 DispatchWorkItem 또는 Operation을 이용하여 이를 구현할 수 있습니다. 이번에는 Operation을 이용해서 구현했습니다. 비동기 Operation을 구현할 경우, start(), isAsynchronous, isExecuting, isFinished를 오버라이드를 해서 언제 끝나는지를 알려야 합니다.

import Foundation
import UIKit

class ImageFetchOperation: Operation {
    let imagePath: String
    let id: String
    private(set) var fetchedImage: UIImage?
    var loadingCompletion: ((Result<UIImage, Error>)-> Void)?
    
    init(imagePath: String, id: String, loadingCompletion: ((Result<UIImage, Error>)-> Void)?) {
        self.imagePath = imagePath
        self.id = id
        self.loadingCompletion = loadingCompletion
    }
    
    private var _excuting = false {
        willSet {
            willChangeValue(forKey: "isExecuting")
        }
        
        didSet {
            didChangeValue(forKey: "isExecuting")
        }
    }
    
    override var isExecuting: Bool {
        return _excuting
    }
    
    override var isAsynchronous: Bool {
        return true
    }
    
    private var _finished = false {
        willSet {
            willChangeValue(forKey: "isFinished")
        }
        
        didSet {
            didChangeValue(forKey: "isFinished")
        }
    }
    
    override var isFinished: Bool {
        return _finished
    }
    
    func finish() {
        self._finished = true
        self._excuting = false
    }
    
    override func start() {
        guard !isCancelled else { 
            finish()
            return
        }
        _finished = false
        _excuting = true
        main()
    }
    
    override func main() {
        guard !isCancelled else {
            finish()
            return
        }
        _finished = false
        _excuting = true
        fetchImage()
    }
    
    func fetchImage() {
        //이미지 가져오는 비동기 작업 코드
    }
}

Thread Safe Dictionary 구현

operationQueue에 Operation을 등록을 하면, Operation은 우리가 직접적으로 접근할 수 없게 됩니다. 따라서 Operation을 저장하고 접근하게 해 줄 객체가 필요합니다. Dictionary를 이용하면 O(1)로 원하는 operation에 빠르게 접근할 수 있습니다. Dictionary에 Operation을 추가하는 시점은 메인스레드에서 추가할 수 있지만, 이미지를 가져오는 작업이 완료되어 클로저를 실행하는 스레드는 백그라운드 스레드입니다. 이미지를 가져오는 작업이 완료된 경우 Dictionary에서 operation을 제거해야 하지만, Dictionary는 thread safe 하지 않으므로, 이를 위한 객체를 구현할 필요가 있습니다.

import Foundation

class ThreadSafeDictionary<T: Hashable, V>: Collection {
    
    private var dictionary: [T: V]
    private let dispatchQueue = DispatchQueue(label: "Thread Safe Dictionary", attributes: .concurrent)
    
    var startIndex: Dictionary<T, V>.Index {
        self.dispatchQueue.sync {
            return self.dictionary.startIndex
        }
    }
    
    var endIndex: Dictionary<T, V>.Index {
        self.dispatchQueue.sync {
            return self.dictionary.endIndex
        }
    }
    
    init(dictionary: [T:V] = [T:V]()) {
        self.dictionary = dictionary
    }
    
    subscript(key: T) -> V? {
        get {
            self.dispatchQueue.sync {
                return self.dictionary[key]
            }
        }
        
        set {
            self.dispatchQueue.async(flags: .barrier) {
                self.dictionary[key] = newValue
            }
        }
    }
    
    subscript(position: Dictionary<T, V>.Index) -> V? {
        get {
            self.dispatchQueue.sync {
                return self.dictionary[position].value
            }
        }
    }
    
    func index(after i: Dictionary<T, V>.Index) -> Dictionary<T, V>.Index {
        self.dispatchQueue.sync {
            return self.dictionary.endIndex
        }
    }
    
    func removeValue(forKey key: T) {
        self.dispatchQueue.async(flags: .barrier) {
            self.dictionary.removeValue(forKey: key)
        }
    }
}

 

Image Fetcher 구현

ImageFetcher는 OperationQueue와 NSCache 로직을 캡슐화 합니다. 

final class ImageFetcher {
    private let imageFetchingQueue = OperationQueue()
    private let imageCache = NSCache<NSString, UIImage>()
    private let loadingOperations: ThreadSafeDictionary<String, ImageFetchOperation> = .init()
    
    func fetchImage(id: String, imagePath: String, completion: ((Result<UIImage, Error>)-> Void)? = nil) {
        if let cachedImage = imageCache.object(forKey: id as NSString) {
            completion?(.success(cachedImage))
        } else {
            if let dataFetchOperation = loadingOperations[id] {
                if let fetchImage = dataFetchOperation.fetchedImage {
                    completion?(.success(fetchImage))
                    loadingOperations.removeValue(forKey: id)
                } else {
                    dataFetchOperation.loadingCompletion = completion
                }
            } else {
                registerNewFetchImageOperation(id: id, imagePath: imagePath, completion: completion)
            }
        }
    }
    
    private func registerNewFetchImageOperation(id: String, imagePath: String, completion: ((Result<UIImage, Error>)-> Void)?) {
        let operation = ImageFetchOperation(imagePath: imagePath, id: id) { result in
            switch result {
            case .success(let fetchImage):
                self.imageCache.setObject(fetchImage, forKey: id as NSString)
                completion?(.success(fetchImage))
                self.loadingOperations.removeValue(forKey: id)
            case .failure(let error):
                completion?(.failure(error))
                self.loadingOperations.removeValue(forKey: id)
            }
        }
        imageFetchingQueue.addOperation(operation)
        loadingOperations[id] = operation
    }
    
    func cancelFetchImage(id: String) {
        if let cancelOperation = loadingOperations[id] {
            cancelOperation.cancel()
            loadingOperations.removeValue(forKey: id)
        }
    }
}

 

이제 UICollectionViewDataSource와 Delegate에서 이벤트를 받아서 처리를 해줍니다. didEndDisplaying이 호출되면 해당 셀의 이미지를 가지고 오는 작업을 cancel 처리합니다. 

이미지를 가지고 오는 작업은 cellForItemAt이 호출되는 시점이 아닌, 셀이 화면에 나타나기 직전에 호출되는 willDisplay에서 호출되도록 합니다. 이렇게 하면 셀이 화면에 보일 경우에만 이미지를 가져오는 작업을 수행합니다.

func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
        imageFetcher.cancelFetch(product)
    }
    
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
        imageFetcher.fetchImage() { result in
			//셀 이미지 업데이트 코드
        }
    }

 

성능 비교

CollectionView의 셀을 계속해서 스크롤 해서 셀에서 이미지를 가져오는 작업을 했을 경우의 CPU 사용량입니다. 좌측 이미지가 이미지 작업을 cancel하지 않았을 경우, 우측이 이미지 작업을 cancel 했을 경우입니다. CPU의 사용량 차이와 스파이크의 차이가 나는 것을 볼 수 있습니다.