[iOS] Kingfisher 라이브러리, 캐싱

hiju·2020년 11월 27일
3

iOS

목록 보기
2/8

kingfisher이란?

캐싱을 이용하여 서버를 통해 이미지를 로드할 때, 자동으로 url을 판별하여 이미 접근한 경우에 또 서버에서 불러오지 않고 비동기적으로 캐시에서 저장된 데이터로 이미지를 가져온다. 캐싱이란 자주 쓰이는 메모리 영역(캐시)으로 데이터를 가져와서 접근하는 방식이다.

ios 개발을 하면서, image를 가져올 때 매번 서버와의 통신 작업을 하면 현저히 속도가 느리고 로드되는 시간이 길어졌다. 비용 문제도 ..

사진이 많이 쓰이는 앱을 개발하면서 어떻게 하면 사진을 빠르게, 여러개 불러올 수 있을까란 생각을 해봤던 것 같다. 나는 처음에, 파이어베이스 스토리지를 이용하여 url에 맞는 사진을 불러왔는데 이 속도가 엄청나게 느려서 내가 개발하면서도 유저들이 이걸 사용하면 백타 바로 앱을 지워버릴 만한 약점이었다고 생각한다. (물론, 이미지의 크기 자체에도 저장 및 불러오는 데에서 많은 바이트를 먹었기에 resizing도 필요했다.)

그래서 생각했던 게 kingfisher. 이 라이브러리는 캐싱을 이용하여 빠르게 이미지를 불러오는데, 훨씬 빠르기도 빠르기지만 피곤한 작업을 중복하여 하지 않아 유용했다. 물론, 직접적으로 url에 계속 접속하지 않다보니까, 스토리지의 대역폭도 완전 감소했다. (여기서 거의 많은 돈을 절약했다.)

일단 그렇다면 kingfisher는 어떻게 사용할까?

kingfisher 사용법

let url = URL(string: "urlstring")
imageView.kf.setImage(with: url)

// 출처 : https://github.com/onevcat/Kingfisher

가장 흔하고 간단하게 쓰이는 방법이다.
나도 UIImageView를 확장시켜 그 안에 func 함수로 넣어 image에 대한 캐싱처리를 했다.

나같은 경우는, 예를 들자면, 파이어베이스 스토리지를 사용해서 이미지만 따로 서버를 두었는데, 이 방식은 아래에 나와있는 코드처럼 구현했다.

extension UIImageView {
    func setImage(with urlString: String) {
        let cache = ImageCache.default
        cache.retrieveImage(forKey: urlString, options: nil) { (image, _) in
            if let image = image {
                self.image = image
            } else {
                let storage = Storage.storage()
                storage.reference(forURL: urlString).downloadURL { (url, error) in
                    if let error = error {
                        print("An error has occured: \(error.localizedDescription)")
                        return
                    }
                    guard let url = url else {
                        return
                    }
                    self.kf.setImage(with: url)
                }
            }
        }
    }
}

먼저, url값을 불러와서 캐시에 저장된 Image를 key값으로 가져오려고 했다. 만약 캐시 내 image가 있으면 그 image를 바로 불러올 수 있게 했고 아니라면, 내 firebase storage를 선언하여, 이 곳에서 url에 맞는 이미지를 불러오게 하였다. 그렇게 되면, 이 이미지는 캐시 내에 저장될 것이고, 나중에 또 image만을 키값으로 불러올 수 있겠다.

이렇게 kingfisher는 간단하고도 유용하게 쓸 수 있고, 많은 양의 데이터 처리 비용에 대한 부담을 현저하게 감소시켜준다. 참 고마웠는데, 나는 이 kingfisher를 어떤 방식에서 어떻게 쓰이는 가보다는,
어떤 코드를 사용해서 캐싱을 할 수 있었는가, 어떤 저장 방식을 이용하였고, 데이터는 캐시 내에 저장되었다가 언제 사라지는가에 대한 원론적인 의구심이 들었다.

그래서 한 번, 라이브러리의 코드를 몇 개, 필요한 것들을 위주로 definition을 찾아보았다.

kingfisher definition들

@discardableResult
    public func setImage(with resource: Resource?,
                         placeholder: Placeholder? = nil,
                         options: KingfisherOptionsInfo? = nil,
                         progressBlock: DownloadProgressBlock? = nil,
                         completionHandler: CompletionHandler? = nil) -> RetrieveImageTask
    {
        guard let resource = resource else {
            self.placeholder = placeholder
            setWebURL(nil)
            completionHandler?(nil, nil, .none, nil)
            return .empty
        }
        
        var options = KingfisherManager.shared.defaultOptions + (options ?? KingfisherEmptyOptionsInfo)
        let noImageOrPlaceholderSet = base.image == nil && self.placeholder == nil
        
        if !options.keepCurrentImageWhileLoading || noImageOrPlaceholderSet { // Always set placeholder while there is no image/placehoer yet.
            self.placeholder = placeholder
        }

        let maybeIndicator = indicator
        maybeIndicator?.startAnimatingView()
        
        setWebURL(resource.downloadURL)

        if base.shouldPreloadAllAnimation() {
            options.append(.preloadAllAnimationData)
        }
        
        let task = KingfisherManager.shared.retrieveImage(
            with: resource,
            options: options,
            progressBlock: { receivedSize, totalSize in
                guard resource.downloadURL == self.webURL else {
                    return
                }
                if let progressBlock = progressBlock {
                    progressBlock(receivedSize, totalSize)
                }
            },
            completionHandler: {[weak base] image, error, cacheType, imageURL in
                DispatchQueue.main.safeAsync {
                    maybeIndicator?.stopAnimatingView()
                    guard let strongBase = base, imageURL == self.webURL else {
                        completionHandler?(image, error, cacheType, imageURL)
                        return
                    }
                    
                    self.setImageTask(nil)
                    guard let image = image else {
                        completionHandler?(nil, error, cacheType, imageURL)
                        return
                    }
                    
                    guard let transitionItem = options.lastMatchIgnoringAssociatedValue(.transition(.none)),
                        case .transition(let transition) = transitionItem, ( options.forceTransition || cacheType == .none) else
                    {
                        self.placeholder = nil
                        strongBase.image = image
                        completionHandler?(image, error, cacheType, imageURL)
                        return
                    }
                    
                    #if !os(macOS)
                        UIView.transition(with: strongBase, duration: 0.0, options: [],
                                          animations: { maybeIndicator?.stopAnimatingView() },
                                          completion: { _ in

                                            self.placeholder = nil
                                            UIView.transition(with: strongBase, duration: transition.duration,
                                                              options: [transition.animationOptions, .allowUserInteraction],
                                                              animations: {
                                                                // Set image property in the animation.
                                                                transition.animations?(strongBase, image)
                                                              },
                                                              completion: { finished in
                                                                transition.completion?(finished)
                                                                completionHandler?(image, error, cacheType, imageURL)
                                                              })
                                          })
                    #endif
                }
            })
        
        setImageTask(task)
        
        return task
    }

이미지를 캐싱 처리하는 코드이다(kf.setimage()). 주석에는 파라미터들에 대해 이렇게 달려있다.

  • resource: 리소스 객체는cacheKeydownloadURL과 같은 정보를 포함한다.

  • placeholder: URL에서 이미지를 검색 할 때의 placeholder 이미지

  • options: 딕셔너리가 일부 동작을 제어 가능, 이 옵션은 따로 KingfisherOptionsInfo에서 참조 (블러 처리, 끝 둥근 이미지처리 등등 이미지를 입맛에 맞게 바꿀 수 있는 옵션을 추가해줌)

  • progressBlock: 이미지 다운로드 진행률이 업데이트될 때 호출

  • completeHandler: 이미지를 검색하고 설정할 때 호출

  • return: task는 작업한 프로세스를 나타냄

  • resourcenil이면placeholder 이미지가 설정되고
    completionHandlererrorimage가 모두nil 인 상태로 호출된다.

일단, setimage(url)을 보낼 때, url은 리소스 파라미터로 받아오는 것 같아, 리소스 중심으로 코드를 이해해봤다.

setWebURL(resource.downloadURL)

이 부분에서, 리소스의 downloadURL을 가지고 Setting 하는 것 같다.
downloadURL을 또 타고 들어가자면,

public protocol Resource {
    /// The key used in cache.
    var cacheKey: String { get }
    
    /// The target image URL.
    var downloadURL: URL { get }
}

public init(downloadURL: URL, cacheKey: String? = nil) {
        self.downloadURL = downloadURL
        self.cacheKey = cacheKey ?? downloadURL.absoluteString
    }

Resource는 하나의 프로토콜로, cacheKey와 downloadURL 프로퍼티를 가지고 있다.
이 리소스는 ImageResource 구조체에서 상속하고 있다. 아래 init 함수는 ImageResource에서의 초기환경 세팅 코드인 것 같다.
이를 이용하여 URL을 받아온 것을, setWebURL() 함수로 옮기게 되는데. 이 함수도 어떤 역할을 하는지 살펴보았다.

fileprivate func setWebURL(_ url: URL?) {
        objc_setAssociatedObject(base, &lastURLKey, url, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
    }

fileprivate 는 open, public, private, fileprivate, internal의 접근 제어자의 다섯 가지 분류 중 하나이다. File-private 접근을 사용하면 해당 정보가 전체 파일 안에서 사용하게 되면, 특정 기능의 구현 정보를 숨길 수 있게 된다.

아무튼, objc_setAssociatedObject라는 용어는 여기서 처음 접하게 되었는데.
궁금해서 또 들여다봤다.

public func objc_setAssociatedObject(_ object: Any, _ key: UnsafeRawPointer, _ value: Any?, _ policy: objc_AssociationPolicy)

Objective C 언어 관련 함수이다.
지정된 키 및 연관된 Policy를 사용하여 지정된 object에 대한 관련 값을 설정한다고 쓰여져 있다.
파라미터들을 하나하나 보면,

  • object: 연결의 원본 object
  • key: 연결을 위한 키
  • value: object의 키와 연결할 값. 기존 연결을 지우려면 nil을 전달.
  • policy: 연관 정책.

여기서, url은 세번째 파라미터인 value값에 들어간다.
첫번째 파라미터인 base는 Base이라는 제너릭 타입의 한 프로퍼티이다. (여기서는 ImageView 타입을 가지고 오는 것 같다.)
두번째 파라미터는 UnsafeRawPointer이라는 타입을 가졌다. 이 키(lastURLkey)를 가지고 value와 매칭시키나보다.
마지막은 정책 유형에 대한 프로퍼티를 넣은 것 같고.

키, 밸류 값을 지정한 뒤,
아래 코드들을 살펴보면,
RetrieveImageDiskTask라는 task가 눈에 띈다. 이는 이미지 검색 작업을 나타낸다고 한다.

ImageCache라는 class에서,
memory cache는 NSCache<NSString, AnyObject>()로 메모리가 나타내어진다.
디스크에 저장되는 캐시의 기간은 여기서 디폴트로, 1주의 기간을 잡아놓았다.

open var maxCachePeriodInSecond: TimeInterval = 60 * 60 * 24 * 7 //Cache exists for 1 week

여기서 만약, 이 변수가 음수라면, 절대 만료되지 않게끔 만들 수 있으니 참고하자.

maxMemoryCost 프로퍼티는 메모리 캐시의 최대 캐시 비용을 나타낸다. 총 비용은 메모리에 캐시 된 모든 이미지의 픽셀 수이며, 기본값은 무제한으로, 메모리 경고 알림을 받으면 메모리 캐시가 자동으로 제거된다고 나와있다.

디스크로는 fileManager을 활용했다.

아래는 디스크 및 메모리에 저장하고, 제거하는 코드의 전문이다.

// MARK: - Store & Remove

    /**
    Store an image to cache. It will be saved to both memory and disk. It is an async operation.
    
    - parameter image:             The image to be stored.
    - parameter original:          The original data of the image.
                                   Kingfisher will use it to check the format of the image and optimize cache size on disk.
                                   If `nil` is supplied, the image data will be saved as a normalized PNG file.
                                   It is strongly suggested to supply it whenever possible, to get a better performance and disk usage.
    - parameter key:               Key for the image.
    - parameter identifier:        The identifier of processor used. If you are using a processor for the image, pass the identifier of
                                   processor to it.
                                   This identifier will be used to generate a corresponding key for the combination of `key` and processor.
    - parameter toDisk:            Whether this image should be cached to disk or not. If false, the image will be only cached in memory.
    - parameter completionHandler: Called when store operation completes.
    */
    open func store(_ image: Image,
                      original: Data? = nil,
                      forKey key: String,
                      processorIdentifier identifier: String = "",
                      cacheSerializer serializer: CacheSerializer = DefaultCacheSerializer.default,
                      toDisk: Bool = true,
                      completionHandler: (() -> Void)? = nil)
    {
        
        let computedKey = key.computedKey(with: identifier)
        memoryCache.setObject(image, forKey: computedKey as NSString, cost: image.kf.imageCost)

        func callHandlerInMainQueue() {
            if let handler = completionHandler {
                DispatchQueue.main.async {
                    handler()
                }
            }
        }
        
        if toDisk {
            ioQueue.async {
                
                if let data = serializer.data(with: image, original: original) {
                    if !self.fileManager.fileExists(atPath: self.diskCachePath) {
                        do {
                            try self.fileManager.createDirectory(atPath: self.diskCachePath, withIntermediateDirectories: true, attributes: nil)
                        } catch _ {}
                    }
                    
                    self.fileManager.createFile(atPath: self.cachePath(forComputedKey: computedKey), contents: data, attributes: nil)
                }
                callHandlerInMainQueue()
            }
        } else {
            callHandlerInMainQueue()
        }
    }
    
    /**
    Remove the image for key for the cache. It will be opted out from both memory and disk. 
    It is an async operation.
    
    - parameter key:               Key for the image.
    - parameter identifier:        The identifier of processor used. If you are using a processor for the image, pass the identifier of processor to it.
                                   This identifier will be used to generate a corresponding key for the combination of `key` and processor.
    - parameter fromMemory:        Whether this image should be removed from memory or not. If false, the image won't be removed from memory.
    - parameter fromDisk:          Whether this image should be removed from disk or not. If false, the image won't be removed from disk.
    - parameter completionHandler: Called when removal operation completes.
    */
    open func removeImage(forKey key: String,
                          processorIdentifier identifier: String = "",
                          fromMemory: Bool = true,
                          fromDisk: Bool = true,
                          completionHandler: (() -> Void)? = nil)
    {
        let computedKey = key.computedKey(with: identifier)

        if fromMemory {
            memoryCache.removeObject(forKey: computedKey as NSString)
        }
        
        func callHandlerInMainQueue() {
            if let handler = completionHandler {
                DispatchQueue.main.async {
                    handler()
                }
            }
        }
        
        if fromDisk {
            ioQueue.async{
                do {
                    try self.fileManager.removeItem(atPath: self.cachePath(forComputedKey: computedKey))
                } catch _ {}
                callHandlerInMainQueue()
            }
        } else {
            callHandlerInMainQueue()
        }
    }

    // MARK: - Get data from cache

    /**
    Get an image for a key from memory or disk.
    
    - parameter key:               Key for the image.
    - parameter options:           Options of retrieving image. If you need to retrieve an image which was 
                                   stored with a specified `ImageProcessor`, pass the processor in the option too.
    - parameter completionHandler: Called when getting operation completes with image result and cached type of 
                                   this image. If there is no such key cached, the image will be `nil`.
    
    - returns: The retrieving task.
    */
    @discardableResult
    open func retrieveImage(forKey key: String,
                               options: KingfisherOptionsInfo?,
                     completionHandler: ((Image?, CacheType) -> Void)?) -> RetrieveImageDiskTask?
    {
        // No completion handler. Not start working and early return.
        guard let completionHandler = completionHandler else {
            return nil
        }
        
        var block: RetrieveImageDiskTask?
        let options = options ?? KingfisherEmptyOptionsInfo
        let imageModifier = options.imageModifier

        if let image = self.retrieveImageInMemoryCache(forKey: key, options: options) {
            options.callbackDispatchQueue.safeAsync {
                completionHandler(imageModifier.modify(image), .memory)
            }
        } else if options.fromMemoryCacheOrRefresh { // Only allows to get images from memory cache.
            options.callbackDispatchQueue.safeAsync {
                completionHandler(nil, .none)
            }
        } else {
            var sSelf: ImageCache! = self
            block = DispatchWorkItem(block: {
                // Begin to load image from disk
                if let image = sSelf.retrieveImageInDiskCache(forKey: key, options: options) {
                    if options.backgroundDecode {
                        sSelf.processQueue.async {

                            let result = image.kf.decoded
                            
                            sSelf.store(result,
                                        forKey: key,
                                        processorIdentifier: options.processor.identifier,
                                        cacheSerializer: options.cacheSerializer,
                                        toDisk: false,
                                        completionHandler: nil)
                            options.callbackDispatchQueue.safeAsync {
                                completionHandler(imageModifier.modify(result), .disk)
                                sSelf = nil
                            }
                        }
                    } else {
                        sSelf.store(image,
                                    forKey: key,
                                    processorIdentifier: options.processor.identifier,
                                    cacheSerializer: options.cacheSerializer,
                                    toDisk: false,
                                    completionHandler: nil
                        )
                        options.callbackDispatchQueue.safeAsync {
                            completionHandler(imageModifier.modify(image), .disk)
                            sSelf = nil
                        }
                    }
                } else {
                    // No image found from either memory or disk
                    options.callbackDispatchQueue.safeAsync {
                        completionHandler(nil, .none)
                        sSelf = nil
                    }
                }
            })
            
            sSelf.ioQueue.async(execute: block!)
        }
    
        return block
    }
    
    /**
    Get an image for a key from memory.
    
    - parameter key:     Key for the image.
    - parameter options: Options of retrieving image. If you need to retrieve an image which was 
                         stored with a specified `ImageProcessor`, pass the processor in the option too.
    - returns: The image object if it is cached, or `nil` if there is no such key in the cache.
    */
    open func retrieveImageInMemoryCache(forKey key: String, options: KingfisherOptionsInfo? = nil) -> Image? {
        
        let options = options ?? KingfisherEmptyOptionsInfo
        let computedKey = key.computedKey(with: options.processor.identifier)
        
        return memoryCache.object(forKey: computedKey as NSString) as? Image
    }
    
    /**
    Get an image for a key from disk.
    
    - parameter key:     Key for the image.
    - parameter options: Options of retrieving image. If you need to retrieve an image which was
                         stored with a specified `ImageProcessor`, pass the processor in the option too.

    - returns: The image object if it is cached, or `nil` if there is no such key in the cache.
    */
    open func retrieveImageInDiskCache(forKey key: String, options: KingfisherOptionsInfo? = nil) -> Image? {
        
        let options = options ?? KingfisherEmptyOptionsInfo
        let computedKey = key.computedKey(with: options.processor.identifier)
        
        return diskImage(forComputedKey: computedKey, serializer: options.cacheSerializer, options: options)
    }

비동기로 유지하며, 키를 대입하여, 데이터를 찾고 이미지를 추출하는 것 같다.
디스크에 저장하여 값을 가지고 있다가, timeinterval을 이용하여 저장된 url과 이미지가 더이상 쓰이지 않는다, 하면 메모리, 디스크에서 제거하고,
계속 그 이미지에 접속하면, 갱신되는 것 같다.
(공부하다가 대강 의미를 이해해보고 적는 것이므로, 맞지 않는 부분이 있을 수 있다.)

생각보다, kingfisher 라이브러리가 매우 친절하기도 하여, 네이밍도 깔끔해서 무엇을 말하고 있는지가 매우 명확했던 점이 좋았고, 많은 사람들이 쓸 수 있게 오픈소스화하여 개발에 도움을 주는 것도 좋았다. 그냥 setimage() 부분만 궁금해서 찾아봤던 건데, 동작하는 시스템이 많아 좀 더 공부해봐야 할 듯. 더 많이 분발해야 겠다.


추가

2022-01-24)
kingfisher Git에 가보면, 위키에 캐쉬에 관한 설명이 친절하게 써져있었다. 직역을 좀만 하자면,

let resource = ImageResource(downloadURL: url, cacheKey: "my_cache_key")
imageView.kf.setImage(with: resource)
  • 기본적으로 URL은 캐시 키에 대한 문자열을 만드는 데 사용된다. 네트워크 URL의 경우 absoluteString이 사용된다. 어떤 경우든 자신의 키로 ImageResource를 만들어 키를 변경할 수 있음!
  • 캐시에 있는 이미지를 찾기 위해 cacheKey를 이용한다.
// Limit memory cache size to 300 MB.
cache.memoryStorage.config.totalCostLimit = 300 * 1024 * 1024

// Limit memory cache to hold 150 images at most. 
cache.memoryStorage.config.countLimit = 150

// Limit disk cache size to 1 GB.
cache.diskStorage.config.sizeLimit =  = 1000 * 1024 * 1024
  • 또! 캐시 사이즈나 이미지의 개수를 제한둘 수 있다. 위 코드 처럼 ㅎ
// Memory image expires after 10 minutes.
cache.memoryStorage.config.expiration = .seconds(600)

// Disk image never expires.
cache.diskStorage.config.expiration = .never

메모리 저장소와 디스크 저장소 모두 기본 만료 설정이 있다. 메모리 저장소의 이미지는 마지막으로 액세스한 후 5분이 지나면 만료되지만 디스크 저장소의 이미지는 일주일이다. 위처럼 이 기간을 정할 수 있다.

뭐 이밖에도 많은 옵션들이 있으니 위키쪽 문서를 흘끔 보면 될 것 같기도 !

코드 출처(Cache)
Kingfisher Git 알아보기

직접 캐싱을 사용하여 만든 서비스 앱
온스 바로가기

profile
IOS 개발자

1개의 댓글

comment-user-thumbnail
2023년 7월 19일

글 잘 읽었습니다. ImageView extension에서 캐시 retrieve하는 로직이 있는데, 해당 부분을 제거해도 문제가 없는 걸까요?
Kingfisher 내부에 캐시 갱신 및 캐시 키 등록 로직이 있어서 중복되는 것 같아서요

답글 달기