
Download and save images using FileManager and NSCache | Continued Learning #28

Combine을 통한 비동기 데이터 패치NSCache, FileManager 데이터 저장MVVN 패턴Identifiable, Codable 프로토콜 통한 디코딩 효율화Combine 프레임워크 사용 (3). 싱글턴 패턴 (4). 해당 데이터 배열 → Published 등록, 다른 뷰 모델에서 Subscribe 가능하도록 설정NSCache에 각 이미지 저장 (2). 이미지 조회FileManager 디렉토리에 각 이미지 저장 (2). 이미지 조회파일 매니저 클래스는 보다 중요한 유저 정보를 다루는 게 일반적이다. 캐시 매니저 클래스는 속도가 메모리를 사용하기 때문에 매우 빠르지만, 임시 데이터이기 때문에 앱을 껐다 다시 켜면 초기화된다는 데 주의하자!
import SwiftUI
struct DownloadingImagesBootCamp: View {
    @StateObject private var viewModel = DownloadingImagesViewModel()
    var body: some View {
        NavigationView {
            List {
                ForEach(viewModel.dataArray) { model in
                    DownloadingImagesRow(model: model)
                }
            }
            .navigationTitle("Downloading Images")
        }
    }
}
import SwiftUI
struct DownloadingImagesRow: View {
    let model: PhotoModel
    var body: some View {
        HStack {
            DownloadingImageView(urlString: model.url, imageKey: "\(model.id)")
                .frame(width: 75, height: 75)
            VStack {
                Text(model.title)
                    .font(.headline)
                Text(model.url)
                    .foregroundColor(.gray)
                    .italic()
            }
            .frame(maxWidth: .infinity, alignment: .leading)
        }
    }
}
import SwiftUI
struct DownloadingImageView: View {
    @StateObject private var loader: ImageLoadingViewModel
    
    init(urlString: String, imageKey: String) {
        _loader = StateObject(wrappedValue: ImageLoadingViewModel(urlString: urlString, imageKey: imageKey))
    }
    var body: some View {
        ZStack {
            if loader.isLoading {
                ProgressView()
            } else if let image = loader.image {
                Image(uiImage: image)
                    .resizable()
                    .clipShape(Circle())
            }
        }
    }
}
StateObject로 선언한 loader에 초깃값을 주기 위한 이니셜라이저는 wrappedValue로 준다.import Foundation
import Combine
class DownloadingImagesViewModel: ObservableObject {
    @Published var dataArray = [PhotoModel]()
    private let dataService = PhotoModelDataService.instance
    var cancellables = Set<AnyCancellable>()
    
    init() {
        addSubsribers()
    }
    
    private func addSubsribers() {
        dataService.$photoModels.sink { [weak self] photoModels in
            guard let self = self else { return }
            self.dataArray = photoModels
        }
        .store(in: &cancellables)
    }
}
Subscriber를 통해 dataArray를 비동기적으로 업데이트, dataArray 또한 Published로 선언해 이 값이 변화할 때 UI 뷰를 리렌더링import Foundation
import SwiftUI
import Combine
class ImageLoadingViewModel: ObservableObject {
    @Published var image: UIImage? = nil
    @Published var isLoading: Bool = false
    var cancellables = Set<AnyCancellable>()
    let cacheManager = PhotoModelCacheManager.instance
    let fmManager = PhotoModelFileManager.instance
    let urlString: String
    let imageKey: String
    
    init(urlString: String, imageKey: String) {
        self.urlString = urlString
        self.imageKey = imageKey
        getImage()
    }
    
    func getImage() {
        // manager.get(key: String)
        if let savedImage = cacheManager.get(key: imageKey) {
            image = savedImage
            print("GETTING SAVED IMAGE")
        } else {
            downloadImage()
            print("DOWNLOADING IMAGE NOW")
        }
    }
    
    func downloadImage() {
        guard let url = URL(string: urlString) else { return }
        
        URLSession.shared.dataTaskPublisher(for: url)
            .map { UIImage(data: $0.data) }
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in
                guard let self = self else { return }
                self.isLoading = false
            } receiveValue: { [weak self] image in
                guard let self = self, let image = image else { return }
                self.image = image
                self.cacheManager.add(key: self.imageKey, value: image)
                // self.fmManager.add(key: self.imageKey, value: image)
            }
            .store(in: &cancellables)
    }
}
import Foundation
struct PhotoModel: Identifiable, Codable {
    let albumId: Int
    let id: Int
    let title: String
    let url: String
    let thumbnailUrl: String
}
import Foundation
import Combine
class PhotoModelDataService {
    static let instance = PhotoModelDataService()
    private let urlString = "https://jsonplaceholder.typicode.com/photos"
    @Published var photoModels: [PhotoModel] = []
    var cancellables = Set<AnyCancellable>()
    private init() {
        downloadData()
    }
    
    private func downloadData() {
        guard let url = URL(string: urlString) else { return }
        URLSession.shared.dataTaskPublisher(for: url)
            .subscribe(on: DispatchQueue.global(qos: .background))
            .receive(on: DispatchQueue.main)
            .tryMap(handleOutput)
            .decode(type:[PhotoModel].self, decoder: JSONDecoder())
            .sink { completion in
                switch completion {
                case .finished:
                    print("SUCCESS")
                case .failure(let error):
                    print("FAIL")
                    print(error.localizedDescription)
                }
            } receiveValue: { [weak self] photoModels in
                guard let self = self else { return }
                self.photoModels = photoModels
            }
            .store(in: &cancellables)
    }
    
    private func handleOutput(output: URLSession.DataTaskPublisher.Output) throws -> Data {
        guard
            let response = output.response as? HTTPURLResponse,
            response.statusCode >= 200 && response.statusCode < 300 else {
            throw URLError(.badServerResponse)
        }
        return output.data
    }
}
photoModels를 Published로 등록, 뷰 모델의 dataArray가 Subscriber가 될 수 있도록 함. 즉 해당 매니저 클래스에서 다운로드한 데이터가 연속적으로 이어지는 과정import Foundation
import SwiftUI
class PhotoModelCacheManager {
    static let instance = PhotoModelCacheManager()
    private init() {}
    var photoCache: NSCache<NSString, UIImage> = {
        let cache = NSCache<NSString, UIImage>()
        cache.countLimit = 200
        cache.totalCostLimit = 1024 * 1024 * 200 // 300MB
        return cache
    }()
    
    func add(key: String, value: UIImage) {
        photoCache.setObject(value, forKey: key as NSString)
    }
    
    func get(key: String) -> UIImage? {
        guard let value = photoCache.object(forKey: key as NSString) else {
            return nil
        }
        return value
    }
}
NSCache 설정 시 데이터 개수, 용량 제한을 커스텀 가능import Foundation
import SwiftUI
class PhotoModelFileManager {
    static let instance = PhotoModelFileManager()
    private let folderName = "downloaded_photos"
    private init() {
        createFolderIfNeeded()
    }
    
    private func createFolderIfNeeded() {
        guard let url = getFolderPath() else { return }
        if !FileManager.default.fileExists(atPath: url.path) {
            do {
                try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)
                print("CREATED FOLDER")
            } catch {
                print("ERROR CREATING FOLDER")
                print(error.localizedDescription)
            }
        }
    }
    
    private func getFolderPath() -> URL? {
        return FileManager
            .default
            .urls(for: .cachesDirectory, in: .userDomainMask)
            .first?
            .appendingPathComponent(folderName)
    }
    
    // .../downloaded_photos/
    // .../downloaded_photos/[image_name].png
    private func getImagePath(key: String) -> URL? {
        guard let folder = getFolderPath() else { return nil }
        return folder.appendingPathComponent(key + ".png")
    }
    
    func add(key: String, value: UIImage) {
        guard let data = value.pngData(), let url = getImagePath(key: key) else { return }
        do {
            try data.write(to: url)
        } catch {
            print("ERROR SAVING FM")
            print(error.localizedDescription)
        }
    }
    
    func get(key: String) -> UIImage? {
        guard let url = getImagePath(key: key),
                FileManager.default.fileExists(atPath: url.path)
        else { return nil }
        return UIImage(contentsOfFile: url.path)
    }
}
