[SwiftUI] Download & Save Images

Junyoung Park·2022년 8월 20일
0

SwiftUI

목록 보기
28/136
post-thumbnail
post-custom-banner

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

Download & Save Images

구현 목표

  • Combine을 통한 비동기 데이터 패치
  • NSCache, FileManager 데이터 저장
  • MVVN 패턴

구현 태스크

  1. UI 구현: (1). 전체 데이터 리스트 뷰 (2). 리스트의 행(Row) 뷰 (3). 이미지 로딩 상태에 따른 이미지 뷰
  2. 뷰 모델 구현: (1). 전체 데이터 리스트 뷰에 모델 데이터를 바인딩하는 뷰 모델 (2). 각 리스트 행 뷰에 이미지를 바인딩하는 뷰 모델. 캐시/파일 매니저 클래스에 저장되어 있는 이미지가 있다면 다운로드하지 않고 그대로 출력
  3. 데이터 모델 구현: API를 통해 패치하는 데이터 모델. Identifiable, Codable 프로토콜 통한 디코딩 효율화
  4. 데이터 서비스 클래스: (1). 모델 데이터 전체를 비동기적으로 받아오기 (2). Combine 프레임워크 사용 (3). 싱글턴 패턴 (4). 해당 데이터 배열 → Published 등록, 다른 뷰 모델에서 Subscribe 가능하도록 설정
  5. 캐시 매니저 클래스: (1). NSCache에 각 이미지 저장 (2). 이미지 조회
  6. 파일 매니저 클래스: (1). FileManager 디렉토리에 각 이미지 저장 (2). 이미지 조회

    파일 매니저 클래스는 보다 중요한 유저 정보를 다루는 게 일반적이다. 캐시 매니저 클래스는 속도가 메모리를 사용하기 때문에 매우 빠르지만, 임시 데이터이기 때문에 앱을 껐다 다시 켜면 초기화된다는 데 주의하자!

핵심 코드

소스 코드

  1. UI 소스 코드
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로 준다.
  1. 뷰 모델 소스 코드
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)
    }
}
  • JSON을 통해 전체 모델을 받아와 리스트 뷰에 데이터를 바인딩하도록 하는 뷰 모델
  • 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)
    }
}
  • 각 모델의 이미지 URL을 통해 로드한 이미지를 각 이미지 뷰에 바인딩하는 뷰 모델
  • 각 모델의 아이디를 별도의 이미지 키로 사용, 캐시/파일 매니저 클래스에 이미지 키로 전달
  • 다운로드 매니저가 기존에 가지고 있는 이미지를 확인, 중복 이미지를 다운로드하지 않도록 구현
  1. 데이터 모델 소스 코드
import Foundation

struct PhotoModel: Identifiable, Codable {
    let albumId: Int
    let id: Int
    let title: String
    let url: String
    let thumbnailUrl: String
}
  1. 데이터 서비스 클래스 소스 코드
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
    }
}
  • photoModelsPublished로 등록, 뷰 모델의 dataArraySubscriber가 될 수 있도록 함. 즉 해당 매니저 클래스에서 다운로드한 데이터가 연속적으로 이어지는 과정
  • 싱글턴 패턴 구현
  1. 캐시 매니저 클래스 소스 코드
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 설정 시 데이터 개수, 용량 제한을 커스텀 가능
  • 캐시 값은 앱을 다시 켤 때 초기화
    8 싱글턴 패턴 구현
  1. 파일 매니저 클래스 소스 코드
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)
    }
}
  • 기존 설정한 이름의 저장 경로 생성, 해당 경로에 데이터 저장
  • 지속적 데이터로 캐시와 달리 앱을 다시 켜도 남아 있는 데이터
  • 싱글턴 패턴 구현

구현 화면

profile
JUST DO IT
post-custom-banner

0개의 댓글