Skip to content

[IDLE-000] 이미지 캐싱 로직 수정 #74

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 152 additions & 45 deletions project/Projects/Data/ConcreteRepository/Cache/CacheRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<NSString, UIImage> = .init()

Expand All @@ -59,18 +62,68 @@ 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
}

public func getImage(imageInfo: ImageDownLoadInfo) -> Single<UIImage> {

Single<UIImage>.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<UIImage?> {

Single<UIImage?>.create { [weak self] observer in

let urlString = imageInfo.imageURL.absoluteString
let cacheInfoKey = urlString
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -253,27 +322,45 @@ 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
lhs.value.date < rhs.value.date
}

// 이미지 파일 삭제
sortedInfo[0..<10].forEach { (key, value) in
sortedInfo[0..<removeFileCountForOveflow].forEach { (key, value) in
dict.removeValue(forKey: key)

if let path = createImagePath(url: value.url) {
do {
try FileManager.default.removeItem(at: path)
} catch {

if FileManager.default.fileExists(atPath: path.path) {

do {
try FileManager.default.removeItem(at: path)
print("이미지 삭제 성공 \(path)")
} catch {
#if DEBUG
print("\(path) 이미지 삭제 실패 reason: \(error.localizedDescription)")
#endif
}
} else {
#if DEBUG
print("\(imageURL) 이미지 삭제 실패")
print("\(path) 파일이 존재하지 않음")
#endif
}

}
}

#if DEBUG
print("디스크 파일삭제완료")
#endif

// 이미지 캐싱정보 저장 (10개 삭제)
setCacheInfoDict(dict: dict)
}
Expand All @@ -283,7 +370,7 @@ extension DefaultCacheRepository {
let fileCreationResult = FileManager.default.createFile(atPath: imagePath.path, contents: contents)

#if DEBUG
print("디스크에 이미지 생성 \(fileCreationResult ? "성공" : "실패")")
print("디스크에 이미지 생성 \(fileCreationResult ? "성공" : "실패") 경로: \(imagePath)")
#endif
}

Expand Down Expand Up @@ -334,4 +421,24 @@ extension DefaultCacheRepository {

return safeFileName
}

func clearImageCacheDirectory() -> 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
}
}
}
79 changes: 47 additions & 32 deletions project/Projects/Data/ConcretesTests/ImageCachingTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ public class CenterProfileViewModel: BaseViewModel, CenterProfileViewModelable {

let profileRequestSuccess = profileRequestResult
.compactMap { $0.value }
.share()

let profileRequestFailure = profileRequestResult
.compactMap { $0.error }
Expand Down