diff --git a/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/FlexibleImageView.swift b/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/FlexibleImageView.swift index 1cec2088..68d18dc5 100644 --- a/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/FlexibleImageView.swift +++ b/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/FlexibleImageView.swift @@ -96,8 +96,7 @@ extension FlexibleImageView { } let options = NeoImageOptions( - transition: .fade(0.2), - retryStrategy: .times(3) + transition: .fade(0.2) ) neo.setImage( diff --git a/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/HeightFixedImageView.swift b/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/HeightFixedImageView.swift index 5169bcd5..2fb840a7 100644 --- a/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/HeightFixedImageView.swift +++ b/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/HeightFixedImageView.swift @@ -111,8 +111,7 @@ extension HeightFixedImageView { } let options = NeoImageOptions( - transition: .fade(0.2), - retryStrategy: .times(3) + transition: .fade(0.2) ) neo.setImage( diff --git a/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/WidthFixedImageView.swift b/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/WidthFixedImageView.swift index f8e43194..50b21fa6 100644 --- a/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/WidthFixedImageView.swift +++ b/BookKitty/BookKitty/DesignSystem/Sources/DesignSystem/Source/Component/UIImageView/WidthFixedImageView.swift @@ -109,8 +109,7 @@ extension WidthFixedImageView { } let options = NeoImageOptions( - transition: .fade(0.2), - retryStrategy: .times(3) + transition: .fade(0.2) ) neo.setImage( diff --git a/BookKitty/BookKitty/NeoImage/Package.swift b/BookKitty/BookKitty/NeoImage/Package.swift index 71cfcaaa..cdab2da5 100644 --- a/BookKitty/BookKitty/NeoImage/Package.swift +++ b/BookKitty/BookKitty/NeoImage/Package.swift @@ -8,17 +8,23 @@ let package = Package( platforms: [.iOS(.v16)], products: [ .library( - name: "NeoImage", - targets: ["NeoImage"] + name: "NeoImage", targets: ["NeoImage"] ), ], + dependencies: [ + .package(url: "https://github.com/onevcat/Kingfisher", from: "8.3.0"), + ], targets: [ .target( - name: "NeoImage" + name: "NeoImage", + path: "Sources" ), .testTarget( - name: "NeoImageTests", - dependencies: ["NeoImage"] + name: "ImageViewExtensionTests", + dependencies: [ + "NeoImage", + .product(name: "Kingfisher", package: "Kingfisher"), + ] ), ] ) diff --git a/BookKitty/BookKitty/NeoImage/Sources/Cache/DiskStorage.swift b/BookKitty/BookKitty/NeoImage/Sources/Cache/DiskStorage.swift new file mode 100644 index 00000000..74681e2b --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/Cache/DiskStorage.swift @@ -0,0 +1,332 @@ +import Foundation + +public actor DiskStorage { + // MARK: - Properties + + var maybeCached: Set? + + private let fileManager: FileManager + private let directoryURL: URL + private var storageReady = true + + // MARK: - Lifecycle + + init( + fileManager: FileManager + ) { + self.fileManager = fileManager + let url = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0] + + directoryURL = url.appendingPathComponent( + "com.neon.NeoImage.ImageCache.default", + isDirectory: true + ) + + Task { + await setupCacheChecking() + try? await prepareDirectory() + } + } + + // MARK: - Functions + + func store(value: T, for hashedKey: String) async throws { + guard storageReady else { + throw NeoImageError.cacheError(reason: .storageNotReady) + } + + let expiration = hashedKey.hasPrefix("priority_") ? NeoImageConstants + .expirationForPriority : NeoImageConstants.expiration + + guard !expiration.isExpired else { + return + } + + guard let data = try? value.toData() else { + throw NeoImageError.cacheError(reason: .invalidData) + } + + let fileURL = cacheFileURL(for: hashedKey) + + // Foundation 내부 Data 타입의 내장 메서드입니다. + // 해당 위치로 data 내부 컨텐츠를 write 합니다. + try data.write(to: fileURL) + + // FileManager를 통해 파일 작성 시 전달해줄 파일의 속성입니다. + // 생성된 날짜, 수정된 일자를 실제 수정된 시간이 아닌, 만료 예정 시간을 저장하는 용도로 재활용합니다. + // 실제로, 파일 시스템의 기본속성을 활용하기에 추가적인 저장공간이 필요 없음 + // 파일과 만료 정보가 항상 동기화되어 있음 (파일이 삭제되면 만료 정보도 자동으로 삭제) + let attributes: [FileAttributeKey: Sendable] = [ + .creationDate: Date(), + .modificationDate: expiration.estimatedExpirationSinceNow, + ] + + // 파일의 메타데이터가 업데이트됨 + // 이는 디스크에 대한 I/O 작업을 수반 + do { + try fileManager.setAttributes(attributes, ofItemAtPath: fileURL.path) + } catch { + try? fileManager.removeItem(at: fileURL) + + throw NeoImageError.cacheError( + reason: .cannotSetCacheFileAttribute( + path: fileURL.path, + attribute: attributes + ) + ) + } + + maybeCached?.insert(fileURL.lastPathComponent) + } + + func value( + for hashedKey: String, + actuallyLoad: Bool = true, + extendingExpiration: ExpirationExtending = .cacheTime + ) async throws -> T? { + guard storageReady else { + throw NeoImageError.cacheError(reason: .storageNotReady) + } + + let fileURL = cacheFileURL(for: hashedKey) + let filePath = fileURL.path + guard maybeCached?.contains(fileURL.lastPathComponent) ?? true else { + return nil + } + + guard fileManager.fileExists(atPath: filePath) else { + return nil + } + + if !actuallyLoad { + return T.empty + } + + let data = try Data(contentsOf: fileURL) + let obj = try T.fromData(data) + + if extendingExpiration != .none { + let expirationDate: Date + switch extendingExpiration { + case .none: + return obj + case .cacheTime: + expirationDate = NeoImageConstants.expiration.estimatedExpirationSinceNow + case let .expirationTime(storageExpiration): + expirationDate = storageExpiration.estimatedExpirationSinceNow + } + + let attributes: [FileAttributeKey: Any] = [ + .creationDate: Date(), + .modificationDate: expirationDate, + ] + + try? FileManager.default.setAttributes(attributes, ofItemAtPath: fileURL.path) + } + + return obj + } + + /// 특정 키에 해당하는 파일을 삭제하는 메서드 + func remove(for hashedKey: String) async throws { + let fileURL = cacheFileURL(for: hashedKey) + try fileManager.removeItem(at: fileURL) + } + + /// 디렉토리 내의 모든 파일을 삭제하는 메서드 + func removeAll() async throws { + try fileManager.removeItem(at: directoryURL) + try prepareDirectory() + } + + func isCached(for hashedKey: String) async -> Bool { + do { + let result = try await value( + for: hashedKey, + actuallyLoad: false + ) + + return result != nil + } catch { + return false + } + } + + func removeExpiredValues() throws -> [URL] { + try removeExpiredValues(referenceDate: Date()) + } +} + +extension DiskStorage { + private func cacheFileURL(for hashedKey: String) -> URL { + directoryURL.appendingPathComponent(hashedKey, isDirectory: false) + } + + // MARK: - 만료기간 종료 여부 파악 관련 메서드들 + + func removeExpiredValues(referenceDate: Date) throws -> [URL] { + let propertyKeys: [URLResourceKey] = [ + .isDirectoryKey, + .contentModificationDateKey, + ] + + let urls = try allFileURLs(for: propertyKeys) + let keys = Set(propertyKeys) + let expiredFiles = urls.filter { fileURL in + do { + let meta = try FileMeta(fileURL: fileURL, resourceKeys: keys) + return meta.expired(referenceDate: referenceDate) + } catch { + return true + } + } + + try expiredFiles.forEach { url in + try fileManager.removeItem(at: url) + } + return expiredFiles + } + + private func allFileURLs(for propertyKeys: [URLResourceKey]) throws -> [URL] { + guard let directoryEnumerator = fileManager.enumerator( + at: directoryURL, includingPropertiesForKeys: propertyKeys, options: .skipsHiddenFiles + ), + let urls = directoryEnumerator.allObjects as? [URL] else { + throw NeoImageError.cacheError(reason: .storageNotReady) + } + + return urls + } + + private func setupCacheChecking() { + do { + maybeCached = Set() + try fileManager.contentsOfDirectory(atPath: directoryURL.path).forEach { + fileName in + self.maybeCached?.insert(fileName) + } + } catch { + maybeCached = nil + } + } + + private func prepareDirectory() throws { + // config에 custom fileManager를 주입할 수 있기 때문에, 여기서 .default를 접근하지 않고 Config 내부 fileManager를 + // 접근합니다. + let path = directoryURL.path + + // Creation 구조체를 통해 생성된 url이 FileSystem에 존재하는지 검증 + guard !fileManager.fileExists(atPath: path) else { + return + } + + do { + try fileManager.createDirectory( + atPath: path, + withIntermediateDirectories: true, + attributes: nil + ) + } catch { + // 만일 디렉토리 생성이 실패할경우, storageReady를 false로 변경합니다. + // 이는 추후 flag로 동작합니다. + print("error creating New Directory") + storageReady = false + + throw NeoImageError.cacheError(reason: .cannotCreateDirectory(error: error)) + } + } + + func preloadPriorityToMemory() async { + do { + print(directoryURL) + let fileURLs = try allFileURLs(for: [.isRegularFileKey, .nameKey]) + let prefixedFiles = fileURLs.filter { url in + let fileName = url.lastPathComponent + return fileName.hasPrefix("priority_") + } + + for fileURL in prefixedFiles { + let hashedKey = fileURL.lastPathComponent + + if let data = try? Data(contentsOf: fileURL) { + await ImageCache.shared.memoryStorage.store(value: data, for: hashedKey) + } + } + + NeoLogger.shared.debug("\(prefixedFiles.count)개의 우선순위 이미지 메모리 프리로드 완료") + } catch { + print("메모리 프리로드 중 오류 발생: \(error)") + } + } +} + +extension DiskStorage { + struct FileMeta { + // MARK: - Properties + + let url: URL + let lastAccessDate: Date? + let estimatedExpirationDate: Date? + + // MARK: - Lifecycle + + init(fileURL: URL, resourceKeys: Set) throws { + let meta = try fileURL.resourceValues(forKeys: resourceKeys) + self.init( + fileURL: fileURL, + lastAccessDate: meta.creationDate, + estimatedExpirationDate: meta.contentModificationDate + ) + } + + init( + fileURL: URL, + lastAccessDate: Date?, + estimatedExpirationDate: Date? + ) { + url = fileURL + self.lastAccessDate = lastAccessDate + self.estimatedExpirationDate = estimatedExpirationDate + } + + // MARK: - Functions + + func expired(referenceDate: Date) -> Bool { + estimatedExpirationDate?.isPast(referenceDate: referenceDate) ?? true + } + + func extendExpiration( + with fileManager: FileManager, + extendingExpiration: ExpirationExtending + ) { + guard let lastAccessDate, + let lastEstimatedExpiration = estimatedExpirationDate else { + return + } + + let attributes: [FileAttributeKey: Any] + + switch extendingExpiration { + case .none: + // not extending expiration time here + return + case .cacheTime: + let originalExpiration = + StorageExpiration + .seconds(lastEstimatedExpiration.timeIntervalSince(lastAccessDate)) + attributes = [ + .creationDate: Date().fileAttributeDate, + .modificationDate: originalExpiration.estimatedExpirationSinceNow + .fileAttributeDate, + ] + case let .expirationTime(expirationTime): + attributes = [ + .creationDate: Date().fileAttributeDate, + .modificationDate: expirationTime.estimatedExpirationSinceNow.fileAttributeDate, + ] + } + + try? fileManager.setAttributes(attributes, ofItemAtPath: url.path) + } + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/Cache/ImageCache.swift b/BookKitty/BookKitty/NeoImage/Sources/Cache/ImageCache.swift new file mode 100644 index 00000000..51cf7ed6 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/Cache/ImageCache.swift @@ -0,0 +1,190 @@ +import Foundation +import UIKit + +/// 쓰기 제어와 같은 동시성이 필요한 부분만 선택적으로 제어하기 위해 전체 ImageCache를 actor로 변경하지 않고, ImageCacheActor 생성 +/// actor를 사용하면 모든 동작이 actor의 실행큐를 통과해야하기 때문에, 동시성 보호가 불필요한 read-only 동작도 직렬화되며 오버헤드가 발생 +public final class ImageCache: Sendable { + // MARK: - Static Properties + + public static let shared = ImageCache(name: "default") + + // MARK: - Properties + + public let memoryStorage: MemoryStorage + public let diskStorage: DiskStorage + + // MARK: - Lifecycle + + public init( + name: String + ) { + if name.isEmpty { + fatalError( + "You should specify a name for the cache. A cache with empty name is not permitted." + ) + } + + let totalMemory = ProcessInfo.processInfo.physicalMemory + let memoryLimit = totalMemory / 4 + + memoryStorage = MemoryStorage( + totalCostLimit: min(Int.max, Int(memoryLimit)) + ) + + diskStorage = DiskStorage(fileManager: .default) + + NeoLogger.shared.debug("initialized") + + Task { @MainActor in + let notifications: [(Notification.Name, Selector)] + notifications = [ + (UIApplication.didReceiveMemoryWarningNotification, #selector(clearMemoryCache)), + (UIApplication.willTerminateNotification, #selector(cleanExpiredDiskCache)), + ] + + for notification in notifications { + NotificationCenter.default.addObserver( + self, + selector: notification.1, + name: notification.0, + object: nil + ) + } + } + + Task { + await diskStorage.preloadPriorityToMemory() + } + } + + // MARK: - Functions + + /// 메모리와 디스크 캐시에 모두 데이터를 저장합니다. + public func store( + _ data: Data, + for hashedKey: String + ) async throws { + await memoryStorage.store(value: data, for: hashedKey) + + let isPriority = hashedKey.hasPrefix("priority_") + + // 우선순위 여부로 같은 데이터가 디스크 캐시에 동시에 존재할 가능성이 있습니다. + // 콜백이 불필요하며 글로벌 스레드에서 전적으로 실행되는 store에서 디스크 캐시에 대한 io작업을 최대한 수행토록하여 우선순위가 엇갈리는 동일한 데이터 유무를 + // 검토하고 제거하는 추가 로직을 구현했습니다. + // 또한 일반 저장 시, 우선순위 적용 키가 캐싱되어있으면 중복 저장을 하지 않는 등의 엣지케이스도 고려했습니다. + if isPriority { + let originalKey = hashedKey.replacingOccurrences(of: "priority_", with: "") + + if await diskStorage.isCached(for: originalKey) { + try await diskStorage.remove(for: originalKey) + NeoLogger.shared.debug("원본 이미지 제거: \(originalKey)") + } + } else { + if await diskStorage.isCached(for: "priority_" + hashedKey) { + NeoLogger.shared.debug("우선순위 이미지가 존재하여 원본 저장 건너뜀: \(hashedKey)") + return + } + } + + try await diskStorage.store(value: data, for: hashedKey) + } + + public func retrieveImage(hashedKey: String) async throws -> Data? { + let isPriority = hashedKey.hasPrefix("priority_") + let otherKey: String + if isPriority { + otherKey = hashedKey.replacingOccurrences(of: "priority_", with: "") + } else { + otherKey = "priority_" + hashedKey + } + + // 우선순위 키로 요청했는데, 일반 키로 저장되어있을때 -> 필요 + // 우선순위 키로 요청했는데, 우선순위 키로 저장되어있을때 -> 동기화 잘 되어있음 + // 일반 키로 요청했는데, 우선순위 키로 있을때 -> 다른 상황에서는 우선순위로 접근할 가능성 있음, 보류 + // 일반 키로 요청했는데, 일반 키로 있을때, -> 동기화 잘 되어있음 + + if let memoryData = await memoryStorage.value(forKey: hashedKey) { + return memoryData + } + + if let memoryDataForOtherKey = await memoryStorage.value(forKey: otherKey) { + if isPriority { + changeDiskDirectoryToPriority(otherKey, memoryDataForOtherKey) + } + + return memoryDataForOtherKey + } + + if let diskData = try await diskStorage.value(for: hashedKey) { + await memoryStorage.store(value: diskData, for: hashedKey) + + return diskData + } + + if let diskDataForOtherKey = try await diskStorage.value(for: otherKey) { + if isPriority { + changeDiskDirectoryToPriority(otherKey, diskDataForOtherKey) + } + + await memoryStorage.store( + value: diskDataForOtherKey, + for: hashedKey + ) + + return diskDataForOtherKey + } + + return nil + } + + /// 메모리와 디스크 모두에 존재하는 모든 데이터를 제거합니다. + public func clearCache() { + Task { + do { + await memoryStorage.removeAll() + + try await diskStorage.removeAll() + } catch { + NeoLogger.shared.error("diskStorage clear failed") + } + } + } + + @objc + public func clearMemoryCache(keepPriorityImages: Bool = true) { + Task { + if keepPriorityImages { + // 우선순위 이미지를 유지하는 전략 + await memoryStorage.removeAllExceptPriority() + } else { + // 모든 이미지 제거 + await memoryStorage.removeAll() + } + } + } + + func changeDiskDirectoryToPriority(_ originKey: String, _ value: Data) { + Task { + guard !originKey.hasPrefix("priority_"), + await diskStorage.isCached(for: originKey) + else { + return + } // 접두사가 없는 상태에서 disk에 원본 키가 없어야함. + try await diskStorage.store(value: value, for: "priority_" + originKey) + try await diskStorage.remove(for: originKey) + + NeoLogger.shared.debug("change diskStorage Directory Succeeded:\(Date())") + } + } + + @objc + func cleanExpiredDiskCache() { + Task { + do { + var removed: [URL] = [] + let removedExpired = try await self.diskStorage.removeExpiredValues() + removed.append(contentsOf: removedExpired) + } catch {} + } + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/Cache/MemoryStorage.swift b/BookKitty/BookKitty/NeoImage/Sources/Cache/MemoryStorage.swift new file mode 100644 index 00000000..cc316d6c --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/Cache/MemoryStorage.swift @@ -0,0 +1,165 @@ +import Foundation + +public actor MemoryStorage { + // MARK: - Properties + + var keys = Set() + + /// 캐시는 NSCache로 접근합니다. + private let storage = NSCache() + private let totalCostLimit: Int + + private var cleanTask: Task? + + // MARK: - Lifecycle + + init(totalCostLimit: Int) { + // 메모리가 사용할 수 있는 공간 상한선 (ImageCache 클래스에서 총 메모리공간의 1/4로 주입하고 있음) 데이터를 아래 private 속성에 주입시킵니다. + self.totalCostLimit = totalCostLimit + storage.totalCostLimit = totalCostLimit + + NeoLogger.shared.debug("initialized") + + Task { + await setupCleanTask() + } + } + + // MARK: - Functions + + public func removeExpired() { + for key in keys { + let nsKey = key as NSString + guard let object = storage.object(forKey: nsKey) else { + keys.remove(key) + continue + } + + if object.isExpired { + storage.removeObject(forKey: nsKey) + keys.remove(key) + } + } + } + + /// Removes all values in this storage. + public func removeAll() { + storage.removeAllObjects() + keys.removeAll() + } + + public func removeAllExceptPriority() { + let priorityKeys = keys.filter { $0.hasPrefix("priority_") } + + var priorityImagesData: [String: Data] = [:] + + for key in priorityKeys { + if let data = value(forKey: key) { + priorityImagesData[key] = data + } + } + + removeAll() + + for (key, data) in priorityImagesData { + store(value: data, for: key, expiration: .days(7)) + + let originalKey = key.replacingOccurrences(of: "priority_", with: "") + store(value: data, for: originalKey, expiration: .days(7)) + } + + NeoLogger.shared.info("메모리 캐시 정리 완료: 우선순위 이미지 \(priorityImagesData.count)개 유지") + } + + /// 캐시에 저장 + func store( + value: Data, + for hashedKey: String, + expiration: StorageExpiration? = nil + ) { + let expiration = expiration ?? NeoImageConstants.expiration + + guard !expiration.isExpired else { + return + } + + let object = StorageObject(value as Data, expiration: expiration) + + storage.setObject(object, forKey: hashedKey as NSString) + + keys.insert(hashedKey) + } + + /// 캐시에서 조회 + func value( + forKey hashedKey: String, + extendingExpiration: ExpirationExtending = .cacheTime + ) -> Data? { + guard let object = storage.object(forKey: hashedKey as NSString) else { + return nil + } + + if object.isExpired { + return nil + } + + object.extendExpiration(extendingExpiration) + return object.value + } + + private func setupCleanTask() { + // Timer 대신 Task로 주기적인 정리 작업 수행 + cleanTask = Task { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 120 * 1_000_000_000) + + // 취소 확인 + if Task.isCancelled { + break + } + + // 만료된 항목 제거 + removeExpired() + } + } + } +} + +extension MemoryStorage { + class StorageObject { + // MARK: - Properties + + var value: Data + let expiration: StorageExpiration + + private(set) var estimatedExpiration: Date + + // MARK: - Computed Properties + + var isExpired: Bool { + estimatedExpiration.isPast + } + + // MARK: - Lifecycle + + init(_ value: Data, expiration: StorageExpiration) { + self.value = value + self.expiration = expiration + + estimatedExpiration = expiration.estimatedExpirationSinceNow + } + + // MARK: - Functions + + func extendExpiration(_ extendingExpiration: ExpirationExtending = .cacheTime) { + switch extendingExpiration { + case .none: + return + case .cacheTime: + estimatedExpiration = expiration.estimatedExpirationSinceNow + case let .expirationTime(expirationTime): + estimatedExpiration = expirationTime.estimatedExpirationSinceNow + } + } + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/Constants/CacheError.swift b/BookKitty/BookKitty/NeoImage/Sources/Constants/CacheError.swift new file mode 100644 index 00000000..e0f980e5 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/Constants/CacheError.swift @@ -0,0 +1,76 @@ +import Foundation + +public enum NeoImageError: Error, Sendable { + case requestError(reason: RequestErrorReason) + + case responseError(reason: ResponseErrorReason) + + case cacheError(reason: CacheErrorReason) + + // MARK: - Nested Types + + public enum RequestErrorReason: Sendable { + case invalidURL + case taskCancelled + case invalidSessionTask + } + + public enum ResponseErrorReason: Sendable { + case networkError(description: String) + case cancelled + case invalidImageData + } + + public enum CacheErrorReason: Sendable { + case invalidData + + case storageNotReady + case fileNotFound(key: String) + + case cannotCreateDirectory(error: Error) + case cannotSetCacheFileAttribute(path: String, attribute: [FileAttributeKey: Sendable]) + } +} + +extension NeoImageError.RequestErrorReason { + var localizedDescription: String { + switch self { + case .invalidURL: + return "잘못된 URL" + case .taskCancelled: + return "작업이 취소됨" + case .invalidSessionTask: + return "SessionDataTask가 존재하지 않음" + } + } +} + +extension NeoImageError.ResponseErrorReason { + var localizedDescription: String { + switch self { + case let .networkError(description): + return "네트워크 에러: \(description)" + case .cancelled: + return "다운로드가 취소됨" + case .invalidImageData: + return "유효하지 않은 이미지 데이터" + } + } +} + +extension NeoImageError.CacheErrorReason { + var localizedDescription: String { + switch self { + case .invalidData: + return "유효하지 않은 데이터" + case .storageNotReady: + return "저장소가 준비되지 않음" + case let .fileNotFound(key): + return "파일을 찾을 수 없음: \(key)" + case let .cannotCreateDirectory(error): + return "디렉토리 생성 실패: \(error.localizedDescription)" + case let .cannotSetCacheFileAttribute(path, attributes): + return "캐시 파일 속성 변경 실패 - 경로:\(path), 속성:\(attributes.keys)" + } + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/ExpirationExtending.swift b/BookKitty/BookKitty/NeoImage/Sources/Constants/ExpirationExtending.swift similarity index 100% rename from BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/ExpirationExtending.swift rename to BookKitty/BookKitty/NeoImage/Sources/Constants/ExpirationExtending.swift diff --git a/BookKitty/BookKitty/NeoImage/Sources/Constants/NeoImageConstants.swift b/BookKitty/BookKitty/NeoImage/Sources/Constants/NeoImageConstants.swift new file mode 100644 index 00000000..04ed7013 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/Constants/NeoImageConstants.swift @@ -0,0 +1,8 @@ +public enum AssociatedKeys { + public nonisolated(unsafe) static var downloadTask = "com.neoimage.UIImageView.DownloadTask" +} + +public enum NeoImageConstants { + public static let expiration = StorageExpiration.days(7) + public static let expirationForPriority = StorageExpiration.days(3) +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/NeoImageOptions.swift b/BookKitty/BookKitty/NeoImage/Sources/Constants/NeoImageOptions.swift similarity index 58% rename from BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/NeoImageOptions.swift rename to BookKitty/BookKitty/NeoImage/Sources/Constants/NeoImageOptions.swift index c03f7c51..bd1113d6 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/NeoImageOptions.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Constants/NeoImageOptions.swift @@ -11,35 +11,26 @@ import UIKit public struct NeoImageOptions: Sendable { // MARK: - Properties - /// 이미지 프로세서 - public let processor: ImageProcessing? - - /// 이미지 전환 효과 public let transition: ImageTransition - /// 다시 시도 전략 - public let retryStrategy: RetryStrategy - /// 캐시 만료 정책 public let cacheExpiration: StorageExpiration + public var cancelOnDisappear = false // MARK: - Lifecycle public init( - processor: ImageProcessing? = nil, transition: ImageTransition = .none, - retryStrategy: RetryStrategy = .none, cacheExpiration: StorageExpiration = .days(7) ) { - self.processor = processor + NeoLogger.shared.debug("initialized") self.transition = transition - self.retryStrategy = retryStrategy self.cacheExpiration = cacheExpiration } } /// 이미지 전환 효과 열거형 -public enum ImageTransition: Sendable { +public enum ImageTransition: Sendable, Equatable { /// 전환 효과 없음 case none /// 페이드 인 효과 @@ -48,23 +39,10 @@ public enum ImageTransition: Sendable { case flip(TimeInterval) } -/// 재시도 전략 열거형 -public enum RetryStrategy: Sendable { - /// 재시도 하지 않음 - case none - /// 지정된 횟수만큼 재시도 - case times(Int) - /// 지정된 횟수와 대기 시간으로 재시도 - case timesWithDelay(times: Int, delay: TimeInterval) -} - extension NeoImageOptions { /// 기본 옵션 (프로세서 없음, 전환 효과 없음, 재시도 없음, 7일 캐시) public static let `default` = NeoImageOptions() /// 페이드 인 효과가 있는 옵션 public static let fade = NeoImageOptions(transition: .fade(0.3)) - - /// 재시도가 있는 옵션 - public static let retry = NeoImageOptions(retryStrategy: .times(3)) } diff --git a/BookKitty/BookKitty/NeoImage/Sources/Delegate.swift b/BookKitty/BookKitty/NeoImage/Sources/Delegate.swift new file mode 100644 index 00000000..9e5e0ae6 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/Delegate.swift @@ -0,0 +1,94 @@ +import Foundation + +public actor Delegate where Input: Sendable, Output: Sendable { + // MARK: - Properties + + private var block: ((Input) -> Output?)? + private var asyncBlock: ((Input) async -> Output?)? + + // MARK: - Computed Properties + + public var isSet: Bool { + block != nil || asyncBlock != nil + } + + // MARK: - Lifecycle + + public init() {} + + // MARK: - Functions + + public func delegate(on target: T, block: ((T, Input) -> Output)?) { + self.block = { [weak target] input in + guard let target else { + return nil + } + return block?(target, input) + } + } + + public func delegate(on target: T, block: ((T, Input) async -> Output)?) { + asyncBlock = { [weak target] input in + guard let target else { + return nil + } + return await block?(target, input) + } + } + + public func call(_ input: Input) -> Output? { + block?(input) + } + + public func callAsFunction(_ input: Input) -> Output? { + call(input) + } + + public func callAsync(_ input: Input) async -> Output? { + await asyncBlock?(input) + } +} + +extension Delegate where Input == Void { + public func call() -> Output? { + call(()) + } + + public func callAsFunction() -> Output? { + call() + } +} + +extension Delegate where Input == Void, Output: OptionalProtocol { + public func call() -> Output { + call(()) + } + + public func callAsFunction() -> Output { + call() + } +} + +extension Delegate where Output: OptionalProtocol { + public func call(_ input: Input) -> Output { + if let result = block?(input) { + return result + } else { + return Output._createNil + } + } + + public func callAsFunction(_ input: Input) -> Output { + call(input) + } +} + +public protocol OptionalProtocol { + static var _createNil: Self { get } +} + +extension Optional: OptionalProtocol { + public static var _createNil: Optional { + nil + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Data+.swift b/BookKitty/BookKitty/NeoImage/Sources/Extensions/Data+.swift similarity index 100% rename from BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Data+.swift rename to BookKitty/BookKitty/NeoImage/Sources/Extensions/Data+.swift diff --git a/BookKitty/BookKitty/NeoImage/Sources/Extensions/Date+.swift b/BookKitty/BookKitty/NeoImage/Sources/Extensions/Date+.swift new file mode 100644 index 00000000..4c483d7f --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/Extensions/Date+.swift @@ -0,0 +1,18 @@ +import Foundation + +extension Date { + var isPast: Bool { + isPast(referenceDate: Date()) + } + + func isPast(referenceDate: Date) -> Bool { + timeIntervalSince(referenceDate) <= 0 + } + + /// `Date` in memory is a wrap for `TimeInterval`. But in file attribute it can only accept `Int` number. + /// By default the system will `round` it. But it is not friendly for testing purpose. + /// So we always `ceil` the value when used for file attributes. + var fileAttributeDate: Date { + Date(timeIntervalSince1970: ceil(timeIntervalSince1970)) + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/String+.swift b/BookKitty/BookKitty/NeoImage/Sources/Extensions/String+.swift similarity index 100% rename from BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/String+.swift rename to BookKitty/BookKitty/NeoImage/Sources/Extensions/String+.swift diff --git a/BookKitty/BookKitty/NeoImage/Sources/Logger.swift b/BookKitty/BookKitty/NeoImage/Sources/Logger.swift new file mode 100644 index 00000000..c55d33d5 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/Logger.swift @@ -0,0 +1,110 @@ +import Foundation +import OSLog + +public enum LogLevel: String { + case debug = "DEBUG" + case info = "INFO" + case log = "LOG" + case error = "ERROR" + + // MARK: - Computed Properties + + var osLogType: OSLogType { + switch self { + case .debug: return .debug + case .info: return .info + case .log: return .default + case .error: return .error + } + } +} + +final class NeoLogger: Sendable { + // MARK: - Static Properties + + public static let shared = NeoLogger() + + // MARK: - Properties + + private let dateFormatter: DateFormatter + + private let logger: Logger + + private let infoHidden = true + private let debugHidden = true + + // MARK: - Lifecycle + + init() { + dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" + logger = Logger() + } + + // MARK: - Functions + + public func error( + _ message: String, + file: String = #file, + function: String = #function, + line _: Int = #line + ) { + log( + .error, + message: message, + file: file, + function: function + ) + } + + public func info( + _ message: String, + file: String = #file, + function: String = #function, + line _: Int = #line + ) { + guard !infoHidden else { + return + } + + log( + .info, + message: message, + file: file, + function: function + ) + } + + public func debug( + _ message: String, + file: String = #file, + function: String = #function, + line _: Int = #line + ) { + guard !debugHidden else { + return + } + + log( + .debug, + message: message, + file: file, + function: function + ) + } +} + +// MARK: - Private Methods + +extension NeoLogger { + private func log( + _ level: LogLevel, + message: String, + file: String = #file, + function: String = #function, + line: Int = #line + ) { + let fileName = (file as NSString).lastPathComponent + logger.log(level: level.osLogType, "[\(fileName):\(line)] \(function) - \(message)") + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/DiskStorage.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/DiskStorage.swift deleted file mode 100644 index bd29d284..00000000 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/DiskStorage.swift +++ /dev/null @@ -1,303 +0,0 @@ -import Foundation - -class DiskStorage: @unchecked Sendable { - // MARK: - Properties - - private let config: Config - - private let directoryURL: URL - - private let serialActor = Actor() - private var storageReady = true - - // MARK: - Lifecycle - - /// FileManager를 통해 디렉토리를 생성하는 과정에서 에러가 발생할 수 있기 때문에 인스턴스 생성 자체에서 throws 키워드를 기입해줍니다. - init(config: Config) throws { - // 외부에서 주입된 디스크 저장소에 대한 설정값과 Creation 구조체로 생성된 디렉토리 URL와 cacheName을 생성 및 self.directoryURL에 - // 저장합니다. - self.config = config - let creation = Creation(config) - directoryURL = creation.directoryURL - try prepareDirectory() - } - - // MARK: - Functions - - func store(value: T, forKey key: String, expiration: StorageExpiration? = nil) async throws { - guard let data = try? value.toData() else { - throw CacheError.invalidData - } - // Disk에 대한 접근이 패키지 외부에서 동시에 이루어질 경우, 동일한 위치에 다른 데이터가 덮어씌워지는 data race 상황이 됩니다. 이를 방지하고자, 기존 - // Kingfisher에서는 DispatchQueue를 통해 직렬화 큐를 구현한 후, store(Write), value(Read)를 직렬화 큐에 전송하여 - // 순차적인 실행이 보장되게 하였습니다. - // 이를 Swift Concurrency로 변경하고자, 동일한 직렬화 기능을 수행하는 Actor 클래스로 대체하였습니다. - try await serialActor.run { - // 별도로 메서드를 통해 기한을 전달하지 않으면, 기본값으로 config.expiration인 7일로 정의합니다. - let expiration = expiration ?? self.config.expiration - let fileURL = self.cacheFileURL(forKey: key) - // Foundation 내부 Data 타입의 내장 메서드입니다. - // 해당 위치로 data 내부 컨텐츠를 write 합니다. - try data.write(to: fileURL) - - // FileManager를 통해 파일 작성 시 전달해줄 파일의 속성입니다. - // 생성된 날짜, 수정된 일자를 실제 수정된 시간이 아닌, 만료 예정 시간을 저장하는 용도로 재활용합니다. - // 실제로, 파일 시스템의 기본속성을 활용하기에 추가적인 저장공간이 필요 없음 - // 파일과 만료 정보가 항상 동기화되어 있음 (파일이 삭제되면 만료 정보도 자동으로 삭제) - let attributes: [FileAttributeKey: Any] = [ - .creationDate: Date(), - .modificationDate: expiration.estimatedExpirationSinceNow, - ] - - // 파일의 메타데이터가 업데이트됨 - // 이는 디스크에 대한 I/O 작업을 수반 - // 파일의 내용은 변경되지 않고 속성만 변경 - try FileManager.default.setAttributes(attributes, ofItemAtPath: fileURL.path) - } - } - - func value( - forKey key: String, // 캐시의 키 - extendingExpiration: ExpirationExtending = .cacheTime // 현재 Confiㅎ - ) async throws -> T? { - try await serialActor.run { () -> T? in - // 주어진 키에 대한 캐시 파일 URL을 생성 - let fileURL = cacheFileURL(forKey: key) - guard FileManager.default.fileExists(atPath: fileURL.path) else { - return nil - } - - // 파일에서 데이터를 읽어옴 - let data = try Data(contentsOf: fileURL) - // DataTransformable 프로토콜의 fromData를 사용해 원본 타입으로 변환 - let obj = try T.fromData(data) - - // 해당 파일이 조회되었기 때문에, 만료 시간 연장을 처리합니다. - // "캐시 적중(Cache Hit)"이 발생했을 때 해당 데이터의 생명주기를 연장하는 일반적인 캐시 전략입니다. - // LRU(Least Recently Used) - if extendingExpiration != .none { - let expirationDate: Date - switch extendingExpiration { - case .none: - return obj - case .cacheTime: - expirationDate = config.expiration.estimatedExpirationSinceNow - // .expirationTime: 지정된 새로운 만료 시간으로 연장 - case let .expirationTime(storageExpiration): - expirationDate = storageExpiration.estimatedExpirationSinceNow - } - - let attributes: [FileAttributeKey: Any] = [ - .creationDate: Date(), - .modificationDate: expirationDate, - ] - - try FileManager.default.setAttributes(attributes, ofItemAtPath: fileURL.path) - } - - return obj - } - } - - /// 특정 키에 해당하는 파일을 삭제하는 메서드 - func remove(forKey key: String) async throws { - try await serialActor.run { - let fileURL = cacheFileURL(forKey: key) - if FileManager.default.fileExists(atPath: fileURL.path) { - try FileManager.default.removeItem(at: fileURL) - } - } - } - - /// 디렉토리 내의 모든 파일을 삭제하는 메서드 - func removeAll() async throws { - try await serialActor.run { - let fileManager = FileManager.default - let contents = try fileManager.contentsOfDirectory( - at: directoryURL, - includingPropertiesForKeys: nil, - options: [] - ) - for fileURL in contents { - try fileManager.removeItem(at: fileURL) - } - } - } - - /// 캐시 확인 - func isCached(forKey key: String) async -> Bool { - let fileURL = cacheFileURL(forKey: key) - return await serialActor.run { - FileManager.default.fileExists(atPath: fileURL.path) - } - } -} - -extension DiskStorage { - private func cacheFileURL(forKey key: String, forcedExtension: String? = nil) -> URL { - let fileName = cacheFileName(forKey: key, forcedExtension: forcedExtension) - - return directoryURL.appendingPathComponent(fileName, isDirectory: false) - } - - /// 사전에 패키지에서 설정된 Config 구조체를 통해 파일명을 해시화하기로 설정했는지 여부, 임의로 전달된 접미사 단어 유무에 따라 캐시될때 저장될 파일명을 변환하여 - /// 반환해줍니다. - private func cacheFileName(forKey key: String, forcedExtension: String? = nil) -> String { - if config.usesHashedFileName { - let hashedKey = key.sha256 - if let ext = forcedExtension ?? config.pathExtension { - return "\(hashedKey).\(ext)" - } - return hashedKey - } else { - if let ext = forcedExtension ?? config.pathExtension { - return "\(key).\(ext)" - } - // 해시화 설정을 false로 하고, pathExtension에 별도 조작을 하지 않을 경우, - // key를 그대로 반환하는 경우도 있습니다. - return key - } - } - - private func prepareDirectory() throws { - // config에 custom fileManager를 주입할 수 있기 때문에, 여기서 .default를 접근하지 않고 Config 내부 fileManager를 - // 접근합니다. - let fileManager = config.fileManager - let path = directoryURL.path - - // Creation 구조체를 통해 생성된 url이 FileSystem에 존재하는지 검증 - guard !fileManager.fileExists(atPath: path) else { - return - } - - do { - // FileManager를 통해 해당 path에 디렉토리 생성 - try fileManager.createDirectory( - atPath: path, - withIntermediateDirectories: true, - attributes: nil - ) - } catch { - // 만일 디렉토리 생성이 실패할경우, storageReady를 false로 변경합니다. - // 이는 추후 flag로 동작합니다. - storageReady = false - throw CacheError.cannotCreateDirectory(error) - } - } -} - -/// 직렬화를 위한 간단한 액터 -/// 에러 처리 여부에 따라 오버로드되어있기에, 에러처리가 필요한지 여부에 따라 선택적으로 try 키워드를 삽입 -actor Actor { - func run(_ operation: @Sendable () throws -> T) throws -> T { - try operation() - } - - func run(_ operation: @Sendable () -> T) -> T { - operation() - } -} - -extension DiskStorage { - /// Represents the configuration used in a ``DiskStorage/Backend``. - public struct Config: @unchecked Sendable { - // MARK: - Properties - - /// The file size limit on disk of the storage in bytes. - /// `0` means no limit. - public var sizeLimit: UInt - - /// The `StorageExpiration` used in this disk storage. - /// The default is `.days(7)`, which means that the disk cache will expire in one week if - /// not accessed anymore. - public var expiration = StorageExpiration.days(7) - - /// The preferred extension of the cache item. It will be appended to the file name as its - /// extension. - /// The default is `nil`, which means that the cache file does not contain a file extension. - public var pathExtension: String? - - /// Whether the cache file name will be hashed before storing. - /// - /// The default is `true`, which means that file name is hashed to protect user information - /// (for example, the - /// original download URL which is used as the cache key). - public var usesHashedFileName = true - - /// Whether the image extension will be extracted from the original file name and appended - /// to the hashed file - /// name, which will be used as the cache key on disk. - /// - /// The default is `false`. - public var autoExtAfterHashedFileName = false - - /// A closure that takes in the initial directory path and generates the final disk cache - /// path. - /// - /// You can use it to fully customize your cache path. - public var cachePathBlock: (@Sendable (_ directory: URL, _ cacheName: String) -> URL)! = { - directory, cacheName in - directory.appendingPathComponent(cacheName, isDirectory: true) - } - - /// The desired name of the disk cache. - /// - /// This name will be used as a part of the cache folder name by default. - public let name: String - - let fileManager: FileManager - let directory: URL? - - // MARK: - Lifecycle - - /// Creates a config value based on the given parameters. - /// - /// - Parameters: - /// - name: The name of the cache. It is used as part of the storage folder and to - /// identify the disk storage. - /// Two storages with the same `name` would share the same folder on the disk, and this - /// should be prevented. - /// - sizeLimit: The size limit in bytes for all existing files in the disk storage. - /// - fileManager: The `FileManager` used to manipulate files on the disk. The default is - /// `FileManager.default`. - /// - directory: The URL where the disk storage should reside. The storage will use this - /// as the root folder, - /// and append a path that is constructed by the input `name`. The default is `nil`, - /// indicating that - /// the cache directory under the user domain mask will be used. - public init( - name: String, - sizeLimit: UInt, - fileManager: FileManager = .default, - directory: URL? = nil - ) { - self.name = name - self.fileManager = fileManager - self.directory = directory - self.sizeLimit = sizeLimit - } - } -} - -extension DiskStorage { - struct Creation { - // MARK: - Properties - - let directoryURL: URL - let cacheName: String - - // MARK: - Lifecycle - - init(_ config: Config) { - let url: URL - if let directory = config.directory { - url = directory - } else { - url = config.fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0] - } - - cacheName = "com.neoself.NeoImage.ImageCache.\(config.name)" - directoryURL = config.cachePathBlock(url, cacheName) - } - } -} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/ImageCache.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/ImageCache.swift deleted file mode 100644 index 19d6fa63..00000000 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/ImageCache.swift +++ /dev/null @@ -1,124 +0,0 @@ -import Foundation - -/// 쓰기 제어와 같은 동시성이 필요한 부분만 선택적으로 제어하기 위해 전체 ImageCache를 actor로 변경하지 않고, ImageCacheActor 생성 -/// actor를 사용하면 모든 동작이 actor의 실행큐를 통과해야하기 때문에, 동시성 보호가 불필요한 read-only 동작도 직렬화되며 오버헤드가 발생 -@globalActor -public actor ImageCacheActor { - public static let shared = ImageCacheActor() -} - -public final class ImageCache: @unchecked Sendable { - // MARK: - Static Properties - - /// ERROR: Static property 'shared' is not concurrency-safe because non-'Sendable' type - /// 'ImageCache' may have shared mutable state - /// ``` - /// public static let shared = ImageCache() - /// ``` - /// Swift 6에서는 동시성 안정성 검사가 더욱 엄격해졌습니다. 이로 인해 여러 스레드에서 동시에 접근할 수 있는 공유 상태 (shared mutable state)인 - /// 싱글톤 패턴을 사용할 경우,위 에러가 발생합니다. - /// 이는 별도의 가변 프로퍼티를 클래스 내부에 지니고 있지 않음에도 발생하는 에러입니다 - /// 이를 해결하기 위해선, Actor를 사용하거나, Serial Queue를 사용해 동기화를 해줘야 합니다. - @ImageCacheActor - public static let shared = try! ImageCache(name: "default") - - // MARK: - Properties - - private let memoryStorage: MemoryStorageActor - private let diskStorage: DiskStorage - - // MARK: - Lifecycle - - // MARK: - Initialization - - public init(name: String) throws { - guard !name.isEmpty else { - throw CacheError.invalidCacheKey - } - - // 메모리 캐싱 관련 설정 과정입니다. - // NSProcessInfo를 통해 총 메모리 크기를 접근한 후, 메모리 상한선을 전체 메모리의 1/4로 한정합니다. - let totalMemory = ProcessInfo.processInfo.physicalMemory - let memoryLimit = totalMemory / 4 - memoryStorage = MemoryStorageActor( - totalCostLimit: min(Int.max, Int(memoryLimit)) - ) - - // 디스크 캐시에 대한 설정을 여기서 정의해줍니다. - let diskConfig = DiskStorage.Config( - name: name, - sizeLimit: 0, - directory: FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first - ) - - // 디스크 캐시 제어 관련 클래스 인스턴스 생성 - diskStorage = try DiskStorage(config: diskConfig) - } - - // MARK: - Functions - - /// 메모리와 디스크 캐시에 모두 데이터를 저장합니다. - @ImageCacheActor - public func store( - _ data: Data, - forKey key: String, - expiration: StorageExpiration? = nil - ) async throws { - await memoryStorage.store(value: data, forKey: key, expiration: expiration) - - try await diskStorage.store( - value: data, - forKey: key, - expiration: expiration - ) - } - - /// 캐시로부터 저장된 이미지를 가져옵니다. - /// 1차적으로 오버헤드가 적은 메모리를 먼저 확인합니다. - /// 이후 메모리에 없을 경우, 디스크를 확인합니다. - /// 디스크에 없을 경우 throw합니다. - /// 디스크에 데이터를 확인할 경우, 다음 조회를 위해 해당 데이터를 메모리로 올립니다. - public func retrieveImage(forKey key: String) async throws -> Data? { - if let memoryData = await memoryStorage.value(forKey: key) { - return memoryData - } - - let diskData = try await diskStorage.value(forKey: key) - - if let diskData { - await memoryStorage.store( - value: diskData, - forKey: key, - expiration: .days(7) - ) - } - - return diskData - } - - /// 메모리와 디스크 모두에서 특정 키에 해당하는 이미지 데이터를 제거합니다. - @ImageCacheActor - public func removeImage(forKey key: String) async throws { - await memoryStorage.remove(forKey: key) - - try await diskStorage.remove(forKey: key) - } - - /// 메모리와 디스크 모두에 존재하는 모든 데이터를 제거합니다. - @ImageCacheActor - public func clearCache() async throws { - await memoryStorage.removeAll() - - try await diskStorage.removeAll() - } - - /// Checks if an image exists in cache (either memory or disk) - @ImageCacheActor - public func isCached(forKey key: String) async -> Bool { - if await memoryStorage.isCached(forKey: key) { - return true - } - - return await diskStorage.isCached(forKey: key) - } -} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/MemoryStorage.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/MemoryStorage.swift deleted file mode 100644 index 5865cc3d..00000000 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/MemoryStorage.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation - -public actor MemoryStorageActor { - // MARK: - Properties - - /// 캐시는 NSCache로 접근합니다. - private let cache = NSCache() - private let totalCostLimit: Int - - // MARK: - Lifecycle - - init(totalCostLimit: Int) { - // 메모리가 사용할 수 있는 공간 상한선 (ImageCache 클래스에서 총 메모리공간의 1/4로 주입하고 있음) 데이터를 아래 private 속성에 주입시킵니다. - self.totalCostLimit = totalCostLimit - cache.totalCostLimit = totalCostLimit - } - - // MARK: - Functions - - /// 캐시에 저장 - func store(value: Data, forKey key: String, expiration _: StorageExpiration?) { - cache.setObject(value as NSData, forKey: key as NSString) - } - - /// 캐시에서 조회 - func value(forKey key: String) -> Data? { - cache.object(forKey: key as NSString) as Data? - } - - /// 캐시에서 제거 - func remove(forKey key: String) { - cache.removeObject(forKey: key as NSString) - } - - /// 캐시에서 일괄 제거 - func removeAll() { - cache.removeAllObjects() - } - - /// 캐시에서 있는지 여부를 조회 - func isCached(forKey key: String) -> Bool { - cache.object(forKey: key as NSString) != nil - } -} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/CacheError.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/CacheError.swift deleted file mode 100644 index 0bc2b101..00000000 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/CacheError.swift +++ /dev/null @@ -1,57 +0,0 @@ -enum CacheError: Error { - // 데이터 관련 에러 - case invalidData - case invalidImage - case dataToImageConversionFailed - case imageToDataConversionFailed - - // 저장소 관련 에러 - case diskStorageError(Error) - case memoryStorageError(Error) - case storageNotReady - - // 파일 관련 에러 - case fileNotFound(String) // key - case cannotCreateDirectory(Error) - case cannotWriteToFile(Error) - case cannotReadFromFile(Error) - - /// 캐시 키 관련 에러 - case invalidCacheKey - - /// 기타 - case unknown(Error) - - // MARK: - Computed Properties - - var localizedDescription: String { - switch self { - case .invalidData: - return "The data is invalid or corrupted" - case .invalidImage: - return "The image data is invalid" - case .dataToImageConversionFailed: - return "Failed to convert data to image" - case .imageToDataConversionFailed: - return "Failed to convert image to data" - case let .diskStorageError(error): - return "Disk storage error: \(error.localizedDescription)" - case let .memoryStorageError(error): - return "Memory storage error: \(error.localizedDescription)" - case .storageNotReady: - return "The storage is not ready" - case let .fileNotFound(key): - return "File not found for key: \(key)" - case let .cannotCreateDirectory(error): - return "Cannot create directory: \(error.localizedDescription)" - case let .cannotWriteToFile(error): - return "Cannot write to file: \(error.localizedDescription)" - case let .cannotReadFromFile(error): - return "Cannot read from file: \(error.localizedDescription)" - case .invalidCacheKey: - return "The cache key is invalid" - case let .unknown(error): - return "Unknown error: \(error.localizedDescription)" - } - } -} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/TimeConstants.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/TimeConstants.swift deleted file mode 100644 index 6fb4806a..00000000 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/TimeConstants.swift +++ /dev/null @@ -1,9 +0,0 @@ -enum TimeConstants { - /// Seconds in a day, a.k.a 86,400s, roughly. - /// also known as - static let secondsInOneDay = 86400 -} - -enum ImageTaskKey { - static let associatedKey = "com.neoimage.UIImageView.ImageTask" -} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Date+.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Date+.swift deleted file mode 100644 index 5fc8aa34..00000000 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Date+.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation - -extension Date { - var isPast: Bool { - self < Date() - } -} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Image/ImageProcesser.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Image/ImageProcesser.swift deleted file mode 100644 index d31be488..00000000 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Image/ImageProcesser.swift +++ /dev/null @@ -1,167 +0,0 @@ -import UIKit - -public enum FilteringAlgorithm: Sendable { - case none - case linear - case trilinear -} - -/// 이미지 처리를 위한 프로토콜 -public protocol ImageProcessing: Sendable { - /// 이미지를 처리하는 메서드 - func process(_ image: UIImage) async throws -> UIImage - - /// 프로세서의 식별자 - /// 캐시 키 생성에 사용됨 - var identifier: String { get } -} - -/// 이미지 리사이징 프로세서 -public struct ResizingImageProcessor: ImageProcessing { - // MARK: - Properties - - /// 대상 크기 - private let targetSize: CGSize - - /// 크기 조정 모드 - private let contentMode: UIView.ContentMode - - /// 크기 조정 시 필터링 방식 - private let filteringAlgorithm: FilteringAlgorithm - - // MARK: - Computed Properties - - public var identifier: String { - let contentModeString: String = { - switch contentMode { - case .scaleToFill: return "ScaleToFill" - case .scaleAspectFit: return "ScaleAspectFit" - case .scaleAspectFill: return "ScaleAspectFill" - default: return "Unknown" - } - }() - - return "com.neoimage.ResizingImageProcessor(\(targetSize),\(contentModeString))" - } - - // MARK: - Lifecycle - - public init( - targetSize: CGSize, - contentMode: UIView.ContentMode = .scaleToFill, - filteringAlgorithm: FilteringAlgorithm = .linear - ) { - self.targetSize = targetSize - self.contentMode = contentMode - self.filteringAlgorithm = filteringAlgorithm - } - - // MARK: - Functions - - public func process(_ image: UIImage) async throws -> UIImage { - let format = UIGraphicsImageRendererFormat() - format.scale = image.scale - - let size = calculateTargetSize(image.size) - let renderer = UIGraphicsImageRenderer(size: size, format: format) - - return renderer.image { _ in - image.draw(in: CGRect(origin: .zero, size: size)) - } - } - - private func calculateTargetSize(_ originalSize: CGSize) -> CGSize { - switch contentMode { - case .scaleToFill: - return targetSize - - case .scaleAspectFit: - let widthRatio = targetSize.width / originalSize.width - let heightRatio = targetSize.height / originalSize.height - let ratio = min(widthRatio, heightRatio) - return CGSize( - width: originalSize.width * ratio, - height: originalSize.height * ratio - ) - - case .scaleAspectFill: - let widthRatio = targetSize.width / originalSize.width - let heightRatio = targetSize.height / originalSize.height - let ratio = max(widthRatio, heightRatio) - return CGSize( - width: originalSize.width * ratio, - height: originalSize.height * ratio - ) - - default: - return targetSize - } - } -} - -/// 둥근 모서리 처리를 위한 프로세서 -public struct RoundCornerImageProcessor: ImageProcessing { - // MARK: - Properties - - /// 모서리 반경 - private let radius: CGFloat - - // MARK: - Computed Properties - - public var identifier: String { - "com.neoimage.RoundCornerImageProcessor(\(radius))" - } - - // MARK: - Lifecycle - - public init(radius: CGFloat) { - self.radius = radius - } - - // MARK: - Functions - - public func process(_ image: UIImage) async throws -> UIImage { - let format = UIGraphicsImageRendererFormat() - format.scale = image.scale - - let renderer = UIGraphicsImageRenderer(size: image.size, format: format) - return renderer.image { context in - let rect = CGRect(origin: .zero, size: image.size) - let path = UIBezierPath(roundedRect: rect, cornerRadius: radius) - - context.cgContext.addPath(path.cgPath) - context.cgContext.clip() - - image.draw(in: rect) - } - } -} - -/// 여러 프로세서를 순차적으로 적용하는 프로세서 -public struct ChainImageProcessor: ImageProcessing { - // MARK: - Properties - - private let processors: [ImageProcessing] - - // MARK: - Computed Properties - - public var identifier: String { - processors.map(\.identifier).joined(separator: "|") - } - - // MARK: - Lifecycle - - public init(_ processors: [ImageProcessing]) { - self.processors = processors - } - - // MARK: - Functions - - public func process(_ image: UIImage) async throws -> UIImage { - var processedImage = image - for processor in processors { - processedImage = try await processor.process(processedImage) - } - return processedImage - } -} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImage.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImage.swift deleted file mode 100644 index 08b22b80..00000000 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImage.swift +++ /dev/null @@ -1,2 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloadManager.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloadManager.swift deleted file mode 100644 index 85ac0743..00000000 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloadManager.swift +++ /dev/null @@ -1,91 +0,0 @@ -import Foundation -import UIKit - -/// 이미지 다운로드 결과 구조체 -public struct ImageLoadingResult: Sendable { - public let image: UIImage - public let url: URL? - public let originalData: Data -} - -/// 이미지 다운로드 관리 액터 (동시성 제어) -public actor ImageDownloadManager { - // MARK: - Static Properties - - // MARK: - 싱글톤 & 초기화 - - public static let shared = ImageDownloadManager() - - // MARK: - Properties - - private var session: URLSession - private let sessionDelegate = SessionDelegate() - - // MARK: - Lifecycle - - private init() { - let config = URLSessionConfiguration.ephemeral - session = URLSession(configuration: config, delegate: sessionDelegate, delegateQueue: nil) - setupDelegates() - } - - // MARK: - Functions - - // MARK: - 핵심 다운로드 메서드 (kf.setImage에서 사용) - - /// 이미지 비동기 다운로드 (async/await) - public func downloadImage(with url: URL) async throws -> ImageLoadingResult { - let request = URLRequest(url: url) - let (data, response) = try await session.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - (200 ..< 400).contains(httpResponse.statusCode) else { -// throw CacheError.invalidHTTPStatusCode - throw CacheError.invalidData - } - - guard let image = UIImage(data: data) else { -// throw KingfisherError.imageMappingError - throw CacheError.dataToImageConversionFailed - } - - return ImageLoadingResult(image: image, url: url, originalData: data) - } - - /// URL 기반 다운로드 취소 - public func cancelDownload(for url: URL) { - sessionDelegate.cancelTasks(for: url) - } - - /// 전체 다운로드 취소 - public func cancelAllDownloads() { - sessionDelegate.cancelAllTasks() - } -} - -// MARK: - 내부 세션 관리 확장 - -extension ImageDownloadManager { - /// actor의 상태를 직접 변경하지 않고 클로저를 설정하는 것이기에 nonisolated를 기입하여, 해당 메서드가 actor의 격리된 상태에 접근하지 않음을 알려줌 - private nonisolated func setupDelegates() { - sessionDelegate.onReceiveChallenge = { [weak self] challenge in - guard let self else { - return (.performDefaultHandling, nil) - } - return await handleAuthChallenge(challenge) - } - - sessionDelegate.onValidateStatusCode = { code in - (200 ..< 400).contains(code) - } - } - - /// 인증 처리 핸들러 - private func handleAuthChallenge(_ challenge: URLAuthenticationChallenge) async - -> (URLSession.AuthChallengeDisposition, URLCredential?) { - guard let trust = challenge.protectionSpace.serverTrust else { - return (.cancelAuthenticationChallenge, nil) - } - return (.useCredential, URLCredential(trust: trust)) - } -} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageTask.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageTask.swift deleted file mode 100644 index 14342ada..00000000 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageTask.swift +++ /dev/null @@ -1,132 +0,0 @@ -import Foundation - -/// 이미지 다운로드 작업의 상태를 나타내는 열거형 -public enum ImageTaskState: Int, Sendable { - /// 대기 중 - case pending = 0 - /// 다운로드 중 - case downloading - /// 취소됨 - case cancelled - /// 완료됨 - case completed - /// 실패 - case failed -} - -/// 이미지 다운로드 작업을 관리하는 actor -public actor ImageTask: Sendable { - // MARK: - Properties - - /// 현재 작업의 상태 - public private(set) var state = ImageTaskState.pending - - /// 다운로드 진행률 - public private(set) var progress: Float = 0 - - /// 작업 시작 시간 - public private(set) var startTime: Date? - - /// 작업 완료 시간 - public private(set) var endTime: Date? - - /// 취소 여부 - public private(set) var isCancelled = false - - /// 다운로드된 데이터 크기 - public private(set) var downloadedDataSize: Int64 = 0 - - /// 전체 데이터 크기 - public private(set) var totalDataSize: Int64 = 0 - - // MARK: - Computed Properties - - /// 작업 소요 시간 (밀리초) - public var duration: TimeInterval? { - guard let start = startTime else { - return nil - } - let end = endTime ?? Date() - return end.timeIntervalSince(start) - } - - /// 다운로드 속도 (bytes/second) - public var downloadSpeed: Double? { - guard let duration, duration > 0 else { - return nil - } - return Double(downloadedDataSize) / duration - } - - // MARK: - CustomStringConvertible Implementation - - public nonisolated var description: String { - "ImageTask" // Note: actor의 격리된 상태에 접근하지 않기 위해 간단한 description 사용 - } - - // MARK: - Lifecycle - - // MARK: - Initializer - - public init() {} - - // MARK: - Functions - - // MARK: - Task Management Methods - - /// 작업 취소 - public func cancel() { - guard state == .pending || state == .downloading else { - return - } - state = .cancelled - isCancelled = true - endTime = Date() - } - - /// 작업 시작 - public func start() { - guard state == .pending else { - return - } - state = .downloading - startTime = Date() - } - - /// 작업 완료 - public func complete() { - guard state == .downloading else { - return - } - state = .completed - endTime = Date() - } - - /// 작업 실패 - public func fail() { - guard state != .completed, state != .cancelled else { - return - } - state = .failed - endTime = Date() - } - - /// 진행률 업데이트 - public func updateProgress(downloaded: Int64, total: Int64) { - downloadedDataSize = downloaded - totalDataSize = total - progress = total > 0 ? Float(downloaded) / Float(total) : 0 - } -} - -// MARK: - Hashable Implementation - -extension ImageTask: Hashable { - public static func == (lhs: ImageTask, rhs: ImageTask) -> Bool { - ObjectIdentifier(lhs) == ObjectIdentifier(rhs) - } - - public nonisolated func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(self)) - } -} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift deleted file mode 100644 index 17aabc77..00000000 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Foundation - -public class SessionDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable { - // MARK: - Properties - - var onReceiveChallenge: ((URLAuthenticationChallenge) async -> ( - URLSession.AuthChallengeDisposition, - URLCredential? - ))? - var onValidateStatusCode: ((Int) -> Bool)? - - private var tasks = [URL: URLSessionTask]() - - // MARK: - Functions - - /// 필수 델리게이트 메서드만 구현 - public func urlSession( - _: URLSession, - task _: URLSessionTask, - didReceive challenge: URLAuthenticationChallenge - ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - await onReceiveChallenge?(challenge) ?? (.performDefaultHandling, nil) - } - - public func urlSession( - _: URLSession, - dataTask _: URLSessionDataTask, - didReceive response: URLResponse - ) async -> URLSession.ResponseDisposition { - guard let httpResponse = response as? HTTPURLResponse, - onValidateStatusCode?(httpResponse.statusCode) == true else { - return .cancel - } - return .allow - } - - func cancelTasks(for url: URL) { - tasks[url]?.cancel() - tasks[url] = nil - } - - func cancelAllTasks() { - tasks.values.forEach { $0.cancel() } - tasks.removeAll() - } -} diff --git a/BookKitty/BookKitty/NeoImage/Sources/Networking/DownloadTask.swift b/BookKitty/BookKitty/NeoImage/Sources/Networking/DownloadTask.swift new file mode 100644 index 00000000..2c5ae98f --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/Networking/DownloadTask.swift @@ -0,0 +1,46 @@ +import Foundation + +public final actor DownloadTask: Sendable { + // MARK: - Properties + + private(set) var sessionTask: SessionDataTask? + private(set) var index: Int? + + // MARK: - Lifecycle + + init( + sessionTask: SessionDataTask? = nil, + index: Int? = nil + ) { + self.sessionTask = sessionTask + self.index = index + } + + // MARK: - Functions + + /// 이 다운로드 작업이 실행 중인 경우 취소합니다. + public func cancelWithError() async throws { + guard let sessionTask, let index else { + return + } + + await sessionTask.cancel(index: index) + + throw NeoImageError.responseError(reason: .cancelled) + } + + public func cancel() async { + guard let sessionTask, let index else { + return + } + + await sessionTask.cancel(index: index) + } + + func linkToTask(_ task: DownloadTask) { + Task { + self.sessionTask = await task.sessionTask + self.index = await task.index + } + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/Networking/ImageDownloader.swift b/BookKitty/BookKitty/NeoImage/Sources/Networking/ImageDownloader.swift new file mode 100644 index 00000000..1005cfb6 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/Networking/ImageDownloader.swift @@ -0,0 +1,164 @@ +import Foundation +import UIKit + +public struct ImageLoadingResult: Sendable { + // MARK: - Properties + + public let image: UIImage + public let url: URL? + public let originalData: Data + + // MARK: - Lifecycle + + public init(image: UIImage, url: URL? = nil, originalData: Data) { + self.image = image + self.url = url + self.originalData = originalData + } +} + +public final class ImageDownloader: Sendable { + // MARK: - Static Properties + + public static let `default` = ImageDownloader(name: "default") + + // MARK: - Properties + + private let downloadTimeout: TimeInterval = 15.0 + private let name: String + private let session: URLSession + + private let sessionDelegate: SessionDelegate + + // MARK: - Lifecycle + + public init( + name: String + ) { + self.name = name + sessionDelegate = SessionDelegate() + + session = URLSession( + configuration: URLSessionConfiguration.ephemeral, + delegate: sessionDelegate, + delegateQueue: nil + ) + } + + deinit { session.invalidateAndCancel() } + + // MARK: - Functions + + public func createTask(with url: URL) async throws -> DownloadTask { + let request = URLRequest( + url: url, + cachePolicy: .reloadIgnoringLocalCacheData, + timeoutInterval: downloadTimeout + ) + + guard let url = request.url, !url.absoluteString.isEmpty else { + throw NeoImageError.requestError(reason: .invalidURL) + } + + return await createDownloadTask(url: url, request: request) + } + + @discardableResult + public func downloadImage( + with downloadTask: DownloadTask, + for url: URL, + hashedKey: String + ) async throws -> ImageLoadingResult { + let imageData = try await downloadImageData(with: downloadTask) + + guard let image = UIImage(data: imageData) else { + throw NeoImageError.responseError(reason: .invalidImageData) + } + + try? await ImageCache.shared.store( + imageData, + for: hashedKey + ) + + NeoLogger.shared.debug("Image stored in cache with key: \(url.absoluteString)") + + return ImageLoadingResult( + image: image, + url: url, + originalData: imageData + ) + } +} + +extension ImageDownloader { + /// 이미지 데이터를 다운로드합니다. + /// - Parameter url: 다운로드할 URL + /// - Returns: 다운로드 작업과 데이터 튜플 + private func downloadImageData(with downloadTask: DownloadTask) async throws -> Data { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + Data, + Error + >) in + Task { + guard let sessionTask = await downloadTask.sessionTask else { + continuation + .resume(throwing: NeoImageError.requestError(reason: .invalidSessionTask)) + return + } + + if await sessionTask.isCompleted, + let taskResult = await sessionTask.taskResult { + switch taskResult { + case let .success((data, _)): + if data.isEmpty { + continuation + .resume( + throwing: NeoImageError + .responseError(reason: .invalidImageData) + ) + } else { + continuation.resume(returning: data) + } + case let .failure(error): + continuation.resume(throwing: error) + } + } + + await sessionTask.onCallbackTaskDone.delegate(on: self) { _, value in + let (result, _) = value + + switch result { + case let .success((data, _)): + if data.isEmpty { + continuation + .resume( + throwing: NeoImageError + .responseError(reason: .invalidImageData) + ) + } else { + continuation.resume(returning: data) + } + case let .failure(error): + continuation.resume(throwing: error) + } + } + } + } + } + + /// 다운로드 작업을 생성하거나 기존 작업을 재사용합니다. + /// - Parameters: + /// - url: 다운로드할 URL + /// - request: URL 요청 + /// - Returns: 다운로드 작업 + private func createDownloadTask(url: URL, request: URLRequest) async -> DownloadTask { + // 기존 작업이 있는지 확인 + if let existingTask = await sessionDelegate.task(for: url) { + return await sessionDelegate.append(existingTask) + } else { + // 새 작업 생성 + let sessionDataTask = session.dataTask(with: request) + return await sessionDelegate.add(sessionDataTask, url: url) + } + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/Networking/SessionDataTask.swift b/BookKitty/BookKitty/NeoImage/Sources/Networking/SessionDataTask.swift new file mode 100644 index 00000000..c294ed6c --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/Networking/SessionDataTask.swift @@ -0,0 +1,79 @@ +import Foundation + +/// `ImageDownloader`에서 사용되는 세션 데이터 작업을 나타냅니다. +/// 기본적으로 `SessionDataTask`는 `URLSessionDataTask`를 래핑하고 다운로드 데이터를 관리합니다. +/// `SessionDataTask/CancelToken`을 사용하여 작업을 추적하고 취소를 관리합니다. +public actor SessionDataTask { + // MARK: - Properties + + public let originalURL: URL? + + let task: URLSessionDataTask + let onCallbackTaskDone = Delegate<(Result<(Data, URLResponse?), Error>, Bool), Void>() + + private(set) var taskResult: Result<(Data, URLResponse?), Error>? + private(set) var isCompleted = false + + private(set) var mutableData: Data + + private var DownloadTaskIndices = Set() + private var currentIndex = 0 + + // MARK: - Lifecycle + + init(task: URLSessionDataTask) { + self.task = task + mutableData = Data() + originalURL = task.originalRequest?.url + + NeoLogger.shared.info("initialized") + } + + // MARK: - Functions + + func didReceiveData(_ data: Data) { + mutableData.append(data) + } + + func resume() { + task.resume() + } + + func complete(with result: Result<(Data, URLResponse?), Error>) { + taskResult = result + isCompleted = true + + Task { + await onCallbackTaskDone((result, true)) + } + } +} + +extension SessionDataTask { + func addDownloadTask() -> Int { + let index = currentIndex + DownloadTaskIndices.insert(index) + currentIndex += 1 + return index + } + + func removeDownloadTask(_ index: Int) -> Bool { + DownloadTaskIndices.remove(index) != nil + } + + var hasActiveDownloadTask: Bool { + !DownloadTaskIndices.isEmpty + } + + func cancel(index: Int) { + if removeDownloadTask(index), !hasActiveDownloadTask { + // 모든 토큰이 취소되었을 때만 실제 작업 취소 + task.cancel() + } + } + + func forceCancel() { + DownloadTaskIndices.removeAll() + task.cancel() + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/Networking/SessionDelegate.swift b/BookKitty/BookKitty/NeoImage/Sources/Networking/SessionDelegate.swift new file mode 100644 index 00000000..36f266b7 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/Networking/SessionDelegate.swift @@ -0,0 +1,198 @@ +import Foundation + +/// 프로젝트에 단일로 존재하며, 이미지 다운로드 URL과 SessionDataTask를 관리합니다. +@objc(NeoImageDelegate) +public actor SessionDelegate: NSObject { + // MARK: - Properties + + var authenticationChallengeHandler: ((URLAuthenticationChallenge) async -> ( + URLSession.AuthChallengeDisposition, + URLCredential? + ))? + + private var tasks: [URL: SessionDataTask] = [:] + + // MARK: - Functions + + func add(_ dataTask: URLSessionDataTask, url: URL) async -> DownloadTask { + let task = SessionDataTask(task: dataTask) + var index = -1 + + Task { + index = await task.addDownloadTask() + await task.resume() + } + + tasks[url] = task + return DownloadTask(sessionTask: task, index: index) + } + + /// 기존 작업에 새 토큰 추가 + func append(_ task: SessionDataTask) async -> DownloadTask { + var index = -1 + + Task { + index = await task.addDownloadTask() + } + + return DownloadTask(sessionTask: task, index: index) + } + + /// URL에 해당하는 SessionDataTask 반환 + func task(for url: URL) -> SessionDataTask? { + tasks[url] + } + + /// 작업 제거 + func removeTask(_ task: SessionDataTask) { + guard let url = task.originalURL else { + return + } + tasks[url] = nil + } + + func cancelAll() { + let taskValues = tasks.values + for task in taskValues { + Task { + await task.forceCancel() + } + } + } + + func cancel(url: URL) { + Task { + if let task = tasks[url] { + await task.forceCancel() + } + } + } + + private func cancelTask(_ dataTask: URLSessionDataTask) { + dataTask.cancel() + } + + private func remove(_ task: SessionDataTask) { + guard let url = task.originalURL else { + return + } + tasks[url] = nil + } + + /// SessionDelegate.onCompleted에 사용 + private func task(for task: URLSessionTask) -> SessionDataTask? { + guard let url = task.originalRequest?.url, + let sessionTask = tasks[url], + sessionTask.task.taskIdentifier == task.taskIdentifier else { + return nil + } + + return sessionTask + } +} + +// MARK: - URLSessionDataDelegate + +/// 각 작업의 상태(ready, running, completed, failed, cancelled)를 세밀하게 추적하기 위해 Delegate 메서드 사용이 필요합니다. +extension SessionDelegate: URLSessionDataDelegate { + public func urlSession( + _: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse + ) async -> URLSession.ResponseDisposition { + guard response is HTTPURLResponse else { + Task { + taskCompleted( + dataTask, + with: nil, + error: NeoImageError + .responseError( + reason: .networkError(description: "Invalid HTTP Status Code") + ) + ) + } + return .cancel + } + + return .allow + } + + public nonisolated func urlSession( + _: URLSession, + dataTask: URLSessionDataTask, + didReceive data: Data + ) { + Task { + guard let task = await self.task(for: dataTask) else { + return + } + + await task.didReceiveData(data) + } + } + + /// Actor-isolated instance method 'urlSession(_:task:didCompleteWithError:)' cannot be @objc + /// Swift의 actor와 Objective-C 런타입 간 호환성 문제에 발생하는 컴파일 에러입니다. + /// URLSessionDelegate 메서드들은 모두 Objective-C 런타임을 통해 호출되기에 프로토콜을 구현하는 메서드는 @objc로 노출되어야 합니다. + /// SessionDelegate 클래스는 actor로 선언되어있기에 actor-isolated입니다. 이는 비동기적으로 실행되어야 합니다. + /// 하지만 Objective-C는 Swift의 async/await의 actor 모델을 이해하지 못하기에 에러가 발생합니다. + public nonisolated func urlSession( + _: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + // SessionDataTask가 actor로 마이그레이션 되면서, 내부 didComplete 메서드는 비동기 컨텍스트에서 실행되어야 합니다. + // Actor-isolated instance method 'urlSession(_:task:didCompleteWithError:)' cannot be @objc + Task { + guard let sessionTask = await self.task(for: task) else { + return + } + + await taskCompleted(task, with: sessionTask.mutableData, error: error) + } + } + + public func urlSession( + _: URLSession, + task _: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge + ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + if let handler = authenticationChallengeHandler { + return await handler(challenge) + } + + return (.performDefaultHandling, nil) + } + + // MARK: - 헬퍼 메서드 + + private func taskCompleted(_ task: URLSessionTask, with data: Data?, error: Error?) { + Task { + guard let sessionTask = self.task(for: task) else { + return + } + + let result: Result<(Data, URLResponse?), Error> + if let error { + result = .failure( + NeoImageError + .responseError(reason: .networkError( + description: error + .localizedDescription + )) + ) + } else if let data { + result = .success((data, task.response)) + } else { + result = .failure(NeoImageError.responseError(reason: .invalidImageData)) + } + + await sessionTask.complete(with: result) + + // 작업 상태에 따라 맵에서 제거 (다운로드 성공 또는 모든 태스크가 취소됨) + if await !sessionTask.hasActiveDownloadTask { + remove(sessionTask) + } + } + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Protocols/DataTransformable.swift b/BookKitty/BookKitty/NeoImage/Sources/Protocols/DataTransformable.swift similarity index 100% rename from BookKitty/BookKitty/NeoImage/Sources/NeoImage/Protocols/DataTransformable.swift rename to BookKitty/BookKitty/NeoImage/Sources/Protocols/DataTransformable.swift diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/NeoImageWrapper.swift b/BookKitty/BookKitty/NeoImage/Sources/Wrapper/ImageView+NeoImage.swift similarity index 55% rename from BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/NeoImageWrapper.swift rename to BookKitty/BookKitty/NeoImage/Sources/Wrapper/ImageView+NeoImage.swift index 5a529724..12143c13 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/NeoImageWrapper.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Wrapper/ImageView+NeoImage.swift @@ -35,202 +35,158 @@ extension NeoImageWrapper where Base: UIImageView { @discardableResult // Return type을 strict하게 확인하지 않습니다. private func setImageAsync( with url: URL?, - placeholder: UIImage? = nil, - options: NeoImageOptions? = nil - ) async throws -> (ImageLoadingResult, ImageTask?) { + options: NeoImageOptions? = nil, + isPriority: Bool = false + ) async throws -> ImageLoadingResult { // 이미지뷰가 실제로 화면에 표시되어 있는지 여부 파악, // 이는 Swift 6로 오면서 비동기 작업으로 간주되기 시작함. guard await base.window != nil else { - throw CacheError.invalidData + throw NeoImageError.responseError(reason: .invalidImageData) } - guard let url else { - await MainActor.run { [weak base] in - guard let base else { - return - } - base.image = placeholder + await MainActor.run { [weak base] in + guard let base else { + return } - throw CacheError.invalidData - } - - // placeholder 먼저 설정 - if let placeholder { - await MainActor.run { [weak base] in - guard let base else { - return - } - base.image = placeholder - print("\(url): Placeholder 설정 완료") + let size = base.bounds.size + let renderer = UIGraphicsImageRenderer(size: size) + let lightGrayImage = renderer.image { context in + UIColor.lightGray.withAlphaComponent(0.3).setFill() + context.fill(CGRect(origin: .zero, size: size)) } + + base.image = lightGrayImage } - // UIImageView에 연결된 ImageTask를 가져옵니다 - // 현재 진행 중인 다운로드 작업이 있는지 확인하는데 사용됩니다 - if let task = objc_getAssociatedObject(base, ImageTaskKey.associatedKey) as? ImageTask { - await task.cancel() - await setImageDownloadTask(nil) - print("\(url): 기존 Task 존재하여 취소") + guard let url else { + throw NeoImageError.responseError(reason: .networkError(description: "url invalid")) } - let cacheKey = url.absoluteString + let _hashedKey = url.absoluteString.sha256 + let hashedKey = isPriority ? "priority_\(_hashedKey)" : _hashedKey - // 메모리 또는 디스크 캐시에서 이미지 데이터 확인 - if let cachedData = try? await ImageCache.shared.retrieveImage(forKey: cacheKey), + if let cachedData = try? await ImageCache.shared.retrieveImage(hashedKey: hashedKey), let cachedImage = UIImage(data: cachedData) { - print("\(url): 기존 저장소에 이미지 존재 확인") - - // 캐시된 이미지 처리 - let processedImage = try await processImage(cachedImage, options: options) - await MainActor.run { [weak base] in guard let base else { return } - base.image = processedImage - print("\(url): 메모리에 위치한 이미지로 로드") + base.image = cachedImage applyTransition(to: base, with: options?.transition) } - return ( - ImageLoadingResult( - image: processedImage, - url: url, - originalData: cachedData - ), - nil + return ImageLoadingResult( + image: cachedImage, + url: url, + originalData: cachedData ) } - let imageTask = ImageTask() - - await setImageDownloadTask(imageTask) - - let downloadResult = try await ImageDownloadManager.shared.downloadImage(with: url) - print("\(url): 이미지 다운로드 완료") - try Task.checkCancellation() + if let task = objc_getAssociatedObject( + base, + &AssociatedKeys.downloadTask + ) as? DownloadTask { + try await task.cancelWithError() + setImageDownloadTask(nil) + } - let processedImage = try await processImage(downloadResult.image, options: options) - try Task.checkCancellation() + let downloadTask = try await ImageDownloader.default.createTask(with: url) + setImageDownloadTask(downloadTask) - // 캐시 저장 - if let data = processedImage.jpegData(compressionQuality: 0.8) { - try await ImageCache.shared.store(data, forKey: url.absoluteString) - print("\(url): 이미지 캐싱 완료") - } + let result = try await ImageDownloader.default.downloadImage( + with: downloadTask, + for: url, + hashedKey: hashedKey + ) - // 최종 UI 업데이트 await MainActor.run { [weak base] in guard let base else { return } - base.image = processedImage - print("\(url): 후처리된 이미지 렌더 완료") + base.image = result.image applyTransition(to: base, with: options?.transition) } - return ( - ImageLoadingResult( - image: processedImage, - url: url, - originalData: downloadResult.originalData - ), - imageTask - ) - } - - @MainActor - private func applyTransition(to imageView: UIImageView, with transition: ImageTransition?) { - guard let transition else { - return - } - - switch transition { - case .none: - break - case let .fade(duration): - UIView.transition( - with: imageView, - duration: duration, - options: .transitionCrossDissolve, - animations: nil, - completion: nil - ) - case let .flip(duration): - UIView.transition( - with: imageView, - duration: duration, - options: .transitionFlipFromLeft, - animations: nil, - completion: nil - ) - } + return result } - // MARK: - Public Async API + // MARK: - Wrapper + /// `Public Async API` /// async/await 패턴이 적용된 환경에서 사용가능한 래퍼 메서드입니다. + @discardableResult public func setImage( with url: URL?, - placeholder: UIImage? = nil, + placeholder _: UIImage? = nil, options: NeoImageOptions? = nil ) async throws -> ImageLoadingResult { - let (result, _) = try await setImageAsync( + try await setImageAsync( with: url, - placeholder: placeholder, options: options ) - - return result } - // MARK: - Public Completion Handler API - - @discardableResult + /// `Public Completion Handler API` public func setImage( with url: URL?, - placeholder: UIImage? = nil, + placeholder _: UIImage? = nil, options: NeoImageOptions? = nil, + isPriority: Bool = false, completion: (@MainActor @Sendable (Result) -> Void)? = nil - ) -> ImageTask? { - let task = ImageTask() - + ) { Task { @MainActor in do { - let (result, _) = try await setImageAsync( + let result = try await setImageAsync( with: url, - placeholder: placeholder, - options: options + options: options, + isPriority: isPriority ) completion?(.success(result)) } catch { - await task.fail() completion?(.failure(error)) } } - - return task } - private func processImage(_ image: UIImage, options: NeoImageOptions?) async throws -> UIImage { - if let processor = options?.processor { - return try await processor.process(image) + @MainActor + private func applyTransition(to imageView: UIImageView, with transition: ImageTransition?) { + guard let transition else { + return } - return image + switch transition { + case .none: + break + case let .fade(duration): + UIView.transition( + with: imageView, + duration: duration, + options: .transitionCrossDissolve, + animations: nil, + completion: nil + ) + case let .flip(duration): + UIView.transition( + with: imageView, + duration: duration, + options: .transitionFlipFromLeft, + animations: nil, + completion: nil + ) + } } // MARK: - Task Management - /// UIImageView는 기본적으로 ImageTask를 저장할 프로퍼티가 없습니다. + /// UIImageView는 기본적으로 DownloadTask를 저장할 프로퍼티가 없습니다. /// - /// 따라서, Objective-C의 런타임 기능을 사용해 UIImageView 인스턴스에 ImageTask를 동적으로 연결하여 저장합니다, + /// 따라서, Objective-C의 런타임 기능을 사용해 UIImageView 인스턴스에 DownloadTask를 동적으로 연결하여 저장합니다, /// 현재 진행중인 이미지 다운로드 작업 추적에 사용됩니다. - private func setImageDownloadTask(_ task: ImageTask?) async { + public func setImageDownloadTask(_ task: DownloadTask?) { // 모든 NSObject의 하위 클래스에 대해 사용할 수 있는 메서드이며, SWift에서는 @obj 마킹이 된 클래스도 대상으로 설정이 가능합니다. // 순수 Swift 타입인 struct와 enum, class에는 사용이 불가하기 때문에, NSObject를 상속하거나 @objc 속성을 사용해야 합니다. // - `UIView` 및 모든 하위 클래스 @@ -242,10 +198,9 @@ extension NeoImageWrapper where Base: UIImageView { // - NSArray // - NSDictionary // - URLSession - objc_setAssociatedObject( base, // 대상 객체 (UIImageView) - ImageTaskKey.associatedKey, // 키 값 + &AssociatedKeys.downloadTask, // 키 값 task, // 저장할 값 .OBJC_ASSOCIATION_RETAIN_NONATOMIC // 메모리 관리 정책 ) diff --git a/BookKitty/BookKitty/NeoImage/Tests/NeoImageTests/NeoImageDownloadTaskCancelTests.swift b/BookKitty/BookKitty/NeoImage/Tests/NeoImageTests/NeoImageDownloadTaskCancelTests.swift new file mode 100644 index 00000000..65f626be --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Tests/NeoImageTests/NeoImageDownloadTaskCancelTests.swift @@ -0,0 +1,143 @@ +import NeoImage +import Testing +import UIKit + +@Suite("NeoImage DownloadTask 취소 테스트") +struct NeoImageDownloadTaskCancelTests { + @Test("동일한 이미지뷰에 새로운 이미지 요청시 이전 다운로드 작업 취소 확인") + func testCancelOnNewRequest() async throws { + // 테스트 환경 준비 + let context = await TestContext() + + // 첫 번째 이미지 로드 시작 (완료되지 않을 큰 이미지) + let firstRequestStarted = await context.startDownload(with: context.largeImageURL) + #expect(firstRequestStarted, "첫 번째 다운로드 작업이 시작되어야 합니다") + + // 작업이 시작될 시간 부여 + try await Task.sleep(for: .milliseconds(200)) + + // 이미지뷰에 다운로드 작업이 연결되었는지 확인 + let hasFirstTask = await context.hasDownloadTask() + #expect(hasFirstTask, "이미지뷰에 다운로드 작업이 연결되어야 합니다") + + // 두 번째 이미지 로드 시작 (이전 작업을 취소해야 함) + let secondRequestStarted = await context.startDownload(with: context.secondImageURL) + #expect(secondRequestStarted, "두 번째 다운로드 작업이 시작되어야 합니다") + + // 두 번째 다운로드 완료까지 대기 + try await Task.sleep(for: .milliseconds(500)) + + // 첫 번째 다운로드가 취소되었는지 확인 + let wasCancelled = await context.wasLastDownloadCancelled() + #expect(wasCancelled, "첫 번째 다운로드 작업이 취소되어야 합니다") + + Task { + await context.cleanup() + } + } + + @Test("수동으로 DownloadTask 취소") + func testManualCancellation() async throws { + // 테스트 환경 준비 + let context = await TestContext() + + // 이미지 로드 시작 + let downloadStarted = await context.startDownload(with: context.largeImageURL) + #expect(downloadStarted, "다운로드 작업이 시작되어야 합니다") + + // 작업이 시작될 시간 부여 + try await Task.sleep(for: .milliseconds(200)) + + // 작업 수동 취소 + let wasCancelled = await context.cancelCurrentDownloadTask() + #expect(wasCancelled, "다운로드 작업이 수동으로 취소되어야 합니다") + Task { + await context.cleanup() + } + } +} + +/// 테스트를 위한 컨텍스트 클래스 +@MainActor +class TestContext { + // MARK: - Properties + + // 테스트용 윈도우와 이미지뷰 + var testWindow: UIWindow + var imageView: UIImageView + + // 테스트용 URL (큰 이미지와 작은 이미지) + let largeImageURL = URL(string: "https://picsum.photos/2000/2000")! + let secondImageURL = URL(string: "https://picsum.photos/id/237/300/300")! + + // 취소 상태 추적 + private var downloadWasCancelled = false + private var downloadCompleted = false + + // MARK: - Lifecycle + + init() { + // 테스트 윈도우 생성 + testWindow = UIWindow(frame: CGRect(x: 0, y: 0, width: 300, height: 300)) + testWindow.makeKeyAndVisible() + + // 이미지뷰 생성 + imageView = UIImageView(frame: testWindow.bounds) + imageView.contentMode = .scaleAspectFit + testWindow.addSubview(imageView) + + // 테스트 전 캐시 비우기 + ImageCache.shared.clearCache() + } + + // MARK: - Functions + + func cleanup() { + // 테스트 후 정리 + imageView.removeFromSuperview() + testWindow.isHidden = true + } + + /// 다운로드 시작 (async/await 방식) + func startDownload(with url: URL) async -> Bool { + do { + try await imageView.neo.setImage(with: url) + downloadCompleted = true + return true + } catch { + if let neoError = error as? NeoImageError, + case let .responseError(reason) = neoError, + case .cancelled = reason { + downloadWasCancelled = true + } + return true // 작업이 시작되었으나 취소됨 + } + } + + /// 현재 다운로드 작업 취소 + func cancelCurrentDownloadTask() async -> Bool { + if let task = objc_getAssociatedObject( + imageView, + &AssociatedKeys.downloadTask + ) as? DownloadTask { + await task.cancel() + // 취소 처리 결과 확인을 위한 대기 + try? await Task.sleep(for: .milliseconds(500)) + return true + } + return false + } + + /// 이미지뷰에 다운로드 작업이 연결되어 있는지 확인 + func hasDownloadTask() -> Bool { + let result = objc_getAssociatedObject(imageView, &AssociatedKeys.downloadTask) + print(result) + return objc_getAssociatedObject(imageView, &AssociatedKeys.downloadTask) as? DownloadTask != + nil + } + + /// 마지막 다운로드가 취소되었는지 확인 + func wasLastDownloadCancelled() -> Bool { + downloadWasCancelled + } +} diff --git a/BookKitty/BookKitty/NeoImage/Tests/NeoImageTests/NeoImageTests.swift b/BookKitty/BookKitty/NeoImage/Tests/NeoImageTests/NeoImageTests.swift deleted file mode 100644 index 5ca39737..00000000 --- a/BookKitty/BookKitty/NeoImage/Tests/NeoImageTests/NeoImageTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -@testable import NeoImage -import XCTest - -final class NeoImageTests: XCTestCase { - func testExample() throws { - // XCTest Documentation - // https://developer.apple.com/documentation/xctest - - // Defining Test Cases and Test Methods - // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods - } -} diff --git a/BookKitty/BookKitty/NeoImage/Tests/NeoImageTests/PerformanceTest.swift b/BookKitty/BookKitty/NeoImage/Tests/NeoImageTests/PerformanceTest.swift new file mode 100644 index 00000000..579d58c4 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Tests/NeoImageTests/PerformanceTest.swift @@ -0,0 +1,612 @@ +import Kingfisher +import NeoImage +import Testing +import UIKit + +actor PerformanceResultsManager { + // MARK: - Properties + + private(set) var neoImageTimes: [Double] = [] + private(set) var kingfisherTimes: [Double] = [] + + // MARK: - Functions + + func resetTimes() { + neoImageTimes = [] + kingfisherTimes = [] + } + + func addNeoImageTime(_ time: Double) { + neoImageTimes.append(time) + } + + func addKingfisherTime(_ time: Double) { + kingfisherTimes.append(time) + } + + func getNeoImageStats() -> (average: Double, min: Double, max: Double) { + guard !neoImageTimes.isEmpty else { + return (0, 0, 0) + } + let validTimes = neoImageTimes.filter { $0 > 0 } + guard !validTimes.isEmpty else { + return (0, 0, 0) + } + + let avg = validTimes.reduce(0, +) / Double(validTimes.count) + let min = validTimes.min() ?? 0 + let max = validTimes.max() ?? 0 + + return (avg, min, max) + } + + func getKingfisherStats() -> (average: Double, min: Double, max: Double) { + guard !kingfisherTimes.isEmpty else { + return (0, 0, 0) + } + let validTimes = kingfisherTimes.filter { $0 > 0 } + guard !validTimes.isEmpty else { + return (0, 0, 0) + } + + let avg = validTimes.reduce(0, +) / Double(validTimes.count) + let min = validTimes.min() ?? 0 + let max = validTimes.max() ?? 0 + + return (avg, min, max) + } +} + +/// 테스트 인프라를 설정하고 관리하는 클래스 +@MainActor +class ImageTestingContext { + // MARK: - Properties + + /// 테스트용 이미지 URL 목록 + let testImageURLs: [URL] = [ + URL(string: "https://picsum.photos/id/1/2000/2000")!, + URL(string: "https://picsum.photos/id/2/2000/2000")!, + URL(string: "https://picsum.photos/id/3/2000/2000")!, + URL(string: "https://picsum.photos/id/4/2000/2000")!, + URL(string: "https://picsum.photos/id/5/2000/2000")!, + URL(string: "https://picsum.photos/id/6/2000/2000")!, + URL(string: "https://picsum.photos/id/7/2000/2000")!, + URL(string: "https://picsum.photos/id/8/2000/2000")!, + URL(string: "https://picsum.photos/id/9/2000/2000")!, + URL(string: "https://picsum.photos/id/10/2000/2000")!, + URL(string: "https://picsum.photos/id/11/2000/2000")!, + URL(string: "https://picsum.photos/id/12/2000/2000")!, + URL(string: "https://picsum.photos/id/13/2000/2000")!, + URL(string: "https://picsum.photos/id/14/2000/2000")!, + URL(string: "https://picsum.photos/id/15/2000/2000")!, + URL(string: "https://picsum.photos/id/16/2000/2000")!, + URL(string: "https://picsum.photos/id/17/2000/2000")!, + URL(string: "https://picsum.photos/id/18/2000/2000")!, + URL(string: "https://picsum.photos/id/19/2000/2000")!, + URL(string: "https://picsum.photos/id/20/2000/2000")!, + URL(string: "https://picsum.photos/id/21/2000/2000")!, + URL(string: "https://picsum.photos/id/22/2000/2000")!, + URL(string: "https://picsum.photos/id/23/2000/2000")!, + URL(string: "https://picsum.photos/id/24/2000/2000")!, + URL(string: "https://picsum.photos/id/25/2000/2000")!, + URL(string: "https://picsum.photos/id/26/2000/2000")!, + URL(string: "https://picsum.photos/id/27/2000/2000")!, + URL(string: "https://picsum.photos/id/28/2000/2000")!, + URL(string: "https://picsum.photos/id/29/2000/2000")!, + URL(string: "https://picsum.photos/id/30/2000/2000")!, + URL(string: "https://picsum.photos/id/31/2000/2000")!, + URL(string: "https://picsum.photos/id/32/2000/2000")!, + URL(string: "https://picsum.photos/id/33/2000/2000")!, + URL(string: "https://picsum.photos/id/34/2000/2000")!, + URL(string: "https://picsum.photos/id/35/2000/2000")!, + URL(string: "https://picsum.photos/id/36/2000/2000")!, + ] + + // 테스트 인프라 + var testWindow: UIWindow! + let resultsManager = PerformanceResultsManager() + let testImageSize = CGSize(width: 160, height: 160) + let gridImageCount = 36 + + // 테스트 컨테이너 + var neoImageViews: [UIImageView] = [] + var kingfisherImageViews: [UIImageView] = [] + var containerView: UIView! + + // MARK: - Lifecycle + + init() { + // 테스트 윈도우 설정 + testWindow = UIWindow(frame: UIScreen.main.bounds) + testWindow.makeKeyAndVisible() + + // 컨테이너 뷰 설정 + containerView = UIView(frame: testWindow.bounds) + testWindow.addSubview(containerView) + + // 이미지뷰 배열 초기화 + neoImageViews = [] + kingfisherImageViews = [] + + // 이미지 뷰 생성 및 배치 + setupImageViews() + } + + // MARK: - Functions + + func cleanUp() { + // UI 자원 정리 + containerView.removeFromSuperview() + testWindow.isHidden = true + + // 참조 제거 + neoImageViews = [] + kingfisherImageViews = [] + containerView = nil + testWindow = nil + } + + func clearAllCaches() async { + // NeoImage 캐시 지우기 + ImageCache.shared.clearCache() + + // Kingfisher 캐시 지우기 + KingfisherManager.shared.cache.clearMemoryCache() + await KingfisherManager.shared.cache.clearDiskCache() + + // 결과 초기화 + await resultsManager.resetTimes() + } + + // MARK: - NeoImage 테스트 메서드 + + func loadWithNeoImageAsync(imageView: UIImageView, url: URL) async throws -> Double { + let startTime = Date() + do { + try await imageView.neo.setImage(with: url) + let elapsedTime = Date().timeIntervalSince(startTime) + print("loaded with NeoImage in \(String(format: "%.3f", elapsedTime)) seconds") + + return elapsedTime + } catch { + print("Error loading image with NeoImage: \(error)") + throw error + } + } + + func loadWithNeoImage(imageView: UIImageView, url: URL) async throws -> Double { + let startTime = Date() + return try await withCheckedThrowingContinuation { continuation in + imageView.neo.setImage( + with: url + ) { result in + switch result { + case .success: + let elapsedTime = Date().timeIntervalSince(startTime) + print("loaded with NeoImage in \(String(format: "%.6f", elapsedTime)) seconds") + continuation.resume(returning: elapsedTime) + case let .failure(error): + print("Error loading image with Kingfisher: \(error)") + continuation.resume(throwing: error) + } + } + } + } + + func loadWithKingfisher(imageView: UIImageView, url: URL) async throws -> Double { + let startTime = Date() + return try await withCheckedThrowingContinuation { continuation in + imageView.kf.setImage( + with: url, + options: nil + ) { result in + switch result { + case .success: + let elapsedTime = Date().timeIntervalSince(startTime) + print( + "loaded with Kingfisher in \(String(format: "%.3f", elapsedTime)) seconds" + ) + continuation.resume(returning: elapsedTime) + case let .failure(error): + print("Error loading image with Kingfisher: \(error)") + continuation.resume(throwing: error) + } + } + } + } + + func loadImagesWithNeoImage() async throws { + for (index, url) in testImageURLs.prefix(gridImageCount).enumerated() { + guard index < neoImageViews.count else { + break + } + + do { + let imageView = neoImageViews[index] + let elapsedTime = try await loadWithNeoImageAsync(imageView: imageView, url: url) + await resultsManager.addNeoImageTime(elapsedTime) + } catch { + print("Error in NeoImage test at index \(index): \(error)") + await resultsManager.addNeoImageTime(-1.0) + } + } + } + + func loadSameImagesWithNeoImage() async throws { + for index in 0 ..< 36 { + do { + let imageView = neoImageViews[index] + let elapsedTime = try await loadWithNeoImageAsync( + imageView: imageView, + url: testImageURLs[0] + ) + await resultsManager.addNeoImageTime(elapsedTime) + } catch { + print("Error in Kingfisher test at index \(index): \(error)") + await resultsManager.addNeoImageTime(-1.0) + } + } + } + + // MARK: - Kingfisher 테스트 메서드 + + func loadImagesWithKingfisher() async throws { + for (index, url) in testImageURLs.prefix(gridImageCount).enumerated() { + guard index < kingfisherImageViews.count else { + break + } + + do { + let imageView = kingfisherImageViews[index] + let elapsedTime = try await loadWithKingfisher(imageView: imageView, url: url) + await resultsManager.addKingfisherTime(elapsedTime) + } catch { + print("Error in Kingfisher test at index \(index): \(error)") + await resultsManager.addKingfisherTime(-1.0) + } + } + } + + func loadSameImagesWithKingfisher() async throws { + for index in 0 ..< 36 { + do { + let imageView = kingfisherImageViews[index] + let elapsedTime = try await loadWithKingfisher( + imageView: imageView, + url: testImageURLs[0] + ) + await resultsManager.addKingfisherTime(elapsedTime) + } catch { + print("Error in Kingfisher test at index \(index): \(error)") + await resultsManager.addKingfisherTime(-1.0) + } + } + } + + private func setupImageViews() { + // 그리드 설정 + let columns = 2 + let spacing: CGFloat = 10 + let width = testImageSize.width + let height = testImageSize.height + + // NeoImage와 Kingfisher 뷰 생성 + for i in 0 ..< gridImageCount { + let row = i / columns + let col = i % columns + + let x = CGFloat(col) * (width + spacing) + let y = CGFloat(row) * (height + spacing) + + // NeoImage용 이미지뷰 + let neoFrame = CGRect(x: x, y: y, width: width, height: height) + let neoImageView = UIImageView(frame: neoFrame) + neoImageView.contentMode = .scaleAspectFill + neoImageView.clipsToBounds = true + neoImageView.tag = i // 태그로 인덱스 저장 + containerView.addSubview(neoImageView) + neoImageViews.append(neoImageView) + + // Kingfisher용 이미지뷰 + let kfFrame = CGRect(x: x + width * 2 + spacing * 2, y: y, width: width, height: height) + let kfImageView = UIImageView(frame: kfFrame) + kfImageView.contentMode = .scaleAspectFill + kfImageView.clipsToBounds = true + kfImageView.tag = i // 태그로 인덱스 저장 + containerView.addSubview(kfImageView) + kingfisherImageViews.append(kfImageView) + } + } +} + +// MARK: - Swift Testing Tests + +@Suite("NeoImage Performance Tests") +struct NeoImagePerformanceTests { + @Test("NeoImage 기본 성능 테스트") + func testNeoImagePerformance() async throws { + let context = await ImageTestingContext() + + // 모든 캐시 비우기 + await context.clearAllCaches() + + // 이미지 로드 + try await context.loadImagesWithNeoImage() + + // 결과 분석 + let stats = await context.resultsManager.getNeoImageStats() + + print(""" + NeoImage Performance: + 평균: \(String(format: "%.3f", stats.average)) 초 + 최단소요: \(String(format: "%.3f", stats.min)) 초 + 최장소요: \(String(format: "%.3f", stats.max)) 초 + """) + + // 기준에 따른 성능 검증 + #expect(stats.average < 2.0, "NeoImage 평균 로딩 시간이 2초 이내여야 합니다") + await context.cleanUp() + } + + @Test("Kingfisher 기본 성능 테스트") + func testKingfisherPerformance() async throws { + let context = await ImageTestingContext() + + // 모든 캐시 비우기 + await context.clearAllCaches() + + // 이미지 로드 + try await context.loadImagesWithKingfisher() + + // 결과 분석 + let stats = await context.resultsManager.getKingfisherStats() + + print(""" + Kingfisher Performance: + 평균: \(String(format: "%.3f", stats.average)) 초 + 최단소요: \(String(format: "%.3f", stats.min)) 초 + 최장소요: \(String(format: "%.3f", stats.max)) 초 + """) + + // 기준에 따른 성능 검증 + #expect(stats.average < 2.0, "Kingfisher 평균 로딩 시간이 2초 이내여야 합니다") + await context.cleanUp() + } + + /// 1 + @Test("NeoImage와 Kingfisher 성능 비교") + func testCompareLibraryPerformance() async throws { + let context = await ImageTestingContext() + + // 모든 캐시 비우기 + await context.clearAllCaches() + + // 두 라이브러리 모두 테스트 + try await context.loadImagesWithNeoImage() + try await context.loadImagesWithKingfisher() + + // 결과 가져오기 + let neoStats = await context.resultsManager.getNeoImageStats() + let kfStats = await context.resultsManager.getKingfisherStats() + + // 성능 비교 출력 + print(""" + ======== 성능 비교 결과 ======== + NeoImage 평균: \(String(format: "%.3f", neoStats.average)) 초 (최소: \(String(format: "%.3f", + neoStats + .min)), 최대: \( + String( + format: "%.3f", + neoStats.max + ) + )) + Kingfisher 평균: \(String(format: "%.3f", kfStats.average)) 초 (최소: \(String(format: "%.3f", + kfStats + .min)), 최대: \( + String( + format: "%.3f", + kfStats.max + ) + )) + 차이: \(String(format: "%.3f", abs(neoStats.average - kfStats.average))) 초 + ============================== + """) + + // 각 라이브러리가 기준 시간 내에 동작하는지 확인 + #expect(neoStats.average < 2.0, "NeoImage 평균 로딩 시간이 2초 이내여야 합니다") + #expect(kfStats.average < 2.0, "Kingfisher 평균 로딩 시간이 2초 이내여야 합니다") + await context.cleanUp() + } + + /// 2 + @Test("NeoImage와 Kingfisher 중복 이미지 다운르도 방지 기능 비교") + func testCompareLibraryPerformanceForSameImages() async throws { + let context = await ImageTestingContext() + + // 모든 캐시 비우기 + await context.clearAllCaches() + + // 두 라이브러리 모두 테스트 + try await context.loadSameImagesWithNeoImage() + try await context.loadSameImagesWithKingfisher() + + // 결과 가져오기 + let neoStats = await context.resultsManager.getNeoImageStats() + let kfStats = await context.resultsManager.getKingfisherStats() + + // 성능 비교 출력 + print(""" + ======== 성능 비교 결과 ======== + NeoImage 평균: \(String(format: "%.3f", neoStats.average)) 초 (최소: \(String(format: "%.3f", + neoStats + .min)), 최대: \( + String( + format: "%.3f", + neoStats.max + ) + )) + Kingfisher 평균: \(String(format: "%.3f", kfStats.average)) 초 (최소: \(String(format: "%.3f", + kfStats + .min)), 최대: \( + String( + format: "%.3f", + kfStats.max + ) + )) + 차이: \(String(format: "%.3f", abs(neoStats.average - kfStats.average))) 초 + ============================== + """) + + // 각 라이브러리가 기준 시간 내에 동작하는지 확인 + #expect(neoStats.average < 2.0, "NeoImage 평균 로딩 시간이 2초 이내여야 합니다") + #expect(kfStats.average < 2.0, "Kingfisher 평균 로딩 시간이 2초 이내여야 합니다") + await context.cleanUp() + } + + /// 3 + @Test("캐시 성능 비교: NeoImage vs Kingfisher") + func testCompareCachePerformance() async throws { + let context = await ImageTestingContext() + + // 모든 캐시 비우기 + await context.clearAllCaches() + + // 첫 번째 로드 - 캐시 없음 + try await context.loadImagesWithNeoImage() + let neoFirstLoadStats = await context.resultsManager.getNeoImageStats() + + await context.resultsManager.resetTimes() + + try await context.loadImagesWithKingfisher() + let kfFirstLoadStats = await context.resultsManager.getKingfisherStats() + + // 결과 초기화 + await context.resultsManager.resetTimes() + + // 두 번째 로드 - 캐시됨 + try await context.loadImagesWithNeoImage() + let neoSecondLoadStats = await context.resultsManager.getNeoImageStats() + + await context.resultsManager.resetTimes() + try await context.loadImagesWithKingfisher() + let kfSecondLoadStats = await context.resultsManager.getKingfisherStats() + + // 개선율 계산 + let neoImprovementRate = 1 - (neoSecondLoadStats.average / neoFirstLoadStats.average) + let kfImprovementRate = 1 - (kfSecondLoadStats.average / kfFirstLoadStats.average) + + print(""" + ======== 캐시 성능 비교 ======== + NeoImage 개선율: \(String(format: "%.1f", neoImprovementRate * 100))% + Kingfisher 개선율: \(String(format: "%.1f", kfImprovementRate * 100))% + ============================== + """) + + // 각 라이브러리의 캐시 개선율을 확인 + #expect(neoImprovementRate > 0.5, "NeoImage 캐시 사용 시 최소 50% 이상 속도가 개선되어야 합니다") + #expect(kfImprovementRate > 0.5, "Kingfisher 캐시 사용 시 최소 50% 이상 속도가 개선되어야 합니다") + + await context.cleanUp() + } + + @Test("NeoImage 중복 이미지 다운르도 방지 기능 비교") + func testNeoImagePerformanceForSameImages() async throws { + let context = await ImageTestingContext() + await context.clearAllCaches() + + try await context.loadSameImagesWithNeoImage() + + let neoStats = await context.resultsManager.getNeoImageStats() + + print(""" + ======== 성능 비교 결과 ======== + NeoImage 평균: \(String(format: "%.3f", neoStats.average)) 초 (최소: \(String(format: "%.3f", + neoStats + .min)), 최대: \( + String( + format: "%.3f", + neoStats.max + ) + )) + ============================== + """) + + #expect(neoStats.average < 2.0, "NeoImage 평균 로딩 시간이 2초 이내여야 합니다") + await context.cleanUp() + } + + @Test("NeoImage 캐시 성능 테스트") + func testNeoImageCachePerformance() async throws { + let context = await ImageTestingContext() + + // 모든 캐시 비우기 + await context.clearAllCaches() + + // 첫 번째 로드 - 캐시 없음 + try await context.loadImagesWithNeoImage() + let firstLoadStats = await context.resultsManager.getNeoImageStats() + + await context.resultsManager.resetTimes() + + try await context.loadImagesWithNeoImage() + let secondLoadStats = await context.resultsManager.getNeoImageStats() + + print(""" + ======== NeoImage 캐시 성능 ======== + 첫 번째 로드 평균: \(String(format: "%.3f", firstLoadStats.average)) 초 + 두 번째 로드 평균: \(String(format: "%.3f", secondLoadStats.average)) 초 + 개선율: \(String( + format: "%.1f", + (1 - secondLoadStats.average / firstLoadStats.average) * 100 + ))% + ================================== + """) + + // 캐시된 로드가 더 빨라야 함 + #expect(secondLoadStats.average < firstLoadStats.average, "캐시된 이미지 로드가 더 빨라야 합니다") + + // 최소 50% 이상 개선되어야 함 (이 값은 조정 가능) + let improvementRate = 1 - (secondLoadStats.average / firstLoadStats.average) + #expect(improvementRate > 0.5, "캐시 사용 시 최소 50% 이상 속도가 개선되어야 합니다") + await context.cleanUp() + } + + @Test("Kingfisher 캐시 성능 테스트") + func testKingfisherCachePerformance() async throws { + let context = await ImageTestingContext() + + // 모든 캐시 비우기 + await context.clearAllCaches() + + // 첫 번째 로드 - 캐시 없음 + try await context.loadImagesWithKingfisher() + let firstLoadStats = await context.resultsManager.getKingfisherStats() + + // 결과 초기화 + await context.resultsManager.resetTimes() + + // 두 번째 로드 - 캐시됨 + try await context.loadImagesWithKingfisher() + let secondLoadStats = await context.resultsManager.getKingfisherStats() + + print(""" + ======== Kingfisher 캐시 성능 ======== + 첫 번째 로드 평균: \(String(format: "%.3f", firstLoadStats.average)) 초 + 두 번째 로드 평균: \(String(format: "%.3f", secondLoadStats.average)) 초 + 개선율: \(String( + format: "%.1f", + (1 - secondLoadStats.average / firstLoadStats.average) * 100 + ))% + ==================================== + """) + + // 캐시된 로드가 더 빨라야 함 + #expect(secondLoadStats.average < firstLoadStats.average, "캐시된 이미지 로드가 더 빨라야 합니다") + + // 최소 50% 이상 개선되어야 함 (이 값은 조정 가능) + let improvementRate = 1 - (secondLoadStats.average / firstLoadStats.average) + #expect(improvementRate > 0.5, "캐시 사용 시 최소 50% 이상 속도가 개선되어야 합니다") + + await context.cleanUp() + } +} diff --git a/BookKitty/BookKitty/Source/Feature/MyLibrary/View/MyLibraryCollectionViewCell.swift b/BookKitty/BookKitty/Source/Feature/MyLibrary/View/MyLibraryCollectionViewCell.swift index dc180fcf..7cf12778 100644 --- a/BookKitty/BookKitty/Source/Feature/MyLibrary/View/MyLibraryCollectionViewCell.swift +++ b/BookKitty/BookKitty/Source/Feature/MyLibrary/View/MyLibraryCollectionViewCell.swift @@ -41,9 +41,18 @@ final class MyLibraryCollectionViewCell: UICollectionViewCell { // MARK: - Functions - // TODO: 고도화 필요 func configureCell(imageUrl: URL?) { - cellImageView.neo.setImage(with: imageUrl) + let startTime = Date() + + cellImageView.neo.setImage(with: imageUrl, isPriority: true) { result in + switch result { + case .success: + let elapsedTime = Date().timeIntervalSince(startTime) + print("loaded with NeoImage in \(String(format: "%.5f", elapsedTime)) seconds") + case let .failure(error): + print("Error loading image with Kingfisher: \(error)") + } + } } private func configureHierarchy() { diff --git a/BookKitty/BookKitty/Source/Repository/LocalBookRepository.swift b/BookKitty/BookKitty/Source/Repository/LocalBookRepository.swift index 1fffdf3d..f810e641 100644 --- a/BookKitty/BookKitty/Source/Repository/LocalBookRepository.swift +++ b/BookKitty/BookKitty/Source/Repository/LocalBookRepository.swift @@ -77,7 +77,7 @@ struct LocalBookRepository: BookRepository { isbnList: isbnList, context: context ) - BookKittyLogger.log("ISBN 배열로부터 책 가져오기 성공") +// BookKittyLogger.log("ISBN 배열로부터 책 가져오기 성공") return bookEntities.compactMap { bookCoreDataManager.entityToModel(entity: $0) } } diff --git a/BookKitty/BookKitty/Source/Repository/Mock/MockBookRepository.swift b/BookKitty/BookKitty/Source/Repository/Mock/MockBookRepository.swift index 38492dd5..c73ca567 100644 --- a/BookKitty/BookKitty/Source/Repository/Mock/MockBookRepository.swift +++ b/BookKitty/BookKitty/Source/Repository/Mock/MockBookRepository.swift @@ -105,7 +105,5 @@ final class MockBookRepository: BookRepository { func recodeOwnedBooksCount() { let count = mockBookList.filter { $0.isOwned == true } .count - - print(count) } }