미니앱을 만들어볼겨
필요한 사전 지식은 요정도가 되겠다
간단한 뷰 구성해주고
jsonPlaceholder사이트를 사용해서
photos 데이터들을 받아줄 거다!!
json 데이터들을 다운받고 이 데이터엔 이미지 url이 있는데
이미지들은 따로 다운받아줄거임!
JSON 데이터를 참고해서 포토모델을 구성해보자
struct PhotoModel: Identifiable, Codable {
let albumId: Int
let id: Int
let title: String
let url: String
let thumbnailUrl: String
}
/*
{
"albumId": 1,
"id": 1,
"title": "accusamus beatae ad facilis cum similique qui sunt",
"url": "https://via.placeholder.com/600/92c952",
"thumbnailUrl": "https://via.placeholder.com/150/92c952"
}
*/
요런형태가 되야겠죠
그리고 뷰모델을 만들어줍시다!
뷰모델에는 PhotoModel 타입의 array가 있을거고 이 데이터를 바탕으로 list를 그려줄거
이미지를 다운로드 하는 건 제네릭하게 쓸 수 있으니까 Utilites 폴더 안에
이 로직을 처리할 클래스를 만들어주면 됨
import Foundation
import Combine
class PhotoModelDataService {
static let instance = PhotoModelDataService()
@Published var photoModels: [PhotoModel] = []
var cancellables = Set<AnyCancellable>()
private init() {
downloadData()
}
func downloadData() {
guard let url = URL(string: "https://jsonplaceholder.typicode.com/photos") 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:
break
case .failure(let error):
print("Error downloading data. \(error)")
}
} receiveValue: { [weak self] returnedPhotoModels in
self?.photoModels = returnedPhotoModels
}
.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
}
}
다운로드 데이터는 콤바인으로 처리해주고 tryMap같은 경우엔 메소드로 따로 빼줬으
여기서 이해 안되는거? 없음 ㅋㅋㅋ🥳
와 진짜 꾸준히 하면 되는구나
한 가지 문제가 있다면 지금 DataService에서 데이터를 처리하고 있기 때문에
뷰모델에선 photoModels 어레이에 접근하기가 어렵죠
photoModels가 @Published로 값이 방출되고 있으니까 이걸 바인딩해서 .sink해서 뷰모델의 dataArray에 넣고, 마찬가지로 .store해주면 됩니다!!
다시 뷰로 돌아오면 데이터가 다운되서 리스트가 그려지고 있겠죠
HStack을 따로 빼서 뷰로 만들어주면 보기 편할 거 같다
지금은 Circle인데 Image를 다운로드 받는 뷰를 만들고 넣어주면 될 것 같지 않음?
로딩 중이라면 프로그레스뷰를 보여주게 하자
다운로드 받는 로직은 뷰모델로 따로 빼줘야할 듯
cancellables에 store도 해줘야함!! (까먹은듯)
뷰모델이 init될 때 url을 받을 수 있게 해주고
다운로드 이미지 뷰에서 기존에 선언되어 있던 뷰모델의 형태를 조금 바꿔줘야함 init될 때 url을 받아서 StateObject로 선언한 loader에게 전달해주려면 지금과 같은 방법으로 작성해줘야한다
_loader = StateObject(wrappedValue: ImageLoadingViewModel(url: url))
이제 선언해준 것들 제대로 맞춰서 들어가게 조정해주면끝!
잘 나옵니다잉
지금 다운로드가 몇개만큼 리스트로 먼저 되는지 궁금하니까
donwloadImage 메소드 구현한 뷰모델에 프린트로 찍어봅시다
어느 정도 갯수의 이미지를 먼저 다운로드 하고 스크롤을 움직이게 될 때
새로운 다운로드들이 시작됨!
근데 문제가 뭐냐면!!!
스크롤을 내렸다가 올리게 되면 다시 다운로드가 된다는겨!!!!
-> 파일매니저랑 캐시 쓰면 좋겠죠
캐시 매니저를 유틸리티 폴더에 만들어줍시다
import Foundation
import SwiftUI
class PhotoModelCacheManager {
static let instance = PhotoModelCacheManager()
private init() { }
var photoCache: NSCache<NSString, UIImage> = {
var cache = NSCache<NSString, UIImage>()
cache.countLimit = 20
cache.totalCostLimit = 1024 * 1024 * 200 // 200mb Maybe...?
return cache
}()
func add(key: String, value: UIImage) {
photoCache.setObject(value, forKey: key as NSString)
}
func get(key: String) -> UIImage? {
return photoCache.object(forKey: key as NSString)
}
}
photoCache를 관리해줄 수 있는 매니저 파일을 싱글톤으로 만들어줌
add랑 get하는 메소드도 작성해주고!
photoFileManager 클래스도 만들어줍시다!
import Foundation
import SwiftUI
class PhotoModelFileManager {
static let instance = PhotoModelFileManager()
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)
print("Created Folder!")
} catch let error {
print("Error creating folder. \(error)")
}
}
}
private func getFolderPath() -> URL? {
return FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
.first?
.appendingPathComponent(folderName)
}
// ... /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 let error {
print("Error saving to file Manager. \(error)")
}
}
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)
}
}
이미지 로딩뷰모델로 돌아와서 방금만들어준 두 매니저를 선언해주자
get하는걸 구현하려고 하는데 이걸 만들 때 key 자체를 urlString으로 써도 될테지만 모델 자체에 id가 있으니까 그걸 사용해서 저장해주면 좋을 거 같음
음 여기서 생각해볼 점은
지금 url이랑 key를 나눠서 넘겨주는 뷰를 구성하게 됐는데
사실 어떻게 보면 model 전체를 넘겨주는 방법도 있을 거임
닉은 지금처럼 구현을 하는게 더 reusable하다고 얘기함
아아아
만약에 모델로 넘겨줬고 내가 또 사용해야한다고 가정해보셈.
photoModel 전체를 넘겨줬었다면 전부다 수정해줬어야할텐데 url이라는 string이랑 key라는 String 타입으로 지정함으로써 내가 string만 넘기면 어떤 모델이든지 스트링 값만 넘겨주면 되니까 이게 맞네!
캐시로 저장할 때 좋은 점은 일정 메모리 (내가 설정해준 limit)을 넘어가게 되면 알아서 삭제하고 다운로드 가능한 만큼만 유지하게 됨
단점은 permanant하게 저장되지 않는다는거!!
manager로 선언한 싱글톤 cacheManager를 FileManager로 이름만 바꿔서 테스트 해보자! (add 랑 get을 똑같이 선언해줘서 바로 테스트 가능)
다운받고나면 기기 자체에 저장되는 걸 볼 수 있다