포켓몬 도감앱 - 3

maxminseok·2024년 12월 31일
1

시작

어제에 이어 포켓몬 도감앱을 마저 작성하였다.

오늘은 DetailViewController를 구현하여 큼직한 기능 구현은 마무리 하는 단계였다.


DetailViewController 구현

UI 코드 작성

import UIKit
import RxSwift
import SnapKit

class DetailViewController: UIViewController {

    private let detailViewModel = DetailViewModel()
    private let disposeBag = DisposeBag()

    // 포켓몬 정보를 띄울 배경 뷰
    private let pokemonView: UIView = {
        let view = UIView()
        view.backgroundColor = UIColor.darkRed
        view.layer.cornerRadius = 10
        return view
    }()

    // 포켓몬 이미지뷰
    private let pokemonImageView: UIImageView = {
        let imageView = UIImageView()
        imageView.backgroundColor = UIColor.darkRed
        imageView.contentMode = .scaleAspectFill
        return imageView
    }()

    // 포켓몬 번호와 이름 스택뷰
    private let nameStackView: UIStackView = {
        let stackView = UIStackView()
        stackView.axis = .horizontal
        stackView.spacing = 10
        stackView.alignment = .center
        stackView.distribution = .fill
        return stackView
    }()

    // 포켓몬 정보 스택뷰
    private let dataStackView: UIStackView = {
        let stackView = UIStackView()
        stackView.axis = .vertical
        stackView.spacing = 10
        stackView.alignment = .center
        stackView.distribution = .fillEqually
        return stackView
    }()

    // 포켓몬 번호 레이블
    private let numberLabel: UILabel = {
        let label = UILabel()
        label.text = "No."
        label.font = UIFont.boldSystemFont(ofSize: 24)
        label.textAlignment = .center
        label.textColor = .white
        return label
    }()

    // 포켓몬 이름 레이블
    private let nameLabel: UILabel = {
        let label = UILabel()
        label.text = "이름"
        label.font = UIFont.boldSystemFont(ofSize: 24)
        label.textAlignment = .center
        label.textColor = .white
        return label
    }()

    // 포켓몬 타입 레이블
    private let typeLabel: UILabel = {
        let label = UILabel()
        label.text = "타입: "
        label.font = UIFont.boldSystemFont(ofSize: 18)
        label.textAlignment = .center
        label.textColor = .white
        return label
    }()

    // 포켓몬 키 레이블
    private let heightLabel: UILabel = {
        let label = UILabel()
        label.text = "키: "
        label.font = UIFont.boldSystemFont(ofSize: 18)
        label.textAlignment = .center
        label.textColor = .white
        return label
    }()

    // 포켓몬 몸무게 레이블
    private let weightLabel: UILabel = {
        let label = UILabel()
        label.text = "몸무게: "
        label.font = UIFont.boldSystemFont(ofSize: 18)
        label.textAlignment = .center
        label.textColor = .white
        return label
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        configureUI()
    }

    private func configureUI() {
        view.backgroundColor = UIColor.mainRed

        // 배경 뷰 추가
        view.addSubview(pokemonView)

        // 배경 뷰에 이미지와 스택뷰 추가
        [
            pokemonImageView,
            nameStackView,
            dataStackView,
        ].forEach { pokemonView.addSubview($0)}

        // 번호와 이름을 horizontal 스택뷰에 추가
        [
            numberLabel,
            nameLabel,
        ].forEach { nameStackView.addArrangedSubview($0) }

        // 타입, 키, 몸무게를 vertical 스택뷰에 추가
        [
            typeLabel,
            heightLabel,
            weightLabel,
        ].forEach { dataStackView.addArrangedSubview($0) }

        pokemonView.snp.makeConstraints {
            $0.top.equalTo(view.safeAreaLayoutGuide.snp.top)
            $0.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(32)
            $0.height.equalTo(view.safeAreaLayoutGuide.snp.height).multipliedBy(0.6)
        }

        pokemonImageView.snp.makeConstraints {
            $0.top.equalTo(pokemonView.snp.top).offset(40)
            $0.centerX.equalTo(pokemonView.snp.centerX)
            $0.width.height.equalTo(pokemonView.snp.width).multipliedBy(0.6)
        }

        nameStackView.snp.makeConstraints{
            $0.top.equalTo(pokemonImageView.snp.bottom).offset(12)
            $0.centerX.equalTo(pokemonView.snp.centerX)
            $0.height.equalTo(pokemonImageView.snp.height).multipliedBy(0.25)
        }

        dataStackView.snp.makeConstraints {
            $0.top.equalTo(nameStackView.snp.bottom).offset(8)
            $0.centerX.equalTo(pokemonView.snp.centerX)
            $0.width.equalTo(nameStackView.snp.width)
        }
    }

위와 같이 코드를 작성해 UI에 대한 코드 먼저 작성하였다.

어두운 배경의 뷰 안에 이미지와, 번호, 이름,타입, 키, 몸무게가 나와야 하므로 UIView 하나와 UIImageView 하나, UILabel 5개를 선언하였다.

그리고 번호와 이름은 같은 줄에 나와야 하므로 UIStackView로 묶고자 했고,

타입, 키, 몸무게도 각각 따로 레이아웃을 잡을 필요 없이 하나의 스택 뷰로 묶어 관리되도록 하고자 했기 때문에 horizontalvertical 스택뷰 하나씩 선언하였다.

UI 값 바인딩

func setDetailViewData(_ data: PokemonData) {
    detailViewModel.fetchPoekemonDetailData(data)
        .observe(on: MainScheduler.instance)
        .subscribe(onSuccess: { [weak self] pokemonDetail in
            self?.updateUI(with: pokemonDetail)
        }, onError: { error in
            print("데이터 에러: \(error)")
        }).disposed(by: disposeBag)
}

func updateUI(with pokemonDetail: PokemonDetail) {
    nameLabel.text = pokemonDetail.name
    numberLabel.text = "No.\(pokemonDetail.id)"
    heightLabel.text = "키: \(pokemonDetail.height)"
    weightLabel.text = "몸무게: \(pokemonDetail.weight)"
    typeLabel.text = "타입: " + pokemonDetail.types.map { $0.type.name }.joined(separator: ", ")
}
// 이런식으로 리팩토링 할까 생각중

MainViewController에서 didSelectItemAt 메서드를 작성할 때 미리 썼던 setDetailViewData 메서드를 선언하고 바인딩 하는 코드를 작성하였다.

func setDetailViewData(_ data: PokemonData) {
        detailViewModel.fetchPoekemonDetailData(data)
            .observe(on: MainScheduler.instance)
            .subscribe(onSuccess: { [weak self] pokemonDetail in
                self?.nameLabel.text = pokemonDetail.name
                self?.numberLabel.text = "No.\(pokemonDetail.id)"
                self?.heightLabel.text = "키: \(pokemonDetail.height)"
                self?.weightLabel.text = "몸무게: \(pokemonDetail.weight)"
                self?.typeLabel.text = "타입: " + pokemonDetail.types.map { $0.type.name }.joined(separator: ", ")
            }, onError: { error in
                print("데이터 에러: \(error)")
            }).disposed(by: disposeBag)
    }

이 과정에서 조금 시간이 걸렸다. UI는 화면에 나오는데, 자꾸 디코딩 에러가 나서 UI 업데이트가 안 된 것이다.

디코딩 에러라고 하면, 데이터는 잘 불러왔는데 디코딩하는 과정에서 문제가 생긴 것이니, url 문제나 서버 응답 문제는 아니고 데이터를 저장할 구조체가 잘못 되었다고 판단하였다.

포켓몬 api 반환 데이터를 다시 살펴보는데, 반환해주는 데이터가 매우 많아 문제를 찾기 힘들었다. 포켓몬 api 깃허브도 가보고 공식사이트를 찾아보다가, 공식사이트의 한참 밑에 각각의 데이터에 대해 설명하는 Description이 있는 것을 발견하였다.

다음부턴 어떤 api를 쓰든 Cmd+F로 Description부터 찾아야 할 것 같다..

문제는 포켓몬 타입을 받는 데이터가 문제였다. 서버로부터 받는 데이터에 포켓몬 타입에 대한 데이터가 types라는 리스트 안에 String 타입의 type이 있는 건줄 알고,

import Foundation

struct PokemonDetail: Codable {
    let id: Int
    let name: String
    let weight: Int
    let height: Int
    let types: [Types]
}

struct Types: Codable {
    let type: String
}

구조체를 이렇게 선언했었는데, types 안에 type 안에 String 타입의 name이라는 형태로 들어있었다.

import Foundation

struct PokemonDetail: Codable {
    let id: Int
    let name: String
    let weight: Int
    let height: Int
    let types: [Types]
}

struct Types: Codable {
    let type: Name
}

struct Name: Codable {
    let name: String
}

따라서 코드를 이렇게 바꾸고,

self?.typeLabel.text = "타입: " + pokemonDetail.types.map { $0.type.name }.joined(separator: ", ")

setDetailViewData 메서드에서 포켓몬 타입을 바인딩 하는 것도 위처럼 작성한 것이다.

이미지 메서드 정리

이후 포켓몬 이미지까지 받아서 UI를 업데이트 해야했는데 이 부분도 고민이 생겼다.

이미지 처리는 MainViewModel에서 하는 상태인데, MainViewModel의 값을 MainViewController가 아닌 DetailViewController가 바인딩해도 되는 것인가 였다.

https://velog.io/@ryuhyewon/MVVM-%EA%B0%9C%EB%85%90%EA%B3%BC-%EC%9E%A5%EB%8B%A8%EC%A0%90

https://velog.io/@rjsdnql123/TILReact%EC%99%80-MVVM%ED%8C%A8%ED%84%B4

MVVM에 대해 찾아보니 MVVM 패턴은 ViewModelView1:N 관계로 적용 되는 특징이 있다고 하였다.

물론 코딩에 정답은 없는 거겠지만 다수의 뷰에서 하나의 ViewModel을 재활용 하는게 MVVM 패턴을 적용하는 이상적인 상황이라고 생각하였다.

그런데 지금 내가 작성한 코드는 ViewDetailViewController가 두 개의 ViewModelMainViewModelDetailViewModel을 적용하는 N:1 관계가 될 상황이라 고민이 된 것이다.

// NetworkManager 클래스
func fetchImage(_ url: URL) -> Single<UIImage> {
    return Single.create { single in
        let task = URLSession.shared.dataTask(with: url) { data, response, error in
            if let error = error {
                single(.failure(error))
                return
            }

            guard let data = data, let image = UIImage(data: data) else {
                single(.failure(NetworkError.invalidData))
                return
            }

            single(.success(image))
        }
        task.resume()
        return Disposables.create {
            task.cancel()
        }
    }
}

고민하다가, 위오 같이 이미지를 받아오는 메서드는 fetch 메서드와 마찬가지로 두개의 뷰 모델에 전부 필요하니 NetworkManager 클래스로 옮기기로 하였다.

이렇게 하면 MainViewModel에서 작성한 이미지 메서드도 좀 더 단순해지고, 두 개의 뷰모델에서 중복 코드 작성을 하지 않고 재사용 가능해지기 때문이다.

func fetchPokemonImage(_ pokemonData: PokemonData) -> Single<UIImage> {
    guard let imageUrl = URL(string: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/\(pokemonData.id).png") else {
        return Single.error(NetworkError.invalidUrl)
    }
    return NetworkManager.shared.fetchImage(imageUrl)
}

MainViewModelDetailViewModel 양쪽에는 위와 같이 UIImage 또는 error를 방출하도록 작성하여 각각의 View에서 이미지를 바인딩할 수 있게 하였다.

처음엔 아예 이 코드도 쓰지 않고 NetworkManager에서 View로 바로 이미지를 받도록 할까도 생각했는데, 그렇게 하면 ViewViewModel이 아니라 네트워크 매니저에게 데이터를 받는 구조라 MVVM이랑은 또 달라진다고 생각하여 ViewModel을 거치도록 만들었다.

func setDetailViewData(_ data: PokemonData) {
		  // 이미지 바인딩
      detailViewModel.fetchPokemonImage(data)
          .observe(on: MainScheduler.instance)
          .subscribe(onSuccess: { [weak self] image in
              self?.pokemonImageView.image = image
          }, onError: { error in
              print("이미지 에러: \(error)")
          }).disposed(by: disposeBag)
          
			// 나머지 UI 데이터 바인딩
      detailViewModel.fetchPoekemonDetailData(data)
          .observe(on: MainScheduler.instance)
          .subscribe(onSuccess: { [weak self] pokemonDetail in
              self?.nameLabel.text = pokemonDetail.name
              self?.numberLabel.text = "No.\(pokemonDetail.id)"
              self?.heightLabel.text = "키: \(pokemonDetail.height)"
              self?.weightLabel.text = "몸무게: \(pokemonDetail.weight)"
              self?.typeLabel.text = "타입: " + pokemonDetail.types.map { $0.type.name }.joined(separator: ", ")
          }, onError: { error in
              print("데이터 에러: \(error)")
          }).disposed(by: disposeBag)
  }

위와 같이 setDetailViewData 메서드에 이미지 바인딩 코드를 추가하여 이미지도 출력되도록 수정하였다.


결과

포켓몬 이름을 한글화 하는 코드를 추가하고 decimetres와 hectograms 단위로 반환해주는 키와 몸무게 값을 적절히 변환한 뒤 출력하였다.


남은 구현 기능

  • 무한 스크롤
  • Kingfisher 활용
  • Relay 활용

오늘 무한 스크롤까지는 구현하려고 했었다.

사실 구현도 하긴 했는데, 데이터 로드가 굉장히 오래걸리고 뭔가 이상한 위치에 들어갔다가 스크롤하면 그때야 제자리에 들어가는 상태여서 마음에 안든다.

데이터가 제자리에 바로 안들어가는 것도 데이터 로드가 이상하게 되는 것 같은데 더 살펴보고 해결해야 할 거 같고, 데이터 로드가 오래 걸리는 문제는 캐싱이랑 Kingfisher를 공부해서 해결해야 할 것 같다.

0개의 댓글