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 하는 시점에 이미지가 계속해서 로드되었다.

이미지 캐싱을 관리하기 위해 간단하게 싱글톤 객체를 만들고, 캐싱된 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를 맞아서 보여지게 된다.
