AsyncImage + Cache

AsyncImage + Cache

 iOS 개발하면서 Kingfisher, SDWebImage 와 같은 라이브러리를 자주 사용하곤 한다.

 이 라이브러리들은 간편하게 이미지를 다운로드하고, 캐싱하는 기능을 제공하지만, iOS 15.0 이후로는 SwiftUI의 AsyncImage를 사용해 같은 기능을 서드파티 없이 구현할 수 있어서

 간단한 SwiftUI App을 만들고자 한다면 라이브러리 없이 AsyncImage를 사용하는 것이 효율적일 수 있다.

 AsyncImage는 URLSession.shared 를 사용하여 URL 이미지를 비동기로 로드하고 보여주는 View 이다.
 AsyncImage(
     url: URL(string: "urlString")
 ) { image in
     image
         .resizable()
 } placeholder: {
     ProgressView()
 }

 URL을 받고, PlaceHolder와 content 클로저를 제공한다. AsyncImage는 Image가 아닌 View 타입이기 때문에 resizable()를 사용할 수 없고 content 클로저 내 Image 인스턴스에 적용해야 한다.

 AsyncImage(url: URL(string: "urlString")) { phase in
     if let image = phase.image {
         image
     } else if phase.error != nil {
        errorView()
     } else {
        ProgressView()
     }
 }

또한 기본 생성자에서 위와 같이 커스텀하게 에러일 때 에러 이미지를 띄운다거나, 현재 로드 상태를 제어할 수 있다.

AsyncImage에서 이미지 캐싱 이용하기

AsyncImage는 URLsession.shared를 사용하기 때문에, 기본 캐시 정책을 사용해서 캐싱할 것 이라 생각했는데, 실제로 구현해보면 Redraw 하는 시점에 이미지가 계속해서 로드되었다.

URLSession.shared 인스턴스를 로드



이미지 캐싱을 관리하기 위해 간단하게 싱글톤 객체를 만들고, 캐싱된 image를 반환하고, 캐싱하는 메서드를 생성한다.


final class ImageCacheManager {
    static let shared = ImageCacheManager()
    private let cache = URLCache.shared
    
    private init() {}
    
    func cachedImage(for url: URL) -> UIImage? {
        if let cachedResponse = cache.cachedResponse(for: .init(url: url)) {
            return UIImage(data: cachedResponse.data)
        }
        return nil
    }
    
    func cacheImage(data: Data, response: URLResponse, for url: URL) {
        let cachedResponse = CachedURLResponse(response: response, data: data)
        cache.storeCachedResponse(cachedResponse, for: .init(url: url))
    }
}

그리고 AsyncImage를 매번 캐싱 되었는지 체크하는 코드의 중복을 막고자 래핑된 AsyncCachedImage View를 생성한다.

@MainActor
struct AsyncCachedImage<ImageView: View, PlaceholderView: View>: View {
    // Input dependencies
    var url: URL?
    @ViewBuilder var content: (Image) -> ImageView
    @ViewBuilder var placeholder: () -> PlaceholderView
    
    // Downloaded image
    @State private var image: UIImage? = nil
    
    init(
        url: URL?,
        @ViewBuilder content: @escaping (Image) -> ImageView,
        @ViewBuilder placeholder: @escaping () -> PlaceholderView
    ) {
        self.url = url
        self.content = content
        self.placeholder = placeholder
    }
    
    var body: some View {
        VStack {
            if let uiImage = image {
                content(Image(uiImage: uiImage))
            } else {
                placeholder()
                    .onAppear {
                        Task {
                            image = await downloadPhoto()
                        }
                    }
            }
        }
    }

    private func downloadPhoto() async -> UIImage? {
        do {
            guard let url else { return nil }
            
            if let cachedImage = ImageCacheManager.shared.cachedImage(for: url) {
                return cachedImage
            } else {
                let (data, response) = try await URLSession.shared.data(from: url)
                ImageCacheManager.shared.cacheImage(data: data, response: response, for: url)
                
                guard let image = UIImage(data: data) else {
                    return nil
                }
                
                return image
            }
        } catch {
            // Error 처리
            return nil
        }
    }
}

주요 포인트는 캐싱되었는지 체크하는 downloadPhoto()를 async 함수로 만들고, 캐싱 되어있으면 이미지를 반환, 캐싱되어있지 않다면 네트워크 통신한 이후, 캐싱처리한다.

먼저 placeHolder ViewBuilder로 들어온 값이 onappear() 되면, 캐싱되었는지 체크해서 image에 값이 들어오게되고, @state 변수에 값이 들어가기 때문에 View가 redraw를 맞아서 보여지게 된다.