UI
구현을 먼저 모두 마친 뒤, 네트워크 통신을 위해 NetworkManager
와 Model
을 정의하고 ViewModel
에서 호출했다. 네트워크 통신의 경우, 다양한 라이브러리를 경험해보고 싶어서 MainView
에 통신할 때는 Moya
를 사용하고, DetailView
에서는 Alamofire
를 사용했다. 그리고 이번 과제에서는 데이터 바인딩 방식에 옵저버 패턴을 적용하기 때문에, 둘다 통신 결과를 RxSwift
의 Observable
에 전송하도록 구현해야 했다.
NetworkManager
구현
싱글톤 패턴
활용- 아래 형태를 만족하는
fetch
메소드 정의func fetch<T: Decodable>(url: URL) -> Single<T>
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
을 사용하면 디코딩까지 되기 때문에 굉장히 편리하다. response
의 result
값에 따라 success
면 Single
도 success
, failure
면 Single
도 failure
를 넣어 반환하면 되기 때문에 굉장히 조합이 잘 맞다고 느꼈다.
이번에 처음 접하는 라이브러리다. Moya
는 프로토콜의 extension
을 통한 기본 구현을 사용하도록 되어있다. 기존의 네트워크 통신 메소드를 정의할 때 request
에 넣어주는 내용(url
의 path
, query
와 같은 요소나 통신 메소드 등)을 TargetType
이라는 프로토콜을 채택하여 넣으면 MoyaProvider
라는 친구가 그 내용을 가지고 통신 요청을 해준다.
https://pokeapi.co/api/v2/pokemon?limit=\(limit)&offset=\(offset)
내가 통신을 요청해야하는 URL이다. 처음에는 baseURL
에 저 값을 통째로 넣고 통신해봤는데 안됐다. 꼭 base
와 path
, 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)
}
Model
구현
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
}
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
}
[스포일러] 내가 이미 다 구현한 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
에 바인딩 해주면 된다. (다음 편에 계속)
ViewModel
구현
위에서 네트워크 통신을 구현하고 디코딩 할 Model
까지 정의했으니, ViewModel
에서 fetch
메소드를 호출하여 Observable
에 결과값을 방출해주면 된다. 이미 위에서 Alamofire
와 Moya
의 호출 코드를 비교하면서 봤지만, 내 ViewModel
코드는 아래와 같다.
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)
}
}
}
}
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)
}
}
각각 다른 방식의 통신을 경험해보고 싶었기 때문에 두 뷰 모델의 코드가 조금 다르다. 이렇게 해보길 잘한 것 같다. Alamofire
와 RxSwift
를 함께 쓰는 것도 처음이고, Moya
의 사용도 처음이었기 때문에 모든 과정이 새롭고 흥미로웠다.
UI
와의 바인딩은 다음에 다룰 것이다.
기본값 메타몽 센스 뭡니까 전 기껏해야 SF 심볼이나 생각했는데