애플사이다의 iOS 개발 일지

[Kingfisher] 이미지 처리 라이브러리의 소스코드 뜯어보기 (1/2) 본문

비전공자용 노력/개발 툴

[Kingfisher] 이미지 처리 라이브러리의 소스코드 뜯어보기 (1/2)

Applecider 2022. 11. 5. 16:24

주니어 개발자 면접을 보면서 이미지 처리에 대한 질문을 꽤 많이 받았다.

앱 출시 프로젝트 때는 서버에서 자체적으로 적당한 크기의 이미지를 저장하도록 수작업..했었기 때문에 대응이 어려웠다..

-> 현업에서도 이미지를 서버에서 일괄 관리하는 게 이상적이라고 한다. 즉, 서버가 특정 화면에 맞는 크기의 이미지를 미리 알아뒀다가 보내주는 방식이다. 클라이언트 단의 로직을 최소화할 수 있고, 서비스 운영 측면에서 보다 안정적이다.

 

현업에서는 대용량 이미지 처리, 특히 캐싱을 위해 Kingfisher 라이브러리를 쓴다고 한다.

Kingfisher의 주요 기능은 URL을 넣으면 비동기로 이미지를 다운로드하고 캐싱 처리해주는 것이다.

 

이번 기회에 Kingfisher 소스코드를 뜯어보자.


공식문서로 Kingfisher 기능 이해하기

Kingfisher GitHub 페이지부터 읽어보자.

Swift 언어로 웹의 이미지를 다운로드 및 캐싱 (Caching)하는 데 사용하는 라이브러리라고 설명하고 있다.

Kingfisher - GitHub README

주요 기능

그 다음 Features 항목 중에 눈에 띄는 게 많았다. 유용할 듯..

  • 이미지 비동기 처리 (Asynchronous)
  • 이미지 processors & filters를 제공
  • 메모리 및 디스크 캐싱을 위한 Multiple-layer hybrid cache (?)
  • 캐싱 정밀 조정 (expiration date, size limit 설정 등)
  • 성능 향상을 위한 다운로드 취소 기능, 기존 데이터 재사용 (캐싱이니까 당연함..), 이미지 prefetching
  • 다운로딩, 캐싱, 이미지 처리 기능을 독립적으로 사용 가능
  • URL만 넣으면 이미지를 넣어주는 기능 (UIImageView, UIButton 등 익스텐션)
  • 이미지 셋팅 시 애니메이션 효과 (ex. fade-in) 
  • 이미지 로딩 시 custom placeholder, activity indicator 
  • Low data 모드 지원

기본 사용방법

Kingfisher 101 항목에서 친절하게 사용방법을 안내하고 있다.

아래처럼 매우 간단하다. Kingfisher의 UIImageView 익스텐션을 통해 URL만으로 이미지를 다운받아 나타낸다.

그리고 자동으로 메모리 및 디스크 캐싱이 된다.

import Kingfisher

let url = URL(string: "https://example.com/image.png")
imageView.kf.setImage(with: url)  // Kingfisher의 UIImageView 익스텐션을 통해 이미지가 적용됨

아래 예시를 보자. 사용방법이 아주 간단하다.

(이외에도 UIImageView 익스텐션 대신 KF builder를 활용하면 SwiftUI로 변환이 아주 쉽다.)

let url = URL(string: "https://example.com/high_resolution_image.png")
let processor = DownsamplingImageProcessor(size: imageView.bounds.size) // ✅ Downsamples it to match the image view size.
             |> RoundCornerImageProcessor(cornerRadius: 20) // Makes it round cornered with a given radius.
imageView.kf.indicatorType = .activity // ✅ Shows a system indicator while downloading.
imageView.kf.setImage(
    with: url,
    placeholder: UIImage(named: "placeholderImage"), // Shows a placeholder image while downloading.
    options: [
        .processor(processor),
        .scaleFactor(UIScreen.main.scale),
        .transition(.fade(1)), // When prepared, it animates the small thumbnail image with a "fade in" effect.
        .cacheOriginalImage // ✅ The original large image is also cached to disk for later use, to get rid of downloading it again in a detail view.
    ])
{
    // A console log is printed when the task finishes, either for success or failure.
    result in
    switch result {
    case .success(let value):
        print("Task done for: \(value.source.url?.absoluteString ?? "")")
    case .failure(let error):
        print("Job failed: \(error.localizedDescription)")
    }
}

자세한 내용은 본문에서 제공하는 Cheat Sheet를 참고해보자.


Kingfisher 소스코드 뜯어보기

라이브러리의 소스코드를 확인하려면 항상 Sources 폴더를 보면 된다.

1. Cache

먼저 Cache 부분을 뜯어보자.

소스코드만 봤는데 이해가 안 돼서.. Cheat Sheet의 Cache 부분을 먼저 살펴보자..

 

"URL" 자체가 cache key가 된다. (cache key를 바꾸고 싶다면 ImageResource를 사용해서 지정할 수 있다.)

cache type은 .memory, .disk, .none 세 가지가 있다.

이미지를 다운받을 때 processor를 사용했다면, processed 이미지가 캐싱된다. (setImage 메서드 매개변수로 processor를 전달)

 

메모리 관리를 위해 캐싱 삭제 정책이 있다. (NSCache 문서 내용과 매우 비슷하다!)

메모리 저장과 관련해서 totalCostLimit, countLimit을 설정할 수 있다.

default로 기기 전체 메모리의 25%를 메모리 캐시의 totalCostLimit으로 설정하고, countLimit은 없다.

디스크 저장과 관련해서 파일시스템의 저장공간인 sizeLimit을 설정할 수 있다.

 

메모리 및 디스크 캐싱 모두 expiration (만료 기간)을 설정 가능하다.

default로 메모리 캐시는 접근 이후 5분 뒤 만료되며, 디스크 캐시는 1주 뒤 만료된다.

(전체/일부 이미지에 대해 만료되지 않도록 설정하는 것도 가능하다.)

 

대략적인 Kingfisher의 Cache 기능에 대해 이해한 것 같다.. 이제 드디어 코드를 뜯어보자.

1) Cache - MemoryStorage

먼저 익숙한 MemoryStorage 폴더를 보자.

public enum MemoryStorage {
    public class Backend<T: CacheCostCalculable> {
        let storage = NSCache<NSString, StorageObject<T>>()
        // Keys trackes the objects once inside the storage.
        var keys = Set<String>()

        private var cleanTimer: Timer? = nil
        private let lock = NSLock()

        public var config: Config {
            didSet {
                storage.totalCostLimit = config.totalCostLimit
                storage.countLimit = config.countLimit
            }
        }

        /// Creates a `MemoryStorage` with a given `config`.
        public init(config: Config) {
            self.config = config
            storage.totalCostLimit = config.totalCostLimit
            storage.countLimit = config.countLimit

            cleanTimer = .scheduledTimer(withTimeInterval: config.cleanInterval, repeats: true) { [weak self] _ in
                guard let self = self else { return }
                self.removeExpired()
            }
        }
 // ...
  • 예상했듯이 storage 타입이 NSCache이다. 위에서 cache key를 URL String으로 쓰고 있기 때문에
    NSCache의 key 타입이 NSString으로 되어있다.
  • NSCache 밖에서 캐시 기능을 구현하고 있기 때문에 NSLock을 통해 thread-safe하게 처리를 해주고 있다.
  • init으로 전달하는 Config 타입을 보면 totalCostLimit, countLimit을 프로퍼티로 가진다.
        /// Removes the expired values from the storage.
        public func removeExpired() {
            lock.lock()
            defer { lock.unlock() }
            for key in keys {
                let nsKey = key as NSString
                guard let object = storage.object(forKey: nsKey) else {
                    keys.remove(key) // 유효하지 않은 key이므로 keys에서 제거함
                    continue
                }
                if object.isExpired { // 만료됐다면 storage에서 제거함
                    storage.removeObject(forKey: nsKey)
                    keys.remove(key)
                }
            }
        }
        
        /// Stores a value to the storage under the specified key and expiration policy.
        public func store(
            value: T,
            forKey key: String,
            expiration: StorageExpiration? = nil)
        {
            storeNoThrow(value: value, forKey: key, expiration: expiration)
        }
        
        func storeNoThrow(
            value: T,
            forKey key: String,
            expiration: StorageExpiration? = nil)
        {
            lock.lock()
            defer { lock.unlock() }
            let expiration = expiration ?? config.expiration
            // The expiration indicates that already expired, no need to store.
            guard !expiration.isExpired else { return }
            
            let object: StorageObject<T>
            if config.keepWhenEnteringBackground {
                object = BackgroundKeepingStorageObject(value, expiration: expiration)
            } else {
                object = StorageObject(value, expiration: expiration)
            }
            storage.setObject(object, forKey: key as NSString, cost: value.cacheCost) // 저장
            keys.insert(key)
        }
  • storage 및 keys에서 만료된 이미지를 삭제하고, 새로운 이미지를 저장하는 기능이다.
        /// Gets a value from the storage.
        public func value(forKey key: String, extendingExpiration: ExpirationExtending = .cacheTime) -> T? {
            guard let object = storage.object(forKey: key as NSString) else {
                return nil
            }
            if object.isExpired {
                return nil
            }
            object.extendExpiration(extendingExpiration)
            return object.value
        }

        /// Whether there is valid cached data under a given key.
        public func isCached(forKey key: String) -> Bool {
            guard let _ = value(forKey: key, extendingExpiration: .none) else {
                return false
            }
            return true
        }
  • value 메서드를 통해 메모리에 캐싱되어 있는 이미지를 얻는다.
    만료된 이미지라면 nil을 반환한다.
  • isCached 메서드를 통해 특정 URL 이미지의 캐싱 여부를 확인 가능하다.
  • 이외에도 Config 타입을 보면 default로 expiration은 .seconds(300) == 5분, keepWhenEnteringBackground는 false (앱이 백그라운드로 넘어가자마자 메모리 캐시를 삭제함), cleanInterval = 120 등 세부사항을 알 수 있다.

2) Cache - Storage

맨 마지막에 있던 StorageExpiration 타입이 궁금했는데, Cache > Storage 파일에 있다.

StorageExpiration 열거형에는 5개 case (never, seconds, days, date, expired)가 있다.

    func estimatedExpirationSince(_ date: Date) -> Date {
        switch self {
        case .never: return .distantFuture
        case .seconds(let seconds):
            return date.addingTimeInterval(seconds)
        case .days(let days):
            let duration: TimeInterval = TimeInterval(TimeConstants.secondsInOneDay) * TimeInterval(days)
            return date.addingTimeInterval(duration)
        case .date(let ref):
            return ref
        case .expired:
            return .distantPast
        }
    }
    
    var estimatedExpirationSinceNow: Date {
        return estimatedExpirationSince(Date())  // Date() == 현재 시각
    }
  • 현재로부터 예상 만료기간을 구하기 위해 위 코드가 작성됐다. 가독성 좋은 코드이다..

ExpirationExtending 열거형은 특정 이미지에 접근했을 때 expiration 기간을 연장시킬지 여부를 관리하기 위한 타입이다.

/// Represents the expiration extending strategy used in storage to after access.
public enum ExpirationExtending {
    /// The item expires after the original time, without extending after access.
    case none
    /// The item expiration extends by the original cache time after each access.
    case cacheTime
    /// The item expiration extends by the provided time after each access.
    case expirationTime(_ expiration: StorageExpiration)
}
  • none이면 해당 이미지에 접근했더라도 만료기한을 연장하지 않는다.

3) Cache - ImageCache

Cache > ImageCache 파일이다.

유효기한 만료, 최대저장용량 초과 등 캐시 삭제 정책에 의해 디스크 캐시가 비워졌을 때 아래의 Notification이 post된다.

extension Notification.Name {
    public static let KingfisherDidCleanDiskCache =
        Notification.Name("com.onevcat.Kingfisher.KingfisherDidCleanDiskCache")
}

CacheType에는 .none, .memory, .disk가 있다.

/// Represents a hybrid caching system which is composed by a `MemoryStorage.Backend` and a `DiskStorage.Backend`.
open class ImageCache {
    public static let `default` = ImageCache(name: "default")  // 싱글톤 사용
    
    public let memoryStorage: MemoryStorage.Backend<KFCrossPlatformImage>
    public let diskStorage: DiskStorage.Backend<Data>
    
    private let ioQueue: DispatchQueue  // 비동기 처리 목적
  • hybrid caching system이라길래 대단한 건 줄 알았는데 그냥 메모리 캐싱 + 디스크 캐싱 둘 다 된다는 뜻이었다..
  • 비동기 처리를 위해 DispatchQueue를 사용한다.

드디어 이미지 저장 기능을 하는 store 메서드...!를 보자.

    // MARK: Storing Images
    open func store(_ image: KFCrossPlatformImage,
                    original: Data? = nil,
                    forKey key: String,
                    options: KingfisherParsedOptionsInfo,
                    toDisk: Bool = true,
                    completionHandler: ((CacheStoreResult) -> Void)? = nil)
    {
        let identifier = options.processor.identifier
        let callbackQueue = options.callbackQueue
        
        let computedKey = key.computedKey(with: identifier)
        // Memory storage should not throw.
        memoryStorage.storeNoThrow(value: image, forKey: computedKey, expiration: options.memoryCacheExpiration)
        
        guard toDisk else {
            if let completionHandler = completionHandler {
                let result = CacheStoreResult(memoryCacheResult: .success(()), diskCacheResult: .success(()))
                callbackQueue.execute { completionHandler(result) }
            }
            return
        }
        
        ioQueue.async {
            let serializer = options.cacheSerializer
            if let data = serializer.data(with: image, original: original) {
                self.syncStoreToDisk(
                    data,
                    forKey: key,
                    processorIdentifier: identifier,
                    callbackQueue: callbackQueue,
                    expiration: options.diskCacheExpiration,
                    writeOptions: options.diskStoreWriteOptions,
                    completionHandler: completionHandler)
            } else {
                guard let completionHandler = completionHandler else { return }
                
                let diskError = KingfisherError.cacheError(
                    reason: .cannotSerializeImage(image: image, original: original, serializer: serializer))
                let result = CacheStoreResult(
                    memoryCacheResult: .success(()),
                    diskCacheResult: .failure(diskError))
                callbackQueue.execute { completionHandler(result) }
            }
        }
    }
  • 생각보다 별 게 없다..
  • 메모리 캐시에 저장 : memoryStorage.storeNoThrow 메서드를 통해 (Cache > MemoryStorage 파일 참고)
    특정 key에 대한 이미지 value를 expiration 옵션으로 저장한다.
  • 매개변수 toDisk가 true이면 completionHadler를 통해 뭔가를 실행하는데..
    completionHandler에 무슨 값이 올지 확인해봐야 할 것 같다.
    일단 CacheStoreResult에다가 메모리 캐시, 디스크 캐시 모두 success 했다고 보낸다.
  • 디스크 캐시에 저장 : cacheSerializer를 통해서 특정 이미지와 원본 이미지 데이터가 있으면,
    이 데이터를 디스크에다가 저장해서 동기화 (sync)하는 작업을 진행한다.
    데이터가 없으면 diskError를 생성해서 failure에 담고 다시 CacheStoreResult에 보낸다.
    (위에서 success라고 처리했는데, serialize 과정에서 문제가 생겼으니까 다시 failure로 바꿔주는 듯)
  • 이 라이브러리를 직접 써봐야 더 확실히 알 것 같다.

2. Processor

이미지 처리 기능은 어떤 게 있는지 보자. (Cheat Sheet 참고)

// Round corner
let processor = RoundCornerImageProcessor(cornerRadius: 20)

// Downsampling
let processor = DownsamplingImageProcessor(size: CGSize(width: 100, height: 100))

// Cropping
let processor = CroppingImageProcessor(size: CGSize(width: 100, height: 100), anchor: CGPoint(x: 0.5, y: 0.5))

// Blur
let processor = BlurImageProcessor(blurRadius: 5.0)
  • RoundCornerImageProcessor : 모서리 둥글게 처리
  • DownSamplingImageProcessor : 샘플이미지 다운
  • CroppingImageProcessor : 이미지 크롭
  • BlurImageProcessor : 블러 처리
  • 이외에도 overlay, tint color 등 여러 가지가 있음

README 예시에서 봤던 것처럼 DownSamplingImageProcessor를 통해

고해상도 이미지를 다운받을 때, 메모리에 로딩하기 전에 썸네일 이미지를 만들어서 UIView 크기에 맞게 사용하도록 한다.

이 기능이 정말 유용할 듯..

성능 효율을 위한 팁

  • CollectionView에서 빠르게 스크롤되면서 화면에서 사라지는 Cell의 이미지는 didEndDisplaying 메서드에서 다운로드 작업을 cancel 시킨다.

 

Cache 내용이 방대해서 포스트가 길어졌는데, 다음에 다른 기능에서 더 다뤄보면 좋을 것 같다.

 

- Reference

 

🍎 포스트가 도움이 되었다면, 공감🤍 / 구독🍹 / 공유🔗 / 댓글✏️ 로 응원해주세요. 감사합니다.

Comments