diff --git a/project/Projects/Data/ConcreteRepository/Cache/CacheRepository.swift b/project/Projects/Data/ConcreteRepository/Cache/CacheRepository.swift index 05261b60..b46ce7a2 100644 --- a/project/Projects/Data/ConcreteRepository/Cache/CacheRepository.swift +++ b/project/Projects/Data/ConcreteRepository/Cache/CacheRepository.swift @@ -41,6 +41,9 @@ public class DefaultCacheRepository: CacheRepository { static let cacheInfoDict = "cacheInfoDictionary" } + private let fileManagerScheduler = SerialDispatchQueueScheduler(qos: .background) + private let downloadScheduler = ConcurrentDispatchQueueScheduler(qos: .background) + /// 이미지를 메모리에서 캐싱하는 NSCache입니다. private let imageMemoryCache: NSCache = .init() @@ -59,10 +62,19 @@ public class DefaultCacheRepository: CacheRepository { return [:] } + /// 디스크캐시 + let maxFileCount: Int + let removeFileCountForOveflow: Int + private let jsonDecoder = JSONDecoder() private let jsonEncoder = JSONEncoder() - public init() { + public init(maxFileCount: Int = 50, removeFileCountForOveflow: Int = 10) { + + // 디스크 캐싱 옵션 설정 + self.maxFileCount = maxFileCount + self.removeFileCountForOveflow = removeFileCountForOveflow + // 이미지 인메모리 캐시 설정 imageMemoryCache.countLimit = 30 imageMemoryCache.totalCostLimit = 20 @@ -70,7 +82,48 @@ public class DefaultCacheRepository: CacheRepository { public func getImage(imageInfo: ImageDownLoadInfo) -> Single { - Single.create { [weak self] observer in + // MARK: 이미지 캐싱정보 확인 + let findCacheResult = findCache(imageInfo: imageInfo) + .subscribe(on: fileManagerScheduler) + .asObservable() + .share() + + let cacheFound = findCacheResult.compactMap { $0 } + let cacheNotFound = findCacheResult.filter { $0 == nil } + + // MARK: 이미지 다운로드 + let imageDownloadResult = cacheNotFound + .observe(on: downloadScheduler) + .map { [imageInfo] _ -> Data? in + try? Data(contentsOf: imageInfo.imageURL) + } + .share() + + let downloadSuccess = imageDownloadResult.compactMap { $0 } + + // MARK: 다운로드된 이미지 캐싱 + let downloadedImage = downloadSuccess + .observe(on: fileManagerScheduler) + .compactMap { [imageInfo, weak self] data -> UIImage? in + + // 이미지 캐싱 + _ = self?.cacheImage(imageInfo: imageInfo, contents: data) + + return self?.createUIImage(data: data, format: imageInfo.imageFormat) + } + + return Observable + .merge( + downloadedImage.asObservable(), + cacheFound.asObservable() + ) + .asSingle() + } + + + func findCache(imageInfo: ImageDownLoadInfo) -> Single { + + Single.create { [weak self] observer in let urlString = imageInfo.imageURL.absoluteString let cacheInfoKey = urlString @@ -104,47 +157,50 @@ public class DefaultCacheRepository: CacheRepository { self?.imageMemoryCache.setObject(image, forKey: memoryKey) observer(.success(image)) - } else { - // 디스크정보 없음, 이미지 다운로드 실행 - do { - let contents = try Data(contentsOf: imageInfo.imageURL) - - // 디스크에 파일 생성 - self?.createImageFile(imageURL: imageInfo.imageURL, contents: contents) - - // UIImage생성 - if let image = self?.createUIImage(data: contents, format: imageInfo.imageFormat) { - - // 캐싱정보 지정 - let cacheInfo = ImageCacheInfo( - url: imageInfo.imageURL.absoluteString, - format: imageInfo.imageFormat, - date: .now - ) - - if var dict = self?.cacheInfoDict { - dict[cacheInfoKey] = cacheInfo - self?.setCacheInfoDict(dict: dict) - } - - // 메모리 캐시에 올리기 - self?.imageMemoryCache.setObject(image, forKey: memoryKey) - - observer(.success(image)) - } - } catch { - #if DEBUG - print("이미지 다운로드 싪패") - #endif - } + // 캐싱된 이미지 정보 없음 + observer(.success(nil)) } } return Disposables.create { } } } + + func cacheImage(imageInfo: ImageDownLoadInfo, contents: Data) -> Bool { + + // 디스크에 파일 생성 + createImageFile(imageURL: imageInfo.imageURL, contents: contents) + + let urlString = imageInfo.imageURL.absoluteString + let cacheInfoKey = urlString + let memoryKey = NSString(string: urlString) + + // UIImage생성 + if let image = createUIImage(data: contents, format: imageInfo.imageFormat) { + + // 캐싱정보 지정 + let cacheInfo = ImageCacheInfo( + url: imageInfo.imageURL.absoluteString, + format: imageInfo.imageFormat, + date: .now + ) + + var dict = cacheInfoDict + dict[cacheInfoKey] = cacheInfo + setCacheInfoDict(dict: dict) + + // 메모리 캐시에 올리기 + imageMemoryCache.setObject(image, forKey: memoryKey) + + // 캐싱 성공 + return true + } + + // 캐싱 실패 + return false + } } // MARK: Cache info @@ -195,12 +251,25 @@ extension DefaultCacheRepository { return nil } - let imageDirectory = path.appendingPathComponent("Image") - try? FileManager.default.createDirectory(at: imageDirectory, withIntermediateDirectories: true) + let imageDirectoryPath = path.appendingPathComponent("Image") + + if !FileManager.default.fileExists(atPath: imageDirectoryPath.path) { + + do { + try FileManager.default.createDirectory(at: imageDirectoryPath, withIntermediateDirectories: true) + } catch { + + #if DEBUG + print("이미지 캐싱 디렉토리 생성 실패") + #endif + + return nil + } + } let imageFileName = safeFileName(from: url) - let imageURL = imageDirectory + let imageURL = imageDirectoryPath .appendingPathComponent(imageFileName) return imageURL } @@ -253,7 +322,10 @@ extension DefaultCacheRepository { if let numOfFiles = try? FileManager.default.contentsOfDirectory(atPath: imageDirectoryPath.path).count { // 최대 파일수를 초과한 경우 삭제(LRU), 하위 10개 파일 삭제 - if numOfFiles >= 50 { + if numOfFiles >= maxFileCount { + #if DEBUG + print("디스크 파일수가 50개를 초과하였음 삭제실행") + #endif var dict = cacheInfoDict let sortedInfo = dict.sorted { (lhs, rhs) in @@ -261,19 +333,34 @@ extension DefaultCacheRepository { } // 이미지 파일 삭제 - sortedInfo[0..<10].forEach { (key, value) in + sortedInfo[0.. Bool { + + guard let path = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { + print("\(#function) 이미지 경로 생성 실패") + return false + } + + let imageDirectoryPath = path.appendingPathComponent("Image") + + do { + try FileManager.default.removeItem(at: imageDirectoryPath) + UserDefaults.standard.removeObject(forKey: Key.cacheInfoDict) + print("\(#function) 이미지 캐싱정보 삭제성공") + return true + } catch { + print("\(#function) 파일 삭제 실패") + return false + } + } } diff --git a/project/Projects/Data/ConcretesTests/ImageCachingTest.swift b/project/Projects/Data/ConcretesTests/ImageCachingTest.swift index 20666083..f8369bfd 100644 --- a/project/Projects/Data/ConcretesTests/ImageCachingTest.swift +++ b/project/Projects/Data/ConcretesTests/ImageCachingTest.swift @@ -14,46 +14,61 @@ import RxSwift class ImageCachingTest: XCTestCase { - let cacheRepo = DefaultCacheRepository() - let disposeBag = DisposeBag() + var cacheRepository: DefaultCacheRepository! + var disposeBag: DisposeBag! - func testCaching() { - - let imageInfo = ImageDownLoadInfo( - imageURL: URL(string: "https://fastly.picsum.photos/id/237/200/300.jpg?hmac=TmmQSbShHz9CdQm0NkEjx1Dyh_Y984R9LpNrpvH2D_U")!, - imageFormat: .jpeg - ) + override func setUp() { + super.setUp() + self.cacheRepository = .init(maxFileCount: 5, removeFileCountForOveflow: 1) + self.disposeBag = .init() + } + + func test_diskcache_oveflows_50images() { - let exp = XCTestExpectation(description: "caching") - cacheRepo - .getImage(imageInfo: imageInfo) - .map { image in - print("--첫번째 이미지--") - } - .flatMap { [cacheRepo]_ in - cacheRepo - .getImage(imageInfo: imageInfo) + class ImageFetchedCounter { + var count: Int + + init(count: Int) { + self.count = count } - .map { image in - print("--메모리 캐싱 체크--") - } - .map { [cacheRepo]_ in - cacheRepo - .checkDiskCache(info: imageInfo) - } - .subscribe(onSuccess: { image in + } + + // 디스크 캐싱 내역 삭제 + _ = cacheRepository.clearImageCacheDirectory() + + let counter = ImageFetchedCounter(count: 0) + + // 이미지 50개를 테스트 + let observables = (0..<10).map { index in + + let url = URL(string: "https://dummyimage.com/300x\(300+index)/000/fff")! + let imageInfo = ImageDownLoadInfo( + imageURL: url, + imageFormat: .png + ) + + return cacheRepository + .getImage(imageInfo: imageInfo) + .asObservable() + } + + let cacheExpectation = XCTestExpectation(description: "cacheExpectation") + + Observable + .merge(observables) + .subscribe(onNext: { [counter] _ in - print("--디스크 체크--") + counter.count += 1 + print("이미지 획득 성공 \(counter.count)") - if let image { - exp.fulfill() - } else { - XCTFail() + if counter.count == 10 { + + cacheExpectation.fulfill() } - }) .disposed(by: disposeBag) + - wait(for: [exp], timeout: 5.0) + wait(for: [cacheExpectation], timeout: 20.0) } } diff --git a/project/Projects/Presentation/Feature/Center/Sources/ViewModel/Profile/CenterProfileViewModel.swift b/project/Projects/Presentation/Feature/Center/Sources/ViewModel/Profile/CenterProfileViewModel.swift index 873283b7..f4bab573 100644 --- a/project/Projects/Presentation/Feature/Center/Sources/ViewModel/Profile/CenterProfileViewModel.swift +++ b/project/Projects/Presentation/Feature/Center/Sources/ViewModel/Profile/CenterProfileViewModel.swift @@ -127,6 +127,7 @@ public class CenterProfileViewModel: BaseViewModel, CenterProfileViewModelable { let profileRequestSuccess = profileRequestResult .compactMap { $0.value } + .share() let profileRequestFailure = profileRequestResult .compactMap { $0.error }