Raywenderlich/Combine: Asynchronous Programming

Chapter 20: In Practice: Building a Complete App

헬조선의알파고 2020. 10. 11. 01:57

Version

Swift 5.3, iOS 14, Xcode 12

 

Combine을 도입(introducing)하고 프레임 워크(frameworks) 전체에 통합(integrating)함으로써, Swift의 선언형(declarative), 반응형(reactive) 프로그래밍(programming)이 Apple 플랫폼(platforms)을 위한 미래의 최고 앱(apps)을 개발(develop)하는 일반적인(prevalent) 방법이라는 것을 명확히(clear) 했다.

마지막 세 섹션(sections)에서 멋진(awesome) Combine 기술을 습득(acquired)했다. 이 마지막 장(chapter)에서는 배운 내용을 활용(utilize)하여 Chuck Norris jokes을 가져올(fetch) 수 있는 앱(app) 개발(developing)한다. 그리고 학습은 아직 끝나지(done) 않았다. 나중에 다시 읽을(peruse) 수 있도록, 좋아하는 농담(favorite jokes)을 저장하기 위해 Combine과 함께 Core Data를 사용하는 방법을 확인한다.

 

Getting started

projects/starter에서 시작(starter) 프로젝트(project)를 연다. 이 앱(app)의 개발(development)을 완료(finishing)하기 전에 잠시 시간을내어 시작(starter) 프로젝트(project)에 이미 구현(implemented)된 사항을 검토(review)한다.

Note: 이 프로젝트(project)는 SwiftUI를 사용한다. SwiftUI에 대한 자세한 내용은 이 장(chapter)의 범위를 벗어나지만, 이에 대해 자세히 알아 보려면 raywenderlich.com의 라이브러리(library)에서 SwiftUI by Tutorials를 확인한다.

프로젝트 탐색기(Project navigator)의 상단(top)에서 ChuckNorrisJokes 프로젝트(project)를 선택(select)한다.

 

이 프로젝트(project)에는 세 가지 대상(targets)이 있다:

  1. ChuckNorrisJokes: 모든 UI 코드를 포함(contains)하는 기본 대상(target)이다.
  2. ChuckNorrisJokesModel: 여기에서 모델(models)과 서비스(services)를 정의(define)한다. 모델(model)을 자체 대상(own target)으로 분리(separating)하는 것은 테스트(test) 대상(targets)이 internal 접근(access)으로만 메서드(methods)에 접근(access)하도록 하면서(allowing), 기본(main) 대상(target)에 대한 접근(access)을 관리(manage)하는 좋은 방법이다.
  3. ChuckNorrisJokesTests: 이 대상(target)에 몇 가지 단위 테스트(unit tests)를 작성(write)한다.

기본(main) 대상(target)인 ChuckNorrisJokes에서 ChuckNorrisJokes/Views/JokeView.swift를 연다. 이것은 앱(app)의 기본(main) 뷰(view)이다. 이 뷰(view)에는 라이트 모드(light mode)의 iPhone 11 Pro Max와 어두운 모드(dark mode)의 iPhone SE(2 세대), 두 가지 미리보기(previews)가 있다. 

Xcode의 오른쪽 상단 모서리(top-right corner)에 있는 Adjust Editor Options 버튼(button)을 클릭(clicking)하고 Canvas를 선택(checking)하면 미리보기(previews)를 볼 수 있다.

 

미리보기(previews)를 업데이트(update)하고 렌더링(render)하려면, 상단의 점프 바(jump bar)에서 Resume 버튼(button)을 주기적으로(periodically) 클릭(click)해야 할 수도 있다.

각 미리보기(preview)의 실시간 미리보기(Live Preview) 버튼(button)을 클릭(click)하면, 시뮬레이터(simulator)에서 앱(app)을 실행(running)하는 것과 유사한 대화형 실행 버전(interactive running version)을 확인할 수 있다.

 

현재는(currently) 농담 카드 뷰(joke card view)를 스와이프(swipe)할 수 있지만, 그 외에는 구현된 것이 별로 없다.

Note: 미리보기(preview) 렌더링(rendering)이 실패(fails)하면, 시뮬레이터(simulator)에서 앱(app)을 빌드(build)하고 실행(run)하여 진행 상황(progress)을 확인(check out)할 수도 있다.

이 앱(app)의 개발(development)을 마무리(finishing)하기 전에, 잠시 시간을 내어 몇 가지 목표(goals)를 설정(set)한다.

 

Setting goals

다음과 같은 여러 유저 스토리(user stories)를 받았다(received). 사용자(user)는 다음을 원한다:

  1. 농담 카드(joke card)를 왼쪽이나 오른쪽 끝까지 스와이프(swipe)하면, 해당 농담(joke)을 싫어(disliked)하거나 좋아(liked)했음을 나타내는 표시기(indicators)를 보여준다.
  2. 나중에 다시 확인(peruse)하기 위해 좋아하는 농담(jokes)을 저장(save)한다.
  3. 왼쪽이나 오른쪽으로 스와이프(swipe)하면 농담 카드(joke card)의 배경색(background color)이 빨간색이나 녹색으로 바뀐다(change).
  4. 현재 농담(joke)을 싫어(dislike)하거나 좋아(like)한 뒤, 새로운 농담(joke)을 가져온다(fetch).
  5. 새로운 농담(joke)을 가져올(fetching) 때, 표시기를(indicator) 볼 수 있어야 한다.
  6. 농담(joke)을 가져올(fetching) 때, 문제(wrong)가 발생하면 표시(indication)를 보여준다(display).
  7. 저장된(saved) 농담(jokes) 목록(list)을 가져온다(pull up).
  8. 저장된(saved) 농담(jokes)을 삭제(delete)한다.

또한(additionally), 탁월한(exceptional) 업무 윤리(work ethic)와 개발 관리자(manager)는 단위 테스트(unit tests) 없이는 작업을 확인(check in)할 수 없게 할 것이다. 따라서 이 장(chapter)의 챌린지(challenge) 섹션(section)에서는 논리(logic)가 올바른지 확인(ensure)하고 향후 회귀(regressions)를 방지(prevent)하기 위해 단위 테스트(unit tests)를 작성(write)하도록 한다.

이 목표(goals)는 받아들이기(accept)로 선택한다면, 개발자의 임무(mission)이다. 시작할 시간이다.

위의 모든 유저 스토리(user stories)는 논리(logic)에 의존(relies on)하므로, UI에 연결(wire up)하고 목록(list)을 확인(checking off)하기 전에 논리(logic)를 구현(implement)해야 한다.

 

Implementing JokesViewModel

이 앱(app)은 단일(single) 뷰 모델(view model)을 사용하여 여러 UI 구성 요소(components)를 구동(drives)하는 상태(state)를 관리(manage)하고, 농담(joke) 가져 오기(fetching) 및 저장(saving)을 실행(triggers)한다.

ChuckNorrisJokesModel 대상(target)에서 View Models/JokesViewModel.swift를 연다. 다음을 포함하는 기본(bare-bones) 구현(implementation)을 확인할 수 있다:

  • Combine과 SwiftUI를 가져오는(Imports) 구문
  • DecisionState 열거형(enum)
  • JSONDecoder 인스턴스(instance)
  • 두 개의 AnyCancellable 컬렉션(collections)
  • 빈(empty) 생성자와 여러(several) 빈(empty) 메서드(methods)

이제 빈칸을 모두 채울(fill) 시간이다.

 

Implementing state

SwiftUI는 몇 가지 상태(state)를 사용하여, 뷰(views)를 렌더링(render)하는 방법을 결정(determine)한다. decoder를 생성하는 행(line) 아래에 다음 코드를 추가한다:

@Published public var fetching: Bool = false
@Published public var joke: Joke = Joke.starter
@Published public var backgroundColor = Color("Gray")
@Published public var decisionState: DecisionState = .undecided

여기에서 여러(several) @Published 속성(properties)을 만들고, 각 속성에 대해 publisher를 합성(synthesizing)한다. $ 접두사(prefix, 예: $fetching)를 사용하여 이러한 속성(properties)의 publishers에 접근(access)할 수 있다. 이들의 이름(names)과 유형(types)이 용도(purpose)를 잘 알려 주지만, 빠르고 정확하게(exactly) 활용(utilize) 방법을 확인할 수 있도록 한 곳에 모아서 배치(put)하는 것이 좋다.

이 뷰 모델(view model)의 나머지 부분(rest)을 구체화(flesh out)하기 전에 몇 가지 추가 사항을 더 구현(implement)해야한다.

 

Implementing services

Services/JokesService.swift를 연다. JokesService를 사용하여 chucknorris.io 데이터베이스(database)에서 임의(random)의 농담(joke)을 가져온다(fetch). 또한 가져오기(fetch)에서 반환(returned)된 데이터의 publisher를 제공(provide)한다.

나중에 단위 테스트(unit tests)에서 이 서비스(service)를 확인(mock)하려면, publisher의 요구 사항(requirement)을 서술(outlining)하는 프로토콜(protocol)의 정의(defining)를 완료(finish)해야 한다. Protocols/JokeServiceDataPublisher.swift를 연다. 이 장(chapter) 대부분(throughout)에서 필요한 대부분의 파일(files)은 Combine이 필요하므로, 여기에서도 이미 가져왔다(imported).

protocol의 정의(definition)를 다음으로 변경(change)한다:

public protocol JokeServiceDataPublisher {
  func publisher() -> AnyPublisher<Data, URLError>
}

이제 Services/JokesService.swift를 열고, 유사하게(similarly) JokeServiceDataPublisher를 준수(conformance)하도록 구현(implement)한다:

extension JokesService: JokeServiceDataPublisher {
  public func publisher() -> AnyPublisher<Data, URLError> {
    URLSession.shared
      .dataTaskPublisher(for: url)
      .map(\.data)
      .eraseToAnyPublisher()
  }
}

이 시점에서 프로젝트(project)의 테스트(test) 대상(target)을 빌드(build)하면, 컴파일러 오류(compiler error)가 발생한다. 이 오류(error)는 테스트(test) 대상(target)에서 모의(mock) ​​서비스(service) 구현(implementation)이 난해(stubbed)하다고 언급한다. 이 오류(error)를 없애려면 ChuckNorrisJokesTests/Services/MockJokesService.swift를 열고, 아래 메서드(method)를 MockJokesService에 추가한다:

func publisher() -> AnyPublisher<Data, URLError> {
  let publisher = CurrentValueSubject<Data, URLError>(data)
  //mocked service의 data 속성으로 초기화된 URLError로,
  //Data 값을 내보내고 fail할 수 있는 mock publisher를 만든다.
  
  DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) {
    if let error = error {
      publisher.send(completion: .failure(error))
    } else {
      publisher.send(data)
    }
  }
  //오류를 보내거나 subject로 data 값을 보낸다.
  
  return publisher.eraseToAnyPublisher()
  //유형이 지워진(type-erased) publisher를 반환한다.
}

이 장(chapter)의 뒷부분에서 DispatchQueue.asyncAfter(deadline:)을 사용하여, 단위 테스트(unit test)에 필요한 데이터 가져 오기(fetching)의 지연(delay)을 시뮬레이션(simulate)한다.

 

Finish implementing JokesViewModel

작업(handiwork)이 완료(done)되면 View Models/JokesViewModel.swift로 돌아가, @Published 항목 뒤에 다음 속성(property)을 추가한다:

private let jokesService: JokeServiceDataPublisher

뷰 모델(view model)은 기본(default) 구현(implementation)을 사용하는 반면, 단위 테스트(unit tests)는 이 서비스(service)의 모의 버전(mock version)을 사용할 수 있다.

기본(default) 구현(implementation)을 사용하도록 생성자(initializer)를 업데이트(update)하고, 서비스(service)를 해당(respective) 속성(property)으로 설정한다:

public init(jokesService: JokeServiceDataPublisher = JokesService()) {
  self.jokesService = jokesService
}

생성자(initializer)에서 $joke publisher에 구독(subscription)을 추가한다:

$joke
  .map { _ in false }
  .assign(to: &$fetching)

게시된(published) fetching 속성(property)을 사용하여, 앱(app)이 농담(joke)을 가져 오는(fetching) 시기를 나타낼(indicate) 수 있다.

 

Fetching jokes

가져 오기(fetching) 작업이 다음 코드와 일치하도록 fetchJoke()의 구현(implementation)을 변경(change)한다:

public func fetchJoke() {
  fetching = true
  //fetching을 true로 설정한다.
  
  jokesService.publisher()
    //joke service의 publisher에 대한 subscription을 시작한다.
    .retry(1)
    //error가 발생하면 fetch을 한 번 다시 시도한다.
    .decode(type: Joke.self, decoder: Self.decoder)
    //publisher로 부터 받은 데이터를 decode 연산자에 전달한다.
    .replaceError(with: Joke.error)
    //error를 error message를 표시하는 Joke 인스턴스로 대체한다.
    .receive(on: DispatchQueue.main)
    //main queue에서 결과를 받는다.
    .assign(to: &$joke)
    //받은 joke을 해당 publisher에 할당한다.
}

 

Changing the background color

updateBackgroundColorForTranslation(_:) 메서드(method)는 농담 카드 뷰(joke card view, 즉, translation)의 위치(position)에 따라(based on) backgroundColor를 업데이트(update)해야 한다. 구현(implementation)을 다음과 같이 변경(change)하여, 제대로 작동하도록 한다:

public func updateBackgroundColorForTranslation(_ translation: Double) {
  switch translation {
  case ...(-0.5):
    backgroundColor = Color("Red")
  case 0.5...:
    backgroundColor = Color("Green")
  default:
    backgroundColor = Color("Gray")
  }
}

여기에서는 전달(passed)된 translation을 switch하여, -0.5(-50%) 이하이면 빨간색, 0.5+(50%+) 이상이면 녹색, 중간(middle)이면 회색을 반환(return)하기만 하면 된다. 이러한 색상은 Supporting Files 기본(main) 대상(target)의 asset catalog에서 정의(defined)된 것을 확인할 수 있다.

또한 농담 카드 뷰(joke card view)의 위치(position)를 사용하여 사용자(user)가 해당 농담(joke)을 좋아했는지 여부를 결정(determine)하므로, updateDecisionStateForTranslation(_:andPredictedEndLocationX:inBounds:) 구현(implementation)을 다음과 같이 변경(change)한다:

public func updateDecisionStateForTranslation(
  _ translation: Double,
  andPredictedEndLocationX x: CGFloat,
  inBounds bounds: CGRect) {
  switch (translation, x) {
  case (...(-0.6), ..<0):
    decisionState = .disliked
  case (0.6..., bounds.width...):
    decisionState = .liked
  default:
    decisionState = .undecided
  }
}

이 메서드(method)의 시그니처(signature)는 실제보다 더 어려워(daunting) 보인다. 여기서 translationx값(values)을 switch한다. 백분율(percent)이 -/+ 60%이면 사용자(user)가 결정(definitive decision)한 것으로 간주(consider)한다. 그렇지 않으면(otherwise) 여전히 미정(undecided)이다.

xbounds.width 값(values)을 사용하여 사용자(user)가 결정 상태 영역(decision state area) 내부(inside)에서 맴돌고(hovering) 있는 경우, 결정 상태(decision state)가 변경(change)되는 것을 방지(prevent)한다. 즉(in other words), 이 값(values)을 넘어서는 마지막 위치(end location)를 예측(predict)할 수 있는 속도(velocity)가 충분하지 않은 경우, 아직 결정(decision)을 내리지 않은 것이다. 그러나(however), 속도(velocity)가 충분하다면 결정(decision)을 완료(complete)할 것이라는 좋은 신호이다.

 

Preparing for the next joke

메서드(method)가 하나 더 있다. reset()을 다음으로 변경(change)한다:

public func reset() {
  backgroundColor = Color("Gray")
}

사용자(user)가 농담(joke)을 좋아하거나 싫어하면(likes or dislikes), 다음 농담(joke)을 준비할 수 있도록 농담 카드(joke card)가 재설정(reset)된다. 수동(manually)으로 처리(handle)해야 하는 유일한 부분은 배경색(background color)을 회색으로 재설정(reset)하는 것이다.

 

Making the view model observable

계속 진행하기 전에 이 뷰 모델(view model)에서 수행할 작업이 하나 더 있다. 앱(app) 전체에서(throughout) 관찰(observed)할 수 있도록 ObservableObject를 준수(conform)해야 한다. 내부적으로(under the hood) ObservableObject는 자동으로(automatically) objectWillChange publisher를 합성(synthesized)한다. 요컨대(more to the point), 뷰 모델(view model)이 이 프로토콜(protocol)을 준수(conform)하도록 하여, SwiftUI 뷰(views)는 뷰 모델(view model)의 @Published 속성(properties)을 구독(subscribe)하고 해당 속성(properties)이 변경(change)될 때 뷰(views)의 body을 업데이트(update) 할 수 있다.

구현(implement)하는 것보다 이를 설명(explain)하는 데 훨씬 더 많은 시간이 걸렸다. 클래스(class) 정의(definition)를 다음으로 변경(change)한다:

public final class JokesViewModel: ObservableObject {

전체 작업의 핵심 요소(brains)인 뷰 모델(view model) 구현(implementing)을 완료(finished)했다.

Note:이 시점의 실제 설정(setting)에서 뷰 모델(view model)에 대한 모든 테스트(tests)를 작성(write)하고, 모든 테스트가 통과(passes)되었는지 확인(ensure)하고, 작업을 확인(check in)할 수도 있다. 하지만 여기에서는 이 대신에(Instead), 방금 구현(implemented)한 뷰 모델(view model)을 사용하여 앱(app)의 UI를 구동(drive)한다. 챌린지(challenge) 섹션(section)에서 단위 테스트(unit tests) 작성(writing)으로 돌아온다(circle back).

 

Wiring JokesViewModel up to the UI

앱(app)의 기본 화면(main screen)에는 기본적으로(essentially) 배경(background)인 JokeView와 떠있는(floating) JokeCardView의 두 가지 View 구성 요소(components)가 있다. 둘 다 업데이트(update) 시기와 표시(display)할 내용을 결정(determine)하기 위해 뷰 모델(view model)을 참조(consult)해야 한다.

이를 구현(implement)하려면 먼저 Views/JokeCardView.swift를 연다. ChuckNorrisJokesModel 모듈(module)은 이미 가져 왔다(imported). 뷰 모델(view model)을 다루기(handle) 위해, JokeCardView의 정의(definition) 맨 위에 다음 속성(property)을 추가한다:

@ObservedObject var viewModel: JokesViewModel

@ObservedObject 속성 래퍼(property wrapper)로 이 속성(property)을 추가(decorated)했다. ObservableObject 채택 선언(declaring adoption)과 함께(conjunction) 사용하면, objectWillChange publisher를 얻는다. 하단 미리보기(preview) 제공자(provider)의 JokeCardView에 대한 합성(synthesized) 생성자(initializer)에서 뷰 모델(view model) 매개 변수(parameter)가 누락(missing)되었기 때문에 컴파일러 오류(compiler error)가 발생한다.

오류(error)가 바로 그 지점(point)을 가리켜야 하지만, 그렇지 않은 경우 하단의 JokeCardView_Previews 내부(inside)에서 JokeCardView() 생성자(initializer)를 찾아 뷰 모델(view model)의 기본 초기화(default initialization)를 추가한다. 결과적으로 구조체(struct)의 구현(implementation)은 다음과 같아야 한다:

struct JokeCardView_Previews: PreviewProvider {
  static var previews: some View {
    JokeCardView(viewModel: JokesViewModel())
      .previewLayout(.sizeThatFits)
  }
}

JokeView에 지금 처리(deal with)해야 할 컴파일러 오류(compiler error)가 있지만, 이 또한 쉽게 수정(fix)할 수 있다.

Views/JokeView.swift를 열고, showJokeView 위의 private 속성(properties) 상단에 다음을 추가한다:

@ObservedObject private var viewModel = JokesViewModel()

이제 jokeCardView의 computed property를 찾고, JokeCardView() 초기화(initialization)를 다음과 같이 변경(change)한다:

JokeCardView(viewModel: viewModel)

오류(error)가 사라진다(disappears). 이제 Views/JokeCardView.swift로 다시 전환(switch back)하여, body 구현(implementation)의 맨 위에서 Text(ChuckNorrisJokesModel.Joke.starter.value) 코드를 찾아 다음과 같이 변경(change)한다:

Text(viewModel.joke.value)

이 코드를 사용하여 시작 농담(starter joke, 하드코딩된 더미 데이터)을 사용하는 것에서 뷰 모델(view model) joke publisher의 현재 값(current value)을 사용하도록 전환(switch)한다.

 

Setting the joke card’s background color

이제 JokeView.swift로 돌아간다(head back to). 이 화면(screen)이 작동하는 데 필요한 사항을 구현(implementing)하도록 집중(focus on)하고, 나중에 다시 돌아와(return) 저장된 농담(saved jokes)을 표시(presenting)한다.

private var jokeCardView 속성(property)을 찾아 .background(Color.white) 수정자(modifier)를 다음으로 변경(change)한다:

.background(viewModel.backgroundColor)

이제 뷰 모델(view model)이 농담 카드 뷰(joke card view)의 배경색(background color)을 결정(determines)한다. 기억(recall)하겠지만, 뷰 모델(view model)은 translation에 따라(based on) 색상(color)을 설정한다.

 

Indicating if a joke was liked or disliked

다음으로 사용자(user)가 해당 농담(joke)을 좋아하는지 싫어하는지(liked or disliked)를 시각적(visual)으로 표시(indication)하도록 설정해야 한다. HUDView의 두 가지 용도(uses)를 확인한다. 하나는 .thumbDown 이미지(image)를 표시(displays)하고, 다른 하나는 .rofl 이미지(image)를 표시(displays)한다. 이러한 이미지(image) 유형(types)은 HUDView.swift에 정의(defined)되어 있으며, Core Graphics을 사용하여 이미지(images)를 그린다.

.opacity(0) 수정자(modifier)의 두 가지 사용례(usages)를 다음과 같이 변경(change)한다:

  • HUDView(imageType: .thumbDown)의 경우:
  • .opacity(viewModel.decisionState == .disliked ? hudOpacity : 0)
  • HUDView(imageType: .rofl)의 경우:
  • .opacity(viewModel.decisionState == .liked ? hudOpacity : 0)

이 코드를 사용하면 .liked.disliked 상태(states)에 대한 올바른(correct) 이미지(image)를 표시(display)하고, 상태(state)가 .undecided이면 이미지(image)가 표시되지 않는다.

 

Handling decision state changes

이제 updateDecisionStateForChange(_:)를 찾아 다음과 같이 변경(change)한다:

private func updateDecisionStateForChange(_ change: DragGesture.Value) {
  viewModel.updateDecisionStateForTranslation(
    translation,
    andPredictedEndLocationX: change.predictedEndLocation.x,
    inBounds: bounds
  )
}

이 메서드(method)는 이전에 구현(implemented)한 뷰 모델(view model)의 updateDecisionStateForTranslation(_:andPredictedEndLocationX:inBounds:) 메서드(method)를 호출(calls)한다. 사용자(user)와 농담 카드뷰(joke card view)의 상호 작용(interaction)을 기반으로(based on), 뷰(view)에서 얻은(obtained) 값(values)을 전달(passes)한다.

이 메서드(method) 바로 아래의 updateBackgroundColor()를 다음과 같이 변경(change)한다:

private func updateBackgroundColor() {
  viewModel.updateBackgroundColorForTranslation(translation)
}

이 메서드(method) 또한(also) 뷰 모델(view model)의 메서드(method)를 호출(calls)하여, 사용자(user)와 농담 카드 뷰(joke card view)의 상호 작용(interaction)을 기반으로 뷰에서 얻은(obtained) translation을 전달(passing)한다.

 

Handling when the user lifts their finger

한 가지 메서드(method)만 더 구현(implement)하면, 이 앱(app)을 사용해 볼 수 있다.

handle(_:) 메서드(method)는 사용자(user)가 손가락을 뗄 때(lifts), 즉, touches up의 처리(handling)를 담당(responsible for)한다. 사용자(user)가 .undecided 상태(state)에서 touches up하면 농담 뷰 카드(joke view card)의 위치(position)가 재설정(resets)된다. 그렇지 않고(otherwise) 사용자(user)가 .liked 또는 .disliked의 결정된 상태(decided state)에 있는 동안 touches up하면, 뷰 모델(view model)을 재설정(reset)하고 새로운 농담(joke)을 가져 오도록(fetch) 지시(directs)한다.

handle(_:) 구현(implementation)을 다음과 같이 변경(change)한다:

private func handle(_ change: DragGesture.Value) {
  let decisionState = viewModel.decisionState
  //view model의 현재 decisionState에 대한 local copy를 만든다.
  
  switch decisionState {
  case .undecided:
    //decision state가 .undecided 인 경우,
    cardTranslation = .zero
    self.viewModel.reset()
    //cardTranslation을 다시 0으로 설정하고 view model을 reset한다.
    //그러면 배경색이 회색으로 재설정된다.
  default:
    //그렇지 않고 .liked 또는 .disliked 상태인 경우,
    let translation = change.translation
    let offset = (decisionState == .liked ? 2 : -2) * bounds.width
    cardTranslation = CGSize(width: translation.width + offset,
                             height: translation.height)
    showJokeView = false
    //상태에 따라 joke card view의 새 offset과 translation을 결정하고 일시적으로 joke card view를 숨긴다.
    
    reset()
    //joke card view를 숨기고, 원래 위치로 다시 이동하는 reset()을 호출하여,
    //view model에 새 joke을 가져 오도록 한 다음, joke card view를 표시한다.
  }
}

이 코드가 영향을 미치(affects)는 두 가지 사항은 아직 다루지 않았다(touched upon):

cardTranslation 속성(property)은 농담 카드 뷰(joke card view)의 현재(current) translation을 추적(tracks)한다. 이 값(value)을 화면(screen)의 현재 너비(current width)를 기준(based)으로 변환(translation)을 계산(calculate)하고 결과를 여러 영역(several areas)의 뷰 모델(view mode)에 전달(passes)하는 translation 속성(property)과 혼동(confuse)하지 않는다.

농담 카드 뷰(joke card view)의 초기(initial) y 오프셋(offset)은 -bounds.height이다. 즉, >보여지는 뷰(visible view) 바로 위에 위치하여 showJokeViewtrue로 변경(changes)될 때 위에서 부터 애니메이션(animate)할 준비가 되어 있다.

마지막으로 handle(_:) 바로 아래의 reset() 메서드(method)에서 cardTranslation.zero로 설정(setting)하는 부분 뒤에 다음 두 줄(lines)을 추가한다:

self.viewModel.reset()
self.viewModel.fetchJoke()

여기에서 reset()이 호출(called)될 때마다 새로운 농담(joke)을 가져 오도록(fetch) 뷰 모델(view model)에 요청(ask)한다. 즉, 농담(joke)의 좋음 또는 싫음(joke is liked or disliked)을 결정하거나 뷰(view)가 나타날(appears) 때이다.

JokeView로 할 일은 이것이 전부이다.

 

Trying out your app

지금까지의 진행 상황(progress)을 확인(check out)하려면, 미리보기(preview)를 표시하고 필요한(necessary) 경우 Resume을 클릭(click)한 다음 실시간 미리보기(Live Preview)의 시작(play) 버튼(button)을 클릭(click)한다.

Note: 시뮬레이터(simulator) 또는 기기(device)에서 앱(app)을 빌드하여 진행 상황(progress)을 확인(check)할 수도 있다.

각각(respectively) 왼쪽이나 오른쪽으로 끝까지 스와이프(swipe)하여, 농담(joke)을 싫어하거나 좋아하는(dislike or like) 것을 나타낼 수 있다. 이렇게 하면, 싫어요(thumb down) 또는 ROFL(Rolling On the Floor Laughing) 이미지(image)가 "가져 오는 중(fetching)" 애니메이션(animation)과 함께 표시(display)된다. 미정 상태(undecided state)에서 카드(card)를 놓으면(release), 농담 카드(joke card )가 원래 위치(original position)로 되돌아 간다(snap back).

앱(app)에서 오류(error)가 발생(encounters)하면 오류 농담(error joke)이 표시(display)된다. 나중에 이를 확인(verify)하기 위해 단위 테스트(unit test)를 작성(write)하지만, 지금 오류 농담(error joke)을 확인하려면 일시적으로(temporarily) Mac의 Wi-Fi를 끄고(shut off) 앱(app)을 실행(run)한 다음 왼쪽으로 스와이프(swipe)하여 새로운 농담(joke)을 가져온다(fetch). Houston we have a problem — no joke. Check your Internet connection and try again."라는 오류 농담(error joke)이 표시된다.

이는 의심할 여지없는(no doubt) 최소한(minimal)의 구현(implementation)이다. 흥미가 있다면 16장(chapter), "Error Handling"에서 배운 내용을 적용(applying)하여 보다 강력한(robust) 오류 처리 메커니즘(error-handling mechanism)을 구현(implement)할 수 있다.

 

Your progress so far

구현(implementation)한 기능(features)을 살펴보면 다음과 같다:

  • 1. 농담 카드(joke card)를 왼쪽이나 오른쪽 끝까지 스와이프(swipe)하면, 해당 농담(joke)을 싫어(disliked)하거나 좋아(liked)했음을 나타내는 표시기(indicators)를 보여준다.
  • 3. 왼쪽이나 오른쪽으로 스와이프(swipe)하면 농담 카드(joke card)의 배경색(background color)이 빨간색이나 녹색으로 바뀐다(change).
  • 4. 현재 농담(joke)을 싫어(dislike)하거나 좋아(like)한 뒤, 새로운 농담(joke)을 가져온다(fetch).
  • 5. 새로운 농담(joke)을 가져올(fetching) 때, 표시기를(indicator) 볼 수 있어야 한다.
  • 6. 농담(joke)을 가져올(fetching) 때, 문제(wrong)가 발생하면 표시(indication)를 보여준다(display).

남은 것은 다음과 같다.

  • 2. 나중에 다시 확인(peruse)하기 위해 좋아하는 농담(jokes)을 저장(save)한다.
  • 7. 저장된(saved) 농담(jokes) 목록(list)을 가져온다(pull up).
  • 8. 저장된(saved) 농담(jokes)을 삭제(delete)한다.

그리고 물론 단위 테스트(unit tests)를 작성(write)해야 한다. 챌린지(challenge)에서 그것을 처리(take care of)할 것이다. 지금은 몇몇의 농담(jokes)을 저장(save)할 때이다.

 

Implementing Core Data with Combine

Core Data 팀(team)은 지난 몇 년간(past few years) 열심히 일해 왔다. Core Data stack을 설정(setting up)하는 과정(process)은 훨씬 더 쉬워졌으며(couldn’t get much easier), 새로 도입(newly-introduced)된 Combine과의 통합(integrations)으로 Combine과 SwiftUI 기반의 앱(driven apps)에서 데이터를 유지(persisting)하기 위한 더욱 매력적(appealing)인 첫 번째 옵션(first choice)이 되었다.

Note: 이 장(chapter)에서는 Core Data 사용(using)에 대한 자세한 내용을 다루지(delve into) 않는다. Combine과 함께 사용하는 데 필요한 단계(necessary steps)만 안내한다. Core Data에 대해 자세히 알아 보려면 raywenderlich.com 라이브러리(library)에서 Core Data by Tutorials를 확인(check out)한다.

 

Review the data model

데이터 모델(data model)은 이미 생성되어 있다. 이를 검토(review)하려면 Models/ChuckNorrisJokes.xcdatamodeld를 열고, ENTITIES 섹션(section)에서 JokeManagedObject를 선택(select)한다. id 속성(attribute)에 대한 고유한 제약 조건(unique constraint)과 함께 다음 속성(attributes)이 정의(defined)된 것을 볼 수 있다:

 

Core Data는 JokeManagedObject에 대한 클래스(class) 정의(definition)를 자동 생성(auto-generate)한다. 다음으로 JokeManagedObject의 확장(extensions)에서 몇 가지 도우미 메서드(methods)를 만들어 JokeManagedObject 컬렉션(collections)에 농담(jokes)을 저장(save)하고 삭제(delete)한다.

 

Extending JokeManagedObject to save jokes

프로젝트 네비게이터(Project navigator)에서 메인 대상(main target)의 Models 폴더(folder)를 우클릭하고 New File...을 선택(select)한다. Swift File을 선택(select)하고 Next을 클릭(click)한 후, JokeManagedObject+.swift라는 이름으로 파일(file)을 저장(save)한다. 이 파일의 전체 본문(entire body)을 다음으로 바꾼다(replace):

import Foundation
import SwiftUI
import CoreData
import ChuckNorrisJokesModel
//Core Data, SwiftUI, model module을 가져온다.

extension JokeManagedObject {
  //자동 생성된 JokeManagedObject 클래스를 확장한다.
  static func save(joke: Joke, inViewContext viewContext: NSManagedObjectContext) {
    //전달된 view context를 사용하여 전달된 joke을 저장하는 static method를 추가한다.
    //Core Data에 익숙하지 않다면, view context를 Core Data의 scratchpad라고 생각할 수 있다.
    //이것은 main queue와 연결되어 있다.
    guard joke.id != "error" else { return }
    //문제가 발생했을 때 나타내는 error joke의 ID는 "error"이다.
    //이때는 joke을 저장할 이유가 없으므로, 진행하기 전에 error joke인지 확인한다.
    let fetchRequest = NSFetchRequest<NSFetchRequestResult>(
      entityName: String(describing: JokeManagedObject.self)
    )
    //JokeManagedObject entity 이름에 대한 fetch request을 생성한다.
    fetchRequest.predicate = NSPredicate(format: "id = %@", joke.id)
    //전달된 joke과 동일한 ID의 jokes으로 fetch하도록, fetch request의 predicate를 filter한다.
    
    if let results = try? viewContext.fetch(fetchRequest),
       let existing = results.first as? JokeManagedObject {
      //viewContext를 사용하여 fetch request을 실행한다.
      existing.value = joke.value
      existing.categories = joke.categories as NSArray
      //성공하면 joke가 이미 존재하는 것이므로, 전달된 joke의 값으로 update한다.
    } else {
      let newJoke = self.init(context: viewContext)
      newJoke.id = joke.id
      newJoke.value = joke.value
      newJoke.categories = joke.categories as NSObject
      //그렇지 않으면 joke이 아직 존재하지 않는 경우이므로, 전달된 joke의 값으로 새 joke를 만든다.
    }
    
    do {
      try viewContext.save()
      //viewContext를 저장한다.
    } catch {
      fatalError("\(#file), \(#function), \(error.localizedDescription)")
    }
  }
}

이는 저장(save)을 처리(takes care of )한다.

 

Extending collections of JokeManagedObject to delete jokes

또한 보다 쉽게 삭제(deleting)하려면, JokeManagedObjectCollection에 다음 확장(extension)을 추가한다:

extension Collection where Element == JokeManagedObject, Index == Int {
  func delete(at indices: IndexSet, inViewContext viewContext: NSManagedObjectContext) {
    //전달된 view context를 사용하여 전달된 indices에서 objects를 삭제하는 method를 구현한다.
    indices.forEach { index in
      viewContext.delete(self[index])
      //indices를 반복하고, viewContext에서 delete(_:)를 호출하여 
      //self의 각 요소, 즉 JokeManagedObjects collection을 전달한다.
    }
    
    do {
      try viewContext.save()
      //context를 저장한다.
    } catch {
      fatalError("\(#file), \(#function), \(error.localizedDescription)")
    }
  }
}

 

Create the Core Data stack

Core Data stack을 설정하는 방법에는 여러 가지(several)가 있다. 이 장(chapter)에서는 접근 제어(access control)를 활용하여 SceneDelegate만 접근(access)할 수 있는 스택(stack)을 생성한다.

App/SceneDelegate.swift를 열고, 맨 위에 다음 가져 오기(imports) 구문를 추가한다:

import Combine
import CoreData

다음으로 파일(file)의 맨 아래에 CoreDataStack의 정의(definition)를 추가한다:

private enum CoreDataStack {
  //CoreDataStack라는 private enum을 정의한다.
  //CoreDataStack은 namespace 역할만 하여 실제로 인스턴스화할 수 없기 때문에, 여기에서는 enum을 사용하는 것이 유용하다.
  static var viewContext: NSManagedObjectContext = {
    //persistent container를 생성한다.
    //이것은 managed object model, persistent store coordinator, managed object context를 캡슐화하는 실제 Core Data stack이다.
    let container = NSPersistentContainer(name: "ChuckNorrisJokes")
    
    container.loadPersistentStores { _, error in
      guard error == nil else {
        fatalError("\(#file), \(#function), \(error!.localizedDescription)")
      }
    }
    
    return container.viewContext
    //container가 있으면 해당 view context를 반환한다.
    //SwiftUI의 Environment API를 사용하여 앱 전체에 이 context를 공유한다.
  }()
  
  static func save() {
    //scene delegate만이 context를 저장하는 데 사용할 수 있는 static save method를 만든다.
    guard viewContext.hasChanges else { return }
    //저장 작업을 시작하기 전에 항상 context가 변경되었는지 확인하는 것이 좋다.
    
    do {
      try viewContext.save()
    } catch {
      fatalError("\(#file), \(#function), \(error.localizedDescription)")
    }
  }
}

이제 Core Data stack을 정의(defined)했으므로, 맨 위의 scene(_:willConnectTo:options:) 메서드(method)로 이동하여 let contentView = JokeView()를 다음과 같이 변경(change)한다:

let contentView = JokeView()
  .environment(\.managedObjectContext, CoreDataStack.viewContext)

여기서 Core Data stack의 view context를 환경(environment)에 추가하여, 앱 전체(globally available)에서 사용할 수 있도록 한다.

앱(app)이 백그라운드(background)로 이동하려고 할 때, viewContext를 저장(save)하려고 한다. 그렇지 않으면(otherwise), 그 동안 수행된 모든 작업이 손실(lost)된다. sceneDidEnterBackground(_:) 메서드(method)를 찾아, 맨 아래에 다음 코드를 추가한다:

CoreDataStack.save()

이제 신뢰할 수 있는(bona fide) Core Data stack을 갖게 되었으며, 이를 효율적으로 활용할 수 있다.

 

Fetching jokes

Views/JokeView.swift를 열고 @ObservedObject private var viewModel 속성(property) 정의(definition) 바로 앞에 다음 코드를 추가하여, 환경(environment)에서 viewContext를 가져온다:

@Environment(\.managedObjectContext) private var viewContext

이제 handle(_:)로 이동하고, default 사례(case) 맨 위의 let translation = change.translation 전에 다음 코드를 추가한다:

if decisionState == .liked {
  JokeManagedObject.save(joke: viewModel.joke,
                         inViewContext: viewContext)
}

이 코드로 사용자(user)가 농담(joke)을 좋아하는지 확인(check)한다. 그렇다면 조금 전에 구현(implemented)한 도우미 메서드(helper method)로 환경(environment)에서 검색(retrieved from)한 view context를 사용하여 저장(save)한다.

 

Showing saved jokes

다음으로 JokeViewbody에서 LargeInlineButton 코드 블록(block of code)을 찾아 다음과 같이 변경(change)한다:

LargeInlineButton(title: "Show Saved") {
  self.presentSavedJokes = true
}
.padding(20)

여기서 presentSavedJokes의 상태(state)를 true로 변경(change)한다. 다음으로 이것을 사용하여 저장된 농담(saved jokes)을 보여준다(present).

코드의 NavigationView 블록 끝에 sheet 수정자(modifier)를 추가한다:

.sheet(isPresented: $presentSavedJokes) {
  SavedJokesView()
    .environment(\.managedObjectContext, self.viewContext)
}

이 코드는 $presentSavedJokes가 새 값(value)을 내보낼(emits) 때마다 실행(triggered)된다. true이면 뷰(view)는 viewContext를 전달하여 저장된 농담 뷰(saved jokes view)를 인스턴스화(instantiate)하고 표시(present)한다.

참고로 전체(entire) NavigationView는 다음과 같아진다:

NavigationView {
  VStack {
    Spacer()
    
    LargeInlineButton(title: "Show Saved") {
      self.presentSavedJokes = true
    }
    .padding(20)
  }
  .navigationBarTitle("Chuck Norris Jokes")
}
.sheet(isPresented: $presentSavedJokes) {
  SavedJokesView()
    .environment(\.managedObjectContext, self.viewContext)
}

이제 JokeView가 완성되었다.

 

Finishing the saved jokes view

이제 저장된 농담 뷰(saved jokes view) 구현(implementing)을 완료(finish)해야 하므로, Views/SavedJokesView.swift를 연다. 모델(model)은 이미 가져왔다(imported).

먼저, body 속성(property) 아래에 다음 속성(property)을 추가한다:

@Environment(\.managedObjectContext) private var viewContext

이미 viewContext를 여러 번(a couple times) 설정(set)했으므로 새로울 것이 없다.

다음으로, private var jokes = [String]()을 다음으로 바꾼다(replace):

@FetchRequest(
  sortDescriptors: [NSSortDescriptor(
                        keyPath: \JokeManagedObject.value,
                        ascending: true
                   )],
  animation: .default
) private var jokes: FetchedResults<JokeManagedObject>

즉시(immediately) 컴파일러 오류(compiler error)가 표시된다. 다음에 농담(jokes)을 삭제(delete)하는 기능(ability)을 추가(enabling)하면서 문제를 해결(fix)할 것이다.

SwiftUI의 속성 래퍼(property wrapper)는 많은 일을 한다:

  • sortDescriptors 배열(array)을 사용하여 가져온 객체(fetched objects)를 정렬(sort)하기 위고, 지정된 애니메이션 유형(animation type)으로 표시(display)할 List를 업데이트(updates)한다.
  • 영구 저장소(persistent store)가 변경(changes)될 때마다 자동으로(automatically) 가져오기(fetches)를 수행(performs)한 다음, 업데이트(updated)된 데이터로 뷰(view)를 다시 렌더링(re-render)할 수 있다.

기본(underlying) FetchRequest의 변형(variations) 생성자(initializers)로 이전에 만든 것과 같은 fetchRequest를 전달(pass)할 수 있다. 그러나(however), 이 여기에서는 모든 농담(jokes)을 가져오기 원하므로 전달(pass)해야할 유일한 것은 결과를 정렬하는 방법(how to sort)에 대한 지침(instructions)이다.

 

Deleting jokes

.onDelete 코드 블록(block of code)을 포함하는 ForEach(jokes, id: \.self) 코드 블록(block of code)을 찾아 다음과 같이 변경(changing)한다:

ForEach(jokes, id: \.self) { joke in
  Text(joke.value ?? "N/A")
  //joke text 또는 joke이 없다면 "N/A"을 표시한다.
}
.onDelete { indices in
  self.jokes.delete(at: indices,
                    inViewContext: self.viewContext)
  //swiping를 활성화하여 joke을 삭제하고, delete(at:inViewContext:)를 호출한다.
}

이제 SavedJokesView가 완료(done)되었다.

앱 미리보기(app preview)를 다시 시작(resume)하거나, 앱(app)을 빌드(build)하고 실행(run)한다. 몇 가지 농담(jokes)을 저장(save)한 다음, Show Saved을 탭(tap)하여 저장된 농담(saved jokes)을 표시(display)한다. 몇 가지 농담(jokes)을 왼쪽으로 스와이프(swiping)하여 삭제(delete) 해본다. 앱(app)을 다시 실행(re-run)하여 저장한 농담(saved jokes)이 여전히 그대로 있는지, 삭제(deleted)한 농담이 없는지확인(confirm)한다.

 

Challenge

이것이 이 책의 마지막 챌린지(challenge)이다. 힘내서 마무리한다(take it on and finish strong).

 

Challenge: Write unit tests against JokesViewModel

ChuckNorrisJokesTests 대상(target)에서 Tests/JokesViewModelTests.swift를 연다. 다음 내용이 표시된다:

  • 일부 예비(preliminary) 설정(setup) 코드.
  • 샘플 농담(sample joke)을 성공적으로 생성할 수 있는지 확인(verifies)하는 test_createJokesWithSampleJokeData.
  • 뷰 모델(view model)의 각 책임(responsibilities)을 실행(exercise)하기 위해 완료(complete)해야 할 5개의 테스트(test stubs).

ChuckNorrisJokesModel 모듈(module)은 이미 가져왔다(imported). 테스트 대상 시스템(system under test)인 뷰 모델(view model)에 대한 접근(access)을 제공한다.

먼저 새로운 뷰 모델(view models)을 제공(vend)하기 위한 팩토리 메서드(factory method)를 구현(implement)해야 한다. 농담(joke)을 "가져오기(fetching)"할 때 오류(error)가 발생(emit)하는지 여부를 나타내는(indicate) 매개 변수(parameters)를 사용해야 한다. 그런 다음 이전에 구현(implemented)한 모의 서비스(mock service)를 사용하는 새로운 뷰 모델(view model)을 반환(return)해야 한다.

추가 챌린지(challenge)를 해결하려면 먼저 직접 구현(implement)할 수 있는지 확인한 다음, 아래 구현(implementation)과 비교하여 작업 내용을 확인(check)한다:

private func viewModel(withJokeError jokeError: Bool = false) -> JokesViewModel {
  JokesViewModel(jokesService: mockJokesService(withError: jokeError))
}

이 메서드(method)를 사용하면 각 테스트(test stub)을 채울 수 있다. 이 테스트(tests)를 작성하기 위해 새로운 지식(knowledge)이 필요하지 않다. 마지막 장(chapter)에서 알아야 할 모든 것을 이미 배웠다.

일부 테스트(tests)는 매우(fairly) 간단(straightforward)하다. 어떤 것들은 비동기(asynchronous) 작업(operations)이 완료(complete)될 때까지 기다리는(wait for) 것과 같이 약간 더 고급 구현(implementation)이 필요(require)하다.

시간을 내어 천천히 진행해 보면 해낼 수 있을 것이다.

작업을 마쳤(done)거나 도중에 문제가 발생하면(get stuck on), projects/challenge/final의 해결책(solution)과 비교하여 작업을 확인할 수 있다. 이 해결책(solution)의 테스트(tests)는 한 가지 접근 방식(approach)을 보여(demonstrate)줄 뿐, 유일한 방법은 아니다(etched in stone as the only way). 가장 중요한(important) 것은 시스템(system)이 예상대로 작동하면 테스트(tests)를 통과(pass)하고, 그렇지 않으면 실패(fail)한다는 것이다.

 

Key points

이 장(chapter)에서 배운 주요 내용(main things)은 다음과 같다:

  • Combine을 SwiftUI, Core Data 및 기타 프레임 워크(frameworks)와 함께(cooperatively) 작업하여, 비동기 작업(asynchronous operations)을 관리(managing)하는 능률적(streamlined)이고 통합된 접근 방식(unified approach)을 제공(provide)한다.
  • @ObservedObject@Published와 함께(conjunction)사용하여, Combine publishers로 SwiftUI 뷰(views)를 구동(drive)한다.
  • @FetchRequest를 사용하여 영구 저장소(persistent store)가 변경(changed)되었을때, Core Data 가져오기(fetch)를 자동으로(automatically) 실행(execute)하고 업데이트(updated)된 데이터를 기반으로(based on) UI를 구동(drive)한다.