[TIL] iOS 심화 주차 과제 : 포켓몬 도감 앱 만들기 day.02

Emily·2024년 12월 31일
1

PictorialBookApp

목록 보기
2/5

UI 구현을 먼저 모두 마친 뒤, 네트워크 통신을 위해 NetworkManagerModel을 정의하고 ViewModel에서 호출했다. 네트워크 통신의 경우, 다양한 라이브러리를 경험해보고 싶어서 MainView에 통신할 때는 Moya를 사용하고, DetailView에서는 Alamofire를 사용했다. 그리고 이번 과제에서는 데이터 바인딩 방식에 옵저버 패턴을 적용하기 때문에, 둘다 통신 결과를 RxSwiftObservable에 전송하도록 구현해야 했다.

Level 1

NetworkManager 구현

  • 싱글톤 패턴 활용
  • 아래 형태를 만족하는 fetch 메소드 정의
func fetch<T: Decodable>(url: URL) -> Single<T>

Alamofire

class NetworkManager {
    static let shared = NetworkManager()
    
    private init() {}
    
    func fetch<T: Decodable>(url: URL) -> Single<T> {
        return Single.create { observer in
            AF.request(url).responseDecodable(of: T.self) { response in
                switch response.result {
                case .success(let value):
                    observer(.success(value))
                case .failure(let error):
                    observer(.failure(error))
                }
            }
            return Disposables.create()
        }
    }
}

이게 내 NetworkManager 코드의 전부다. Alamofire를 사용하면 코드가 아주 간결해진다. 특히, .responseDecodable을 사용하면 디코딩까지 되기 때문에 굉장히 편리하다. responseresult 값에 따라 successSinglesuccess, failureSinglefailure를 넣어 반환하면 되기 때문에 굉장히 조합이 잘 맞다고 느꼈다.

Moya

이번에 처음 접하는 라이브러리다. Moya는 프로토콜의 extension을 통한 기본 구현을 사용하도록 되어있다. 기존의 네트워크 통신 메소드를 정의할 때 request에 넣어주는 내용(urlpath, query와 같은 요소나 통신 메소드 등)을 TargetType이라는 프로토콜을 채택하여 넣으면 MoyaProvider라는 친구가 그 내용을 가지고 통신 요청을 해준다.

https://pokeapi.co/api/v2/pokemon?limit=\(limit)&offset=\(offset)

내가 통신을 요청해야하는 URL이다. 처음에는 baseURL에 저 값을 통째로 넣고 통신해봤는데 안됐다. 꼭 basepath, query들을 구별하여 넣어야 한다.

enum PokemonAPI {
    case fetchURL(offset: Int)
}

extension PokemonAPI: TargetType {
    var baseURL: URL {
        URL(string: "https://pokeapi.co/api/v2")!
    }
    
    var path: String {
        switch self {
        case .fetchURL:
            return "/pokemon"
        }
    }
    
    var method: Moya.Method {
        .get
    }
    
    var task: Moya.Task {
        switch self {
        case .fetchURL(let offset):
		// 파라미터의 경우 limit은 불러오고 싶은 포켓몬 개수고 offset은 포켓몬 번호의 시작점인데,
		// 불러 올 포켓몬 수는 21로 고정할 것이기 때문에 offset만 호출 시 받아 넣는 것으로 정의했다.
                .requestParameters(parameters: ["limit": "21", "offset": "\(offset)"], encoding: URLEncoding.queryString)
        }
    }
    
    var headers: [String : String]? {
        nil
    }
}

호출 시 차이

// MainViewModel
private let provider = MoyaProvider<PokemonAPI>()
    
func fetchPokemonList() {
    provider.request(.fetchURL(offset: 24)) { [weak self] result in
        switch result {
        case .success(let response):
            // decoding 한 뒤에 Observable에 전달
        case .failure(let error):
        	// Observable에 error 전달
        }
    }
}
// DetailViewModel
private let networkManager = NetworkManager.shared

func fetchPokemonDetail(_ urlString: String) {
	// url을 기본 구현해둔 Moya와 달리 호출 시 url 구성
    guard let url = URL(string: urlString) else { return }
        
    networkManager.fetch(url: url)
        .subscribe(
            onSuccess: { (response: Pokemon) in
                // decoding 이미 되어있기 때문에 바로 Observable에 전달
            },
            onFailure: { error in
                // Observable에 error 전달
            }
        )
        .disposed(by: disposeBag)
}

Level 2

Model 구현

  1. MainView의 데이터소스가 될 PokemonURL 정의
https://pokeapi.co/api/v2/pokemon?limit=10&offset=1 로 통신했을 때 response 데이터
struct PokemonURL: Decodable {
    let results: [PokemonResult]
}

struct PokemonResult: Decodable, Hashable {
    let name: String
    let url: String
}
  1. DetailView의 데이터소스가 될 Pokemon 정의
https://pokeapi.co/api/v2/pokemon/2/ 로 통신했을 때 JSON은 11082줄에 달하여 가져올 수가 없다.

응답 형태와 내 화면에 필요한 요소를 고려하여 정의하였다.

struct Pokemon: Decodable {
    let id: Int
    let name: String
    let types: [PokemonType]
    let height: Double
    let weight: Double
}

struct PokemonType: Decodable {
    let type: PokemonTypeName
}

struct PokemonTypeName: Decodable {
    let name: String
}

Computed Property 활용

[스포일러] 내가 이미 다 구현한 main view 화면

포켓몬 리스트 API 응답에는 포켓몬 이름포켓몬 상세 정보 URL 밖에 없는데 이미지 목록을 어떻게 만들 수 있을까?

https://pokeapi.co/api/v2/pokemon/2/

URL의 마지막에 있는 숫자가 포켓몬의 id 값이고, 이 값을

https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/\(id).png

여기에 넣어주면 그 포켓몬의 이미지를 불러올 수 있다. 그래서 나는 url 값을 활용해 포켓몬 id를 추출하는 계산 프로퍼티를 정의하였다.

내가 URL에서 id 값을 추출하기 위해 Playground에서 연습한 내용
struct PokemonResult: Decodable, Hashable {
    let name: String
    let url: String
    
    // 연습결과를 활용하여 정의한 계산 프로퍼티
    var pokemonId: String {
        guard let firstIndex = url.dropLast().lastIndex(of: "/") else { return "" }
        return String(url[firstIndex..<url.endIndex].dropFirst().dropLast())
    }
}

이제 pokemonId를 활용하여 이미지를 구한 뒤 MainView에 바인딩 해주면 된다. (다음 편에 계속)

Level 3, 6

ViewModel 구현

위에서 네트워크 통신을 구현하고 디코딩 할 Model까지 정의했으니, ViewModel에서 fetch 메소드를 호출하여 Observable에 결과값을 방출해주면 된다. 이미 위에서 AlamofireMoya의 호출 코드를 비교하면서 봤지만, 내 ViewModel 코드는 아래와 같다.

MainViewModel

class MainViewModel {
    
    // Moya Provider
    private let provider = MoyaProvider<PokemonAPI>()
    
    // view와 바인딩 될 RxSwift Observable
    let pokemonList = PublishSubject<[PokemonResult]>()
    
    init() {
    	// 생성과 동시에 통신 요청
        fetchPokemonList()
    }
    
    func fetchPokemonList() {
    	// 피카츄부터 나오게 하기 위해 offset 24
        provider.request(.fetchURL(offset: 24)) { [weak self] result in
            switch result {
            case .success(let response):
                do {
                	// 디코딩 후 값 방출
                    let data = try JSONDecoder().decode(PokemonURL.self, from: response.data)
                    self?.pokemonList.onNext(data.results)
                } catch {
                    self?.pokemonList.onError(NetworkError.decodingFailed)
                }
            case .failure(let error):
                self?.pokemonList.onError(error)
            }
        }
    }
}

DetailViewModel

class DetailViewModel {
    
    private let disposeBag = DisposeBag()
    private let networkManager = NetworkManager.shared
    
    // view와 바인딩 될 RxSwift Observable
    let pokemonDetail = PublishSubject<Pokemon>()
    
    // 생성과 동시에 통신 요청 - 생성 시 포켓몬 url을 전달할 거지만, 그 전에 기본값으로는 메타몽을 넣었다.
    init(_ urlString: String = "https://pokeapi.co/api/v2/pokemon/132") {
        fetchPokemonDetail(urlString)
    }

    func fetchPokemonDetail(_ urlString: String) {
        guard let url = URL(string: urlString) else {
            pokemonDetail.onError(NetworkError.invalidURL)
            return
        }
        
        // 구독과 동시에 값 방출. 디코딩 된 상태의 데이터를 구독하기 때문에 main vm과 달리 디코딩 과정이 없다.
        networkManager.fetch(url: url)
            .subscribe(
                onSuccess: { [weak self] (response: Pokemon) in
                    self?.pokemonDetail.onNext(response)
                },
                onFailure: { [weak self] error in
                    self?.pokemonDetail.onError(error)
                }
            )
            .disposed(by: disposeBag)
    }
}

각각 다른 방식의 통신을 경험해보고 싶었기 때문에 두 뷰 모델의 코드가 조금 다르다. 이렇게 해보길 잘한 것 같다. AlamofireRxSwift를 함께 쓰는 것도 처음이고, Moya의 사용도 처음이었기 때문에 모든 과정이 새롭고 흥미로웠다.

UI와의 바인딩은 다음에 다룰 것이다.

profile
iOS Junior Developer

2개의 댓글

comment-user-thumbnail
2025년 1월 2일

기본값 메타몽 센스 뭡니까 전 기껏해야 SF 심볼이나 생각했는데

1개의 답글