[아이폰 앱 3] 투로나 (투데이 코로나)

김상우·2021년 11월 29일
0

개발 정보

  • 앱 이름 : 투로나
  • 개발 언어 : swift
  • 개발 환경 : Xcode, StoryBoard, AutoLayout
  • 앱 설명 :
    시도별 신규 확진자 수가 파이 차트로 표시됩니다.
    도시 항목을 선택하면 상세 현황을 볼 수 있는 화면으로 이동됩니다.
  • 활용 기술 :
    1. 굿바이 코로나19 API
    2. Alamofire
    3. Cocoapods
    4. Charts - Pie Chart
    5. Navigation Controller
    6. Static Table View
  • github :

앱 실행 화면 gif


Cocoapods 설치 중 에러 해결

  • 코코아팟 설치를 위해 터미널에 설치 커맨드를 입력했지만 다음과 같은 에러가 발생했습니다.
$ sudo gem install cocoapods                                                                                                                 
Building native extensions. This could take a while...
ERROR:  Error installing cocoapods:
	ERROR: Failed to build gem native extension.
  • xcode-select 설치 문제로 알고, 설치 해보았지만 그 문제는 아니었습니다.
$ xcode-select --install                                                                                                               
xcode-select: error: command line tools are already installed, use "Software Update" to install updates
$ brew cleanup -d -v    

$ brew install cocoapods  

프로젝트에 Cocoapods 적용하기

  • 터미널에서 Cocoapods 를 적용시킬 프로젝트 폴더로 이동합니다.
$ cd ~/iOSProject/투로나
  • Podfile을 생성합니다.
$ pod init

  • 위 명령어를 수행하면 Podfile 이 생성되고, Podfile 을 수정하면 외부 라이브러리를 프로젝트에 추가할 수 있습니다. 수정하기 전 Podfile 모습입니다.

라이브러리와 버전명

  • 둘 다 Podfile에 넣어줍니다.

  • Podfile 을 변경하고 저장한 뒤, 터미널에서 라이브러리 설치를 진행합니다.
$ pod install

  • 그럼 workspace 파일이 생성되는데, 이 파일이 Podfile 을 적용하는 프로젝트 파일이므로 이제 이 workspace 파일안에서 개발을 진행하면 됩니다.

  • 실제 workspace 안에 들어가보면, Pods 를 따로 관리할 수 있게 된 것을 확인할 수 있습니다.


  1. 스토리 보드에 Navigation Controller 오브젝트를 추가합니다.
    그럼 다음과 같이 한 쌍의 뷰가 생성됩니다.

  1. Navigation Controller 에서 is Initial View Controller 를 체크합니다.

  1. 기본적으로 생성된 root View Controller 를 삭제한 뒤, 마우스 우클릭으로 원하는 View 에 끌어당겨 root View Controller 로 설정합니다.


Pie Chart View 생성하기

  • object 에서 UIView 를 추가한뒤에, Class 이름을 PieChartView 로 변경합니다. 그러면 Charts 라이브러리의 PieChartView 가 됩니다.

Static Table View 사용하기

  • Static Table View 는 말그대로 "설정" 과 같이 정적인 테이블 뷰에 사용됩니다.
  1. UI Table View Controller 에서만 사용이 가능하기 때문에 UI Table View Controller 를 먼저 생성해줍니다.

  1. 그 뒤, 스토리 보드에 추가된 UI Table View Controller 와 UI Table View Controller Class 를 연결해야 합니다.
  • New File -> Cocoa Touch Class -> Subclass of : UITableViewController -> 생성
    이름은 CovidDetailViewController로 설정했습니다.

  • TableViewController 는 TableView 의 컨텐츠를 관리하거나 변화에 대응하는 delegate 와 데이터 소스 프로토콜을 채택합니다.
    그렇기 때문에 UITableViewController 를 SubClassing 하는 ViewController 를 생성하면 기본적으로 필수 데이터 소스 메소드가 오버라이드 되어있고, delegate 메소드들도 주석 처리 되어있습니다.
  • 이 앱에서는 static table view 만 사용할 것이기 때문에 불필요한 주석과 데이터 소스 메소드 들을 삭제 했습니다.

  • 그 다음 스토리보드로 돌아와서 생성한 Table View Controller 의 Class 를 방금 생성한 Class 인 CovidDetailViewController 로 설정해줍니다.
  1. Table View Controller 안에 있는 Table View 를 Static Table View 로 변경합니다.

  1. Table View Section 에서 row 를 7줄로 늘리고, Style 을 Right Detail 로 변경합니다.

  1. Table View 의 Cell 들을 어시스턴스를 사용해서 코드에 Outlet 변수로 추가해줍니다.


json pasing 을 위한 구조체 선언하기

json 데이터 구조.

Swift 코드

import Foundation

struct CityCovidOverview: Codable {
    let korea: CovidOverview
    let seoul: CovidOverview
    let busan: CovidOverview
    let daegu: CovidOverview
    let incheon: CovidOverview
    let gwangju: CovidOverview
    let daejeon: CovidOverview
    let ulsan: CovidOverview
    let sejong: CovidOverview
    let gyeonggi: CovidOverview
    let gangwon: CovidOverview
    let chungbuk: CovidOverview
    let chungnam: CovidOverview
    let jeonbuk: CovidOverview
    let jeonnam: CovidOverview
    let gyeongbuk: CovidOverview
    let gyeongnam: CovidOverview
    let jeju: CovidOverview
}

struct CovidOverview: Codable {
    let countryName: String
    let newCase: String
    let totalCase: String
    let recovered: String
    let death: String
    let percentage: String
    let newCcase: String
    let newFcase: String
}

문제 해결 : Escaping Closure (탈출 클로저) 사용

  • 문제 : Alamofire 로 서버로부터 데이터를 받아오는 과정에서 자꾸 nil 값이 뜸
  • Escaping Closure

직역하면 클로저가 함수 밖으로 탈출하는 것을 말합니다. 함수 인자로 클로저가 전달되지만, 함수가 반환된 후에도 실행되는 것을 말합니다.

비유적으로 말하면 local 변수의 영역을 뛰어넘는 것입니다. 보통 비동기 작업을 하는 경우에 많이 사용됩니다. 그리고 대부분의 네트워크 작업이 비동기적으로 작업됩니다.

  • ViewController.swift 의 일부
// 서버에서 코로나 관련 json 데이터를 불러오는 함수
func fetchCovidOverview (
        // API 요청하고 json 데이터를 응답받거나 실패했을 때,
        // 이 클로저로 해당 클로저를 정의한 곳에 응답받은 데이터를 전달하려 함
        // 탈출 클로저로 선언함으로써 responseData 의 completionHandler 가 호출되기 전에,
        // 함수가 반환돼버려서 오류가 생기는 경우를 방지.
        myClosure: @escaping (Result<CityCovidOverview, Error>) -> Void
    ) {
        let url = "https://api.corona-19.kr/korea/country/new/"
        let param = [
            "serviceKey" : "8UQF357Imbgx2BEzTnkNvs69tdAjePrYR"
        ]
        // 해당 API 호출
        // param 에 딕셔너리 형태로 넣으면 알아서 url 뒤에 쿼리 파라미터를 추가한다.
        // 그래서 뒤에 필요한 매개변수를 생략해도 된다.
        AF.request(url, method: .get, parameters: param)
        // request 메서드를 이용해 요청을 했으면 응답 데이터를 받을 수 있는 메서드를 체이닝 해야한다.
        // 응답 데이터가 클로저 파라미터로 전달된다.
            .responseData(completionHandler: { response in
                // 응답 받은 데이터는 response.result 에 열거형으로 저장된다.
                switch response.result {
                // 만약 요청 결과가 success 이면 연관값으로 서버에서 받은 data 가 전달된다.
                case let .success(data):
                    // 응답 받은 json 데이터를 CityCovidOverview 구조체에 매핑되는 코드 작성
                    do {
                        let decoder = JSONDecoder()
                        let result = try decoder.decode(CityCovidOverview.self, from: data)
                        myClosure(.success(result))
                    } catch {
                        // json 매핑이 실패 했을 경우
                        myClosure(.failure(error))
                    }
                // 요청 결과가 failure 인 경우
                case let .failure(error):
                    myClosure(.failure(error))
                }
                
            })
    }

위 코드와 같이, responseData 메소드 파라미터에 정의한 completion handler 클로저는 fetchCovidOverview 함수가 반환된 후에 호출됩니다. 그 이유는 서버에서 데이터를 언제 응답시켜줄지 모르기 때문입니다.

비동기적 코드의 실행 결과는 동기적 코드가 전부 실행 되고나서 값을 반환합니다.

그렇기 때문에 escaping closure 로 completionHandler (후에 myClosure 로 이름 변경했습니다) 를 정의하지 않는다면, 서버에서 비동기로 데이터를 응답받기 전, 즉 responseData 메소드 파라미터에 정의한 completionHandler 클로저가 호출되기 전에 함수가 종료돼서, 서버의 응답을 받아도 fetchCovidOverview 함수에 정의한 completionHandler (myClosure) 가 호출되지 않을 것입니다.

그래서 함수 내에서 비동기 작업을 하고, 비동기 작업의 결과를 completion handler 로 callback 을 시켜줘야 한다면 escaping closure 를 사용하여 함수가 반환된 후에도 실행되게 만들어줘야 했습니다.

코드에서 클로저 앞에 @escaping 을 붙여주어 선언합니다.


동기 vs 비동기

  • 동기 방식은 서버에서 요청을 보냈을 때 응답이 돌아와야 다음 동작을 수행할 수 있습니다. 즉 A작업이 모두 진행 될때까지 B작업은 대기해야합니다.
  • 비동기 방식은 반대로 요청을 보냈을 때 응답 상태와 상관없이 다음 동작을 수행 할 수 있습니다. 즉 A작업이 시작하면 동시에 B작업이 실행된다. A작업은 결과값이 나오는대로 출력됩니다.

ref : https://velog.io/@daybreak/동기-비동기-처리


Cocoapods 에러 해결

  • 프로젝트에 cocoapods 을 잘 적용해줬지만 framework not found Pods__ 에러가 발생했습니다.
  • Framework 폴더 안의 빨갛게 표시된 파일을 삭제하라는 글을 읽고 삭제 해보았지만, 일이 더 커졌습니다.

잘 모르고 삭제했는데, 삭제해서는 안되는 중요한 파일이었나봅니다.
scene delgate 파일이 제대로 있음에도 There is no scene delgate set 에러가 발생했습니다.

결국 프로젝트를 지우고 처음부터 다시 만들어서 해결했습니다..


투로나 ViewController.swift 의 핵심 구조

func fetchCovidOverview 에서 매개변수인 completionHandler 는 클로저 타입인데, Alamofire 의 json 요청 결과를 Result 타입에 담기위한 클로저입니다.

이름이 completionHandler 이지만, 스스로 코드 리뷰 하던중에 사실 myClosure 라고 선언했으면 더 직관적이고 좋았을 것 같았다고 생각이들어 변수명을 수정하였습니다.

사실 진짜 completionHandler 역할을 하는 것은 responseData 안의 completionHandler 입니다. 이 클로저는 API 서버와의 소통이 마무리 될때 호출됩니다. 이 클로저에서 myClosure 의 매개변수에 서버 소통 결과를 전달하고, 이 myClosure 는 escaping Closure 로 선언했기 때문에 viewDidLoad 에 언제든지 호출되어도 상관이 없게 됩니다.


지역 선택시 넘어가는 화면 구현

1. CovidDetailViewController 에 데이터를 받을 프로퍼티와 함수 구현

  • CovidDetailViewController.swift
    프로퍼티에 전달받기
// 이 프로퍼티에 선택된 지역의 발생 현황 데이터를 전달받음
var covidOverview: CovidOverview?
...
override func viewDidLoad(){
  super.viewDidLoad()
  self.configureView()
}
...
// 전달받은 데이터를 화면에 표시
func configureView(){
  guard let covidOverview = self.covidOverview else {return}
  self.title = covidOverview.countryName
  self.newCaseCell.detailTextLabel?.text = 
  "\(covidOverview.newCase)명"
  self.totalCaseCell.detailTextLabel?.text = 
  "\(covidOverview.totalCase)명"
  ...
}

2. ViewController.swift 코드 작성

  • 먼저 pieChartView 를 delegate 받습니다.
func configureChartView(covidOverviewList: [CovidOverview[]){
  self.pieChartView.delegate = self
  • delegate 프로토콜 준수
    delegate을 받았으면 클래스에 그 프로토콜을 준수해야합니다.
extension ViewController: ChartViewDelegate {
  // 차트에서 항목을 선택했을 때 실행되는 메서드
  // ChartViewDelegate 프로토콜의 메서드
  func charViewSelected(_ chartView: ChartViewBase, entry: ChartDataEntry, highlight: Highlight){
  // viewController 인스턴스화, 다운 캐스팅
  guard let covidDetailViewController =
    self.storyboard?.instantiateViewController(
    withIdentifier: "covidDetailViewController")
    as? CovidDetailViewController else {return}
  
  // entry 타입 -> covidOverview 타입 다운 캐스팅  
  guard let covidOverview = entry.data as? CovidOverview else {return}
  
  // 넘어갈 화면의 프로퍼티에 데이터 전달
  covidDetailViewController.covidOverview = covidOverview
  
  // 네비게이션 컨트롤러 스택 푸시
  self.navigationController?.pushViewController(covidDetailViewController, animated: true)
  }
  
}

정리해보면, 먼저 pieChartView 의 delegate 를 받음으로 ChartViewDelegate 프로토콜을 준수하게 되었습니다. 그리고ChartViewDelegate 안에는 chartViewSelected 라는 항목 선택시 액션 메서드가 정의 되어있습니다. 여기서 이동할 화면의 viewController 를 인스턴스화 시킨다음, 그 타입으로 다운 캐스팅합니다. 타입을 맞췄으므로 그 viewController 에 정의해두었던 프로퍼티에 접근할 수 있게됩니다. 그래서 pieChart 의 항목을 선택했을 때 넘어가는 화면에, 데이터를 전달하는 코드를 작성할 수 있게됩니다.


로딩 인디케이터

  • 서버에서 응답이 오기 전이라면 로딩 중임을 표시합니다.
  1. ViewController 에 Activity Indicator View 를 추가합니다.


  1. 화면 정중앙에 Activity Indicator View 를 배치했습니다.


  1. 서버에서 응답받기 전까지는 스택 뷰와 PieChartView 가 표시되면 안되기 때문에 hidden 속성을 체크합니다.


  1. 스택 뷰와 IndicatorView 를 아울렛 변수에 추가하고 코드를 작성합니다. (pieChartView 는 이전에 이미 추가했습니다.)
  • ViewController.swift
override func viewDidLoad(){
  super.viewDidLoad()
  // 로딩중 애니메이션 시작
  self.indicatorView.startAnimating()
  
  self.fetchCovidOverview(myClosure: { [weak self] result in 
  guard let self = self else {return}
    // completion Handler 는 서버 응답이 온뒤로 호출되므로 여기에 로딩 종료
    self.indicatorView.stopAnimating()
    self.indicatorView.isHidden = true
    // hidden 속성 해제
    self.labelStackView.isHidden = false
    self.pieChartView.isHidden =false
  ...
  }
  ...
}

비판적 클론코딩

  • 수동으로 데이터를 업데이트 하기 위해서 새로고침 버튼을 추가했습니다.
  • 새로고침 버튼 클릭 시 몇시 몇분 몇초 갱신완료 토스트 메시지를 띄웁니다.
// 출처: https://royhelen.tistory.com/46 [꾸르꾸르]
    func showToast(message : String, font: UIFont) {
        let toastLabel = UILabel(frame: CGRect(x: self.view.frame.size.width/2 - 75, y: self.view.frame.size.height-100, width: 150, height: 35))
        
        toastLabel.backgroundColor = UIColor.black.withAlphaComponent(0.6)
        toastLabel.textColor = UIColor.white
        toastLabel.font = font
        toastLabel.textAlignment = .center;
        toastLabel.text = message
        toastLabel.alpha = 1.0
        toastLabel.layer.cornerRadius = 5;
        toastLabel.clipsToBounds = true
        toastLabel.sizeToFit()
        
        self.view.addSubview(toastLabel)
        UIView.animate(withDuration: 4.0, delay: 0.1, options: .curveEaseOut, animations: {
            toastLabel.alpha = 0.0 }, completion: {(isCompleted) in toastLabel.removeFromSuperview() }) }
// 새로 추가한 기능. 새로고침과 새로고침 되었음을 날짜와 함께 표시
    @IBAction func tapRefreshButton(_ sender: UIButton) {
        // 새로고침 할 때 파이차트를 감춤
        self.labelStackView.isHidden = true
        self.pieChartView.isHidden = true
        
        // 다시 indicator 를 보여줌
        self.indicatorView.isHidden = false
        self.indicatorView.startAnimating()
        
        // 서버에서 다시 데이터를 가져옴
        self.fetchCovidOverview(
  	...
  	)
        
        let formatter = DateFormatter()
        formatter.dateFormat = "hh시 mm분 ss초 갱신 완료."
        let msg = formatter.string(from: Date())
        let font = UIFont.systemFont(ofSize: 14.0)
        
        self.showToast(message: msg, font: font)
        
    }

개발을 통해서 얻어갈 것

  • Alamofire 을 통한 API 호출
    Alamofire 에서의 response Data 의 Completion Handler 는 메인 쓰레드에서 동작하기 때문에, URLSession 과 같이 Dispatch Queue 을 따로 적용하지 않아도 됩니다.
  • @escaping 클로저
  • Charts 을 이용한 차트 표현
  • compactMap 고차함수를 통해 데이터 타입 매핑
  • NumberFormatter() 로 number 타입 변환 가능
  • delegate 패턴
  • Activity Indicator View 로 로딩중 표시
  • Date, DateFormatter

profile
안녕하세요, iOS 와 알고리즘에 대한 글을 씁니다.

0개의 댓글