iOS

[MVVM 연습 프로젝트][Lotto](0) - MVC로 만들어보기

miniOS 2021. 12. 28. 23:33

Device : 2021 M1 Macbook pro
OS : macOS Monterey 12.0.1
Xcode : Version 13.1 (13A1030d)

안녕하세요! miniOS입니다.

이번에는 MVVM을 공부하면서 간단하게 적용해볼 수 있는 연습용 프로젝트를 만들어 보려고 합니다.

회차별로 로또 추첨 결과에 대해서 얻어오는 서비스를 만들어 보겠습니다.

코드는 아래 명령을 통해서 다운받을 수 있습니다.

git clone -b lotto_0 https://github.com/sseungmn/MVVM_Practice.git

준비

로또 API

https://www.dhlottery.co.kr/common.do?method=getLottoNumber&drwNo=<#회차번호#>

Project

Info.plist를 다른 위치로 옮겼을 때에는

TARGETS -&gt; Build Setting -&gt; Packaging

위 요소를 현재 Info.plist의 위치로 수정해주어야 합니다.

withoutStoryboard

처음이신 분들은 Storyboard없이 code로 UI 구현하기를 참고해주세요!

API Handing

위에 기입해놓은 로또 API를 호출해보면 결과는 아래와 같습니다.

이 JSON구조를 구조화 하기 위해서 QuickType을 이용합니다.

왼쪽 필드에 JSON결과를 붙여넣어주면 오른쪽에 구조체 코드가 나오는데, 이것이 우리의 Model이 됩니다.

위 코드를 Model파일에 넣어주면 됩니다.

이제, 코드를 통해서 API 통신을 하고, 위와 같은 결과를 받아와봅시다.

// 각각의 error에 대해서 처리하기 위해서, 간단한 세부 오류를 정의했습니다.
enum APIError: Error {
  case invalidResponse, invalidData, noData, failed
}

class APIService {
// 요청에 성공했을 때와 오류가 발생했을 때 각각의 값들을 전달해주기 위해서
// completion closure을 아래와 같이 선언했습니다.
  static func requestLotto(_ round: Int, completion: @escaping (Lotto?, APIError?) -> Void) {
    let url = URL(string: "https://www.dhlottery.co.kr/common.do?method=getLottoNumber&drwNo=\(round)")!

    URLSession.shared.dataTask(with: url) { data, response, error in
      guard error == nil else {
        completion(nil, .failed)
        return
      }

      guard let response = response as? HTTPURLResponse else {
        completion(nil, .invalidResponse)
        return
      }

      // 이곳의 response는 위에서 캐스팅해준 HTTPURLResponse타입입니다. 순서를 주의!
      guard response.statusCode == 200 else {
        completion(nil, .failed)
        return
      }

      guard let data = data else {
        completion(nil, .noData)
        return
      }

      do {
      // 이곳에서 JSON으로 받은 data를 우리의 Model에 맞춰서 decoding해줍니다.
        let decodedData = try JSONDecoder().decode(Lotto.self, from: data)
        completion(decodedData, nil)
      } catch {
        completion(nil, .invalidData)
      }
      // dataTask는 기본적으로 suspend상태이기 때문에 명시적으로 resume()을 해주어야 합니다.
    }.resume()
  }
}

요청이 잘 되는지 테스트 해보기 위해서 LottoViewController에 넣고 실행해보겠습니다.

override func viewDidLoad() {
    super.viewDidLoad()

    APIService.requestLotto(854) { lotto, error in
      print(lotto)
    }
  }

결과는 잘 나온다!

UI

당첨번호, 당첨금, 날짜. 세 가지 정보를 보여주기 위해서 간단하게 UI를 구성해보겠습니다.

LottoView

import UIKit
import SnapKit

class LottoView: UIView {

  let num1Label = UILabel()
  let num2Label = UILabel()
  let num3Label = UILabel()
  let num4Label = UILabel()
  let num5Label = UILabel()
  let num6Label = UILabel()
  let num7Label = UILabel()
  let dateLabel = UILabel()
  let moneyLabel = UILabel()

  let stackView: UIStackView = {
    let view = UIStackView()
    view.axis = .horizontal
    view.spacing = 8
    view.backgroundColor = nil
    view.distribution = .fillEqually
    return view
  }()

// code로만 View를 구성했을 때 불리는 method이다.
  override init(frame: CGRect) {
    super.init(frame: frame)
    backgroundColor = .white
    setConfig()
    setConstraints()
  }

// xib파일로 View를 구성했을 때 호출되는 method이다.
// coder를 통해서 xib를 nib으로 바꿔준다.
  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  func setConfig() {
    [num1Label, num2Label, num3Label, num4Label,
     num5Label, num6Label, num7Label, dateLabel, moneyLabel].forEach { label in
      label.backgroundColor = .lightGray
      label.textAlignment = .center
      label.textColor = .black
    }
  }

  func setConstraints() {
    addSubview(dateLabel)
    dateLabel.snp.makeConstraints { make in
      make.leading.trailing.equalToSuperview()
      make.center.equalToSuperview()
      make.height.equalTo(50)
    }

// 포함관계를 갖는 View의 Contraints를 구성할 때에는 항상 순서에 주의해야한다.
// 가장 큰 View의 Contraints를 먼저 잡아주고 점점 그 하위 View를 적용해주어야 한다.
    addSubview(stackView)
    stackView.snp.makeConstraints { make in
      make.leading.trailing.equalToSuperview()
      make.bottom.equalTo(dateLabel.snp.top).offset(-20)
      make.height.equalTo(50)
    }
    [num1Label, num2Label, num3Label, num4Label,
     num5Label, num6Label, num7Label].forEach { label in
      stackView.addArrangedSubview(label)
    }

    addSubview(moneyLabel)
    moneyLabel.snp.makeConstraints { make in
      make.top.equalTo(dateLabel.snp.bottom).offset(20)
      make.leading.trailing.equalToSuperview()
      make.height.equalTo(50)
    }
  }
}

LottoViewController

import UIKit

class LottoViewController: UIViewController {

  let mainView = LottoView()

// 앱의 생명주기 상 viewDidLoad보다 먼저 불리는 method이다.
// 이곳에서 view를 바꿔주면 기본적으로 ViewController에 보여주는 view를 바꿀 수 있다.
  override func loadView() {
    super.loadView()
    view = mainView
  }

  override func viewDidLoad() {
    super.viewDidLoad()
  }
}

각각의 위치가 정해진 View를 볼 수 있다.

UI에 Data 보여주기

LottoViewController에 아래 코드를 넣게 되면 API를 통해서 가져온 데이터를 View에 보여주게 됩니다.

override func viewDidLoad() {
    super.viewDidLoad()

    APIService.requestLotto(854) { lotto, error in
      guard error == nil else { return }
      guard let lotto = lotto else { return }
      self.mainView.num1Label.text = "\(lotto.drwtNo1)"
      self.mainView.num2Label.text = "\(lotto.drwtNo2)"
      self.mainView.num3Label.text = "\(lotto.drwtNo3)"
      self.mainView.num4Label.text = "\(lotto.drwtNo4)"
      self.mainView.num5Label.text = "\(lotto.drwtNo5)"
      self.mainView.num6Label.text = "\(lotto.drwtNo6)"
      self.mainView.num7Label.text = "\(lotto.bnusNo)"
      self.mainView.moneyLabel.text = "\(lotto.firstWinamnt)원"
      self.mainView.dateLabel.text = lotto.drwNoDate
    }
  }

하지만, 예상과는 달리 오류가 발생하게 됩니다.

신기한 보라색 오류

UI에 대한 코드를 적용할 때, mainThread에서 하지 않았기 때문에 발생한 오류입니다.

이에 대한 자세한 내용은 UIKit과 DispatchQueue.main의 관계에서 보실 수 있습니다.

LottoAPI

override func viewDidLoad() {
    super.viewDidLoad()

    APIService.requestLotto(854) { lotto, error in
      guard error == nil else { return }
      guard let lotto = lotto else { return }
      DispatchQueue.main.async {
          // 기존 코드
      }
    }
  }

결과는 아래와 같이 나옵니다

잘 나온다!