Skip to content
135 changes: 135 additions & 0 deletions DesignSystem/Sources/Image/AsyncImage+Cache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
//
// AsyncImage+Cache.swift
// DesignSystem
//
// Created by Wonji Suh on 12/16/25.
//

import SwiftUI

// MARK: - SwiftUI AsyncImage Replacement

/// ๊ธฐ์กด SwiftUI.AsyncImage๋ฅผ ์™„์ „ํžˆ ๋Œ€์ฒดํ•˜๋Š” typealias
/// ์‚ฌ์šฉ์ž๋Š” ์ „ํ˜€ ๋ชจ๋ฅด์ง€๋งŒ ์ž๋™์œผ๋กœ ์บ์‹ฑ๋จ
public typealias AsyncImage = DesignSystemAsyncImage

/// SwiftUI AsyncImage์™€ ๋™์ผํ•œ API๋ฅผ ์ œ๊ณตํ•˜๋Š” ํ–ฅ์ƒ๋œ AsyncImage
public struct DesignSystemAsyncImage<Content: View>: View {
private let url: URL?
private let scale: CGFloat
private let transaction: Transaction
private let content: (AsyncImagePhase) -> Content

@State private var loadedImage: UIImage?
@State private var isLoading: Bool = false
@State private var loadError: Error?

// MARK: - Initializers

public init(
url: URL?,
scale: CGFloat = 1,
transaction: Transaction = Transaction(),
@ViewBuilder content: @escaping (AsyncImagePhase) -> Content
) {
// ImageCacheService ์ดˆ๊ธฐํ™” ํ™•์ธ
_ = autoSetup

self.url = url
self.scale = scale
self.transaction = transaction
self.content = content
}

public var body: some View {
Group {
if let loadedImage {
content(.success(Image(uiImage: loadedImage)))
} else if isLoading {
content(.empty)
} else if loadError != nil {
content(.failure(loadError!))
} else {
content(.empty)
}
}
.task(id: url) {
await loadImage()
}
.transaction { transcation in
transcation.animation = transaction.animation
transcation.disablesAnimations = transaction.disablesAnimations
transcation.isContinuous = transaction.isContinuous
}
}

private func loadImage() async {
guard let url = url else {
await MainActor.run {
isLoading = false
loadedImage = nil
loadError = nil
}
return
}

await MainActor.run {
isLoading = true
loadError = nil
}

// ๐Ÿš€ ์ง์ ‘ ImageCacheService ์‚ฌ์šฉ์œผ๋กœ ๋น ๋ฅธ ๋กœ๋”ฉ!
let image = await ImageCacheService.shared.image(for: url)

await MainActor.run {
isLoading = false
loadedImage = image

if image == nil {
loadError = URLError(.resourceUnavailable)
}
}
}

private let autoSetup: Void = {
Task {
_ = ImageCacheService.shared
await TransparentImageCaching.activate()
}
}()
}

// MARK: - Convenience Initializers

extension DesignSystemAsyncImage where Content == Image {
public init(
url: URL?,
scale: CGFloat = 1
) {
self.init(
url: url,
scale: scale,
content: { phase in
phase.image ?? Image(systemName: "photo")
}
)
}
}

extension DesignSystemAsyncImage {
public init<I: View, P: View>(
url: URL?,
scale: CGFloat = 1,
transaction: Transaction = Transaction(),
@ViewBuilder content: @escaping (Image) -> I,
@ViewBuilder placeholder: @escaping () -> P
) where Content == _ConditionalContent<I, P> {
self.init(url: url, scale: scale, transaction: transaction) { phase in
if case .success(let image) = phase {
content(image)
} else {
placeholder()
}
}
}
}
184 changes: 184 additions & 0 deletions DesignSystem/Sources/Image/ImageCacheService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
//
// ImageCacheService.swift
// DesignSystem
//
// Created by Wonji Suh on 12/04/25.
//

import Foundation
import UIKit
import CryptoKit

public actor ImageCacheService {
public static let shared = ImageCacheService()

private let cache = NSCache<NSString, UIImage>()
private var inFlightTasks: [URL: Task<UIImage?, Never>] = [:]
private let session: URLSession
private let urlCache: URLCache
private let diskCacheURL: URL
private init() {
cache.totalCostLimit = 50 * 1024 * 1024
cache.countLimit = 200 // ๋” ๋งŽ์€ ์ด๋ฏธ์ง€๋ฅผ ๋ฉ”๋ชจ๋ฆฌ์— ์บ์‹œ
cache.evictsObjectsWithDiscardedContent = true // ๋ฉ”๋ชจ๋ฆฌ ์••๋ฐ• ์‹œ ์ž๋™ ์ œ๊ฑฐ

let configuration = URLSessionConfiguration.default
configuration.protocolClasses = [] // TransparentImageCaching์™€ ์ค‘์ฒฉ๋˜์ง€ ์•Š๋„๋ก ๋ถ„๋ฆฌ
configuration.requestCachePolicy = .returnCacheDataElseLoad
configuration.timeoutIntervalForRequest = 10
configuration.timeoutIntervalForResource = 20

let cachesDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory
let cacheDir = cachesDir.appendingPathComponent("ImageCacheService", isDirectory: true)
if !FileManager.default.fileExists(atPath: cacheDir.path) {
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
}
diskCacheURL = cacheDir

urlCache = URLCache(
memoryCapacity: 30 * 1024 * 1024,
diskCapacity: 120 * 1024 * 1024,
directory: cacheDir
)
configuration.urlCache = urlCache

session = URLSession(configuration: configuration)

}

public func image(
for url: URL
) async -> UIImage? {
let key = url.absoluteString as NSString

if let cached = cache.object(forKey: key) {
return cached
}

if let diskImage = loadDiskImage(for: url) {
cache.setObject(diskImage, forKey: key)
return diskImage
}

if let task = inFlightTasks[url] {
return await task.value
}

// ํ”„๋กœํ•„ ์ด๋ฏธ์ง€๋Š” ๋†’์€ ์šฐ์„ ์ˆœ์œ„๋กœ ์ฒ˜๋ฆฌ
let priority: TaskPriority = isProfileImage(url) ? .high : .userInitiated
let task = Task(priority: priority) { [weak self] () -> UIImage? in
guard let self else { return nil }
return await self.fetchAndCache(url: url, key: key)
}

inFlightTasks[url] = task

let image = await task.value
inFlightTasks[url] = nil
return image
}

public func image(
for urlString: String
) async -> UIImage? {
guard let url = URL(string: urlString) else { return nil }
return await image(for: url)
}

public func store(
_ image: UIImage,
for url: URL
) {
let key = url.absoluteString as NSString
cache.setObject(image, forKey: key)
}

public func removeImage(
for url: URL
) {
let key = url.absoluteString as NSString
cache.removeObject(forKey: key)
}

public func clear() {
cache.removeAllObjects()
try? FileManager.default.removeItem(at: diskCacheURL)
try? FileManager.default.createDirectory(at: diskCacheURL, withIntermediateDirectories: true)
urlCache.removeAllCachedResponses()
}

private func fetchAndCache(
url: URL,
key: NSString
) async -> UIImage? {
do {
let (data, response) = try await session.data(from: url)
guard
let httpResponse = response as? HTTPURLResponse,
200..<300 ~= httpResponse.statusCode
else { return nil }

guard let image = decodedImage(from: data) else { return nil }
cache.setObject(image, forKey: key, cost: data.count)
storeToDisk(data: data, for: url)
return image
} catch {
return nil
}
}

/// ํ”„๋กœํ•„ ์ด๋ฏธ์ง€์ธ์ง€ ํ™•์ธํ•˜์—ฌ ์šฐ์„ ์ˆœ์œ„ ์ ์šฉ
private func isProfileImage(_ url: URL) -> Bool {
let urlString = url.absoluteString.lowercased()
let profileKeywords = ["profile", "avatar", "user", "member"]

return profileKeywords.contains { keyword in
urlString.contains(keyword)
}
}

private func storeToDisk(data: Data, for url: URL) {
let fileURL = diskFileURL(for: url)
try? data.write(to: fileURL, options: .atomic)
}

private func loadDiskImage(for url: URL) -> UIImage? {
let fileURL = diskFileURL(for: url)
guard FileManager.default.fileExists(atPath: fileURL.path),
let data = try? Data(contentsOf: fileURL),
let image = decodedImage(from: data) else {
return nil
}
return image
}

private func diskFileURL(for url: URL) -> URL {
let hash = SHA256.hash(data: Data(url.absoluteString.utf8))
.map { String(format: "%02x", $0) }
.joined()
return diskCacheURL.appendingPathComponent(hash).appendingPathExtension("cache")
}

private func decodedImage(from data: Data) -> UIImage? {
guard let image = UIImage(data: data), let cgImage = image.cgImage else { return nil }

let size = CGSize(width: cgImage.width, height: cgImage.height)
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue

guard let context = CGContext(
data: nil,
width: Int(size.width),
height: Int(size.height),
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: bitmapInfo
) else { return image }

context.draw(cgImage, in: CGRect(origin: .zero, size: size))
guard let decodedImage = context.makeImage() else { return image }

return UIImage(cgImage: decodedImage, scale: image.scale, orientation: image.imageOrientation)
}
}
Loading