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)
}
}