[SwiftUI Crypto App] Project Setup (5~9)

Woozoo·2023년 2월 25일
0

[SwiftUI Review]

목록 보기
8/41
post-custom-banner

Adding a List of Coins

리스트를 넣어줄거!
근데 리스트에 들어가는 데이터들은 뷰모델에서 처리를 해줘야겠죠

새로만들게 되는 홈뷰모델은 여러 뷰에서 사용할 예정이라 environmentObject로 만들어줘야할 거 같다



홈뷰에 EnvironmentObject를 추가해줄건데 이거 프리뷰에도 들어가야 되잖음
근데 매번 새로운 객체 만드는 것보다 PreviewProvider에 extension으로 만들어주면 편할 듯



홈뷰모델이 init될 때 네트워크에서 다운로드 받는 거랑 비슷한 환경 조성할라고
DispatchQueue.main.asyncAfter로 살짝 딜레이 주고
[CoinModel]에 append해줌

홈뷰에서 List에 추가해봅시다

버튼을 누르게 되면 이 리스트가 트랜지션 되야함
.transition 추가하고


그리고 listRow인셋도 수정해줬다
지금 만들어준 리스트를 extension의 변수로 빼주고


새로운 if문으로 portfolioCoinsList가 표시되게 해줌

ListRow위로 상세표시 바를 만들어주고
이 뷰를 변수로 빼줌


Downloading Coin Data


이제 뷰모델에서 데이터를 다운로드해야하는데
다운로드하는 과정은 generic하게 쓸 수 있으니
뷰모델에서 직접적으로 만들어주기 보다는 새로운 파일을 만들고
reference를 가지고 오자

import Foundation
import Combine

class CoinDataService {
    @Published var allCoins: [CoinModel] = []
    var coinSubscription: AnyCancellable?
    
    init() {
        getCoins()
    }
    
    private func getCoins() {
        guard let url = URL(string: "https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=250&page=1&sparkline=true&price_change_percentage=24h") else { return }
        
        coinSubscription = URLSession.shared.dataTaskPublisher(for: url)
            .subscribe(on: DispatchQueue.global(qos: .default))
            .tryMap { output -> Data in
                guard let response = output.response as? HTTPURLResponse,
                      response.statusCode >= 200 && response.statusCode < 300 else {
                    throw URLError(.badServerResponse)
                }
                return output.data
            }
            .receive(on: DispatchQueue.main)
            .decode(type: [CoinModel].self, decoder: JSONDecoder())
            .sink { completion in
                switch completion {
                case .finished:
                    break
                case .failure(let error):
                    print(error.localizedDescription)
                }
            } receiveValue: { [weak self] returnedCoins in
                self?.allCoins = returnedCoins
                self?.coinSubscription?.cancel()
            }
    }
    
}

여태껏 했던 거랑 다른점은 AnyCancellable을 Set으로 설정해서 .store해줬는데 이러면 어떤 Publisher를 cancel시킬 건지 특정시키기 어려워서
coinSubscription이라는 변수를 AnyCancellable?타입으로 지정해주고
이 변수에 URLSession을 담아줬음!
그리고 현재 요청해주는 request가 get해오는 과정임을 알기에 receiveValue에서 coinSubscription.cancel()을 명시해줌!!

import Foundation
import Combine

class HomeViewModel: ObservableObject {
    @Published var allCoins: [CoinModel] = []
    @Published var portfolioCoins: [CoinModel] = []
    
    private let dataService = CoinDataService()
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        addSubscriber()
    }
    
    func addSubscriber() {
        dataService.$allCoins
            .sink { [weak self] returnedCoins in
                self?.allCoins = returnedCoins
            }
            .store(in: &cancellables)
    }
    
}

홈뷰모델에선 dataService에서 퍼블리쉬되는 allCoins를 자신이 가진 allCoins에 subscribe되게 해줬다!
(이번엔 Set에 저장시켜줬음)


Adding a Networking Layer

네트워크에서 다운로드하는 과정을 generic하게 쓸 수 있게
새로운 클래스로 만들어주자
그럼 데이터서비스를 새로 만들게 되더라도 네트워킹은 따로 구현 안해줘도 되겠죠


네트워킹매니저 클래스를 만들었다
데이터 서비스에서 URLSession.shared 부터 .receive까지 복사해서 가져옵시다

그리고 이 전역 함수가 무엇을 반환하는 지 알아내야하는데


temp라는 상수에 담고 확인할 수 있습니다~


타입 복사해서 붙여놓고 return!!

근데 이 타입 너무 보기 힘들잖음 ㅋㅋㅋ...

combine의 좋은 점이 여기서 하나 등장함

.eraseToAnyPublisher()를 붙여줘서 타입을
AnyPublisher<Data,Error>타입으로 바꿔줄 수 있습니다~

뷰모델로 돌아와서 getCoins에서 네트워킹 처리해주던 부분
방금 작성해준 static func로 바꿔주면 쫜


완전 깔끔해졌죠


.sink의 컴프리션 부분도 깔끔하게 만들어주고 싶다

네트워크 매니저에서 handleCompletion이라는 함수를 작성하고
completion의 타입을 Subscriber.Completion<Error>로 만들어줌
(원래 .sink 쳐놓고 거기 적힌 타입 복사해서 가져오려고 했는데 옛날 버전이랑 다른지 저렇게 Subscribers로 나오지 않고 지정된 특정한 subscriber이름이 나옴)

오케이!


진짜 깔끔해졌다!!


NetworkingManager 조금 정리해줍시다

URLResponse 체크하는 부분 따로 빼줬음!

에러 타입들도 지정해주면 좋을 것 같다


NetworkingError라는 enum을 지정해주고 LocalizedError 프로토콜을 채택하면
커스텀한 에러 메시지를 콘솔에 띄울 수 있게 됨


이렇게 연관값을 작성해서 전달할 수도 있다!!
(enum에서 연관값은 이런 용도로 사용하게 된다는 걸 알게 됨)


테스트를 위해서 항상 에러를 던지게 throw를 첫줄에 넣고 실행하면~
요렇게 잘 나온다는 걸 확인했습니다🥳


Downloading Coin Images


컴포넌트 폴더 안에 코인 이미지들을 표현하는 뷰와 뷰모델을 구성했다

홈뷰모델이 데이터서비스가 한 네트워킹 처리를 구독해서 반영하고 있는 것처럼
이번에도 비슷하게 구현해보자

import Foundation
import SwiftUI
import Combine

class CoinImageService {
    
    @Published var image: UIImage? = nil
    
    var imageSubscription: AnyCancellable?
    
    init(urlString: String) {
        getCoinImage(urlString: urlString)
    }
    
    private func getCoinImage(urlString: String) {
        guard let url = URL(string: urlString) else { return }
        
        imageSubscription = NetworkingManager.download(url: url)
            .tryMap({ (data) -> UIImage? in
                return UIImage(data: data)
            })
            .sink(receiveCompletion: NetworkingManager.handleCompletion,
                  receiveValue: { [weak self] returnedImage in
                self?.image = returnedImage
                self?.imageSubscription?.cancel()
            })
    }
    
}

CoinImageService가 이니셜라이즈 될 때 getCoinImage를 호출하게 되고 이때 urlString을 외부로 부터 받아서 사용할거!

import Foundation
import SwiftUI

class CoinImageViewModel: ObservableObject {
    @Published var image: UIImage? = nil
    @Published var isLoading: Bool = false
    
    private let coin: CoinModel
    private let dataService: CoinImageService
    
    init(coin: CoinModel) {
        self.coin = coin
        self.dataService = CoinImageService(urlString: coin.image)
        getImage()
    }
    
    private func getImage() {
        
    }
}

CoinImageService를 CoinImageViewModel에서 이니셜라이즈 할 때
coinModel을 받아서 CoinImageService에 넣어줬다!!



ImageService에서 urlString받아오는 걸로 설정했던 거 coin으로 수정해줌 (CoinModel이 오는 게 나을 거 같다고함)

coinImageViewModel에선 CoinImageService를 구독해서 image값이 변경되면 뷰모델의 image에도 적용될 수 있게 해줌

그리고 초기화될 때 코인모델을 받아서 CoinImageService에 넘겨줄 수 있게 해주고!


코인 이미지뷰 의 StateObject가 init될 때도 마찬가지로 coin을 받아서 가져올 수 있게 해주고!!


Saving Images to FileManager

파일매니저를 만들어서 스크롤할 때 불필요한 다운로드 요청을 줄여보자

import Foundation
import SwiftUI

class LocalFileManager {
    static let instance = LocalFileManager()
    private init() { }
    
    func saveImage(image: UIImage, imageName: String, folderName: String) {
        
        // create folder
        createFolderIfNeeded(folderName: folderName)
        
        // get path for image
        guard
            let data = image.pngData(),
            let url = getURLForImage(imageName: imageName, folderName: folderName)
        else { return }
        
        // save image to path
        do {
            try data.write(to: url)
        } catch let error {
            print("Error saving image: \(imageName). \(error)")
        }
    }
    
    func getImage(imageName: String, folderName: String) -> UIImage? {
        
        guard let url = getURLForImage(imageName: imageName, folderName: folderName),
              FileManager.default.fileExists(atPath: url.path) else {
            return nil
        }
        return UIImage(contentsOfFile: url.path)
    }
    
    private func createFolderIfNeeded(folderName: String) {
        guard let url = getURLForFolder(folderName: folderName) else { return }
        if !FileManager.default.fileExists(atPath: url.path) {
            do {
                try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
            } catch let error {
                print("Error creating directory. FolderName: \(folderName). \(error)")
            }
        }
    }
    
    private func getURLForFolder(folderName: String) -> URL? {
        guard let url = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
            .first else { return nil }
        
        return url.appendingPathComponent(folderName)
    }
    
    private func getURLForImage(imageName: String, folderName: String) -> URL? {
        guard let folderURL = getURLForFolder(folderName: folderName) else {
            return nil
        }
        return folderURL.appendingPathComponent(imageName + ".png")
    }
    
}

코인이미지서비스로 돌아와서
방금 만들어준 FileManager를 이용해봅시다

기존에 getCoinImage라고 작성되어 있던 메소드는 downloadCoinImage로 이름을 바꿔주고 getCoin은 fileManager에서 가져오는 메소드로 만들어줌

그리고 folderName같은 경우엔 이 이미지서비스에서만 specific하게 쓸거라 따로 상수를 하나 만들어줬음

import Foundation
import SwiftUI
import Combine

class CoinImageService {
    
    @Published var image: UIImage? = nil
    
    private var imageSubscription: AnyCancellable?
    private let coin: CoinModel
    private let fileManager = LocalFileManager.instance
    private let folderName = "coin_images"
    private let imageName: String
    
    init(coin: CoinModel) {
        self.coin = coin
        self.imageName = coin.id
        getCoinImage()
    }
    
    private func getCoinImage() {
        if let savedImage = fileManager.getImage(imageName: imageName, folderName: folderName) {
            image = savedImage
            print("Retrieved image from File Manger!")
        } else {
            downloadCoinImage()
            print("Downloading Image now")
        }
    }
    
    private func downloadCoinImage() {
        guard let url = URL(string: coin.image) else { return }
        
        imageSubscription = NetworkingManager.download(url: url)
            .tryMap { (data) -> UIImage? in
                return UIImage(data: data)
            }
            .sink(receiveCompletion: NetworkingManager.handleCompletion,
                  receiveValue: { [weak self] returnedImage in
                guard let self = self,
                      let downloadedImage = returnedImage
                else { return }
                self.image = downloadedImage
                self.imageSubscription?.cancel()
                self.fileManager.saveImage(image: downloadedImage, imageName: self.imageName, folderName: self.folderName)
            })
    }
    
}

getCoinImage를 init할 때 호출해주게 되고 이 로직을 통해서
이미 다운받은적이 있다면 다시 다운할 필요없이 저장된 fileManager의 이미지를 가져오게 되고,
downloadCoinImage를 할 때 파일매니저에도 저장되게 해줬다!!

profile
우주형
post-custom-banner

0개의 댓글