From 94f48ee07192e6ff99a9352fec94e2d52006f2fc Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Sun, 9 Mar 2025 10:22:07 +0900 Subject: [PATCH 01/10] =?UTF-8?q?[Refactor]=20NeoImage=20=EB=A6=AC?= =?UTF-8?q?=ED=8E=99=ED=86=A0=EB=A7=81=20=EB=B0=8F=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?Migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UIImageView/FlexibleImageView.swift | 3 +- .../UIImageView/HeightFixedImageView.swift | 3 +- .../UIImageView/WidthFixedImageView.swift | 3 +- .../Sources/NeoImage/Cache/DiskStorage.swift | 428 +++++++++--------- .../Sources/NeoImage/Cache/ImageCache.swift | 108 ++--- .../NeoImage/Cache/MemoryStorage.swift | 138 +++++- .../NeoImage/Constants/CacheError.swift | 35 +- .../Constants/NeoImageConstants.swift | 4 + .../NeoImage/Constants/NeoImageOptions.swift | 21 +- .../NeoImage/Constants/TimeConstants.swift | 9 - .../NeoImage/Sources/NeoImage/Delegate.swift | 117 +++++ .../Sources/NeoImage/Extensions/Date+.swift | 13 +- .../Sources/NeoImage/NeoImageManager.swift | 81 ++++ .../NeoImage/Networking/DownloadTask.swift | 46 ++ .../Networking/ImageDownloadManager.swift | 91 ---- .../NeoImage/Networking/ImageDownloader.swift | 217 +++++++++ .../NeoImage/Networking/ImageTask.swift | 127 +----- .../NeoImage/Networking/SessionDataTask.swift | 153 +++++++ .../NeoImage/Networking/SessionDelegate.swift | 240 +++++++++- .../ImageView+NeoImage.swift} | 151 ++---- .../NeoImage/Wrapper/SwiftUI+NeoImage.swift | 316 +++++++++++++ 21 files changed, 1652 insertions(+), 652 deletions(-) create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/NeoImageConstants.swift delete mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/TimeConstants.swift create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Delegate.swift create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageManager.swift create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/DownloadTask.swift delete mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloadManager.swift create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloader.swift create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDataTask.swift rename BookKitty/BookKitty/NeoImage/Sources/NeoImage/{Extensions/NeoImageWrapper.swift => Wrapper/ImageView+NeoImage.swift} (67%) create mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Wrapper/SwiftUI+NeoImage.swift 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 1cec208..68d18dc 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 5169bcd..2fb840a 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 f8e4319..50b21fa 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/Sources/NeoImage/Cache/DiskStorage.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/DiskStorage.swift index bd29d28..beb18ef 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/DiskStorage.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/DiskStorage.swift @@ -1,168 +1,229 @@ import Foundation -class DiskStorage: @unchecked Sendable { +public actor DiskStorage { // MARK: - Properties - private let config: Config + var maybeCached: Set? + private let name: String + private let fileManager: FileManager 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() + init( + name: String, + fileManager: FileManager + ) { + self.name = name + self.fileManager = fileManager + + let url = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0] + let cacheName = "com.neon.NeoImage.ImageCache.\(name)" + + directoryURL = url.appendingPathComponent(cacheName, isDirectory: true) + + Task { + await setupCacheChecking() + try? await prepareDirectory() + } } // MARK: - Functions func store(value: T, forKey key: String, expiration: StorageExpiration? = nil) async throws { + guard storageReady else { + throw CacheError.storageNotReady + } + + let expiration = expiration ?? NeoImageConstants.expiration + + guard !expiration.isExpired else { + return + } + guard let data = try? value.toData() else { - throw CacheError.invalidData + throw CacheError.cannotConvertToData(object: value) } - // 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) + let fileURL = cacheFileURL(forKey: key) + + // 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 CacheError.cannotSetCacheFileAttribute( + filePath: fileURL.path, + attributes: attributes, + error: error + ) } + + maybeCached?.insert(fileURL.lastPathComponent) } func value( forKey key: String, // 캐시의 키 - extendingExpiration: ExpirationExtending = .cacheTime // 현재 Confiㅎ + actuallyLoad: Bool = true, + extendingExpiration: ExpirationExtending = .cacheTime // 현재 Config ) 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 - } + guard storageReady else { + throw CacheError.storageNotReady + } - // 파일에서 데이터를 읽어옴 - 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, - ] + let fileURL = cacheFileURL(forKey: key) + let filePath = fileURL.path + guard maybeCached?.contains(fileURL.lastPathComponent) ?? true else { + return nil + } - try FileManager.default.setAttributes(attributes, ofItemAtPath: fileURL.path) + 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 + // .expirationTime: 지정된 새로운 만료 시간으로 연장 + case let .expirationTime(storageExpiration): + expirationDate = storageExpiration.estimatedExpirationSinceNow } - return obj + 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) - } - } + let fileURL = cacheFileURL(forKey: key) + try fileManager.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) - } - } + try fileManager.removeItem(at: directoryURL) + try prepareDirectory() } - /// 캐시 확인 func isCached(forKey key: String) async -> Bool { - let fileURL = cacheFileURL(forKey: key) - return await serialActor.run { - FileManager.default.fileExists(atPath: fileURL.path) + do { + let result = try await value( + forKey: key, + actuallyLoad: false + ) + + return result != nil + } catch { + return false } } + + func removeExpiredValues() throws -> [URL] { + try removeExpiredValues(referenceDate: Date()) + } } extension DiskStorage { - private func cacheFileURL(forKey key: String, forcedExtension: String? = nil) -> URL { - let fileName = cacheFileName(forKey: key, forcedExtension: forcedExtension) - + private func cacheFileURL(forKey key: String) -> URL { + let fileName = cacheFileName(forKey: key) 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)" + private func cacheFileName(forKey key: String) -> String { + key.sha256 + } + + // 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 } - return hashedKey - } else { - if let ext = forcedExtension ?? config.pathExtension { - return "\(key).\(ext)" + } + + 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 + ) + else { + throw CacheError.fileEnumeratorCreationFailed + } + + guard let urls = directoryEnumerator.allObjects as? [URL] else { + throw CacheError.fileEnumeratorCreationFailed + } + return urls + } + + private func setupCacheChecking() { + do { + maybeCached = Set() + try fileManager.contentsOfDirectory(atPath: directoryURL.path).forEach { + fileName in + self.maybeCached?.insert(fileName) } - // 해시화 설정을 false로 하고, pathExtension에 별도 조작을 하지 않을 경우, - // key를 그대로 반환하는 경우도 있습니다. - return key + } catch { + maybeCached = nil } } private func prepareDirectory() throws { // config에 custom fileManager를 주입할 수 있기 때문에, 여기서 .default를 접근하지 않고 Config 내부 fileManager를 // 접근합니다. - let fileManager = config.fileManager let path = directoryURL.path // Creation 구조체를 통해 생성된 url이 FileSystem에 존재하는지 검증 @@ -171,7 +232,6 @@ extension DiskStorage { } do { - // FileManager를 통해 해당 path에 디렉토리 생성 try fileManager.createDirectory( atPath: path, withIntermediateDirectories: true, @@ -180,124 +240,80 @@ extension DiskStorage { } catch { // 만일 디렉토리 생성이 실패할경우, storageReady를 false로 변경합니다. // 이는 추후 flag로 동작합니다. + print("error creating New Directory") 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 { + struct FileMeta { // 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? + let url: URL + let lastAccessDate: Date? + let estimatedExpirationDate: Date? // 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 + 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? ) { - self.name = name - self.fileManager = fileManager - self.directory = directory - self.sizeLimit = sizeLimit + url = fileURL + self.lastAccessDate = lastAccessDate + self.estimatedExpirationDate = estimatedExpirationDate } - } -} -extension DiskStorage { - struct Creation { - // MARK: - Properties + // MARK: - Functions - let directoryURL: URL - let cacheName: String + func expired(referenceDate: Date) -> Bool { + estimatedExpirationDate?.isPast(referenceDate: referenceDate) ?? true + } - // MARK: - Lifecycle + func extendExpiration( + with fileManager: FileManager, + extendingExpiration: ExpirationExtending + ) { + guard let lastAccessDate, + let lastEstimatedExpiration = estimatedExpirationDate else { + return + } - init(_ config: Config) { - let url: URL - if let directory = config.directory { - url = directory - } else { - url = config.fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0] + 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, + ] } - cacheName = "com.neoself.NeoImage.ImageCache.\(config.name)" - directoryURL = config.cachePathBlock(url, cacheName) + try? fileManager.setAttributes(attributes, ofItemAtPath: url.path) } } } diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/ImageCache.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/ImageCache.swift index 19d6fa6..a640bde 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/ImageCache.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/ImageCache.swift @@ -1,70 +1,68 @@ import Foundation +import UIKit /// 쓰기 제어와 같은 동시성이 필요한 부분만 선택적으로 제어하기 위해 전체 ImageCache를 actor로 변경하지 않고, ImageCacheActor 생성 /// actor를 사용하면 모든 동작이 actor의 실행큐를 통과해야하기 때문에, 동시성 보호가 불필요한 read-only 동작도 직렬화되며 오버헤드가 발생 -@globalActor -public actor ImageCacheActor { - public static let shared = ImageCacheActor() -} - -public final class ImageCache: @unchecked Sendable { +public final class ImageCache: 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") + public static let shared = ImageCache(name: "default") // MARK: - Properties - private let memoryStorage: MemoryStorageActor - private let diskStorage: DiskStorage + public let memoryStorage: MemoryStorage + public let diskStorage: DiskStorage // MARK: - Lifecycle - // MARK: - Initialization - - public init(name: String) throws { - guard !name.isEmpty else { - throw CacheError.invalidCacheKey + public init( + name: String + ) { + if name.isEmpty { + fatalError( + "You should specify a name for the cache. A cache with empty name is not permitted." + ) } - // 메모리 캐싱 관련 설정 과정입니다. - // NSProcessInfo를 통해 총 메모리 크기를 접근한 후, 메모리 상한선을 전체 메모리의 1/4로 한정합니다. let totalMemory = ProcessInfo.processInfo.physicalMemory let memoryLimit = totalMemory / 4 - memoryStorage = MemoryStorageActor( + + memoryStorage = MemoryStorage( totalCostLimit: min(Int.max, Int(memoryLimit)) ) - // 디스크 캐시에 대한 설정을 여기서 정의해줍니다. - let diskConfig = DiskStorage.Config( + diskStorage = DiskStorage( name: name, - sizeLimit: 0, - directory: FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + fileManager: .default ) - // 디스크 캐시 제어 관련 클래스 인스턴스 생성 - diskStorage = try DiskStorage(config: diskConfig) + 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 + ) + } // 각 알림에 대해 옵저버 등록 + } } // 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) + memoryStorage.store(value: data, forKey: key, expiration: expiration) try await diskStorage.store( value: data, @@ -73,20 +71,15 @@ public final class ImageCache: @unchecked Sendable { ) } - /// 캐시로부터 저장된 이미지를 가져옵니다. - /// 1차적으로 오버헤드가 적은 메모리를 먼저 확인합니다. - /// 이후 메모리에 없을 경우, 디스크를 확인합니다. - /// 디스크에 없을 경우 throw합니다. - /// 디스크에 데이터를 확인할 경우, 다음 조회를 위해 해당 데이터를 메모리로 올립니다. public func retrieveImage(forKey key: String) async throws -> Data? { - if let memoryData = await memoryStorage.value(forKey: key) { + if let memoryData = memoryStorage.value(forKey: key) { return memoryData } let diskData = try await diskStorage.value(forKey: key) if let diskData { - await memoryStorage.store( + memoryStorage.store( value: diskData, forKey: key, expiration: .days(7) @@ -96,29 +89,26 @@ public final class ImageCache: @unchecked Sendable { 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() + 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 - } + @objc + public func clearMemoryCache() { + memoryStorage.removeAll() + } - return await diskStorage.isCached(forKey: key) + @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/NeoImage/Cache/MemoryStorage.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/MemoryStorage.swift index 5865cc3..c0a9476 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/MemoryStorage.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/MemoryStorage.swift @@ -1,44 +1,148 @@ import Foundation -public actor MemoryStorageActor { +public class MemoryStorage: @unchecked Sendable { // MARK: - Properties + var keys = Set() + /// 캐시는 NSCache로 접근합니다. - private let cache = NSCache() + private let storage = NSCache() private let totalCostLimit: Int + private var cleanTimer: Timer? + private let lock = NSLock() + // MARK: - Lifecycle init(totalCostLimit: Int) { // 메모리가 사용할 수 있는 공간 상한선 (ImageCache 클래스에서 총 메모리공간의 1/4로 주입하고 있음) 데이터를 아래 private 속성에 주입시킵니다. self.totalCostLimit = totalCostLimit - cache.totalCostLimit = totalCostLimit + storage.totalCostLimit = totalCostLimit + + cleanTimer = .scheduledTimer(withTimeInterval: 120, repeats: true) { [weak self] _ in + guard let self else { + return + } + removeExpired() + } } // MARK: - Functions - /// 캐시에 저장 - func store(value: Data, forKey key: String, expiration _: StorageExpiration?) { - cache.setObject(value as NSData, forKey: key as NSString) + public func removeExpired() { + lock.lock() + defer { lock.unlock() } + for key in keys { + let nsKey = key as NSString + guard let object = storage.object(forKey: nsKey) else { + // This could happen if the object is moved by cache `totalCostLimit` or + // `countLimit` rule. + // We didn't remove the key yet until now, since we do not want to introduce + // additional lock. + // See https://github.com/onevcat/Kingfisher/issues/1233 + keys.remove(key) + continue + } + + if object.isExpired { + storage.removeObject(forKey: nsKey) + keys.remove(key) + } + } } - /// 캐시에서 조회 - func value(forKey key: String) -> Data? { - cache.object(forKey: key as NSString) as Data? + /// 캐시에서 있는지 여부를 조회 + public func isCached(forKey key: String) -> Bool { + guard let _ = value(forKey: key, extendingExpiration: .none) else { + return false + } + return true } /// 캐시에서 제거 - func remove(forKey key: String) { - cache.removeObject(forKey: key as NSString) + public func remove(forKey key: String) { + lock.lock() + defer { lock.unlock() } + storage.removeObject(forKey: key as NSString) + keys.remove(key) } - /// 캐시에서 일괄 제거 - func removeAll() { - cache.removeAllObjects() + /// Removes all values in this storage. + public func removeAll() { + lock.lock() + defer { lock.unlock() } + storage.removeAllObjects() + keys.removeAll() } - /// 캐시에서 있는지 여부를 조회 - func isCached(forKey key: String) -> Bool { - cache.object(forKey: key as NSString) != nil + /// 캐시에 저장 + func store( + value: Data, + forKey key: String, + expiration: StorageExpiration? = nil + ) { + lock.lock() + defer { lock.unlock() } + let expiration = expiration ?? NeoImageConstants.expiration + // The expiration indicates that already expired, no need to store. + guard !expiration.isExpired else { + return + } + + let object = StorageObject(value as Data, expiration: expiration) + + storage.setObject(object, forKey: key as NSString) + keys.insert(key) + } + + /// 캐시에서 조회 + func value(forKey key: String, extendingExpiration: ExpirationExtending = .cacheTime) -> Data? { + guard let object = storage.object(forKey: key as NSString) else { + return nil + } + if object.isExpired { + return nil + } + object.extendExpiration(extendingExpiration) + return object.value + } +} + +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/NeoImage/Constants/CacheError.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/CacheError.swift index 0bc2b10..b69ccbc 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/CacheError.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/CacheError.swift @@ -1,10 +1,14 @@ -enum CacheError: Error { +import Foundation + +enum CacheError: Sendable, Error { // 데이터 관련 에러 case invalidData case invalidImage case dataToImageConversionFailed case imageToDataConversionFailed + case fileEnumeratorCreationFailed + // 저장소 관련 에러 case diskStorageError(Error) case memoryStorageError(Error) @@ -16,6 +20,14 @@ enum CacheError: Error { case cannotWriteToFile(Error) case cannotReadFromFile(Error) + case invalidURLResource + case cannotConvertToData(object: Sendable) + case cannotSetCacheFileAttribute( + filePath: String, + attributes: [FileAttributeKey: Sendable], + error: any Error + ) + /// 캐시 키 관련 에러 case invalidCacheKey @@ -52,6 +64,27 @@ enum CacheError: Error { return "The cache key is invalid" case let .unknown(error): return "Unknown error: \(error.localizedDescription)" + default: + return "" } } } + +public enum NeoImageError: Error { + case requestError(reason: RequestErrorReason) + case responseError(reason: ResponseErrorReason) + + // MARK: - Nested Types + + public enum RequestErrorReason: Sendable { + case invalidURL(request: URLRequest) + case emptyRequest + case taskCancelled(task: SessionDataTask, token: Int) + } + + public enum ResponseErrorReason: Sendable { + case URLSessionError(description: String) + case cancelled + case invalidImageData + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/NeoImageConstants.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/NeoImageConstants.swift new file mode 100644 index 0000000..a82f171 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/NeoImageConstants.swift @@ -0,0 +1,4 @@ +enum NeoImageConstants { + static let associatedKey = "com.neoimage.UIImageView.ImageTask" + static let expiration = StorageExpiration.days(7) +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/NeoImageOptions.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/NeoImageOptions.swift index c03f7c5..fd59189 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/NeoImageOptions.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/NeoImageOptions.swift @@ -17,29 +17,25 @@ public struct NeoImageOptions: Sendable { /// 이미지 전환 효과 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 self.transition = transition - self.retryStrategy = retryStrategy self.cacheExpiration = cacheExpiration } } /// 이미지 전환 효과 열거형 -public enum ImageTransition: Sendable { +public enum ImageTransition: Sendable, Equatable { /// 전환 효과 없음 case none /// 페이드 인 효과 @@ -48,23 +44,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/NeoImage/Constants/TimeConstants.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/TimeConstants.swift deleted file mode 100644 index 6fb4806..0000000 --- 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/Delegate.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Delegate.swift new file mode 100644 index 0000000..3ec3960 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Delegate.swift @@ -0,0 +1,117 @@ +import Foundation + +/// 클로저 기반의 델리게이트 패턴을 구현한 유틸리티 클래스. +/// 클로저를 저장하고 호출하는 역할을 수행 +/// 메모리 관리와 스레드 안정성을 고려하여 설계 +/// Delegate는 클로저(block 또는 asyncBlock)를 저장하고, 필요할 때 호출 +/// 입력(Input)을 받아 출력(Output)을 반환하는 클로저를 관리 +public class Delegate: @unchecked Sendable { + // MARK: - Properties + + /// 스레드 안정성을 보장할 수있는 DispatchQueue를 사용하기 위해 이벤트 핸들링 구현 시, Delegate 클래스를 사용합니다. + private let propertyQueue = DispatchQueue(label: "com.neon.NeoImage.DelegateQueue") + + /// 클로저(block 또는 asyncBlock)를 저장하고, 필요할 때 호출 + private var _block: ((Input) -> Output?)? + /// 동기 클로저(block)와 비동기 클로저(asyncBlock)를 모두 지원 + private var _asyncBlock: ((Input) async -> Output?)? + + // MARK: - Computed Properties + + public var isSet: Bool { + block != nil || asyncBlock != nil + } + + private var block: ((Input) -> Output?)? { + get { propertyQueue.sync { _block } } + set { propertyQueue.sync { _block = newValue } } + } + + private var asyncBlock: ((Input) async -> Output?)? { + get { propertyQueue.sync { _asyncBlock } } + set { propertyQueue.sync { _asyncBlock = newValue } } + } + + // 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 } +} + +/// Output이 Optional인 경우, nil을 반환하는 기능을 제공 +extension Optional: OptionalProtocol { + public static var _createNil: Optional { + nil + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Date+.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Date+.swift index 5fc8aa3..4c483d7 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Date+.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Date+.swift @@ -2,6 +2,17 @@ import Foundation extension Date { var isPast: Bool { - self < Date() + 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/NeoImageManager.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageManager.swift new file mode 100644 index 0000000..33d7d7a --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageManager.swift @@ -0,0 +1,81 @@ +import Foundation +import UIKit + +public final class NeoImageManager: Sendable { + // MARK: - Static Properties + + /// NeoImage 전체에서 사용되는 공유 매니저 인스턴스 + public static let shared = NeoImageManager() + + // MARK: - Properties + + public let cache: ImageCache + public let downloader: ImageDownloader + + // MARK: - Lifecycle + + // MARK: - Initialization + + /// 지정된 다운로더와 캐시로 이미지 다운로드 매니저를 생성합니다. + public init( + downloader: ImageDownloader = .default, + cache: ImageCache = ImageCache(name: "default") + ) { + self.downloader = downloader + self.cache = cache + } + + // MARK: - Functions + + // MARK: - Image Downloading + + /// URL에서 이미지를 다운로드하고 처리합니다. + /// - Parameters: + /// - url: 이미지를 다운로드할 URL + /// - options: 이미지 처리 옵션 + /// - Returns: 처리된 이미지와 관련 정보를 포함하는 `ImageLoadingResult` + public func downloadImage( + with url: URL, + options: NeoImageOptions? = nil + ) async throws -> ImageLoadingResult { + let cacheKey = url.absoluteString + + if let cachedData = try? await cache.retrieveImage(forKey: cacheKey), + let cachedImage = UIImage(data: cachedData) { + return ImageLoadingResult( + image: cachedImage, + url: url, + originalData: cachedData + ) + } + + // 캐시에 없으면 다운로드 + let imageData = try await downloader.downloadImageData(with: url) + + guard let image = UIImage(data: imageData) else { + throw NeoImageError.responseError(reason: .invalidImageData) + } + + // 다운로드 결과 캐싱 + try? await cache.store(imageData, forKey: cacheKey) + + let result = ImageLoadingResult( + image: image, + url: url, + originalData: imageData + ) + + // 이미지 프로세서가 있으면 이미지 처리 + if let processor = options?.processor { + let processedImage = try await processor.process(result.image) + + return ImageLoadingResult( + image: processedImage, + url: url, + originalData: result.originalData + ) + } + + return result + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/DownloadTask.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/DownloadTask.swift new file mode 100644 index 0000000..355d35e --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/DownloadTask.swift @@ -0,0 +1,46 @@ +import Foundation + +public final actor DownloadTask: Sendable { + // MARK: - Properties + + private(set) var sessionTask: SessionDataTask? + private(set) var cancelToken: SessionDataTask.CancelToken? + + // MARK: - Computed Properties + + /// DownloadTask가 제대로 초기화되었는지 확인하는 메서드 + public var isInitialized: Bool { + sessionTask != nil && cancelToken != nil + } + + // MARK: - Lifecycle + + init( + sessionTask: SessionDataTask? = nil, + cancelToken: SessionDataTask.CancelToken? = nil + ) { + self.sessionTask = sessionTask + self.cancelToken = cancelToken + } + + // MARK: - Functions + + /// Cancel this single download task if it is running. + public func cancel() { + guard let sessionTask, let cancelToken else { + return + } + sessionTask.cancel(token: cancelToken) + } + + /// 다른 DownloadTask의 sessionTask와 cancelToken을 이 DownloadTask에 연결 + func linkToTask(_ task: DownloadTask) { + Task { + guard await task.isInitialized else { + return + } + await sessionTask = task.sessionTask + await cancelToken = task.cancelToken + } + } +} 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 85ac074..0000000 --- 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/ImageDownloader.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloader.swift new file mode 100644 index 0000000..78e814a --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloader.swift @@ -0,0 +1,217 @@ +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 + } +} + +typealias DownloadResult = Result + +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 requestsUsePipelining: Bool + private let sessionDelegate: SessionDelegate + + // MARK: - Lifecycle + + public init( + name: String, + requestsUsePipelining: Bool = false + ) { + self.name = name + self.requestsUsePipelining = requestsUsePipelining + sessionDelegate = SessionDelegate() + + session = URLSession( + configuration: URLSessionConfiguration.ephemeral, + delegate: sessionDelegate, + delegateQueue: nil + ) + } + + deinit { session.invalidateAndCancel() } + + // MARK: - Functions + + @discardableResult + public func downloadImageData(with url: URL) async throws -> Data { + let downloadTask = DownloadTask() + + let context = try await createDownloadContext(with: url) + let (actualDownloadTask, imageData) = try await createDownloadTask(context: context) + + await downloadTask.linkToTask(actualDownloadTask) + + return imageData + } + + /// Downloads an image with a URL and option. + /// - Parameters: + /// - url: Target URL. + /// - options: The options that can control download behavior. + /// - Returns: The image loading result. + public func downloadImage(with url: URL) async throws -> ImageLoadingResult { + // 이미지 데이터 다운로드 + let imageData = try await downloadImageData(with: url) + + guard let image = UIImage(data: imageData) else { + throw NeoImageError.responseError(reason: .invalidImageData) + } + + return ImageLoadingResult(image: image, url: url, originalData: imageData) + } + + private func createDownloadContext(with url: URL) async throws -> DownloadingContext { + var request = URLRequest( + url: url, + cachePolicy: .reloadIgnoringLocalCacheData, + timeoutInterval: downloadTimeout + ) + request.httpShouldUsePipelining = requestsUsePipelining + + guard let url = request.url, !url.absoluteString.isEmpty else { + throw NeoImageError.requestError(reason: .invalidURL(request: request)) + } + + return DownloadingContext(url: url, request: request) + } + + private func createDownloadTask(context: DownloadingContext) async throws + -> (DownloadTask, Data) { + // 여러 요청이 동시에 실행될 경우를 위한 액터 또는 동기화 메커니즘 필요 + try await withCheckedThrowingContinuation { continuation in + // 기존 태스크 확인 (중복 다운로드 방지) + if let existingTask = sessionDelegate.task(for: context.url) { + existingTask.onTaskDone.delegate(on: self) { _, values in + let (result, callbacks) = values + let downloadResult: DownloadResult + + switch result { + case let .success((data, _)): + if let image = UIImage(data: data) { + let imageResult = ImageLoadingResult( + image: image, + url: context.url, + originalData: data + ) + + downloadResult = .success(imageResult) + } else { + downloadResult = .failure( + NeoImageError + .responseError(reason: .invalidImageData) + ) + } + case let .failure(error): + downloadResult = .failure(error) + } + + // 모든 콜백 호출 + for callbackItem in callbacks { + callbackItem.onCompleted?.call(downloadResult) + } + } + + let downloadTask = DownloadTask() + + let onCompleted = Delegate() + + onCompleted.delegate(on: self) { _, result in + switch result { + case let .success(imageResult): + continuation.resume(returning: (downloadTask, imageResult.originalData)) + case let .failure(error): + continuation.resume(throwing: error) + } + } + + let callback = SessionDataTask.TaskCallback(onCompleted: onCompleted) + let task = sessionDelegate.append(existingTask, callback: callback) + + Task { + await downloadTask.linkToTask(task) + } + } else { + let sessionDataTask = session.dataTask(with: context.request) + let onCompleted = Delegate() + let callback = SessionDataTask.TaskCallback(onCompleted: onCompleted) + + let downloadTask = sessionDelegate.add( + sessionDataTask, + url: context.url, + callback: callback + ) + Task { + await downloadTask.sessionTask?.onTaskDone.delegate(on: self) { _, values in + let (result, callbacks) = values + + // 결과를 DownloadResult로 변환 + let downloadResult: DownloadResult + switch result { + case let .success((data, _)): + if let image = UIImage(data: data) { + let imageResult = ImageLoadingResult( + image: image, + url: context.url, + originalData: data + ) + downloadResult = .success(imageResult) + } else { + downloadResult = .failure( + NeoImageError + .responseError(reason: .invalidImageData) + ) + } + case let .failure(error): + downloadResult = .failure(error) + } + + // 모든 콜백 호출 + for callbackItem in callbacks { + callbackItem.onCompleted?.call(downloadResult) + } + } + } + + onCompleted.delegate(on: self) { _, result in + switch result { + case let .success(imageResult): + continuation.resume(returning: (downloadTask, imageResult.originalData)) + case let .failure(error): + continuation.resume(throwing: error) + } + } + + sessionDataTask.resume() + } + } + } +} + +extension ImageDownloader { + struct DownloadingContext { + let url: URL + let request: URLRequest + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageTask.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageTask.swift index 14342ad..2352e08 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageTask.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageTask.swift @@ -1,132 +1,31 @@ import Foundation -/// 이미지 다운로드 작업의 상태를 나타내는 열거형 -public enum ImageTaskState: Int, Sendable { - /// 대기 중 - case pending = 0 - /// 다운로드 중 - case downloading - /// 취소됨 - case cancelled - /// 완료됨 - case completed - /// 실패 - case failed -} - -/// 이미지 다운로드 작업을 관리하는 actor -public actor ImageTask: Sendable { +/// 이미지 작업을 나타내는 클래스로, 취소 기능을 제공합니다. +public final actor ImageTask { // 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 사용 - } + private var downloadTask: DownloadTask? + private var isCancelled = false // MARK: - Lifecycle - // MARK: - Initializer - public init() {} // MARK: - Functions - // MARK: - Task Management Methods - - /// 작업 취소 - public func cancel() { - guard state == .pending || state == .downloading else { - return - } - state = .cancelled + /// 작업을 취소합니다. + public func cancel() async { 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() + await downloadTask?.cancel() } - /// 작업 실패 - 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 func fail() async { + isCancelled = true } - public nonisolated func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(self)) + /// 다운로드 작업을 설정합니다. + public func setDownloadTask(_ task: DownloadTask) { + downloadTask = task } } diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDataTask.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDataTask.swift new file mode 100644 index 0000000..fab85b4 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDataTask.swift @@ -0,0 +1,153 @@ +import Foundation + +/// `ImageDownloader`에서 사용되는 세션 데이터 작업을 나타냅니다. +/// +/// 기본적으로 `SessionDataTask`는 `URLSessionDataTask`를 래핑하고 다운로드 데이터를 관리합니다. +/// `SessionDataTask/CancelToken`을 사용하여 작업을 추적하고 취소를 관리합니다. +public class SessionDataTask: @unchecked Sendable { + // MARK: - Nested Types + + /// 작업을 취소하는 데 사용되는 토큰 타입입니다. + public typealias CancelToken = Int + + /// 작업 콜백을 나타내는 구조체입니다. + struct TaskCallback { + let onCompleted: Delegate< + Result, + Void + >? // 작업 완료 시 호출될 콜백 + } + + // MARK: - Properties + + // `task.originalRequest?.url`의 복사본입니다. iOS 13에서 발생할 수 있는 레이스 컨디션을 방지하기 위해 사용됩니다. + // 참고: https://github.com/onevcat/Kingfisher/issues/1511 + public let originalURL: URL? + + /// 내부적으로 사용되는 다운로드 작업입니다. + /// + /// 이 작업은 오류가 발생했을 때 디버깅 목적으로만 사용됩니다. 이 작업의 내용을 수정하거나 직접 시작해서는 안 됩니다. + public let task: URLSessionDataTask + + /// 작업이 완료되었을 때 호출될 델리게이트입니다. + /// `클래스 내부에는 델리게이트 선언만 진행하고, 등록은 ImageDownloader의 createDownloadTask 메서드에서 진행합니다.` + let onTaskDone = Delegate<(Result<(Data, URLResponse?), NeoImageError>, [TaskCallback]), Void>() + /// 콜백이 취소되었을 때 호출될 델리게이트입니다. + let onCallbackCancelled = Delegate<(CancelToken, TaskCallback), Void>() + + var started = false // 작업이 시작되었는지 여부를 나타냄 + + private var _mutableData: Data // 다운로드된 데이터를 저장하는 변수 + private var callbacksStore = [CancelToken: TaskCallback]() // 콜백을 저장하는 딕셔너리 + + private var currentToken = 0 // 콜백을 식별하기 위한 고유 토큰 + private let lock = NSLock() // 스레드 안전성을 위한 lock + + // MARK: - Computed Properties + + // 현재 작업에서 다운로드된 원시 데이터입니다. + + /// ImageCache와 같은 공유 클래스에서 DispatchQueue를 사용한 것과 달리, locking 매커니즘을 사용하고 있습니다. + /// 이는 DispatchQueue를 사용한 동기화는 간편하지만, 큐에 작업을 넣고 실행하는 오버헤드가 있기 때문입니다. + /// 작은 청크로 자주 도착하는 네트워크 데이터의 경우, 빈번하고 짧은 연산이기에 직접적인 locking 매커니즘을 택하는 것이 더 적은 오버헤드로 이어집니다. + public var mutableData: Data { + lock.lock() + defer { lock.unlock() } + return _mutableData // 스레드 안전성을 위해 lock을 사용하여 데이터 접근 + } + + /// 다운로드가 완료되었을 때 + /// 다운로드 중 오류가 발생했을 때 + /// 다운로드 진행 상태가 업데이트되었을 때 + /// 위 이벤트 발생 시, 등록하여 매핑한 콜백이 호출 + var callbacks: [SessionDataTask.TaskCallback] { + lock.lock() + defer { lock.unlock() } + return Array(callbacksStore.values) // 현재 등록된 모든 콜백을 반환 + } + + var containsCallbacks: Bool { + // `task.state != .running`을 사용하여 확인할 수 있어야 하지만, + // 드물게 작업을 취소해도 작업 상태가 즉시 `.cancelling`으로 변경되지 않고 `.running` 상태로 남아있는 경우가 있습니다. + // 따라서 작업을 안전하게 제거하기 위해 콜백 개수를 확인합니다. + !callbacks.isEmpty + } + + // MARK: - Lifecycle + + /// `SessionDataTask`를 초기화합니다. + init(task: URLSessionDataTask) { + self.task = task + originalURL = task.originalRequest?.url // 원본 URL을 저장 + _mutableData = Data() // 데이터 저장을 위한 빈 `Data` 객체 초기화 + } + + // MARK: - Functions + + /// 새로운 콜백을 추가하고 고유 토큰을 반환합니다. + func addCallback(_ callback: TaskCallback) -> CancelToken { + lock.lock() + defer { lock.unlock() } + callbacksStore[currentToken] = callback // 콜백을 딕셔너리에 저장 + defer { currentToken += 1 } // 토큰 값을 증가시킴 + // 종료되기 `직전에` 호출되는 defer 키워드를 사용해 addCallback이 정상적으로 종료될때에만 실행되도록 하여, 코드의 안정성을 높일 수 있음. + return currentToken // 고유 토큰 반환 + } + + /// 특정 토큰에 해당하는 콜백을 제거하고 반환합니다. + /// 다운로드 작업의 중단 및 리소스 관리 관련 상황에서 호출됩니다. + /// 일일히 제거하는 이유는 불필요한 메모리 사용을 줄이는 것도 있지만, 추후 메모리 누수가 발생할 수도 있기 때문 + func removeCallback(_ token: CancelToken) -> TaskCallback? { + lock.lock() + defer { lock.unlock() } + if let callback = callbacksStore[token] { + callbacksStore[token] = nil // 콜백 제거 + return callback // 제거된 콜백 반환 + } + return nil // 해당 토큰에 대한 콜백이 없을 경우 nil 반환 + } + + /// 모든 콜백을 제거하고 제거된 콜백 목록을 반환합니다. + @discardableResult + func removeAllCallbacks() -> [TaskCallback] { + lock.lock() + defer { lock.unlock() } + let callbacks = callbacksStore.values // 모든 콜백을 가져옴 + callbacksStore.removeAll() // 딕셔너리 비우기 + return Array(callbacks) // 제거된 콜백 목록 반환 + } + + /// 작업을 시작합니다. + func resume() { + guard !started else { + return + } // 이미 시작된 작업은 다시 시작하지 않음 + started = true // 작업 상태를 시작됨으로 표시 + task.resume() // 내부 `URLSessionDataTask` 시작 + } + + /// 특정 토큰에 해당하는 작업을 취소합니다. + func cancel(token: CancelToken) { + guard let callback = removeCallback(token) else { + return // 해당 토큰에 대한 콜백이 없을 경우 종료 + } + + // removeCallback과 같이 직접적인 잠금 매커니즘이 적용되지 않았음에도, 사용하는 Delegate 객체들이 이미 내부적으로 스레드 안정성을 보장하고 + // 있음. + onCallbackCancelled.call((token, callback)) // 콜백 취소 이벤트 호출 + } + + /// 모든 콜백을 강제로 취소합니다. + func forceCancel() { + for token in callbacksStore.keys { + cancel(token: token) // 모든 토큰에 대해 취소 작업 수행 + } + } + + /// 데이터를 수신하고 저장합니다. + func didReceiveData(_ data: Data) { + lock.lock() + defer { lock.unlock() } + _mutableData.append(data) // 수신된 데이터를 기존 데이터에 추가 + } +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift index 17aabc7..ac1f273 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift @@ -1,46 +1,238 @@ + import Foundation -public class SessionDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable { +@objc(NeoImageDelegate) +open class SessionDelegate: NSObject, @unchecked Sendable { // MARK: - Properties - var onReceiveChallenge: ((URLAuthenticationChallenge) async -> ( - URLSession.AuthChallengeDisposition, - URLCredential? - ))? - var onValidateStatusCode: ((Int) -> Bool)? + let onValidStatusCode = Delegate() + let onReceiveChallenge = Delegate< + URLAuthenticationChallenge, + (URLSession.AuthChallengeDisposition, URLCredential?) + >() + let onDownloadingFinished = Delegate<(URL, Result), Void>() - private var tasks = [URL: URLSessionTask]() + private var tasks: [URL: SessionDataTask] = [:] + private let lock = NSLock() // MARK: - Functions - /// 필수 델리게이트 메서드만 구현 - public func urlSession( - _: URLSession, - task _: URLSessionTask, - didReceive challenge: URLAuthenticationChallenge - ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - await onReceiveChallenge?(challenge) ?? (.performDefaultHandling, nil) + /// Adds a new download task with a URL and callback + func add( + _ dataTask: URLSessionDataTask, + url: URL, + callback: SessionDataTask.TaskCallback + ) -> DownloadTask { + lock.lock() + defer { lock.unlock() } + + let task = SessionDataTask(task: dataTask) + + task.onCallbackCancelled.delegate(on: self) { [weak task] (self, value) in + guard let task else { + return + } + + let (token, callback) = value + + let error = NeoImageError.requestError(reason: .taskCancelled(task: task, token: token)) + task.onTaskDone.call((.failure(error), [callback])) + + // Remove the task if no other callbacks waiting + if !task.containsCallbacks { + let dataTask = task.task + self.cancelTask(dataTask) + self.remove(task) + } + } + + // Add callback and get token + let token = task.addCallback(callback) + tasks[url] = task + + return DownloadTask(sessionTask: task, cancelToken: token) + } + + /// Appends a callback to an existing task and returns a new DownloadTask + func append( + _ task: SessionDataTask, + callback: SessionDataTask.TaskCallback + ) -> DownloadTask { + let token = task.addCallback(callback) + return DownloadTask(sessionTask: task, cancelToken: token) + } + + /// Gets a task by URL + func task(for url: URL) -> SessionDataTask? { + lock.lock() + defer { lock.unlock() } + return tasks[url] + } + + /// Cancels all tasks + func cancelAll() { + lock.lock() + let taskValues = tasks.values + lock.unlock() + for task in taskValues { + task.forceCancel() + } + } + + /// Cancels a task for a URL + func cancel(url: URL) { + lock.lock() + let task = tasks[url] + lock.unlock() + task?.forceCancel() + } + + /// Cancels a URLSessionDataTask + private func cancelTask(_ dataTask: URLSessionDataTask) { + lock.lock() + defer { lock.unlock() } + dataTask.cancel() + } + + /// Removes a task + private func remove(_ task: SessionDataTask) { + lock.lock() + defer { lock.unlock() } + + guard let url = task.originalURL else { + return + } + + task.removeAllCallbacks() + tasks[url] = nil + } + + /// Gets a task by URLSessionTask + private func task(for task: URLSessionTask) -> SessionDataTask? { + lock.lock() + defer { lock.unlock() } + + guard let url = task.originalRequest?.url else { + return nil + } + guard let sessionTask = tasks[url] else { + return nil + } + guard sessionTask.task.taskIdentifier == task.taskIdentifier else { + return nil + } + return sessionTask } +} + +// MARK: - URLSessionDataDelegate - public func urlSession( +extension SessionDelegate: URLSessionDataDelegate { + open func urlSession( _: URLSession, - dataTask _: URLSessionDataTask, + dataTask: URLSessionDataTask, didReceive response: URLResponse ) async -> URLSession.ResponseDisposition { - guard let httpResponse = response as? HTTPURLResponse, - onValidateStatusCode?(httpResponse.statusCode) == true else { + guard response is HTTPURLResponse else { + let error = NeoImageError + .responseError(reason: .URLSessionError(description: "invalid http Response")) + onCompleted(task: dataTask, result: .failure(error)) + return .cancel } + return .allow } - func cancelTasks(for url: URL) { - tasks[url]?.cancel() - tasks[url] = nil + open func urlSession( + _: URLSession, + dataTask: URLSessionDataTask, + didReceive data: Data + ) { + guard let task = task(for: dataTask) else { + return + } + + task.didReceiveData(data) } - func cancelAllTasks() { - tasks.values.forEach { $0.cancel() } - tasks.removeAll() + open func urlSession( + _: URLSession, + task: URLSessionTask, + didCompleteWithError error: (any Error)? + ) { + guard let sessionTask = self.task(for: task) else { + return + } + if let url = sessionTask.originalURL { + let result: Result + if let error { + result = .failure( + NeoImageError + .responseError(reason: .URLSessionError( + description: error + .localizedDescription + )) + ) + } else if let response = task.response { + result = .success(response) + } else { + result = .failure( + NeoImageError + .responseError(reason: .URLSessionError(description: "no http Response")) + ) + } + + onDownloadingFinished.call((url, result)) + } + + let result: Result<(Data, URLResponse?), NeoImageError> + + if let error { + result = .failure( + NeoImageError + .responseError(reason: .URLSessionError( + description: error + .localizedDescription + )) + ) + } else { + result = .success((sessionTask.mutableData, task.response)) + } + + onCompleted(task: task, result: result) + } + + /// Called for task authentication challenge + open func urlSession( + _: URLSession, + task _: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge + ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + onReceiveChallenge(challenge) ?? (.performDefaultHandling, nil) + } + + private func onCompleted( + task: URLSessionTask, + result: Result<(Data, URLResponse?), NeoImageError> + ) { + // SessionDataTask 탐색 + guard let sessionTask = self.task(for: task) else { + return + } + + let finalResult: Result<(Data, URLResponse?), NeoImageError> + + if case .failure = result { + finalResult = result + } else { + finalResult = .success((sessionTask.mutableData, task.response)) + } + + let callbacks = sessionTask.removeAllCallbacks() + // 대응되는 SessionDataTask의 onTaskDone 델리게이트를 통해 결과 및 콜백 전달 + sessionTask.onTaskDone.call((finalResult, callbacks)) + + remove(sessionTask) } } diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/NeoImageWrapper.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Wrapper/ImageView+NeoImage.swift similarity index 67% rename from BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/NeoImageWrapper.swift rename to BookKitty/BookKitty/NeoImage/Sources/NeoImage/Wrapper/ImageView+NeoImage.swift index 5a52972..f430770 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/NeoImageWrapper.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Wrapper/ImageView+NeoImage.swift @@ -44,134 +44,56 @@ extension NeoImageWrapper where Base: UIImageView { throw CacheError.invalidData } - guard let url else { + if let placeholder { await MainActor.run { [weak base] in guard let base else { return } base.image = placeholder } - - throw CacheError.invalidData } + // TODO: gray로 차선책 placeholder 렌더 넣기 - // placeholder 먼저 설정 - if let placeholder { - await MainActor.run { [weak base] in - guard let base else { - return - } - base.image = placeholder - print("\(url): Placeholder 설정 완료") - } + guard let url else { + throw CacheError.invalidData } + // TODO: ImageTask 연결하기 // UIImageView에 연결된 ImageTask를 가져옵니다 // 현재 진행 중인 다운로드 작업이 있는지 확인하는데 사용됩니다 - if let task = objc_getAssociatedObject(base, ImageTaskKey.associatedKey) as? ImageTask { + if let task = objc_getAssociatedObject( + base, + NeoImageConstants.associatedKey + ) as? ImageTask { await task.cancel() await setImageDownloadTask(nil) - print("\(url): 기존 Task 존재하여 취소") - } - - let cacheKey = url.absoluteString - - // 메모리 또는 디스크 캐시에서 이미지 데이터 확인 - if let cachedData = try? await ImageCache.shared.retrieveImage(forKey: cacheKey), - 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): 메모리에 위치한 이미지로 로드") - - applyTransition(to: base, with: options?.transition) - } - - return ( - ImageLoadingResult( - image: processedImage, - url: url, - originalData: cachedData - ), - nil - ) } let imageTask = ImageTask() - await setImageDownloadTask(imageTask) - let downloadResult = try await ImageDownloadManager.shared.downloadImage(with: url) - print("\(url): 이미지 다운로드 완료") - try Task.checkCancellation() - - let processedImage = try await processImage(downloadResult.image, options: options) + // NeoImageManager를 사용해 이미지 다운로드 (캐시 확인 + 이미지 후처리) + let downloadResult = try await NeoImageManager.shared.downloadImage( + with: url, + options: options + ) try Task.checkCancellation() - // 캐시 저장 - if let data = processedImage.jpegData(compressionQuality: 0.8) { - try await ImageCache.shared.store(data, forKey: url.absoluteString) - print("\(url): 이미지 캐싱 완료") - } - - // 최종 UI 업데이트 + // UI 업데이트 await MainActor.run { [weak base] in guard let base else { return } - - base.image = processedImage - print("\(url): 후처리된 이미지 렌더 완료") + base.image = downloadResult.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 - ) - } +// imageTask.setDownloadTask(down) + return (downloadResult, imageTask) } - // MARK: - Public Async API + // MARK: - Wrapper + /// `Public Async API` /// async/await 패턴이 적용된 환경에서 사용가능한 래퍼 메서드입니다. public func setImage( with url: URL?, @@ -187,8 +109,7 @@ extension NeoImageWrapper where Base: UIImageView { return result } - // MARK: - Public Completion Handler API - + /// `Public Completion Handler API` @discardableResult public func setImage( with url: URL?, @@ -216,12 +137,32 @@ extension NeoImageWrapper where Base: UIImageView { 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 @@ -245,7 +186,7 @@ extension NeoImageWrapper where Base: UIImageView { objc_setAssociatedObject( base, // 대상 객체 (UIImageView) - ImageTaskKey.associatedKey, // 키 값 + NeoImageConstants.associatedKey, // 키 값 task, // 저장할 값 .OBJC_ASSOCIATION_RETAIN_NONATOMIC // 메모리 관리 정책 ) diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Wrapper/SwiftUI+NeoImage.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Wrapper/SwiftUI+NeoImage.swift new file mode 100644 index 0000000..57b0e0c --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Wrapper/SwiftUI+NeoImage.swift @@ -0,0 +1,316 @@ +import SwiftUI +import UIKit + +/// NeoImage 바인딩을 위한 ObservableObject +@MainActor +class NeoImageBinder: ObservableObject { + // MARK: - Properties + + /// 다운로드 작업 정보 + var imageTask: ImageTask? + // 이미지 로딩 상태와 결과 + @Published var loaded = false + @Published var animating = false + @Published var loadedImage: UIImage? = nil + @Published var progress = Progress() + + private var loading = false + + // MARK: - Computed Properties + + /// 로딩 상태 정보 + var loadingOrSucceeded: Bool { + loading || loadedImage != nil + } + + // MARK: - Lifecycle + + init() {} + + // MARK: - Functions + + func markLoading() { + loading = true + } + + func markLoaded() { + loaded = true + } + + /// 이미지 로딩 시작 + func start(url: URL?, options: NeoImageOptions?) async { + guard let url else { + loading = false + markLoaded() + return + } + + loading = true + progress = .init() + + do { + // 이미지 매니저를 통해 다운로드 + let result = try await NeoImageManager.shared.downloadImage(with: url, options: options) + + await MainActor.run { + loadedImage = result.image + loading = false + markLoaded() + } + } catch { + await MainActor.run { + loadedImage = nil + loading = false + markLoaded() + } + } + } + + /// 로딩 취소 + func cancel() async { + await imageTask?.cancel() + imageTask = nil + loading = false + } +} + +/// SwiftUI에서 사용 가능한 비동기 이미지 로딩 View +public struct NeoImage: View { + // MARK: - Nested Types + + /// 이미지 소스를 나타내는 열거형 + public enum Source { + case url(URL?) + case urlString(String?) + } + + // MARK: - SwiftUI Properties + + /// 이미지 로딩 바인더 + @StateObject private var binder = NeoImageBinder() + + // MARK: - Properties + + /// 이미지 소스 + private let source: Source + + // 옵션 및 콜백 + private var placeholder: AnyView? + private var options: NeoImageOptions + private var onSuccess: ((ImageLoadingResult) -> Void)? + private var onFailure: ((Error) -> Void)? + private var contentMode: SwiftUI.ContentMode + + // MARK: - Lifecycle + + // MARK: - Initializers + + /// URL로 초기화 + public init(url: URL?) { + source = .url(url) + options = .default + contentMode = .fill + } + + /// URL 문자열로 초기화 + public init(urlString: String?) { + source = .urlString(urlString) + options = .default + contentMode = .fill + } + + // MARK: - Content Properties + + // MARK: - View 구현 + + public var body: some View { + ZStack { + // 이미지가 로드된 경우 표시 + if let image = binder.loadedImage { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: contentMode == .fill ? .fill : .fit) + } + // 로딩 중이거나 로드되지 않은 경우 플레이스홀더 표시 + else if !binder.loaded || binder.loadedImage == nil { + if let placeholder { + placeholder + } + } + } + .clipped() // 이미지가 경계를 넘지 않도록 클리핑 + .onAppear { + // 뷰가 나타날 때 이미지 로딩 시작 + startLoading() + } + .onDisappear { + // 옵션에 따라 뷰가 사라질 때 로딩 취소 + if options.cancelOnDisappear { + Task { await binder.cancel() } + } + } + } + + // MARK: - Functions + + // MARK: - 모디파이어 + + /// 플레이스홀더 이미지 설정 + public func placeholder(_ content: @escaping () -> some View) -> NeoImage { + var result = self + result.placeholder = AnyView(content()) + return result + } + + /// 옵션 설정 + public func options(_ options: NeoImageOptions) -> NeoImage { + var result = self + result.options = options + return result + } + + /// 이미지 로딩 성공 시 호출될 콜백 + public func onSuccess(_ action: @escaping (ImageLoadingResult) -> Void) -> NeoImage { + var result = self + result.onSuccess = action + return result + } + + /// 이미지 로딩 실패 시 호출될 콜백 + public func onFailure(_ action: @escaping (Error) -> Void) -> NeoImage { + var result = self + result.onFailure = action + return result + } + + /// 이미지 프로세서 설정 모디파이어 + public func processor(_ processor: ImageProcessing) -> NeoImage { + var result = self + result.options = NeoImageOptions( + processor: processor, + transition: result.options.transition, + cacheExpiration: result.options.cacheExpiration + ) + return result + } + + /// 페이드 트랜지션 설정 + public func fade(duration: TimeInterval = 0.3) -> NeoImage { + var result = self + result.options = NeoImageOptions( + processor: result.options.processor, + transition: .fade(duration), + cacheExpiration: result.options.cacheExpiration + ) + return result + } + + /// 콘텐츠 모드 설정 (fill/fit) + public func contentMode(_ contentMode: SwiftUI.ContentMode) -> NeoImage { + var result = self + result.contentMode = contentMode + return result + } + + /// 뷰가 사라질 때 다운로드 취소 여부 설정 + public func cancelOnDisappear(_ cancel: Bool) -> NeoImage { + var result = self + var newOptions = result.options + newOptions.cancelOnDisappear = cancel + result.options = newOptions + return result + } + + private func startLoading() { + // 이미 로딩 중이거나 성공한 경우 스킵 + if binder.loadingOrSucceeded { + return + } + + let url: URL? = { + switch source { + case let .url(url): + return url + case let .urlString(string): + if let string { + return URL(string: string) + } + return nil + } + }() + + // 비동기 로딩 시작 + Task { + await binder.start(url: url, options: options) + + // 결과 처리 + if let image = binder.loadedImage, let url { + // 이미지 변환이 필요한 경우 + if options.transition != .none { + binder.animating = true + withAnimation( + Animation + .linear(duration: transitionDuration(for: options.transition)) + ) { + binder.animating = false + } + } + + // 성공 콜백 호출 + let result = ImageLoadingResult(image: image, url: url, originalData: Data()) + onSuccess?(result) + } else if url != nil { + // 실패 콜백 호출 + onFailure?(NeoImageError.responseError(reason: .invalidImageData)) + } + } + } + + private func transitionDuration(for transition: ImageTransition) -> TimeInterval { + switch transition { + case .none: + return 0 + case let .fade(duration): + return duration + case let .flip(duration): + return duration + } + } +} + +// MARK: - View Extensions for NeoImage + +/// NeoImage 생성을 위한 편의 확장 +extension View { + /// URL로부터 NeoImage를 생성하는 모디파이어 + public func neoImage( + url: URL?, + placeholder: AnyView? = nil, + options: NeoImageOptions = .default + ) -> some View { + let neoImage = NeoImage(url: url) + .options(options) + + if let placeholder { + return neoImage.placeholder { placeholder } + } + + return neoImage + } + + /// URL 문자열로부터 NeoImage를 생성하는 모디파이어 + public func neoImage( + urlString: String?, + placeholder: AnyView? = nil, + options: NeoImageOptions = .default + ) -> some View { + let neoImage = NeoImage(urlString: urlString) + .options(options) + + if let placeholder { + return neoImage.placeholder { placeholder } + } + + return neoImage + } +} From b091dcdb0bdc5085b7d892c779d198874ba97b84 Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Sun, 16 Mar 2025 15:40:27 +0900 Subject: [PATCH 02/10] =?UTF-8?q?[Refactor]=20=EC=9B=90=EB=B3=B8=20?= =?UTF-8?q?=EB=A0=88=ED=8F=AC=EC=97=90=EC=84=9C=EC=9D=98=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/NeoSelf1/Kingfisher_With_SwiftConcurrency --- BookKitty/BookKitty/NeoImage/Package.swift | 16 +- .../{NeoImage => }/Cache/DiskStorage.swift | 27 +- .../{NeoImage => }/Cache/ImageCache.swift | 42 ++-- .../{NeoImage => }/Cache/MemoryStorage.swift | 56 ++--- .../Sources/Constants/CacheError.swift | 76 ++++++ .../Constants/ExpirationExtending.swift | 0 .../Sources/Constants/NeoImageConstants.swift | 7 + .../Constants/NeoImageOptions.swift | 7 +- .../Sources/{NeoImage => }/Delegate.swift | 29 +-- .../{NeoImage => }/Extensions/Data+.swift | 0 .../{NeoImage => }/Extensions/Date+.swift | 0 .../{NeoImage => }/Extensions/String+.swift | 0 .../BookKitty/NeoImage/Sources/Logger.swift | 110 ++++++++ .../NeoImage/Constants/CacheError.swift | 90 ------- .../Constants/NeoImageConstants.swift | 4 - .../NeoImage/Image/ImageProcesser.swift | 167 ------------ .../NeoImage/Sources/NeoImage/NeoImage.swift | 2 - .../Sources/NeoImage/NeoImageManager.swift | 81 ------ .../NeoImage/Networking/DownloadTask.swift | 46 ---- .../NeoImage/Networking/ImageDownloader.swift | 217 ---------------- .../NeoImage/Networking/ImageTask.swift | 31 --- .../NeoImage/Networking/SessionDataTask.swift | 153 ----------- .../NeoImage/Networking/SessionDelegate.swift | 238 ------------------ .../Sources/Networking/DownloadTask.swift | 46 ++++ .../Sources/Networking/ImageDownloader.swift | 161 ++++++++++++ .../Sources/Networking/SessionDataTask.swift | 79 ++++++ .../Sources/Networking/SessionDelegate.swift | 198 +++++++++++++++ .../Protocols/DataTransformable.swift | 0 .../Wrapper/ImageView+NeoImage.swift | 87 ++++--- .../Wrapper/SwiftUI+NeoImage.swift | 62 ++--- 30 files changed, 835 insertions(+), 1197 deletions(-) rename BookKitty/BookKitty/NeoImage/Sources/{NeoImage => }/Cache/DiskStorage.swift (93%) rename BookKitty/BookKitty/NeoImage/Sources/{NeoImage => }/Cache/ImageCache.swift (76%) rename BookKitty/BookKitty/NeoImage/Sources/{NeoImage => }/Cache/MemoryStorage.swift (74%) create mode 100644 BookKitty/BookKitty/NeoImage/Sources/Constants/CacheError.swift rename BookKitty/BookKitty/NeoImage/Sources/{NeoImage => }/Constants/ExpirationExtending.swift (100%) create mode 100644 BookKitty/BookKitty/NeoImage/Sources/Constants/NeoImageConstants.swift rename BookKitty/BookKitty/NeoImage/Sources/{NeoImage => }/Constants/NeoImageOptions.swift (86%) rename BookKitty/BookKitty/NeoImage/Sources/{NeoImage => }/Delegate.swift (57%) rename BookKitty/BookKitty/NeoImage/Sources/{NeoImage => }/Extensions/Data+.swift (100%) rename BookKitty/BookKitty/NeoImage/Sources/{NeoImage => }/Extensions/Date+.swift (100%) rename BookKitty/BookKitty/NeoImage/Sources/{NeoImage => }/Extensions/String+.swift (100%) create mode 100644 BookKitty/BookKitty/NeoImage/Sources/Logger.swift delete mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/CacheError.swift delete mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/NeoImageConstants.swift delete mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Image/ImageProcesser.swift delete mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImage.swift delete mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageManager.swift delete mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/DownloadTask.swift delete mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloader.swift delete mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageTask.swift delete mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDataTask.swift delete mode 100644 BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift create mode 100644 BookKitty/BookKitty/NeoImage/Sources/Networking/DownloadTask.swift create mode 100644 BookKitty/BookKitty/NeoImage/Sources/Networking/ImageDownloader.swift create mode 100644 BookKitty/BookKitty/NeoImage/Sources/Networking/SessionDataTask.swift create mode 100644 BookKitty/BookKitty/NeoImage/Sources/Networking/SessionDelegate.swift rename BookKitty/BookKitty/NeoImage/Sources/{NeoImage => }/Protocols/DataTransformable.swift (100%) rename BookKitty/BookKitty/NeoImage/Sources/{NeoImage => }/Wrapper/ImageView+NeoImage.swift (70%) rename BookKitty/BookKitty/NeoImage/Sources/{NeoImage => }/Wrapper/SwiftUI+NeoImage.swift (86%) diff --git a/BookKitty/BookKitty/NeoImage/Package.swift b/BookKitty/BookKitty/NeoImage/Package.swift index 71cfcaa..cdab2da 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/NeoImage/Cache/DiskStorage.swift b/BookKitty/BookKitty/NeoImage/Sources/Cache/DiskStorage.swift similarity index 93% rename from BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/DiskStorage.swift rename to BookKitty/BookKitty/NeoImage/Sources/Cache/DiskStorage.swift index beb18ef..d4264e1 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/DiskStorage.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Cache/DiskStorage.swift @@ -34,7 +34,7 @@ public actor DiskStorage { func store(value: T, forKey key: String, expiration: StorageExpiration? = nil) async throws { guard storageReady else { - throw CacheError.storageNotReady + throw NeoImageError.cacheError(reason: .storageNotReady) } let expiration = expiration ?? NeoImageConstants.expiration @@ -44,7 +44,7 @@ public actor DiskStorage { } guard let data = try? value.toData() else { - throw CacheError.cannotConvertToData(object: value) + throw NeoImageError.cacheError(reason: .invalidData) } let fileURL = cacheFileURL(forKey: key) @@ -69,10 +69,11 @@ public actor DiskStorage { } catch { try? fileManager.removeItem(at: fileURL) - throw CacheError.cannotSetCacheFileAttribute( - filePath: fileURL.path, - attributes: attributes, - error: error + throw NeoImageError.cacheError( + reason: .cannotSetCacheFileAttribute( + path: fileURL.path, + attribute: attributes + ) ) } @@ -85,7 +86,7 @@ public actor DiskStorage { extendingExpiration: ExpirationExtending = .cacheTime // 현재 Config ) async throws -> T? { guard storageReady else { - throw CacheError.storageNotReady + throw NeoImageError.cacheError(reason: .storageNotReady) } let fileURL = cacheFileURL(forKey: key) @@ -198,14 +199,11 @@ extension DiskStorage { private func allFileURLs(for propertyKeys: [URLResourceKey]) throws -> [URL] { guard let directoryEnumerator = fileManager.enumerator( at: directoryURL, includingPropertiesForKeys: propertyKeys, options: .skipsHiddenFiles - ) - else { - throw CacheError.fileEnumeratorCreationFailed + ), + let urls = directoryEnumerator.allObjects as? [URL] else { + throw NeoImageError.cacheError(reason: .storageNotReady) } - guard let urls = directoryEnumerator.allObjects as? [URL] else { - throw CacheError.fileEnumeratorCreationFailed - } return urls } @@ -242,7 +240,8 @@ extension DiskStorage { // 이는 추후 flag로 동작합니다. print("error creating New Directory") storageReady = false - throw CacheError.cannotCreateDirectory(error) + + throw NeoImageError.cacheError(reason: .cannotCreateDirectory(error: error)) } } } diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/ImageCache.swift b/BookKitty/BookKitty/NeoImage/Sources/Cache/ImageCache.swift similarity index 76% rename from BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/ImageCache.swift rename to BookKitty/BookKitty/NeoImage/Sources/Cache/ImageCache.swift index a640bde..fabfa6d 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/ImageCache.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Cache/ImageCache.swift @@ -36,6 +36,8 @@ public final class ImageCache: Sendable { fileManager: .default ) + NeoLogger.shared.debug("initialized") + Task { @MainActor in let notifications: [(Notification.Name, Selector)] notifications = [ @@ -50,7 +52,7 @@ public final class ImageCache: Sendable { name: notification.0, object: nil ) - } // 각 알림에 대해 옵저버 등록 + } } } @@ -59,46 +61,46 @@ public final class ImageCache: Sendable { /// 메모리와 디스크 캐시에 모두 데이터를 저장합니다. public func store( _ data: Data, - forKey key: String, - expiration: StorageExpiration? = nil + forKey key: String ) async throws { - memoryStorage.store(value: data, forKey: key, expiration: expiration) + await memoryStorage.store(value: data, forKey: key) - try await diskStorage.store( - value: data, - forKey: key, - expiration: expiration - ) + try await diskStorage.store(value: data, forKey: key) } - public func retrieveImage(forKey key: String) async throws -> Data? { - if let memoryData = memoryStorage.value(forKey: key) { + public func retrieveImage(key: String) async throws -> Data? { + if let memoryData = await memoryStorage.value(forKey: key) { + print("Retriving from memory") return memoryData } let diskData = try await diskStorage.value(forKey: key) if let diskData { - memoryStorage.store( - value: diskData, - forKey: key, - expiration: .days(7) - ) + await memoryStorage.store(value: diskData, forKey: key, expiration: .days(7)) } return diskData } /// 메모리와 디스크 모두에 존재하는 모든 데이터를 제거합니다. - public func clearCache() async throws { - memoryStorage.removeAll() + public func clearCache() { + Task { + do { + await memoryStorage.removeAll() - try await diskStorage.removeAll() + try await diskStorage.removeAll() + } catch { + NeoLogger.shared.error("diskStorage clear failed") + } + } } @objc public func clearMemoryCache() { - memoryStorage.removeAll() + Task { + await memoryStorage.removeAll() + } } @objc diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/MemoryStorage.swift b/BookKitty/BookKitty/NeoImage/Sources/Cache/MemoryStorage.swift similarity index 74% rename from BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/MemoryStorage.swift rename to BookKitty/BookKitty/NeoImage/Sources/Cache/MemoryStorage.swift index c0a9476..4fc4dba 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Cache/MemoryStorage.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Cache/MemoryStorage.swift @@ -1,6 +1,6 @@ import Foundation -public class MemoryStorage: @unchecked Sendable { +public actor MemoryStorage { // MARK: - Properties var keys = Set() @@ -9,8 +9,7 @@ public class MemoryStorage: @unchecked Sendable { private let storage = NSCache() private let totalCostLimit: Int - private var cleanTimer: Timer? - private let lock = NSLock() + private var cleanTask: Task? // MARK: - Lifecycle @@ -19,27 +18,19 @@ public class MemoryStorage: @unchecked Sendable { self.totalCostLimit = totalCostLimit storage.totalCostLimit = totalCostLimit - cleanTimer = .scheduledTimer(withTimeInterval: 120, repeats: true) { [weak self] _ in - guard let self else { - return - } - removeExpired() + NeoLogger.shared.debug("initialized") + + Task { + await setupCleanTask() } } // MARK: - Functions public func removeExpired() { - lock.lock() - defer { lock.unlock() } for key in keys { let nsKey = key as NSString guard let object = storage.object(forKey: nsKey) else { - // This could happen if the object is moved by cache `totalCostLimit` or - // `countLimit` rule. - // We didn't remove the key yet until now, since we do not want to introduce - // additional lock. - // See https://github.com/onevcat/Kingfisher/issues/1233 keys.remove(key) continue } @@ -51,26 +42,14 @@ public class MemoryStorage: @unchecked Sendable { } } - /// 캐시에서 있는지 여부를 조회 - public func isCached(forKey key: String) -> Bool { - guard let _ = value(forKey: key, extendingExpiration: .none) else { - return false - } - return true - } - /// 캐시에서 제거 public func remove(forKey key: String) { - lock.lock() - defer { lock.unlock() } storage.removeObject(forKey: key as NSString) keys.remove(key) } /// Removes all values in this storage. public func removeAll() { - lock.lock() - defer { lock.unlock() } storage.removeAllObjects() keys.removeAll() } @@ -81,10 +60,8 @@ public class MemoryStorage: @unchecked Sendable { forKey key: String, expiration: StorageExpiration? = nil ) { - lock.lock() - defer { lock.unlock() } let expiration = expiration ?? NeoImageConstants.expiration - // The expiration indicates that already expired, no need to store. + guard !expiration.isExpired else { return } @@ -100,12 +77,31 @@ public class MemoryStorage: @unchecked Sendable { guard let object = storage.object(forKey: key 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 { diff --git a/BookKitty/BookKitty/NeoImage/Sources/Constants/CacheError.swift b/BookKitty/BookKitty/NeoImage/Sources/Constants/CacheError.swift new file mode 100644 index 0000000..e0f980e --- /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 0000000..9a4b2e3 --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/Constants/NeoImageConstants.swift @@ -0,0 +1,7 @@ +public enum AssociatedKeys { + public nonisolated(unsafe) static var downloadTask = "com.neoimage.UIImageView.DownloadTask" +} + +public enum NeoImageConstants { + public static let expiration = StorageExpiration.days(7) +} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/NeoImageOptions.swift b/BookKitty/BookKitty/NeoImage/Sources/Constants/NeoImageOptions.swift similarity index 86% rename from BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/NeoImageOptions.swift rename to BookKitty/BookKitty/NeoImage/Sources/Constants/NeoImageOptions.swift index fd59189..bd1113d 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/NeoImageOptions.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Constants/NeoImageOptions.swift @@ -11,10 +11,6 @@ import UIKit public struct NeoImageOptions: Sendable { // MARK: - Properties - /// 이미지 프로세서 - public let processor: ImageProcessing? - - /// 이미지 전환 효과 public let transition: ImageTransition /// 캐시 만료 정책 @@ -24,11 +20,10 @@ public struct NeoImageOptions: Sendable { // MARK: - Lifecycle public init( - processor: ImageProcessing? = nil, transition: ImageTransition = .none, cacheExpiration: StorageExpiration = .days(7) ) { - self.processor = processor + NeoLogger.shared.debug("initialized") self.transition = transition self.cacheExpiration = cacheExpiration } diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Delegate.swift b/BookKitty/BookKitty/NeoImage/Sources/Delegate.swift similarity index 57% rename from BookKitty/BookKitty/NeoImage/Sources/NeoImage/Delegate.swift rename to BookKitty/BookKitty/NeoImage/Sources/Delegate.swift index 3ec3960..9e5e0ae 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Delegate.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Delegate.swift @@ -1,20 +1,10 @@ import Foundation -/// 클로저 기반의 델리게이트 패턴을 구현한 유틸리티 클래스. -/// 클로저를 저장하고 호출하는 역할을 수행 -/// 메모리 관리와 스레드 안정성을 고려하여 설계 -/// Delegate는 클로저(block 또는 asyncBlock)를 저장하고, 필요할 때 호출 -/// 입력(Input)을 받아 출력(Output)을 반환하는 클로저를 관리 -public class Delegate: @unchecked Sendable { +public actor Delegate where Input: Sendable, Output: Sendable { // MARK: - Properties - /// 스레드 안정성을 보장할 수있는 DispatchQueue를 사용하기 위해 이벤트 핸들링 구현 시, Delegate 클래스를 사용합니다. - private let propertyQueue = DispatchQueue(label: "com.neon.NeoImage.DelegateQueue") - - /// 클로저(block 또는 asyncBlock)를 저장하고, 필요할 때 호출 - private var _block: ((Input) -> Output?)? - /// 동기 클로저(block)와 비동기 클로저(asyncBlock)를 모두 지원 - private var _asyncBlock: ((Input) async -> Output?)? + private var block: ((Input) -> Output?)? + private var asyncBlock: ((Input) async -> Output?)? // MARK: - Computed Properties @@ -22,23 +12,12 @@ public class Delegate: @unchecked Sendable { block != nil || asyncBlock != nil } - private var block: ((Input) -> Output?)? { - get { propertyQueue.sync { _block } } - set { propertyQueue.sync { _block = newValue } } - } - - private var asyncBlock: ((Input) async -> Output?)? { - get { propertyQueue.sync { _asyncBlock } } - set { propertyQueue.sync { _asyncBlock = newValue } } - } - // 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 { @@ -57,7 +36,6 @@ public class Delegate: @unchecked Sendable { } } - /// 등록된 클로저를 호출 public func call(_ input: Input) -> Output? { block?(input) } @@ -109,7 +87,6 @@ public protocol OptionalProtocol { static var _createNil: Self { get } } -/// Output이 Optional인 경우, nil을 반환하는 기능을 제공 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/NeoImage/Extensions/Date+.swift b/BookKitty/BookKitty/NeoImage/Sources/Extensions/Date+.swift similarity index 100% rename from BookKitty/BookKitty/NeoImage/Sources/NeoImage/Extensions/Date+.swift rename to BookKitty/BookKitty/NeoImage/Sources/Extensions/Date+.swift 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 0000000..c55d33d --- /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/Constants/CacheError.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/CacheError.swift deleted file mode 100644 index b69ccbc..0000000 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/CacheError.swift +++ /dev/null @@ -1,90 +0,0 @@ -import Foundation - -enum CacheError: Sendable, Error { - // 데이터 관련 에러 - case invalidData - case invalidImage - case dataToImageConversionFailed - case imageToDataConversionFailed - - case fileEnumeratorCreationFailed - - // 저장소 관련 에러 - case diskStorageError(Error) - case memoryStorageError(Error) - case storageNotReady - - // 파일 관련 에러 - case fileNotFound(String) // key - case cannotCreateDirectory(Error) - case cannotWriteToFile(Error) - case cannotReadFromFile(Error) - - case invalidURLResource - case cannotConvertToData(object: Sendable) - case cannotSetCacheFileAttribute( - filePath: String, - attributes: [FileAttributeKey: Sendable], - error: any 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)" - default: - return "" - } - } -} - -public enum NeoImageError: Error { - case requestError(reason: RequestErrorReason) - case responseError(reason: ResponseErrorReason) - - // MARK: - Nested Types - - public enum RequestErrorReason: Sendable { - case invalidURL(request: URLRequest) - case emptyRequest - case taskCancelled(task: SessionDataTask, token: Int) - } - - public enum ResponseErrorReason: Sendable { - case URLSessionError(description: String) - case cancelled - case invalidImageData - } -} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/NeoImageConstants.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/NeoImageConstants.swift deleted file mode 100644 index a82f171..0000000 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Constants/NeoImageConstants.swift +++ /dev/null @@ -1,4 +0,0 @@ -enum NeoImageConstants { - static let associatedKey = "com.neoimage.UIImageView.ImageTask" - static let expiration = StorageExpiration.days(7) -} 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 d31be48..0000000 --- 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 08b22b8..0000000 --- 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/NeoImageManager.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageManager.swift deleted file mode 100644 index 33d7d7a..0000000 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/NeoImageManager.swift +++ /dev/null @@ -1,81 +0,0 @@ -import Foundation -import UIKit - -public final class NeoImageManager: Sendable { - // MARK: - Static Properties - - /// NeoImage 전체에서 사용되는 공유 매니저 인스턴스 - public static let shared = NeoImageManager() - - // MARK: - Properties - - public let cache: ImageCache - public let downloader: ImageDownloader - - // MARK: - Lifecycle - - // MARK: - Initialization - - /// 지정된 다운로더와 캐시로 이미지 다운로드 매니저를 생성합니다. - public init( - downloader: ImageDownloader = .default, - cache: ImageCache = ImageCache(name: "default") - ) { - self.downloader = downloader - self.cache = cache - } - - // MARK: - Functions - - // MARK: - Image Downloading - - /// URL에서 이미지를 다운로드하고 처리합니다. - /// - Parameters: - /// - url: 이미지를 다운로드할 URL - /// - options: 이미지 처리 옵션 - /// - Returns: 처리된 이미지와 관련 정보를 포함하는 `ImageLoadingResult` - public func downloadImage( - with url: URL, - options: NeoImageOptions? = nil - ) async throws -> ImageLoadingResult { - let cacheKey = url.absoluteString - - if let cachedData = try? await cache.retrieveImage(forKey: cacheKey), - let cachedImage = UIImage(data: cachedData) { - return ImageLoadingResult( - image: cachedImage, - url: url, - originalData: cachedData - ) - } - - // 캐시에 없으면 다운로드 - let imageData = try await downloader.downloadImageData(with: url) - - guard let image = UIImage(data: imageData) else { - throw NeoImageError.responseError(reason: .invalidImageData) - } - - // 다운로드 결과 캐싱 - try? await cache.store(imageData, forKey: cacheKey) - - let result = ImageLoadingResult( - image: image, - url: url, - originalData: imageData - ) - - // 이미지 프로세서가 있으면 이미지 처리 - if let processor = options?.processor { - let processedImage = try await processor.process(result.image) - - return ImageLoadingResult( - image: processedImage, - url: url, - originalData: result.originalData - ) - } - - return result - } -} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/DownloadTask.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/DownloadTask.swift deleted file mode 100644 index 355d35e..0000000 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/DownloadTask.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Foundation - -public final actor DownloadTask: Sendable { - // MARK: - Properties - - private(set) var sessionTask: SessionDataTask? - private(set) var cancelToken: SessionDataTask.CancelToken? - - // MARK: - Computed Properties - - /// DownloadTask가 제대로 초기화되었는지 확인하는 메서드 - public var isInitialized: Bool { - sessionTask != nil && cancelToken != nil - } - - // MARK: - Lifecycle - - init( - sessionTask: SessionDataTask? = nil, - cancelToken: SessionDataTask.CancelToken? = nil - ) { - self.sessionTask = sessionTask - self.cancelToken = cancelToken - } - - // MARK: - Functions - - /// Cancel this single download task if it is running. - public func cancel() { - guard let sessionTask, let cancelToken else { - return - } - sessionTask.cancel(token: cancelToken) - } - - /// 다른 DownloadTask의 sessionTask와 cancelToken을 이 DownloadTask에 연결 - func linkToTask(_ task: DownloadTask) { - Task { - guard await task.isInitialized else { - return - } - await sessionTask = task.sessionTask - await cancelToken = task.cancelToken - } - } -} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloader.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloader.swift deleted file mode 100644 index 78e814a..0000000 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageDownloader.swift +++ /dev/null @@ -1,217 +0,0 @@ -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 - } -} - -typealias DownloadResult = Result - -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 requestsUsePipelining: Bool - private let sessionDelegate: SessionDelegate - - // MARK: - Lifecycle - - public init( - name: String, - requestsUsePipelining: Bool = false - ) { - self.name = name - self.requestsUsePipelining = requestsUsePipelining - sessionDelegate = SessionDelegate() - - session = URLSession( - configuration: URLSessionConfiguration.ephemeral, - delegate: sessionDelegate, - delegateQueue: nil - ) - } - - deinit { session.invalidateAndCancel() } - - // MARK: - Functions - - @discardableResult - public func downloadImageData(with url: URL) async throws -> Data { - let downloadTask = DownloadTask() - - let context = try await createDownloadContext(with: url) - let (actualDownloadTask, imageData) = try await createDownloadTask(context: context) - - await downloadTask.linkToTask(actualDownloadTask) - - return imageData - } - - /// Downloads an image with a URL and option. - /// - Parameters: - /// - url: Target URL. - /// - options: The options that can control download behavior. - /// - Returns: The image loading result. - public func downloadImage(with url: URL) async throws -> ImageLoadingResult { - // 이미지 데이터 다운로드 - let imageData = try await downloadImageData(with: url) - - guard let image = UIImage(data: imageData) else { - throw NeoImageError.responseError(reason: .invalidImageData) - } - - return ImageLoadingResult(image: image, url: url, originalData: imageData) - } - - private func createDownloadContext(with url: URL) async throws -> DownloadingContext { - var request = URLRequest( - url: url, - cachePolicy: .reloadIgnoringLocalCacheData, - timeoutInterval: downloadTimeout - ) - request.httpShouldUsePipelining = requestsUsePipelining - - guard let url = request.url, !url.absoluteString.isEmpty else { - throw NeoImageError.requestError(reason: .invalidURL(request: request)) - } - - return DownloadingContext(url: url, request: request) - } - - private func createDownloadTask(context: DownloadingContext) async throws - -> (DownloadTask, Data) { - // 여러 요청이 동시에 실행될 경우를 위한 액터 또는 동기화 메커니즘 필요 - try await withCheckedThrowingContinuation { continuation in - // 기존 태스크 확인 (중복 다운로드 방지) - if let existingTask = sessionDelegate.task(for: context.url) { - existingTask.onTaskDone.delegate(on: self) { _, values in - let (result, callbacks) = values - let downloadResult: DownloadResult - - switch result { - case let .success((data, _)): - if let image = UIImage(data: data) { - let imageResult = ImageLoadingResult( - image: image, - url: context.url, - originalData: data - ) - - downloadResult = .success(imageResult) - } else { - downloadResult = .failure( - NeoImageError - .responseError(reason: .invalidImageData) - ) - } - case let .failure(error): - downloadResult = .failure(error) - } - - // 모든 콜백 호출 - for callbackItem in callbacks { - callbackItem.onCompleted?.call(downloadResult) - } - } - - let downloadTask = DownloadTask() - - let onCompleted = Delegate() - - onCompleted.delegate(on: self) { _, result in - switch result { - case let .success(imageResult): - continuation.resume(returning: (downloadTask, imageResult.originalData)) - case let .failure(error): - continuation.resume(throwing: error) - } - } - - let callback = SessionDataTask.TaskCallback(onCompleted: onCompleted) - let task = sessionDelegate.append(existingTask, callback: callback) - - Task { - await downloadTask.linkToTask(task) - } - } else { - let sessionDataTask = session.dataTask(with: context.request) - let onCompleted = Delegate() - let callback = SessionDataTask.TaskCallback(onCompleted: onCompleted) - - let downloadTask = sessionDelegate.add( - sessionDataTask, - url: context.url, - callback: callback - ) - Task { - await downloadTask.sessionTask?.onTaskDone.delegate(on: self) { _, values in - let (result, callbacks) = values - - // 결과를 DownloadResult로 변환 - let downloadResult: DownloadResult - switch result { - case let .success((data, _)): - if let image = UIImage(data: data) { - let imageResult = ImageLoadingResult( - image: image, - url: context.url, - originalData: data - ) - downloadResult = .success(imageResult) - } else { - downloadResult = .failure( - NeoImageError - .responseError(reason: .invalidImageData) - ) - } - case let .failure(error): - downloadResult = .failure(error) - } - - // 모든 콜백 호출 - for callbackItem in callbacks { - callbackItem.onCompleted?.call(downloadResult) - } - } - } - - onCompleted.delegate(on: self) { _, result in - switch result { - case let .success(imageResult): - continuation.resume(returning: (downloadTask, imageResult.originalData)) - case let .failure(error): - continuation.resume(throwing: error) - } - } - - sessionDataTask.resume() - } - } - } -} - -extension ImageDownloader { - struct DownloadingContext { - let url: URL - let request: URLRequest - } -} 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 2352e08..0000000 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/ImageTask.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Foundation - -/// 이미지 작업을 나타내는 클래스로, 취소 기능을 제공합니다. -public final actor ImageTask { - // MARK: - Properties - - private var downloadTask: DownloadTask? - private var isCancelled = false - - // MARK: - Lifecycle - - public init() {} - - // MARK: - Functions - - /// 작업을 취소합니다. - public func cancel() async { - isCancelled = true - await downloadTask?.cancel() - } - - /// 작업이 실패했음을 표시합니다. - public func fail() async { - isCancelled = true - } - - /// 다운로드 작업을 설정합니다. - public func setDownloadTask(_ task: DownloadTask) { - downloadTask = task - } -} diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDataTask.swift b/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDataTask.swift deleted file mode 100644 index fab85b4..0000000 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDataTask.swift +++ /dev/null @@ -1,153 +0,0 @@ -import Foundation - -/// `ImageDownloader`에서 사용되는 세션 데이터 작업을 나타냅니다. -/// -/// 기본적으로 `SessionDataTask`는 `URLSessionDataTask`를 래핑하고 다운로드 데이터를 관리합니다. -/// `SessionDataTask/CancelToken`을 사용하여 작업을 추적하고 취소를 관리합니다. -public class SessionDataTask: @unchecked Sendable { - // MARK: - Nested Types - - /// 작업을 취소하는 데 사용되는 토큰 타입입니다. - public typealias CancelToken = Int - - /// 작업 콜백을 나타내는 구조체입니다. - struct TaskCallback { - let onCompleted: Delegate< - Result, - Void - >? // 작업 완료 시 호출될 콜백 - } - - // MARK: - Properties - - // `task.originalRequest?.url`의 복사본입니다. iOS 13에서 발생할 수 있는 레이스 컨디션을 방지하기 위해 사용됩니다. - // 참고: https://github.com/onevcat/Kingfisher/issues/1511 - public let originalURL: URL? - - /// 내부적으로 사용되는 다운로드 작업입니다. - /// - /// 이 작업은 오류가 발생했을 때 디버깅 목적으로만 사용됩니다. 이 작업의 내용을 수정하거나 직접 시작해서는 안 됩니다. - public let task: URLSessionDataTask - - /// 작업이 완료되었을 때 호출될 델리게이트입니다. - /// `클래스 내부에는 델리게이트 선언만 진행하고, 등록은 ImageDownloader의 createDownloadTask 메서드에서 진행합니다.` - let onTaskDone = Delegate<(Result<(Data, URLResponse?), NeoImageError>, [TaskCallback]), Void>() - /// 콜백이 취소되었을 때 호출될 델리게이트입니다. - let onCallbackCancelled = Delegate<(CancelToken, TaskCallback), Void>() - - var started = false // 작업이 시작되었는지 여부를 나타냄 - - private var _mutableData: Data // 다운로드된 데이터를 저장하는 변수 - private var callbacksStore = [CancelToken: TaskCallback]() // 콜백을 저장하는 딕셔너리 - - private var currentToken = 0 // 콜백을 식별하기 위한 고유 토큰 - private let lock = NSLock() // 스레드 안전성을 위한 lock - - // MARK: - Computed Properties - - // 현재 작업에서 다운로드된 원시 데이터입니다. - - /// ImageCache와 같은 공유 클래스에서 DispatchQueue를 사용한 것과 달리, locking 매커니즘을 사용하고 있습니다. - /// 이는 DispatchQueue를 사용한 동기화는 간편하지만, 큐에 작업을 넣고 실행하는 오버헤드가 있기 때문입니다. - /// 작은 청크로 자주 도착하는 네트워크 데이터의 경우, 빈번하고 짧은 연산이기에 직접적인 locking 매커니즘을 택하는 것이 더 적은 오버헤드로 이어집니다. - public var mutableData: Data { - lock.lock() - defer { lock.unlock() } - return _mutableData // 스레드 안전성을 위해 lock을 사용하여 데이터 접근 - } - - /// 다운로드가 완료되었을 때 - /// 다운로드 중 오류가 발생했을 때 - /// 다운로드 진행 상태가 업데이트되었을 때 - /// 위 이벤트 발생 시, 등록하여 매핑한 콜백이 호출 - var callbacks: [SessionDataTask.TaskCallback] { - lock.lock() - defer { lock.unlock() } - return Array(callbacksStore.values) // 현재 등록된 모든 콜백을 반환 - } - - var containsCallbacks: Bool { - // `task.state != .running`을 사용하여 확인할 수 있어야 하지만, - // 드물게 작업을 취소해도 작업 상태가 즉시 `.cancelling`으로 변경되지 않고 `.running` 상태로 남아있는 경우가 있습니다. - // 따라서 작업을 안전하게 제거하기 위해 콜백 개수를 확인합니다. - !callbacks.isEmpty - } - - // MARK: - Lifecycle - - /// `SessionDataTask`를 초기화합니다. - init(task: URLSessionDataTask) { - self.task = task - originalURL = task.originalRequest?.url // 원본 URL을 저장 - _mutableData = Data() // 데이터 저장을 위한 빈 `Data` 객체 초기화 - } - - // MARK: - Functions - - /// 새로운 콜백을 추가하고 고유 토큰을 반환합니다. - func addCallback(_ callback: TaskCallback) -> CancelToken { - lock.lock() - defer { lock.unlock() } - callbacksStore[currentToken] = callback // 콜백을 딕셔너리에 저장 - defer { currentToken += 1 } // 토큰 값을 증가시킴 - // 종료되기 `직전에` 호출되는 defer 키워드를 사용해 addCallback이 정상적으로 종료될때에만 실행되도록 하여, 코드의 안정성을 높일 수 있음. - return currentToken // 고유 토큰 반환 - } - - /// 특정 토큰에 해당하는 콜백을 제거하고 반환합니다. - /// 다운로드 작업의 중단 및 리소스 관리 관련 상황에서 호출됩니다. - /// 일일히 제거하는 이유는 불필요한 메모리 사용을 줄이는 것도 있지만, 추후 메모리 누수가 발생할 수도 있기 때문 - func removeCallback(_ token: CancelToken) -> TaskCallback? { - lock.lock() - defer { lock.unlock() } - if let callback = callbacksStore[token] { - callbacksStore[token] = nil // 콜백 제거 - return callback // 제거된 콜백 반환 - } - return nil // 해당 토큰에 대한 콜백이 없을 경우 nil 반환 - } - - /// 모든 콜백을 제거하고 제거된 콜백 목록을 반환합니다. - @discardableResult - func removeAllCallbacks() -> [TaskCallback] { - lock.lock() - defer { lock.unlock() } - let callbacks = callbacksStore.values // 모든 콜백을 가져옴 - callbacksStore.removeAll() // 딕셔너리 비우기 - return Array(callbacks) // 제거된 콜백 목록 반환 - } - - /// 작업을 시작합니다. - func resume() { - guard !started else { - return - } // 이미 시작된 작업은 다시 시작하지 않음 - started = true // 작업 상태를 시작됨으로 표시 - task.resume() // 내부 `URLSessionDataTask` 시작 - } - - /// 특정 토큰에 해당하는 작업을 취소합니다. - func cancel(token: CancelToken) { - guard let callback = removeCallback(token) else { - return // 해당 토큰에 대한 콜백이 없을 경우 종료 - } - - // removeCallback과 같이 직접적인 잠금 매커니즘이 적용되지 않았음에도, 사용하는 Delegate 객체들이 이미 내부적으로 스레드 안정성을 보장하고 - // 있음. - onCallbackCancelled.call((token, callback)) // 콜백 취소 이벤트 호출 - } - - /// 모든 콜백을 강제로 취소합니다. - func forceCancel() { - for token in callbacksStore.keys { - cancel(token: token) // 모든 토큰에 대해 취소 작업 수행 - } - } - - /// 데이터를 수신하고 저장합니다. - func didReceiveData(_ data: Data) { - lock.lock() - defer { lock.unlock() } - _mutableData.append(data) // 수신된 데이터를 기존 데이터에 추가 - } -} 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 ac1f273..0000000 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Networking/SessionDelegate.swift +++ /dev/null @@ -1,238 +0,0 @@ - -import Foundation - -@objc(NeoImageDelegate) -open class SessionDelegate: NSObject, @unchecked Sendable { - // MARK: - Properties - - let onValidStatusCode = Delegate() - let onReceiveChallenge = Delegate< - URLAuthenticationChallenge, - (URLSession.AuthChallengeDisposition, URLCredential?) - >() - let onDownloadingFinished = Delegate<(URL, Result), Void>() - - private var tasks: [URL: SessionDataTask] = [:] - private let lock = NSLock() - - // MARK: - Functions - - /// Adds a new download task with a URL and callback - func add( - _ dataTask: URLSessionDataTask, - url: URL, - callback: SessionDataTask.TaskCallback - ) -> DownloadTask { - lock.lock() - defer { lock.unlock() } - - let task = SessionDataTask(task: dataTask) - - task.onCallbackCancelled.delegate(on: self) { [weak task] (self, value) in - guard let task else { - return - } - - let (token, callback) = value - - let error = NeoImageError.requestError(reason: .taskCancelled(task: task, token: token)) - task.onTaskDone.call((.failure(error), [callback])) - - // Remove the task if no other callbacks waiting - if !task.containsCallbacks { - let dataTask = task.task - self.cancelTask(dataTask) - self.remove(task) - } - } - - // Add callback and get token - let token = task.addCallback(callback) - tasks[url] = task - - return DownloadTask(sessionTask: task, cancelToken: token) - } - - /// Appends a callback to an existing task and returns a new DownloadTask - func append( - _ task: SessionDataTask, - callback: SessionDataTask.TaskCallback - ) -> DownloadTask { - let token = task.addCallback(callback) - return DownloadTask(sessionTask: task, cancelToken: token) - } - - /// Gets a task by URL - func task(for url: URL) -> SessionDataTask? { - lock.lock() - defer { lock.unlock() } - return tasks[url] - } - - /// Cancels all tasks - func cancelAll() { - lock.lock() - let taskValues = tasks.values - lock.unlock() - for task in taskValues { - task.forceCancel() - } - } - - /// Cancels a task for a URL - func cancel(url: URL) { - lock.lock() - let task = tasks[url] - lock.unlock() - task?.forceCancel() - } - - /// Cancels a URLSessionDataTask - private func cancelTask(_ dataTask: URLSessionDataTask) { - lock.lock() - defer { lock.unlock() } - dataTask.cancel() - } - - /// Removes a task - private func remove(_ task: SessionDataTask) { - lock.lock() - defer { lock.unlock() } - - guard let url = task.originalURL else { - return - } - - task.removeAllCallbacks() - tasks[url] = nil - } - - /// Gets a task by URLSessionTask - private func task(for task: URLSessionTask) -> SessionDataTask? { - lock.lock() - defer { lock.unlock() } - - guard let url = task.originalRequest?.url else { - return nil - } - guard let sessionTask = tasks[url] else { - return nil - } - guard sessionTask.task.taskIdentifier == task.taskIdentifier else { - return nil - } - return sessionTask - } -} - -// MARK: - URLSessionDataDelegate - -extension SessionDelegate: URLSessionDataDelegate { - open func urlSession( - _: URLSession, - dataTask: URLSessionDataTask, - didReceive response: URLResponse - ) async -> URLSession.ResponseDisposition { - guard response is HTTPURLResponse else { - let error = NeoImageError - .responseError(reason: .URLSessionError(description: "invalid http Response")) - onCompleted(task: dataTask, result: .failure(error)) - - return .cancel - } - - return .allow - } - - open func urlSession( - _: URLSession, - dataTask: URLSessionDataTask, - didReceive data: Data - ) { - guard let task = task(for: dataTask) else { - return - } - - task.didReceiveData(data) - } - - open func urlSession( - _: URLSession, - task: URLSessionTask, - didCompleteWithError error: (any Error)? - ) { - guard let sessionTask = self.task(for: task) else { - return - } - if let url = sessionTask.originalURL { - let result: Result - if let error { - result = .failure( - NeoImageError - .responseError(reason: .URLSessionError( - description: error - .localizedDescription - )) - ) - } else if let response = task.response { - result = .success(response) - } else { - result = .failure( - NeoImageError - .responseError(reason: .URLSessionError(description: "no http Response")) - ) - } - - onDownloadingFinished.call((url, result)) - } - - let result: Result<(Data, URLResponse?), NeoImageError> - - if let error { - result = .failure( - NeoImageError - .responseError(reason: .URLSessionError( - description: error - .localizedDescription - )) - ) - } else { - result = .success((sessionTask.mutableData, task.response)) - } - - onCompleted(task: task, result: result) - } - - /// Called for task authentication challenge - open func urlSession( - _: URLSession, - task _: URLSessionTask, - didReceive challenge: URLAuthenticationChallenge - ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { - onReceiveChallenge(challenge) ?? (.performDefaultHandling, nil) - } - - private func onCompleted( - task: URLSessionTask, - result: Result<(Data, URLResponse?), NeoImageError> - ) { - // SessionDataTask 탐색 - guard let sessionTask = self.task(for: task) else { - return - } - - let finalResult: Result<(Data, URLResponse?), NeoImageError> - - if case .failure = result { - finalResult = result - } else { - finalResult = .success((sessionTask.mutableData, task.response)) - } - - let callbacks = sessionTask.removeAllCallbacks() - // 대응되는 SessionDataTask의 onTaskDone 델리게이트를 통해 결과 및 콜백 전달 - sessionTask.onTaskDone.call((finalResult, callbacks)) - - remove(sessionTask) - } -} diff --git a/BookKitty/BookKitty/NeoImage/Sources/Networking/DownloadTask.swift b/BookKitty/BookKitty/NeoImage/Sources/Networking/DownloadTask.swift new file mode 100644 index 0000000..2c5ae98 --- /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 0000000..45590bd --- /dev/null +++ b/BookKitty/BookKitty/NeoImage/Sources/Networking/ImageDownloader.swift @@ -0,0 +1,161 @@ +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 + ) async throws -> ImageLoadingResult { + let imageData = try await downloadImageData(with: downloadTask) + + guard let image = UIImage(data: imageData) else { + throw NeoImageError.responseError(reason: .invalidImageData) + } + + let cacheKey = url.absoluteString + try? await ImageCache.shared.store(imageData, forKey: cacheKey) + + NeoLogger.shared.debug("Image stored in cache with key: \(cacheKey)") + + 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 0000000..c294ed6 --- /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 0000000..36f266b --- /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/Wrapper/ImageView+NeoImage.swift b/BookKitty/BookKitty/NeoImage/Sources/Wrapper/ImageView+NeoImage.swift similarity index 70% rename from BookKitty/BookKitty/NeoImage/Sources/NeoImage/Wrapper/ImageView+NeoImage.swift rename to BookKitty/BookKitty/NeoImage/Sources/Wrapper/ImageView+NeoImage.swift index f430770..611c3fb 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Wrapper/ImageView+NeoImage.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Wrapper/ImageView+NeoImage.swift @@ -37,11 +37,12 @@ extension NeoImageWrapper where Base: UIImageView { with url: URL?, placeholder: UIImage? = nil, options: NeoImageOptions? = nil - ) async throws -> (ImageLoadingResult, ImageTask?) { + ) async throws -> ImageLoadingResult { // 이미지뷰가 실제로 화면에 표시되어 있는지 여부 파악, // 이는 Swift 6로 오면서 비동기 작업으로 간주되기 시작함. + let startTime = Date() guard await base.window != nil else { - throw CacheError.invalidData + throw NeoImageError.responseError(reason: .invalidImageData) } if let placeholder { @@ -52,76 +53,90 @@ extension NeoImageWrapper where Base: UIImageView { base.image = placeholder } } - // TODO: gray로 차선책 placeholder 렌더 넣기 - guard let url else { - throw CacheError.invalidData + guard let url + else { + throw NeoImageError.responseError(reason: .networkError(description: "url invalid")) + } + + let cacheKey = url.absoluteString + + if let cachedData = try? await ImageCache.shared.retrieveImage(key: cacheKey), + let cachedImage = UIImage(data: cachedData) { + await MainActor.run { [weak base] in + guard let base else { + return + } + base.image = cachedImage + applyTransition(to: base, with: options?.transition) + let elapsedTime = Date().timeIntervalSince(startTime) + print("loaded in \(String(format: "%.3f", elapsedTime)) seconds") + } + + return ImageLoadingResult( + image: cachedImage, + url: url, + originalData: cachedData + ) } - // TODO: ImageTask 연결하기 - // UIImageView에 연결된 ImageTask를 가져옵니다 - // 현재 진행 중인 다운로드 작업이 있는지 확인하는데 사용됩니다 if let task = objc_getAssociatedObject( base, - NeoImageConstants.associatedKey - ) as? ImageTask { - await task.cancel() - await setImageDownloadTask(nil) + &AssociatedKeys.downloadTask + ) as? DownloadTask { + try await task.cancelWithError() + setImageDownloadTask(nil) } - let imageTask = ImageTask() - await setImageDownloadTask(imageTask) - - // NeoImageManager를 사용해 이미지 다운로드 (캐시 확인 + 이미지 후처리) - let downloadResult = try await NeoImageManager.shared.downloadImage( - with: url, - options: options - ) - try Task.checkCancellation() + let downloadTask = try await ImageDownloader.default.createTask(with: url) + setImageDownloadTask(downloadTask) + let result = try await ImageDownloader.default.downloadImage(with: downloadTask, for: url) // UI 업데이트 await MainActor.run { [weak base] in guard let base else { return } - base.image = downloadResult.image + base.image = result.image applyTransition(to: base, with: options?.transition) } -// imageTask.setDownloadTask(down) - return (downloadResult, imageTask) + + return result } // MARK: - Wrapper /// `Public Async API` /// async/await 패턴이 적용된 환경에서 사용가능한 래퍼 메서드입니다. + @discardableResult public func setImage( with url: URL?, placeholder: UIImage? = nil, options: NeoImageOptions? = nil ) async throws -> ImageLoadingResult { - let (result, _) = try await setImageAsync( + let currentTime = Date() + let result = try await setImageAsync( with: url, placeholder: placeholder, options: options ) + print( + "**setImageAsync Done: \(String(format: "%.6f", Date().timeIntervalSince(currentTime)))" + ) return result } /// `Public Completion Handler API` - @discardableResult public func setImage( with url: URL?, placeholder: UIImage? = nil, options: NeoImageOptions? = nil, 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 @@ -129,12 +144,9 @@ extension NeoImageWrapper where Base: UIImageView { completion?(.success(result)) } catch { - await task.fail() completion?(.failure(error)) } } - - return task } @MainActor @@ -167,11 +179,11 @@ extension NeoImageWrapper where Base: UIImageView { // 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` 및 모든 하위 클래스 @@ -183,10 +195,9 @@ extension NeoImageWrapper where Base: UIImageView { // - NSArray // - NSDictionary // - URLSession - objc_setAssociatedObject( base, // 대상 객체 (UIImageView) - NeoImageConstants.associatedKey, // 키 값 + &AssociatedKeys.downloadTask, // 키 값 task, // 저장할 값 .OBJC_ASSOCIATION_RETAIN_NONATOMIC // 메모리 관리 정책 ) diff --git a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Wrapper/SwiftUI+NeoImage.swift b/BookKitty/BookKitty/NeoImage/Sources/Wrapper/SwiftUI+NeoImage.swift similarity index 86% rename from BookKitty/BookKitty/NeoImage/Sources/NeoImage/Wrapper/SwiftUI+NeoImage.swift rename to BookKitty/BookKitty/NeoImage/Sources/Wrapper/SwiftUI+NeoImage.swift index 57b0e0c..4672c3b 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/NeoImage/Wrapper/SwiftUI+NeoImage.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Wrapper/SwiftUI+NeoImage.swift @@ -7,7 +7,7 @@ class NeoImageBinder: ObservableObject { // MARK: - Properties /// 다운로드 작업 정보 - var imageTask: ImageTask? + var downloadTask: DownloadTask? // 이미지 로딩 상태와 결과 @Published var loaded = false @Published var animating = false @@ -38,7 +38,7 @@ class NeoImageBinder: ObservableObject { } /// 이미지 로딩 시작 - func start(url: URL?, options: NeoImageOptions?) async { + func start(url: URL?, options _: NeoImageOptions?) async { guard let url else { loading = false markLoaded() @@ -48,28 +48,44 @@ class NeoImageBinder: ObservableObject { loading = true progress = .init() + // 캐시에서 먼저 확인 + let cacheKey = url.absoluteString + if let cachedData = try? await ImageCache.shared.retrieveImage(key: cacheKey), + let cachedImage = UIImage(data: cachedData) { + loadedImage = cachedImage + loading = false + markLoaded() + return + } + + // 기존 다운로드 작업이 있으면 취소 + if let task = downloadTask { + await task.cancel() + downloadTask = nil + } + do { - // 이미지 매니저를 통해 다운로드 - let result = try await NeoImageManager.shared.downloadImage(with: url, options: options) + // 다운로드 태스크 생성 및 저장 + let task = try await ImageDownloader.default.createTask(with: url) + downloadTask = task - await MainActor.run { - loadedImage = result.image - loading = false - markLoaded() - } + // 다운로드 수행 + let result = try await ImageDownloader.default.downloadImage(with: task, for: url) + + loadedImage = result.image + loading = false + markLoaded() } catch { - await MainActor.run { - loadedImage = nil - loading = false - markLoaded() - } + loadedImage = nil + loading = false + markLoaded() } } /// 로딩 취소 func cancel() async { - await imageTask?.cancel() - imageTask = nil + await downloadTask?.cancel() + downloadTask = nil loading = false } } @@ -183,22 +199,10 @@ public struct NeoImage: View { return result } - /// 이미지 프로세서 설정 모디파이어 - public func processor(_ processor: ImageProcessing) -> NeoImage { - var result = self - result.options = NeoImageOptions( - processor: processor, - transition: result.options.transition, - cacheExpiration: result.options.cacheExpiration - ) - return result - } - /// 페이드 트랜지션 설정 public func fade(duration: TimeInterval = 0.3) -> NeoImage { var result = self result.options = NeoImageOptions( - processor: result.options.processor, transition: .fade(duration), cacheExpiration: result.options.cacheExpiration ) @@ -259,7 +263,7 @@ public struct NeoImage: View { // 성공 콜백 호출 let result = ImageLoadingResult(image: image, url: url, originalData: Data()) onSuccess?(result) - } else if url != nil { + } else if url != nil, binder.loadedImage == nil { // 실패 콜백 호출 onFailure?(NeoImageError.responseError(reason: .invalidImageData)) } From e59a83f5b604208164cc8ea1fdf417695de4f715 Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Fri, 21 Mar 2025 14:04:37 +0900 Subject: [PATCH 03/10] =?UTF-8?q?[Refactor]=20SwiftFormat=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EB=B0=8F=20=EB=B6=88=ED=95=84=EC=9A=94=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=B6=9C=EB=A0=A5=EB=AC=B8=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Wrapper/ImageView+NeoImage.swift | 37 ++++++++++--------- .../Repository/LocalBookRepository.swift | 2 +- .../Repository/Mock/MockBookRepository.swift | 2 - 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/BookKitty/BookKitty/NeoImage/Sources/Wrapper/ImageView+NeoImage.swift b/BookKitty/BookKitty/NeoImage/Sources/Wrapper/ImageView+NeoImage.swift index 611c3fb..b95acf6 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/Wrapper/ImageView+NeoImage.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Wrapper/ImageView+NeoImage.swift @@ -36,11 +36,11 @@ extension NeoImageWrapper where Base: UIImageView { private func setImageAsync( with url: URL?, placeholder: UIImage? = nil, - options: NeoImageOptions? = nil + options: NeoImageOptions? = nil, + isPriority: Bool = false ) async throws -> ImageLoadingResult { // 이미지뷰가 실제로 화면에 표시되어 있는지 여부 파악, // 이는 Swift 6로 오면서 비동기 작업으로 간주되기 시작함. - let startTime = Date() guard await base.window != nil else { throw NeoImageError.responseError(reason: .invalidImageData) } @@ -50,27 +50,27 @@ extension NeoImageWrapper where Base: UIImageView { guard let base else { return } + base.image = placeholder } } - guard let url - else { + 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(key: cacheKey), + if let cachedData = try? await ImageCache.shared.retrieveImage(hashedKey: hashedKey), let cachedImage = UIImage(data: cachedData) { await MainActor.run { [weak base] in guard let base else { return } + base.image = cachedImage applyTransition(to: base, with: options?.transition) - let elapsedTime = Date().timeIntervalSince(startTime) - print("loaded in \(String(format: "%.3f", elapsedTime)) seconds") } return ImageLoadingResult( @@ -91,12 +91,17 @@ extension NeoImageWrapper where Base: UIImageView { let downloadTask = try await ImageDownloader.default.createTask(with: url) setImageDownloadTask(downloadTask) - let result = try await ImageDownloader.default.downloadImage(with: downloadTask, for: url) - // UI 업데이트 + let result = try await ImageDownloader.default.downloadImage( + with: downloadTask, + for: url, + hashedKey: hashedKey + ) + await MainActor.run { [weak base] in guard let base else { return } + base.image = result.image applyTransition(to: base, with: options?.transition) } @@ -114,17 +119,11 @@ extension NeoImageWrapper where Base: UIImageView { placeholder: UIImage? = nil, options: NeoImageOptions? = nil ) async throws -> ImageLoadingResult { - let currentTime = Date() - let result = try await setImageAsync( + try await setImageAsync( with: url, placeholder: placeholder, options: options ) - - print( - "**setImageAsync Done: \(String(format: "%.6f", Date().timeIntervalSince(currentTime)))" - ) - return result } /// `Public Completion Handler API` @@ -132,6 +131,7 @@ extension NeoImageWrapper where Base: UIImageView { with url: URL?, placeholder: UIImage? = nil, options: NeoImageOptions? = nil, + isPriority: Bool = false, completion: (@MainActor @Sendable (Result) -> Void)? = nil ) { Task { @MainActor in @@ -139,7 +139,8 @@ extension NeoImageWrapper where Base: UIImageView { let result = try await setImageAsync( with: url, placeholder: placeholder, - options: options + options: options, + isPriority: isPriority ) completion?(.success(result)) diff --git a/BookKitty/BookKitty/Source/Repository/LocalBookRepository.swift b/BookKitty/BookKitty/Source/Repository/LocalBookRepository.swift index 1fffdf3..f810e64 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 38492dd..c73ca56 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) } } From 2c4ad7d559dc4eb34447896a5c40da72cfaa8817 Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Fri, 21 Mar 2025 14:04:53 +0900 Subject: [PATCH 04/10] =?UTF-8?q?[Feat]=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NeoImageDownloadTaskCancelTests.swift | 143 ++++ .../Tests/NeoImageTests/NeoImageTests.swift | 12 - .../Tests/NeoImageTests/PerformanceTest.swift | 612 ++++++++++++++++++ 3 files changed, 755 insertions(+), 12 deletions(-) create mode 100644 BookKitty/BookKitty/NeoImage/Tests/NeoImageTests/NeoImageDownloadTaskCancelTests.swift delete mode 100644 BookKitty/BookKitty/NeoImage/Tests/NeoImageTests/NeoImageTests.swift create mode 100644 BookKitty/BookKitty/NeoImage/Tests/NeoImageTests/PerformanceTest.swift diff --git a/BookKitty/BookKitty/NeoImage/Tests/NeoImageTests/NeoImageDownloadTaskCancelTests.swift b/BookKitty/BookKitty/NeoImage/Tests/NeoImageTests/NeoImageDownloadTaskCancelTests.swift new file mode 100644 index 0000000..65f626b --- /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 5ca3973..0000000 --- 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 0000000..579d58c --- /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() + } +} From 62cf53a39f8280ec6240f13d63f268bf1530995f Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Fri, 21 Mar 2025 14:06:11 +0900 Subject: [PATCH 05/10] =?UTF-8?q?[Feat]=20=ED=8A=B9=EC=A0=95=20prefix=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=EB=90=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=20=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NeoImage/Sources/Cache/DiskStorage.swift | 69 ++++++++++++------- .../NeoImage/Sources/Cache/ImageCache.swift | 25 +++---- .../Sources/Cache/MemoryStorage.swift | 20 +++--- .../Sources/Networking/ImageDownloader.swift | 11 +-- .../Sources/Wrapper/SwiftUI+NeoImage.swift | 10 +-- 5 files changed, 77 insertions(+), 58 deletions(-) diff --git a/BookKitty/BookKitty/NeoImage/Sources/Cache/DiskStorage.swift b/BookKitty/BookKitty/NeoImage/Sources/Cache/DiskStorage.swift index d4264e1..f76f54f 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/Cache/DiskStorage.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Cache/DiskStorage.swift @@ -5,7 +5,6 @@ public actor DiskStorage { var maybeCached: Set? - private let name: String private let fileManager: FileManager private let directoryURL: URL private var storageReady = true @@ -13,17 +12,16 @@ public actor DiskStorage { // MARK: - Lifecycle init( - name: String, fileManager: FileManager ) { - self.name = name self.fileManager = fileManager - let url = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0] - let cacheName = "com.neon.NeoImage.ImageCache.\(name)" - - directoryURL = url.appendingPathComponent(cacheName, isDirectory: true) - + + directoryURL = url.appendingPathComponent( + "com.neon.NeoImage.ImageCache.default", + isDirectory: true + ) + Task { await setupCacheChecking() try? await prepareDirectory() @@ -32,7 +30,7 @@ public actor DiskStorage { // MARK: - Functions - func store(value: T, forKey key: String, expiration: StorageExpiration? = nil) async throws { + func store(value: T, for hashedKey: String, expiration: StorageExpiration? = nil) async throws { guard storageReady else { throw NeoImageError.cacheError(reason: .storageNotReady) } @@ -47,7 +45,7 @@ public actor DiskStorage { throw NeoImageError.cacheError(reason: .invalidData) } - let fileURL = cacheFileURL(forKey: key) + let fileURL = cacheFileURL(for: hashedKey) // Foundation 내부 Data 타입의 내장 메서드입니다. // 해당 위치로 data 내부 컨텐츠를 write 합니다. @@ -81,15 +79,15 @@ public actor DiskStorage { } func value( - forKey key: String, // 캐시의 키 + for hashedKey: String, actuallyLoad: Bool = true, - extendingExpiration: ExpirationExtending = .cacheTime // 현재 Config + extendingExpiration: ExpirationExtending = .cacheTime ) async throws -> T? { guard storageReady else { throw NeoImageError.cacheError(reason: .storageNotReady) } - let fileURL = cacheFileURL(forKey: key) + let fileURL = cacheFileURL(for: hashedKey) let filePath = fileURL.path guard maybeCached?.contains(fileURL.lastPathComponent) ?? true else { return nil @@ -130,8 +128,8 @@ public actor DiskStorage { } /// 특정 키에 해당하는 파일을 삭제하는 메서드 - func remove(forKey key: String) async throws { - let fileURL = cacheFileURL(forKey: key) + func remove(for hashedKey: String) async throws { + let fileURL = cacheFileURL(for: hashedKey) try fileManager.removeItem(at: fileURL) } @@ -141,10 +139,10 @@ public actor DiskStorage { try prepareDirectory() } - func isCached(forKey key: String) async -> Bool { + func isCached(for hashedKey: String) async -> Bool { do { let result = try await value( - forKey: key, + for: hashedKey, actuallyLoad: false ) @@ -160,15 +158,8 @@ public actor DiskStorage { } extension DiskStorage { - private func cacheFileURL(forKey key: String) -> URL { - let fileName = cacheFileName(forKey: key) - return directoryURL.appendingPathComponent(fileName, isDirectory: false) - } - - /// 사전에 패키지에서 설정된 Config 구조체를 통해 파일명을 해시화하기로 설정했는지 여부, 임의로 전달된 접미사 단어 유무에 따라 캐시될때 저장될 파일명을 변환하여 - /// 반환해줍니다. - private func cacheFileName(forKey key: String) -> String { - key.sha256 + private func cacheFileURL(for hashedKey: String) -> URL { + directoryURL.appendingPathComponent(hashedKey, isDirectory: false) } // MARK: - 만료기간 종료 여부 파악 관련 메서드들 @@ -244,6 +235,32 @@ extension DiskStorage { throw NeoImageError.cacheError(reason: .cannotCreateDirectory(error: error)) } } + + func preloadPriorityToMemory() async { + do { + let prefix = "priority_" + let fileURLs = try allFileURLs(for: [.isRegularFileKey, .nameKey]) + + let prefixedFiles = fileURLs.filter { url in + let fileName = url.lastPathComponent + return fileName.hasPrefix(prefix) + } + + for fileURL in prefixedFiles { + let fileName = fileURL.lastPathComponent + let hashedKey = fileName.replacingOccurrences(of: "priority_", with: "") + + print("fileURL from preload:", fileURL) + if let data = try? Data(contentsOf: fileURL) { + await ImageCache.shared.memoryStorage.store(value: data, for: hashedKey) + } + } + + NeoLogger.shared.info("우선순위 이미지 메모리 프리로드 완료") + } catch { + print("메모리 프리로드 중 오류 발생: \(error)") + } + } } extension DiskStorage { diff --git a/BookKitty/BookKitty/NeoImage/Sources/Cache/ImageCache.swift b/BookKitty/BookKitty/NeoImage/Sources/Cache/ImageCache.swift index fabfa6d..75b59fe 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/Cache/ImageCache.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Cache/ImageCache.swift @@ -31,10 +31,7 @@ public final class ImageCache: Sendable { totalCostLimit: min(Int.max, Int(memoryLimit)) ) - diskStorage = DiskStorage( - name: name, - fileManager: .default - ) + diskStorage = DiskStorage(fileManager: .default) NeoLogger.shared.debug("initialized") @@ -54,6 +51,10 @@ public final class ImageCache: Sendable { ) } } + + Task { + await diskStorage.preloadPriorityToMemory() + } } // MARK: - Functions @@ -61,23 +62,23 @@ public final class ImageCache: Sendable { /// 메모리와 디스크 캐시에 모두 데이터를 저장합니다. public func store( _ data: Data, - forKey key: String + for hashedKey: String ) async throws { - await memoryStorage.store(value: data, forKey: key) + await memoryStorage.store(value: data, for: hashedKey) - try await diskStorage.store(value: data, forKey: key) + try await diskStorage.store(value: data, for: hashedKey) } - public func retrieveImage(key: String) async throws -> Data? { - if let memoryData = await memoryStorage.value(forKey: key) { - print("Retriving from memory") + public func retrieveImage(hashedKey: String) async throws -> Data? { + if let memoryData = await memoryStorage.value(forKey: hashedKey) { + print("hashedKey from retrieveImage:", hashedKey) return memoryData } - let diskData = try await diskStorage.value(forKey: key) + let diskData = try await diskStorage.value(for: hashedKey) if let diskData { - await memoryStorage.store(value: diskData, forKey: key, expiration: .days(7)) + await memoryStorage.store(value: diskData, for: hashedKey, expiration: .days(7)) } return diskData diff --git a/BookKitty/BookKitty/NeoImage/Sources/Cache/MemoryStorage.swift b/BookKitty/BookKitty/NeoImage/Sources/Cache/MemoryStorage.swift index 4fc4dba..dccf264 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/Cache/MemoryStorage.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Cache/MemoryStorage.swift @@ -43,9 +43,9 @@ public actor MemoryStorage { } /// 캐시에서 제거 - public func remove(forKey key: String) { - storage.removeObject(forKey: key as NSString) - keys.remove(key) + public func remove(forKey hashedKey: String) { + storage.removeObject(forKey: hashedKey as NSString) + keys.remove(hashedKey) } /// Removes all values in this storage. @@ -57,7 +57,7 @@ public actor MemoryStorage { /// 캐시에 저장 func store( value: Data, - forKey key: String, + for hashedKey: String, expiration: StorageExpiration? = nil ) { let expiration = expiration ?? NeoImageConstants.expiration @@ -68,13 +68,17 @@ public actor MemoryStorage { let object = StorageObject(value as Data, expiration: expiration) - storage.setObject(object, forKey: key as NSString) - keys.insert(key) + storage.setObject(object, forKey: hashedKey as NSString) + + keys.insert(hashedKey) } /// 캐시에서 조회 - func value(forKey key: String, extendingExpiration: ExpirationExtending = .cacheTime) -> Data? { - guard let object = storage.object(forKey: key as NSString) else { + func value( + forKey hashedKey: String, + extendingExpiration: ExpirationExtending = .cacheTime + ) -> Data? { + guard let object = storage.object(forKey: hashedKey as NSString) else { return nil } diff --git a/BookKitty/BookKitty/NeoImage/Sources/Networking/ImageDownloader.swift b/BookKitty/BookKitty/NeoImage/Sources/Networking/ImageDownloader.swift index 45590bd..1005cfb 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/Networking/ImageDownloader.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Networking/ImageDownloader.swift @@ -66,7 +66,8 @@ public final class ImageDownloader: Sendable { @discardableResult public func downloadImage( with downloadTask: DownloadTask, - for url: URL + for url: URL, + hashedKey: String ) async throws -> ImageLoadingResult { let imageData = try await downloadImageData(with: downloadTask) @@ -74,10 +75,12 @@ public final class ImageDownloader: Sendable { throw NeoImageError.responseError(reason: .invalidImageData) } - let cacheKey = url.absoluteString - try? await ImageCache.shared.store(imageData, forKey: cacheKey) + try? await ImageCache.shared.store( + imageData, + for: hashedKey + ) - NeoLogger.shared.debug("Image stored in cache with key: \(cacheKey)") + NeoLogger.shared.debug("Image stored in cache with key: \(url.absoluteString)") return ImageLoadingResult( image: image, diff --git a/BookKitty/BookKitty/NeoImage/Sources/Wrapper/SwiftUI+NeoImage.swift b/BookKitty/BookKitty/NeoImage/Sources/Wrapper/SwiftUI+NeoImage.swift index 4672c3b..dbc454c 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/Wrapper/SwiftUI+NeoImage.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Wrapper/SwiftUI+NeoImage.swift @@ -37,7 +37,6 @@ class NeoImageBinder: ObservableObject { loaded = true } - /// 이미지 로딩 시작 func start(url: URL?, options _: NeoImageOptions?) async { guard let url else { loading = false @@ -48,9 +47,8 @@ class NeoImageBinder: ObservableObject { loading = true progress = .init() - // 캐시에서 먼저 확인 - let cacheKey = url.absoluteString - if let cachedData = try? await ImageCache.shared.retrieveImage(key: cacheKey), + let hashedKey = url.absoluteString.sha256 + if let cachedData = try? await ImageCache.shared.retrieveImage(hashedKey: hashedKey), let cachedImage = UIImage(data: cachedData) { loadedImage = cachedImage loading = false @@ -58,18 +56,15 @@ class NeoImageBinder: ObservableObject { return } - // 기존 다운로드 작업이 있으면 취소 if let task = downloadTask { await task.cancel() downloadTask = nil } do { - // 다운로드 태스크 생성 및 저장 let task = try await ImageDownloader.default.createTask(with: url) downloadTask = task - // 다운로드 수행 let result = try await ImageDownloader.default.downloadImage(with: task, for: url) loadedImage = result.image @@ -82,7 +77,6 @@ class NeoImageBinder: ObservableObject { } } - /// 로딩 취소 func cancel() async { await downloadTask?.cancel() downloadTask = nil From 5a665c9e4497729096ec729d51df9e0cb83320c9 Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Fri, 21 Mar 2025 14:06:36 +0900 Subject: [PATCH 06/10] =?UTF-8?q?[Refactor]=20preLoad=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20MyLibrary=20=ED=83=AD=EC=97=90=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../View/MyLibraryCollectionViewCell.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/BookKitty/BookKitty/Source/Feature/MyLibrary/View/MyLibraryCollectionViewCell.swift b/BookKitty/BookKitty/Source/Feature/MyLibrary/View/MyLibraryCollectionViewCell.swift index dc180fc..492f394 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: false) { 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() { From 9e070ae94851f2f7906318be7b072b95f63acaed Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Sun, 23 Mar 2025 18:30:05 +0900 Subject: [PATCH 07/10] =?UTF-8?q?[Feat]=20=EC=9A=B0=EC=84=A0=EC=88=9C?= =?UTF-8?q?=EC=9C=84=20=EC=BA=90=EC=8B=9C=20=EB=A1=9C=EB=93=9C=20=EC=A0=84?= =?UTF-8?q?=EB=9E=B5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NeoImage/Sources/Cache/DiskStorage.swift | 14 ++++- .../NeoImage/Sources/Cache/ImageCache.swift | 59 ++++++++++++++++--- .../Sources/Cache/MemoryStorage.swift | 34 ++++++++++- .../Sources/Wrapper/ImageView+NeoImage.swift | 24 ++++---- .../Sources/Wrapper/SwiftUI+NeoImage.swift | 7 ++- 5 files changed, 114 insertions(+), 24 deletions(-) diff --git a/BookKitty/BookKitty/NeoImage/Sources/Cache/DiskStorage.swift b/BookKitty/BookKitty/NeoImage/Sources/Cache/DiskStorage.swift index f76f54f..0009711 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/Cache/DiskStorage.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Cache/DiskStorage.swift @@ -16,12 +16,12 @@ public actor DiskStorage { ) { 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() @@ -129,8 +129,18 @@ public actor DiskStorage { /// 특정 키에 해당하는 파일을 삭제하는 메서드 func remove(for hashedKey: String) async throws { + let otherKey: String + if hashedKey.hasPrefix("priority_") { + otherKey = hashedKey.replacingOccurrences(of: "priority_", with: "") + } else { + otherKey = "priority_" + hashedKey + } + let fileURL = cacheFileURL(for: hashedKey) try fileManager.removeItem(at: fileURL) + + let otherKeyFileURL = cacheFileURL(for: otherKey) + try fileManager.removeItem(at: otherKeyFileURL) } /// 디렉토리 내의 모든 파일을 삭제하는 메서드 diff --git a/BookKitty/BookKitty/NeoImage/Sources/Cache/ImageCache.swift b/BookKitty/BookKitty/NeoImage/Sources/Cache/ImageCache.swift index 75b59fe..6d81569 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/Cache/ImageCache.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Cache/ImageCache.swift @@ -66,22 +66,57 @@ public final class ImageCache: Sendable { ) 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) { - print("hashedKey from retrieveImage:", hashedKey) return memoryData } - - let diskData = try await diskStorage.value(for: hashedKey) - - if let diskData { + + if let memoryDataForOtherKey = await memoryStorage.value(forKey: otherKey) { + return memoryDataForOtherKey + } + + if let diskData = try await diskStorage.value(for: hashedKey){ await memoryStorage.store(value: diskData, for: hashedKey, expiration: .days(7)) + return diskData } - - return diskData + + if let diskDataForOtherKey = try await diskStorage.value(for: otherKey){ + await memoryStorage.store(value: diskDataForOtherKey, for: hashedKey, expiration: .days(7)) + return diskDataForOtherKey + } + + return nil } /// 메모리와 디스크 모두에 존재하는 모든 데이터를 제거합니다. @@ -98,9 +133,15 @@ public final class ImageCache: Sendable { } @objc - public func clearMemoryCache() { + public func clearMemoryCache(keepPriorityImages: Bool = true) { Task { - await memoryStorage.removeAll() + if keepPriorityImages { + // 우선순위 이미지를 유지하는 전략 + await memoryStorage.removeAllExceptPriority() + } else { + // 모든 이미지 제거 + await memoryStorage.removeAll() + } } } diff --git a/BookKitty/BookKitty/NeoImage/Sources/Cache/MemoryStorage.swift b/BookKitty/BookKitty/NeoImage/Sources/Cache/MemoryStorage.swift index dccf264..d3db739 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/Cache/MemoryStorage.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Cache/MemoryStorage.swift @@ -1,7 +1,6 @@ import Foundation public actor MemoryStorage { - // MARK: - Properties var keys = Set() @@ -44,8 +43,18 @@ public actor MemoryStorage { /// 캐시에서 제거 public func remove(forKey hashedKey: String) { + let otherKey: String + if hashedKey.hasPrefix("priority_") { + otherKey = hashedKey.replacingOccurrences(of: "priority_", with: "") + } else { + otherKey = "priority_" + hashedKey + } + storage.removeObject(forKey: hashedKey as NSString) keys.remove(hashedKey) + + storage.removeObject(forKey: otherKey as NSString) + keys.remove(otherKey) } /// Removes all values in this storage. @@ -54,6 +63,29 @@ public actor MemoryStorage { keys.removeAll() } + public func removeAllExceptPriority() async { + 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, diff --git a/BookKitty/BookKitty/NeoImage/Sources/Wrapper/ImageView+NeoImage.swift b/BookKitty/BookKitty/NeoImage/Sources/Wrapper/ImageView+NeoImage.swift index b95acf6..12143c1 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/Wrapper/ImageView+NeoImage.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Wrapper/ImageView+NeoImage.swift @@ -35,7 +35,6 @@ extension NeoImageWrapper where Base: UIImageView { @discardableResult // Return type을 strict하게 확인하지 않습니다. private func setImageAsync( with url: URL?, - placeholder: UIImage? = nil, options: NeoImageOptions? = nil, isPriority: Bool = false ) async throws -> ImageLoadingResult { @@ -45,14 +44,19 @@ extension NeoImageWrapper where Base: UIImageView { throw NeoImageError.responseError(reason: .invalidImageData) } - if let placeholder { - await MainActor.run { [weak base] in - guard let base else { - return - } + await MainActor.run { [weak base] in + guard let base else { + return + } - base.image = 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 } guard let url else { @@ -116,12 +120,11 @@ extension NeoImageWrapper where Base: UIImageView { @discardableResult public func setImage( with url: URL?, - placeholder: UIImage? = nil, + placeholder _: UIImage? = nil, options: NeoImageOptions? = nil ) async throws -> ImageLoadingResult { try await setImageAsync( with: url, - placeholder: placeholder, options: options ) } @@ -129,7 +132,7 @@ extension NeoImageWrapper where Base: UIImageView { /// `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 @@ -138,7 +141,6 @@ extension NeoImageWrapper where Base: UIImageView { do { let result = try await setImageAsync( with: url, - placeholder: placeholder, options: options, isPriority: isPriority ) diff --git a/BookKitty/BookKitty/NeoImage/Sources/Wrapper/SwiftUI+NeoImage.swift b/BookKitty/BookKitty/NeoImage/Sources/Wrapper/SwiftUI+NeoImage.swift index dbc454c..df305eb 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/Wrapper/SwiftUI+NeoImage.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Wrapper/SwiftUI+NeoImage.swift @@ -64,8 +64,13 @@ class NeoImageBinder: ObservableObject { do { let task = try await ImageDownloader.default.createTask(with: url) downloadTask = task + let hashedKey = url.absoluteString.sha256 - let result = try await ImageDownloader.default.downloadImage(with: task, for: url) + let result = try await ImageDownloader.default.downloadImage( + with: task, + for: url, + hashedKey: hashedKey + ) loadedImage = result.image loading = false From 985fdc30380a35f81d726d3aa03d03ed1fe48661 Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Tue, 25 Mar 2025 18:02:48 +0900 Subject: [PATCH 08/10] =?UTF-8?q?[Fix]=20remove=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=EA=B0=80=20=EC=96=91=EC=AA=BD=20=ED=82=A4=20=EB=AA=A8?= =?UTF-8?q?=EB=91=90=20=EC=A0=9C=EA=B1=B0=ED=95=98=EB=8A=94=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NeoImage/Sources/Cache/DiskStorage.swift | 24 +++------ .../NeoImage/Sources/Cache/ImageCache.swift | 50 +++++++++++++++---- .../Sources/Cache/MemoryStorage.swift | 17 +------ .../Sources/Constants/NeoImageConstants.swift | 1 + .../View/MyLibraryCollectionViewCell.swift | 4 +- 5 files changed, 51 insertions(+), 45 deletions(-) diff --git a/BookKitty/BookKitty/NeoImage/Sources/Cache/DiskStorage.swift b/BookKitty/BookKitty/NeoImage/Sources/Cache/DiskStorage.swift index 0009711..41ca9e6 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/Cache/DiskStorage.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Cache/DiskStorage.swift @@ -31,11 +31,12 @@ public actor DiskStorage { // MARK: - Functions func store(value: T, for hashedKey: String, expiration: StorageExpiration? = nil) async throws { + func store(value: T, for hashedKey: String) async throws { guard storageReady else { throw NeoImageError.cacheError(reason: .storageNotReady) } - let expiration = expiration ?? NeoImageConstants.expiration + let expiration = hashedKey.hasPrefix("priority_") ? NeoImageConstants guard !expiration.isExpired else { return @@ -111,7 +112,6 @@ public actor DiskStorage { return obj case .cacheTime: expirationDate = NeoImageConstants.expiration.estimatedExpirationSinceNow - // .expirationTime: 지정된 새로운 만료 시간으로 연장 case let .expirationTime(storageExpiration): expirationDate = storageExpiration.estimatedExpirationSinceNow } @@ -129,18 +129,9 @@ public actor DiskStorage { /// 특정 키에 해당하는 파일을 삭제하는 메서드 func remove(for hashedKey: String) async throws { - let otherKey: String - if hashedKey.hasPrefix("priority_") { - otherKey = hashedKey.replacingOccurrences(of: "priority_", with: "") } else { - otherKey = "priority_" + hashedKey - } - let fileURL = cacheFileURL(for: hashedKey) try fileManager.removeItem(at: fileURL) - - let otherKeyFileURL = cacheFileURL(for: otherKey) - try fileManager.removeItem(at: otherKeyFileURL) } /// 디렉토리 내의 모든 파일을 삭제하는 메서드 @@ -248,25 +239,22 @@ extension DiskStorage { func preloadPriorityToMemory() async { do { - let prefix = "priority_" + print(directoryURL) let fileURLs = try allFileURLs(for: [.isRegularFileKey, .nameKey]) - let prefixedFiles = fileURLs.filter { url in let fileName = url.lastPathComponent - return fileName.hasPrefix(prefix) + return fileName.hasPrefix("priority_") } for fileURL in prefixedFiles { - let fileName = fileURL.lastPathComponent - let hashedKey = fileName.replacingOccurrences(of: "priority_", with: "") + let hashedKey = fileURL.lastPathComponent - print("fileURL from preload:", fileURL) if let data = try? Data(contentsOf: fileURL) { await ImageCache.shared.memoryStorage.store(value: data, for: hashedKey) } } - NeoLogger.shared.info("우선순위 이미지 메모리 프리로드 완료") + NeoLogger.shared.debug("\(prefixedFiles.count)개의 우선순위 이미지 메모리 프리로드 완료") } catch { print("메모리 프리로드 중 오류 발생: \(error)") } diff --git a/BookKitty/BookKitty/NeoImage/Sources/Cache/ImageCache.swift b/BookKitty/BookKitty/NeoImage/Sources/Cache/ImageCache.swift index 6d81569..51cf7ed 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/Cache/ImageCache.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Cache/ImageCache.swift @@ -97,25 +97,43 @@ public final class ImageCache: Sendable { } 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, expiration: .days(7)) + + 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){ - await memoryStorage.store(value: diskDataForOtherKey, for: hashedKey, expiration: .days(7)) + + 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 } @@ -145,6 +163,20 @@ public final class ImageCache: Sendable { } } + 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 { diff --git a/BookKitty/BookKitty/NeoImage/Sources/Cache/MemoryStorage.swift b/BookKitty/BookKitty/NeoImage/Sources/Cache/MemoryStorage.swift index d3db739..e2cb682 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/Cache/MemoryStorage.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Cache/MemoryStorage.swift @@ -1,6 +1,7 @@ import Foundation public actor MemoryStorage { + // MARK: - Properties var keys = Set() @@ -41,22 +42,6 @@ public actor MemoryStorage { } } - /// 캐시에서 제거 - public func remove(forKey hashedKey: String) { - let otherKey: String - if hashedKey.hasPrefix("priority_") { - otherKey = hashedKey.replacingOccurrences(of: "priority_", with: "") - } else { - otherKey = "priority_" + hashedKey - } - - storage.removeObject(forKey: hashedKey as NSString) - keys.remove(hashedKey) - - storage.removeObject(forKey: otherKey as NSString) - keys.remove(otherKey) - } - /// Removes all values in this storage. public func removeAll() { storage.removeAllObjects() diff --git a/BookKitty/BookKitty/NeoImage/Sources/Constants/NeoImageConstants.swift b/BookKitty/BookKitty/NeoImage/Sources/Constants/NeoImageConstants.swift index 9a4b2e3..04ed701 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/Constants/NeoImageConstants.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Constants/NeoImageConstants.swift @@ -4,4 +4,5 @@ public enum AssociatedKeys { public enum NeoImageConstants { public static let expiration = StorageExpiration.days(7) + public static let expirationForPriority = StorageExpiration.days(3) } diff --git a/BookKitty/BookKitty/Source/Feature/MyLibrary/View/MyLibraryCollectionViewCell.swift b/BookKitty/BookKitty/Source/Feature/MyLibrary/View/MyLibraryCollectionViewCell.swift index 492f394..7cf1277 100644 --- a/BookKitty/BookKitty/Source/Feature/MyLibrary/View/MyLibraryCollectionViewCell.swift +++ b/BookKitty/BookKitty/Source/Feature/MyLibrary/View/MyLibraryCollectionViewCell.swift @@ -43,8 +43,8 @@ final class MyLibraryCollectionViewCell: UICollectionViewCell { func configureCell(imageUrl: URL?) { let startTime = Date() - - cellImageView.neo.setImage(with: imageUrl, isPriority: false) { result in + + cellImageView.neo.setImage(with: imageUrl, isPriority: true) { result in switch result { case .success: let elapsedTime = Date().timeIntervalSince(startTime) From 0de8cc5efe70418e2f25105f1f07109004c7eb1d Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Tue, 25 Mar 2025 18:03:19 +0900 Subject: [PATCH 09/10] =?UTF-8?q?[Fix]=20=EC=9A=B0=EC=84=A0=EC=88=9C?= =?UTF-8?q?=EC=9C=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=EC=97=90=20=EB=8C=80?= =?UTF-8?q?=ED=95=9C=20=EB=A7=8C=EB=A3=8C=EA=B8=B0=ED=95=9C=20=EC=B6=95?= =?UTF-8?q?=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BookKitty/BookKitty/NeoImage/Sources/Cache/DiskStorage.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/BookKitty/BookKitty/NeoImage/Sources/Cache/DiskStorage.swift b/BookKitty/BookKitty/NeoImage/Sources/Cache/DiskStorage.swift index 41ca9e6..74681e2 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/Cache/DiskStorage.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Cache/DiskStorage.swift @@ -30,13 +30,13 @@ public actor DiskStorage { // MARK: - Functions - func store(value: T, for hashedKey: String, expiration: StorageExpiration? = nil) async throws { 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 @@ -129,7 +129,6 @@ public actor DiskStorage { /// 특정 키에 해당하는 파일을 삭제하는 메서드 func remove(for hashedKey: String) async throws { - } else { let fileURL = cacheFileURL(for: hashedKey) try fileManager.removeItem(at: fileURL) } From 79d948383e5e30dfc776ee751a85a3609dd31574 Mon Sep 17 00:00:00 2001 From: Hyeongseok Kim <102458207+NeoSelf1@users.noreply.github.com> Date: Tue, 25 Mar 2025 18:15:32 +0900 Subject: [PATCH 10/10] =?UTF-8?q?[Delete]=20SwiftUI=20=EB=9E=98=ED=8D=BC?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Cache/MemoryStorage.swift | 2 +- .../Sources/Wrapper/SwiftUI+NeoImage.swift | 319 ------------------ 2 files changed, 1 insertion(+), 320 deletions(-) delete mode 100644 BookKitty/BookKitty/NeoImage/Sources/Wrapper/SwiftUI+NeoImage.swift diff --git a/BookKitty/BookKitty/NeoImage/Sources/Cache/MemoryStorage.swift b/BookKitty/BookKitty/NeoImage/Sources/Cache/MemoryStorage.swift index e2cb682..cc316d6 100644 --- a/BookKitty/BookKitty/NeoImage/Sources/Cache/MemoryStorage.swift +++ b/BookKitty/BookKitty/NeoImage/Sources/Cache/MemoryStorage.swift @@ -48,7 +48,7 @@ public actor MemoryStorage { keys.removeAll() } - public func removeAllExceptPriority() async { + public func removeAllExceptPriority() { let priorityKeys = keys.filter { $0.hasPrefix("priority_") } var priorityImagesData: [String: Data] = [:] diff --git a/BookKitty/BookKitty/NeoImage/Sources/Wrapper/SwiftUI+NeoImage.swift b/BookKitty/BookKitty/NeoImage/Sources/Wrapper/SwiftUI+NeoImage.swift deleted file mode 100644 index df305eb..0000000 --- a/BookKitty/BookKitty/NeoImage/Sources/Wrapper/SwiftUI+NeoImage.swift +++ /dev/null @@ -1,319 +0,0 @@ -import SwiftUI -import UIKit - -/// NeoImage 바인딩을 위한 ObservableObject -@MainActor -class NeoImageBinder: ObservableObject { - // MARK: - Properties - - /// 다운로드 작업 정보 - var downloadTask: DownloadTask? - // 이미지 로딩 상태와 결과 - @Published var loaded = false - @Published var animating = false - @Published var loadedImage: UIImage? = nil - @Published var progress = Progress() - - private var loading = false - - // MARK: - Computed Properties - - /// 로딩 상태 정보 - var loadingOrSucceeded: Bool { - loading || loadedImage != nil - } - - // MARK: - Lifecycle - - init() {} - - // MARK: - Functions - - func markLoading() { - loading = true - } - - func markLoaded() { - loaded = true - } - - func start(url: URL?, options _: NeoImageOptions?) async { - guard let url else { - loading = false - markLoaded() - return - } - - loading = true - progress = .init() - - let hashedKey = url.absoluteString.sha256 - if let cachedData = try? await ImageCache.shared.retrieveImage(hashedKey: hashedKey), - let cachedImage = UIImage(data: cachedData) { - loadedImage = cachedImage - loading = false - markLoaded() - return - } - - if let task = downloadTask { - await task.cancel() - downloadTask = nil - } - - do { - let task = try await ImageDownloader.default.createTask(with: url) - downloadTask = task - let hashedKey = url.absoluteString.sha256 - - let result = try await ImageDownloader.default.downloadImage( - with: task, - for: url, - hashedKey: hashedKey - ) - - loadedImage = result.image - loading = false - markLoaded() - } catch { - loadedImage = nil - loading = false - markLoaded() - } - } - - func cancel() async { - await downloadTask?.cancel() - downloadTask = nil - loading = false - } -} - -/// SwiftUI에서 사용 가능한 비동기 이미지 로딩 View -public struct NeoImage: View { - // MARK: - Nested Types - - /// 이미지 소스를 나타내는 열거형 - public enum Source { - case url(URL?) - case urlString(String?) - } - - // MARK: - SwiftUI Properties - - /// 이미지 로딩 바인더 - @StateObject private var binder = NeoImageBinder() - - // MARK: - Properties - - /// 이미지 소스 - private let source: Source - - // 옵션 및 콜백 - private var placeholder: AnyView? - private var options: NeoImageOptions - private var onSuccess: ((ImageLoadingResult) -> Void)? - private var onFailure: ((Error) -> Void)? - private var contentMode: SwiftUI.ContentMode - - // MARK: - Lifecycle - - // MARK: - Initializers - - /// URL로 초기화 - public init(url: URL?) { - source = .url(url) - options = .default - contentMode = .fill - } - - /// URL 문자열로 초기화 - public init(urlString: String?) { - source = .urlString(urlString) - options = .default - contentMode = .fill - } - - // MARK: - Content Properties - - // MARK: - View 구현 - - public var body: some View { - ZStack { - // 이미지가 로드된 경우 표시 - if let image = binder.loadedImage { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: contentMode == .fill ? .fill : .fit) - } - // 로딩 중이거나 로드되지 않은 경우 플레이스홀더 표시 - else if !binder.loaded || binder.loadedImage == nil { - if let placeholder { - placeholder - } - } - } - .clipped() // 이미지가 경계를 넘지 않도록 클리핑 - .onAppear { - // 뷰가 나타날 때 이미지 로딩 시작 - startLoading() - } - .onDisappear { - // 옵션에 따라 뷰가 사라질 때 로딩 취소 - if options.cancelOnDisappear { - Task { await binder.cancel() } - } - } - } - - // MARK: - Functions - - // MARK: - 모디파이어 - - /// 플레이스홀더 이미지 설정 - public func placeholder(_ content: @escaping () -> some View) -> NeoImage { - var result = self - result.placeholder = AnyView(content()) - return result - } - - /// 옵션 설정 - public func options(_ options: NeoImageOptions) -> NeoImage { - var result = self - result.options = options - return result - } - - /// 이미지 로딩 성공 시 호출될 콜백 - public func onSuccess(_ action: @escaping (ImageLoadingResult) -> Void) -> NeoImage { - var result = self - result.onSuccess = action - return result - } - - /// 이미지 로딩 실패 시 호출될 콜백 - public func onFailure(_ action: @escaping (Error) -> Void) -> NeoImage { - var result = self - result.onFailure = action - return result - } - - /// 페이드 트랜지션 설정 - public func fade(duration: TimeInterval = 0.3) -> NeoImage { - var result = self - result.options = NeoImageOptions( - transition: .fade(duration), - cacheExpiration: result.options.cacheExpiration - ) - return result - } - - /// 콘텐츠 모드 설정 (fill/fit) - public func contentMode(_ contentMode: SwiftUI.ContentMode) -> NeoImage { - var result = self - result.contentMode = contentMode - return result - } - - /// 뷰가 사라질 때 다운로드 취소 여부 설정 - public func cancelOnDisappear(_ cancel: Bool) -> NeoImage { - var result = self - var newOptions = result.options - newOptions.cancelOnDisappear = cancel - result.options = newOptions - return result - } - - private func startLoading() { - // 이미 로딩 중이거나 성공한 경우 스킵 - if binder.loadingOrSucceeded { - return - } - - let url: URL? = { - switch source { - case let .url(url): - return url - case let .urlString(string): - if let string { - return URL(string: string) - } - return nil - } - }() - - // 비동기 로딩 시작 - Task { - await binder.start(url: url, options: options) - - // 결과 처리 - if let image = binder.loadedImage, let url { - // 이미지 변환이 필요한 경우 - if options.transition != .none { - binder.animating = true - withAnimation( - Animation - .linear(duration: transitionDuration(for: options.transition)) - ) { - binder.animating = false - } - } - - // 성공 콜백 호출 - let result = ImageLoadingResult(image: image, url: url, originalData: Data()) - onSuccess?(result) - } else if url != nil, binder.loadedImage == nil { - // 실패 콜백 호출 - onFailure?(NeoImageError.responseError(reason: .invalidImageData)) - } - } - } - - private func transitionDuration(for transition: ImageTransition) -> TimeInterval { - switch transition { - case .none: - return 0 - case let .fade(duration): - return duration - case let .flip(duration): - return duration - } - } -} - -// MARK: - View Extensions for NeoImage - -/// NeoImage 생성을 위한 편의 확장 -extension View { - /// URL로부터 NeoImage를 생성하는 모디파이어 - public func neoImage( - url: URL?, - placeholder: AnyView? = nil, - options: NeoImageOptions = .default - ) -> some View { - let neoImage = NeoImage(url: url) - .options(options) - - if let placeholder { - return neoImage.placeholder { placeholder } - } - - return neoImage - } - - /// URL 문자열로부터 NeoImage를 생성하는 모디파이어 - public func neoImage( - urlString: String?, - placeholder: AnyView? = nil, - options: NeoImageOptions = .default - ) -> some View { - let neoImage = NeoImage(urlString: urlString) - .options(options) - - if let placeholder { - return neoImage.placeholder { placeholder } - } - - return neoImage - } -}