반응형

원문 : https://www.raywenderlich.com/4161005-mvvm-with-combine-tutorial-for-ios

MVVM with Combine Tutorial for iOS

MVVM과 Combine 튜토리얼에서는, MVVM 패턴을 사용해서 앱을 만들기 위해 SwiftUI와 Combine 프레임워크 사용하는 방법을 배우게 될 것입니다.

Apple의 최신 프레임워크 Combine은 SwiftUI와 함께 WWDC에 큰 파장을 일으켰습니다. Combine은 데이터의 논리적인 스트림(streams)을 제공하는 프레임워크이며 값을 내보내고(emit)나서 성공 또는 오류로 끝나는 선택을 합니다. 이러한 스트림은 Function Reactive Programming(FRP: 함수 반응형 프로그래밍)의 핵심이며, 최근 몇 년 동안 인기를 얻었습니다. Apple이 지향하는 것이 분명하며, SwiftUI로 인터페이스를 만드는 선언적인 방법만 사용하는게 아니라, 시간이 지남에 따른 상태를 관리하기 위해 Combine을 사용합니다. MVVM과 Combine 튜토리얼에서는, SwiftUI, Combine과 아키텍쳐 패턴인 MVVM을 사용해서 날씨 앱을 만들 것입니다. 튜토리얼이 끝날때쯤에는, 수월하게 사용할 수 있을 것입니다.

  • 상태(state)를 관리하기 위한 Combine 사용하기
  • SwiftUI를 사용해서 UI와 ViewModel간에 바인딩(bindings) 만들기
  • 3가지 개념 모두 조화롭게 사용하는 방법을 이해하기

이 튜토리얼이 끝날때, 앱은 다음과 같이 보일것입니다.

특정 접근 방식의 장단점과 문제점을 다르게 해결할 수 있는 것을 알아볼 것입니다. 이렇게 해서 앞으로 다가올 일을 더 잘 대비할 수 있을것입니다! :]

시작하기(Getting Started)

Download Materials

튜토리얼의 상단과 하단의 Download Materials 를 사용해서 프로젝트를 다운로드 받는 것부터 시작합니다. CombineWeatherApp-Starter 폴더 안쪽에 있는 프로젝트를 열어주세요.

날씨 정보를 보기 전에, OpenWeatherMap에 반드시 등록하고 API 키를 받아야 합니다. 이 과정을 마치는데 몇 분 정도 밖에 걸리지 않으며, 다음과 비슷한 화면을 보게 될 것입니다.

WeatherFetcher.swift을 열어주세요. 그리고나서 struct OpenWeatherAPI안쪽을 여러분의 키(key)로 WeatherFetcher.OpenWeatherAPI를 업데이트해주세요.

struct OpenWeatherAPI {
  ...
  static let key = "<your key>" // Replace with your own API Key
}

이 작업을 완료하고나서, 프로젝트를 빌드하고 실행합니다. 메인 화면에 탭을 하기 위한 버튼이 있습니다.

Best weather app을 탭하면 상세화면을 보게 될 것입니다.

지금은 별로 좋아보이지 않지만, 이 튜토리얼이 끝날때즘에는, 많이 좋아질 것입니다. :]

MVVM 패턴 소개(An Introduction to the MVVM Pattern)

Model-View-ViewModel(MVVM) 패턴은 UI 디자인 패턴입니다. 그것은 MV라고 알려진 더 큰 패턴 모음중에 하나이며, Model View  Controller(MVC), Model View Present(MVP), 그외 여러가지가 포함됩니다.

이러한 각 패턴은 개발과 테스트가 더 쉬운 앱을 만들기 위해 비즈니스 로직으로 부터 UI 로직을 분리합니다.

참고 
디자인 패턴과 iOS 아키텍쳐에 대한 자세한 내용은
Design Patterns by Tutorials 또는 Advanced iOS App Architecture를 확인하세요.

이 패턴을 더 잘 이해하기 위해서는 MVVM의 기원을 살펴보는게 도움이 됩니다.

MVC는 첫번째 UI 디자인 패턴이고, 그 기원은 1970년대 Smalltalk 언어로 거슬러 올라갑니다. 아래 이미지는 MVC 패턴의 주요 구성요소를 보여줍니다.

이 패턴은 UI를 앱의 상태를 표현하는 Model로 분리하며, View는 UI 컨트롤로 구성되어 있고, Controller는 사용자 상호작용을 처리하고 모델을 적절하게 업데이트 합니다.

MVC 패턴의 큰 문제점 하나는 매우 혼란스럽다는 것입니다. 개념은 좋지만 사람들은 MVC를 구현할때 종종, 위에서 설명한 원형 관계로 인해서 Model, View, Controller가 커지고 끔찍해 집니다.

최근에, Martin Fowler는 Presentation Model이라는 용어를 사용하는 MVC 패턴의 변형을 도입했으며, Microsoft는 MVVM이라는 이름을 채택하고 대중화 시켰습니다.

이 패턴의 핵심은 ViewModel이며, 앱의 UI 상태를 표현하는 모델의 특별한 타입입니다. ViewModel에는 모든 UI 컨트롤과 각각의 상태를 자세히 표현하는 속성이 포함되어 있습니다. 예를 들어, Text Field의 현재 텍스트, 또는 특정 버튼의 활성화 여부입니다. 또한 버튼 탭이나 제스쳐와 같이 수행할 수 있는 동작을 표현합니다.

ViewModel을 뷰의 모델(model-of-the-view) 처럼 생각하면 도움이 될 수 있습니다.

MVVM 패턴의 3개의 구성요소들 간의 관계는 MVC 보다 간단하며, 다음과 같은 엄격한 규칙을 따릅니다:

  1. View는 ViewModel에 대한 참조를 가지지만, 반대로는 아닙니다.
  2. ViewModel은 Model에 대한 참조를 가지지만, 반대로는 아닙니다.
  3. View는 Model을 참조하지 않거나 Model이 View를 참조하지 않습니다.

이러한 규칙들을 어기면 MVVM은 잘못된 것입니다.

이 패턴의 직접적인 장점은 다음과 같습니다.

  1. Lightweight Views: 모든 UI 로직이 ViewModel에 있으므로, 결과적으로 View가 매우 가벼워집니다.
  2. Testing: View없이도 전체 앱을 실행할 수 있으며, 테스트 능력을 크게 향상시킵니다.

참고 
테스트는 매우 작게 실행되고 코드가 포함되지 않기 때문에, View를 테스트하는 것은 매우 어렵습니다. 일반적으로 controllers는 다른 앱 상태와 관련된 화면(scene)에 View를 추가하고 구성합니다. 이는 작게 실행하는 테스트가 깨지기 쉽고 성가실수 있습니다. 

이 시점에, 문제를 발견할 수 있습니다. 만약 View가 ViewModel에 대한 참조를 가지지만 반대인 경우에, ViewModel은 View를 어떻게 업데이트 하나요?

아하! 여기에서 MVVM 패턴의 비밀 소스가 등장합니다.

MVVM과 데이터 바인딩(MVVM and Data Binding)

Data Binding은 View를 ViewModel과 연결하는 것입니다. 올해 WWDC 이전에는, RxSwift(RxCocoa) 또는 ReactiveSwift(ReactiveCocoa)와 비슷한 것을 사용해야 했습니다. 이 튜토리얼에서는 SwiftUI와 Combine을 사용해서 이런 연결을 하는 방법을 살펴볼 것입니다.

MVVVM With Combine

Combine은 실제로 바인딩(bindings)이 필요하지 않지만, 그 능력을 활용하지 못한다는 의미는 아닙니다. 여러분은 자체 바인딩을 만들어서 SwiftUI를 사용할 수 있습니다. 하지만 Combine을 사용하면 더 많은 것을 할 수 있습니다. 튜토리얼 전체에서 볼수 있는 것처럼, ViewModel 측면에서, Combine을 자연스럽게 선택해서 사용하게 됩니다. UI에서 시작해서 네트워크 호출까지 연결하는 것을 명확하게 정의할 수 있습니다. SwiftUI와 Combine을 조합해서 쉽게 할 수 있습니다. 또 다른 통신 패턴(예를들어 델리게이터)을 사용하는 것이 가능하지만, SwiftUI에서 설정하는 선언적인 접근법과 연계해야하고, 바인딩은 필수사항입니다.

앱 만들기(Building the App)

주의
SwiftUI 또는 Combine을 처음 사용해보는 경우에, 코드를 보고 혼란스러울지도 모릅니다. 만약 그렇다면 걱정하지 마세요. 이것은 고급 주제이며 시간과 연습이 필요합니다. 말이 안된다면, 앱을 실행하고 중단점을 설정해서 동작하는 것을 살펴보세요.

모델 레이어에서 시작해서 UI로 이동할 것입니다.

OpenWeatherMap API에서 JSON을 사용하므로, 데이터를 디코딩된 객체로 변환하는 유틸리티 메소드가 필요합니다. Parsing.swift를 열고 다음을 추가하세요.

import Foundation
import Combine

func decode<T: Decodable>(_ data: Data) -> AnyPublisher<T, WeatherError> {
  let decoder = JSONDecoder()
  decoder.dateDecodingStrategy = .secondsSince1970

  return Just(data)
    .decode(type: T.self, decoder: decoder)
    .mapError { error in
      .parsing(description: error.localizedDescription)
    }
    .eraseToAnyPublisher()
}

OpenWeatherMap API에서 JSON을 디코딩하기 위해서 표준 JSONDecoder를 사용합니다. mapError(_:)과 eraseToAnyPublisher()에 대한 자세한 내용은 바로 알게 될 것입니다.

주의
여러분이 직접 또는 QuickType 같은 서비스를 사용해서 디코딩 로직을 작성할 수 있습니다. 경험상(As a rule of thumb), 자체 서비스는 직접 처리합니다. 타사 서비스의 경우에 QuickType을 사용해서 상용구(boilerplate)을 만듭니다. 이 프로젝트에서는, 이 서비스로 된 요소들을(entities) Responses.swift에서 찾을수 있을 것입니다.

이제 WeatherFetcher.swift을 열어주세요. 해당 요소(entity)는 OpenWeatherMap API로 가져오는 정보에 대해 응답할 수 있으며, 데이터를 분석하고 소비자에게 제공합니다.

훌륭한 Swift 사용자 처럼, 프로토콜부터 시작할 것입니다. imports 아래에 다음을 추가하세요.

protocol WeatherFetchable {
  func weeklyWeatherForecast(
    forCity city: String
  ) -> AnyPublisher<WeeklyForecastResponse, WeatherError>

  func currentWeatherForecast(
    forCity city: String
  ) -> AnyPublisher<CurrentWeatherForecastResponse, WeatherError>
}

5일 동안의 일기예보를 표시하기 위해 첫 번째 화면에 대해 첫 번째 메소드를 사용할 것입니다. 좀 더 자세한 날씨 정보를 보여주기 위해 두번째 뷰를 사용할 것입니다. 

AnyPublisher가 무엇이고 왜 2가지 타입의 매개변수가 있는지 궁금해 할지도 모릅니다. 계산 대상으로 생각하거나 구독 후에 실행할 것이라 생각할 수 있습니다. 첫번째 매개변수(WeeklyForecastResponse)는 계산이 성공하는 경우 반환되는 타입을 참조하고, 짐작하셨겠지만, 두번째는(WeatherError) 실패한 경우에 대한 타입을 참조합니다.

클래스 선언 아래에 다음에 오는 코드를 추가해서 메소드 2개를 구현하세요.

// MARK: - WeatherFetchable
extension WeatherFetcher: WeatherFetchable {
  func weeklyWeatherForecast(
    forCity city: String
  ) -> AnyPublisher<WeeklyForecastResponse, WeatherError> {
    return forecast(with: makeWeeklyForecastComponents(withCity: city))
  }

  func currentWeatherForecast(
    forCity city: String
  ) -> AnyPublisher<CurrentWeatherForecastResponse, WeatherError> {
    return forecast(with: makeCurrentDayForecastComponents(withCity: city))
  }

  private func forecast<T>(
    with components: URLComponents
  ) -> AnyPublisher<T, WeatherError> where T: Decodable {
    // 1
    guard let url = components.url else {
      let error = WeatherError.network(description: "Couldn't create URL")
      return Fail(error: error).eraseToAnyPublisher()
    }

    // 2
    return session.dataTaskPublisher(for: URLRequest(url: url))
      // 3
      .mapError { error in
        .network(description: error.localizedDescription)
      }
      // 4
      .flatMap(maxPublishers: .max(1)) { pair in
        decode(pair.data)
      }
      // 5
      .eraseToAnyPublisher()
  }
}

다음은 무엇을 하는지 입니다.

  1. URLComponents로 부터 URL 인스턴스를 만들려고 합니다. 만약 실패하면, Fail 값으로 감싸진 오류를 반환합니다. 그리고 나서, 해당 타입을 지우고 메소드 반환 타입인 AnyPublisher에 타입을 지웁니다.
  2. 데이터를 가져오기 위해 URLSession의 새로운 메소드 dataTaskPublisher(for:)를 사용합니다. 이 메소드는 URLRequest의 인스턴스를 가지고 튜플 (Data, URLResponse) 또는 URLError중 하나를 반환합니다.
  3. 메소드가 AnyPublisher<T, WeatherError>를 반환하기 때문에, URLError에서 WeatherError로 오류를 매핑합니다. 
  4. flatMap 사용은 자체적으로 사용할만 합니다. 여기에서 여러분은 서버에서 오는 JSON 데이터를 완전한 개게로 변환하기 위해 flatmap을 사용합니다. 이를 위한 보조기능으로 decode(_:)를 사용합니다. 네트워크 요청으로 받은 첫번째 값에만 관심이 있기에, .max(1)을 설정합니다.
  5. eraseToAnyPublisher()을 사용하지 않는 경우에 flatMap에서 반환된 전체 타입을 처리해야 합니다 : Publishers.FlatMap<AnyPublisher<_, WeatherError>, Publishers.MapError<URLSession.DataTaskPublisher, WeatherError>>. API 소비자로서, 여러분은 이런 자세한 내용으로 부담을 느끼고 싶지 않습니다. 따라서 API 인체공학을 개선하기 위해, AnyPublisher에 타입을 지웁니다. 이것은 또한 새로운 변환(예를 들어 filter)을 추가하면 반환된 타입을 변경하고 세부정보가 유출되기 때문에 유용합니다.

모델 수준에서, 필요한 모든 것이 있어야 합니다. 모든 것이 동작하도록 앱을 빌드합니다.

ViewModel 살펴보기(Diving Into the ViewModels)

다음으로, 주간 기상예보를 보여주는 ViewModel에서 작업할 것입니다.

WeeklyWeatherViewModel.swift 파일을 열고 다음을 추가하세요.

import SwiftUI
import Combine

// 1
class WeeklyWeatherViewModel: ObservableObject, Identifiable {
  // 2
  @Published var city: String = ""

  // 3
  @Published var dataSource: [DailyWeatherRowViewModel] = []

  private let weatherFetcher: WeatherFetchable

  // 4
  private var disposables = Set<AnyCancellable>()

  init(weatherFetcher: WeatherFetchable) {
    self.weatherFetcher = weatherFetcher
  }
}

다음은 코드에서 하는 일입니다.

  1. WeeklyWeatherViewModel이 ObservableObject와 Identifiable을 준수하도록 만듭니다. 이것을 준수하면 WeeklyWeatherViewModel의 프로퍼티가 바인딩(bindings)을 사용할 수 있다는 의미입니다. View 레이어에 오면 만드는 방법을 볼 수 있을 것입니다.
  2. 제대로된 @Published 수정자 위임(delegate)은 city 프로퍼티를 관찰(observe)하는 것이 가능하도록 만듭니다. 이를 활용하는 방법은 잠시 뒤에 보게 될 것입니다.
  3. ViewModel에 있는 View의 데이터 소스를 유지할 것입니다. 이것은 MVC에서 사용했던 것과는 대조적입니다. 왜냐하면 그 프로퍼티는 @Published로 표시 되었기 때문에, 컴파일러는 자동으로 게시자(publisher)를 합성(synthsizes) 합니다. SwiftUI는 해당 게시자(publisher)를 구독(subscribes)하고 프로퍼티가 변할때 화면을 다시 그려줍니다. 
  4. 요청에 대한 참조 집합으로 disposables을 사용합니다. 해당 참조를 유지할 필요없이, 네트워크 요청이 계속 유지하지 않을 것이며, 서버로부터 응답 받지 못하게 할 것입니다. 

이제, 초기화 아래에 다음에 오는 것을 추가해서 WeatherFetcher를 사용합니다.

func fetchWeather(forCity city: String) {
  // 1
  weatherFetcher.weeklyWeatherForecast(forCity: city)
    .map { response in
      // 2
      response.list.map(DailyWeatherRowViewModel.init)
    }

    // 3
    .map(Array.removeDuplicates)

    // 4
    .receive(on: DispatchQueue.main)

    // 5
    .sink(
      receiveCompletion: { [weak self] value in
        guard let self = self else { return }
        switch value {
        case .failure:
          // 6
          self.dataSource = []
        case .finished:
          break
        }
      },
      receiveValue: { [weak self] forecast in
        guard let self = self else { return }

        // 7
        self.dataSource = forecast
    })

    // 8
    .store(in: &disposables)
}

여기에서 많은 일을 하지만, 이후에는 모두 쉬어질 것이라 약속합니다!

  1. OpenWeatherMap API로 부터 정보를 가져오는 새로운 요청을 만드는 것부터 시작합니다. 도시 이름을 인자(argument)로 전달합니다.
  2. 응답받은 것(WeeklyForecastResponse 객체)을 DailyWeatherRowViewModel 객체의 배열로 매핑합니다. 해당 요소(entity)는 목록에서 한 행(row)을 나타냅니다. DailyWeatherRowViewModel.swift에 있는 곳에서 구현을 확인 할 수 있습니다. MVVM에서, 필요한 데이터를 정확하게 View에 보여주는 ViewModel 레이어가 가장 중요합니다. View에 WeeklyForecastResponse를 직접 노출하는 것은 말이 안되며, View 레이어가 그것을 사용하기 위해 모델을 강제로 구성(format)하기 때문입니다. View는 가능한 모르게 만들고 랜더링 하는데만 집중하도록 하는 것이 좋은 생각입니다.
  3. OpenWeatherMap API는 하루에 시간에 따라 여러개의 온도를 반환하므로, 중복되는 것을 제거합니다. 이를 어떻게 하는지는 Array+Filtering.swift에서 확인 할 수 있습니다.
  4. 서버로부터 데이터를 가져오거나 JSON의 blob로 파싱하는 것이 백그라운드 큐(background queue)에서 수행하지만, UI 업데이트 하는 것은 반드시 메인 큐(main queue)에서 수행해야 합니다. receive(on:)에서, 5, 6, 7 단계에서 수행한 업데이트가 올바른 위치에서 수행하는지를 확인합니다.
  5. sink(receiveCompletion:receiveValue:)를 사용해서 게시자(publisher)를 시작합니다. 이곳에서 dataSource를 적절하게 업데이트 합니다. 값을 처리하는 것과는 별개로 -성공이나 실패중 하나에 대한- 완료(completion)를 처리하는 것이 중요합니다. 
  6. 이벤트가 실패한 경우에,dataSource는 비어있는 배열을 설정합니다.
  7. 새로운 날씨가 도착할때 dataSource를 업데이트 합니다.
  8. 마지막으로, disposable 설정에 취소 가능한 참조를 추가합니다. 이전에 언급했던 것처럼, 참조를 유지하지 않고, 네트워크 게시자(publisher)는 즉시 종료할 것입니다.

앱을 빌드하세요. 모두 컴파일 되야 합니다! 지금의 앱은 여전히 아무것도 하지 않습니다. 왜냐하면 뷰가 없기 때문이며, 그것을 해결할 시간입니다.

주간 날씨 뷰(Weekly Weather View)

WeeklyWeatherview.swift을 여는 것으로 시작합니다. 그리고 나서 struct 내부에 viewModel 프로퍼티와 초기화를 추가합니다.

@ObservedObject var viewModel: WeeklyWeatherViewModel

init(viewModel: WeeklyWeatherViewModel) {
  self.viewModel = viewModel
}

@ObservedObject 프로퍼티 델리게이터(delegate)는 WeeklyWeatherView와 WeeklyWeatherViewModel간에 연결을 해줍니다. 이는 WeeklyWeatherView의 프로퍼티 objectWillChange가 값을 보낼때, View는 데이터 소스가 변경되는 것을 알리고 결과적으로 View는 다시 그려집니다(re-rendered).

이제 SceneDelegate.swift을 열고 기존 weeklyView 프로퍼티를 다음에 오는 것으로 교체합니다.

let fetcher = WeatherFetcher()
let viewModel = WeeklyWeatherViewModel(weatherFetcher: fetcher)
let weeklyView = WeeklyWeatherView(viewModel: viewModel)

모든 것이 컴파일 되도록 하기 위해 프로젝트를 다시 빌드합니다.

WeeklyWeatherView.swift로 돌아가서 앱의 실제 구현부인 body를 교체합니다.

var body: some View {
  NavigationView {
    List {
      searchField

      if viewModel.dataSource.isEmpty {
        emptySection
      } else {
        cityHourlyWeatherSection
        forecastSection
      }
    }
    .listStyle(GroupedListStyle())
    .navigationBarTitle("Weather ⛅️")
  }
}

dataSource가 비었을때, 빈 섹션을 보게 될 것입니다. 반면에, 날씨 섹션을 보게 될 것이고 검색한 특정 도시에 대한 세부사항을 볼수 있을 것입니다. 파일의 하단부에 다음에 오는 것을 추가하세요.

private extension WeeklyWeatherView {
  var searchField: some View {
    HStack(alignment: .center) {
      // 1
      TextField("e.g. Cupertino", text: $viewModel.city)
    }
  }

  var forecastSection: some View {
    Section {
      // 2
      ForEach(viewModel.dataSource, content: DailyWeatherRow.init(viewModel:))
    }
  }

  var cityHourlyWeatherSection: some View {
    Section {
      NavigationLink(destination: CurrentWeatherView()) {
        VStack(alignment: .leading) {
          // 3
          Text(viewModel.city)
          Text("Weather today")
            .font(.caption)
            .foregroundColor(.gray)
        }
      }
    }
  }

  var emptySection: some View {
    Section {
      Text("No results")
        .foregroundColor(.gray)
    }
  }
}

여기에 있는 코드가 약간 있지만, 중요한 부분은 3군데 입니다.

  1. 첫번째 바인딩(bind)! $viewModel.city은 TextField에 입력된 값과 WeeklyWeatherViewModel의 city 프로퍼티 간의 연결을 설정합니다. $을 사용해서 city 프로퍼티를 Binding<String>으로 만듭니다. 이는 WeeklyWeatherViewModel이 ObservableObject를 준수하기 때문에 가능하고 @ObservedObject 프로퍼티 래퍼(property wrapper)로 선언됬습니다.
  2. 자체 ViewModels을 사용해서 일일 기상 예보 행을 초기화합니다. 어떻게 동작하는지 보기 위해서 DailyWeatherRow.swift을 열어주세요.
  3. 어떤 바인딩 없이, WeeklyWeatherViewModel 프로퍼티들을 계속 사용하고 접근할수 있습니다. 이것은 Text로 도시 이름을 보여줍니다.

앱을 빌드하고 실행하고 다음과 같이 보여야 합니다.

놀랬거나 아니든, 아무일도 일어나지 않습니다. 그 이유는 아직 city는 실제 HTTP 요청과 바인딩(bind)을 연결하지 않았기 때문입니다. 그것을 고칠때입니다.

WeeklyWeatherViewModel.swift을 열고 현재 초기화를 다음에 오는 것으로 교체합니다.

// 1
init(
  weatherFetcher: WeatherFetchable,
  scheduler: DispatchQueue = DispatchQueue(label: "WeatherViewModel")
) {
  self.weatherFetcher = weatherFetcher
  
  // 2
  _ = $city
    // 3
    .dropFirst(1)
    // 4
    .debounce(for: .seconds(0.5), scheduler: scheduler)
    // 5
    .sink(receiveValue: fetchWeather(forCity:))
}

이 코드는 두 세계(SwiftUI와 Combine)를 연결하기 때문에 중요합니다: 

  1. scheduler 매개변수를 추가해서, HTTP 요청에서 사용할 큐(queue)를 지정할 수 있습니다.
  2. city 프로퍼티는 @Published 프로퍼티 델리게이터를 사용므로 다른 Publisher와 같은 역할을 합니다. 이것은 관찰될(observed)수 있고 Publiser에서 사용할 수 있는 다른 메소드를 사용할 수 있다는 의미입니다.
  3. 관찰(observation)을 만들자마자, $city는 첫번째 값을 내보냅니다. 첫번째 값이 빈 문자열이므로, 의도하지 않는 네트워크 호출을 피하려면 그것을 건너뛰어야 합니다.
  4. 더 나은 사용자 경험을 제공하기 위해 debounce(for:scheduler:)을 사용합니다. 그것이 없다면 fetchWeather는 입력된 모든 문자에 대해 새로운 HTTP 요청을 만들것입니다. debounce는 사용자가 입력을 멈추고 마지막 값을 전달할때까지 0.5초 동안 기다리다가 동작합니다. 이 동작에 대한 훌륭한 시각화를 RxMarbles에서 볼수 있습니다. 또한 인자로 scheduler을 전달하는 것은, 특정 큐에서 모든 값이 내보내지는 것을 의미합니다. 경험상(Rule of thumb), 백그라운드 큐(background queue)에서 값을 처리하고 메인 큐(main queue)에 전달해야 합니다.
  5. 마지막으로, sink(receiveValue:)를 통해서 해당 이벤트를 관찰하고 이전에 구현했던 fetchWeather(forCity:)로 처리합니다.

프로젝트를 빌드하고 실행합니다. 마침내 메인 화면이 동작하는 것을 보게 됩니다.

네비게이션과 현재 날씨 화면(Navigation and Current Weather Screen)

아키텍쳐 패턴으로써의 MVVM은 핵심 세부사항에 들어가지 않습니다. 일부 결정은 개발자의 재량에 달려있습니다. 그 중 하나는(one of those) 한 화면에서 다른 화면으로 이동하는 방법과, 그 책임을 가지고 있는 요소(entity)입니다. SwiftUI는 NavigationLink 사용하도록 암시하고, 따라서 이 튜토리얼에서는 그것을 사용할 것입니다.

NavigationLink의 가장 기본적은 초기화를 살펴보는 경우: public init<V>(destionation: V, label: () -> label) where V: View, 인자가 View인 것을 예상할 수 있습니다. 이것은, 본질적으로 현재 View(origin)를 다른 View(destionation)와 연결합니다. 이런 관계는 단순한 앱에서는 괜찮을지도 모르겠지만 외부 로직(서버 응답과 같은)을 기반으로 요청하는 복잡한 경우에는 문제가 될수 있습니다. 

MVVM 규칙에 따라, View는 ViewModel에게 다음에 수행해야 할 작업을 물어볼 수 있지만, 예상되는 매개변수가 View이고 ViewModel은 이를 무시해야하기 때문에 까다롭습니다. 이 문제는 FlowControllers 또는 Coordinators를 통해서 해결되며, 앱 전체의 경로(routing)를 관리하기 위해서 ViewModel과 함께 동작하는 또 다른 요소(entity)로 표현됩니다. 이러한 접근 방식은 잘 적용되지만, NavigationLink 같은 것을 사용하지 못하게 할 것입니다. 

이러한 모든 것들은 이 튜토리얼의 범위를 벗어나므로, 지금은, 실용적이고 하이브리드(hybrid) 접근법을 사용할 것입니다.

네비게이션을 살펴보기전에, 먼저 CurrentWeatherView와 CurrentWeatherViewModel을 업데이트 합니다. CurrentWeatherViewModel.swift을 열고 다음을 추가하세요.

import SwiftUI
import Combine

// 1
class CurrentWeatherViewModel: ObservableObject, Identifiable {
  // 2
  @Published var dataSource: CurrentWeatherRowViewModel?

  let city: String
  private let weatherFetcher: WeatherFetchable
  private var disposables = Set<AnyCancellable>()

  init(city: String, weatherFetcher: WeatherFetchable) {
    self.weatherFetcher = weatherFetcher
    self.city = city
  }

  func refresh() {
    weatherFetcher
      .currentWeatherForecast(forCity: city)
      // 3
      .map(CurrentWeatherRowViewModel.init)
      .receive(on: DispatchQueue.main)
      .sink(receiveCompletion: { [weak self] value in
        guard let self = self else { return }
        switch value {
        case .failure:
          self.dataSource = nil
        case .finished:
          break
        }
        }, receiveValue: { [weak self] weather in
          guard let self = self else { return }
          self.dataSource = weather
      })
      .store(in: &disposables)
  }
}

CurrentWeatherVeiwModel은 이전에 WeeklyWeatherViewModel에서 했던 것을 따라합니다.

  1. CurrentWeatherViewModel을 ObservableObject와 Identifiable을 준수하도록 만듭니다.
  2. CurrentWeatherRowViewModel을 옵셔널로 데이터 소스로 사용합니다.
  3. CurrentWeatherForecastResponse형식으로 제공되는 CurrentWeatherRowViewModel로 새 값을 변환합니다.

이제, UI를 다룹니다. CurrentWeatherView.swift를 열고 struct의 상단에 초기화를 추가하세요.

@ObservedObject var viewModel: CurrentWeatherViewModel

init(viewModel: CurrentWeatherViewModel) {
  self.viewModel = viewModel
}

WeeklyWeatherView에서 적용했던 패턴과 동일하게 하고 여러분 자체 프로젝트에서 SwiftUI를 사용할때 해야할게 가장 많은 것입니다: 
View 안에 ViewModel을 삽입하고 공개 API를 사용합니다.

이제 body 계산(computed) 프로퍼티를 업데이트 합니다.

var body: some View {
  List(content: content)
    .onAppear(perform: viewModel.refresh)
    .navigationBarTitle(viewModel.city)
    .listStyle(GroupedListStyle())
}

onAppear(perform:) 메소드를 사용하는 것을 알게 될 것입니다. 이것은 () -> Void 타입의 함수를 가지고 뷰가 나타날때 실행합니다. 이 경우에, View Model에서 refresh()를 호출해서 dataSource를 새로고침 할 수 있습니다.

마지막으로, 파일의 하단에 다음에 오는 것을 추가하세요.

private extension CurrentWeatherView {
  func content() -> some View {
    if let viewModel = viewModel.dataSource {
      return AnyView(details(for: viewModel))
    } else {
      return AnyView(loading)
    }
  }

  func details(for viewModel: CurrentWeatherRowViewModel) -> some View {
    CurrentWeatherRow(viewModel: viewModel)
  }

  var loading: some View {
    Text("Loading \(viewModel.city)'s weather...")
      .foregroundColor(.gray)
  }
}

남아있는 UI 부분을 추가합니다.

CurrentWeatherView 초기화를 변경하지 않았기 때문에, 프로젝트는 아직 컴파일 되지 않습니다.

이제 대부분 제자리에 놓였으니, 네비게이션을 마무리(wrap up) 할 때입니다. WeeklyWeatherBuilder.swift를 열고 다음에 오는 것을 추가하세요.

import SwiftUI

enum WeeklyWeatherBuilder {
  static func makeCurrentWeatherView(
    withCity city: String,
    weatherFetcher: WeatherFetchable
  ) -> some View {
    let viewModel = CurrentWeatherViewModel(
      city: city,
      weatherFetcher: weatherFetcher)
    return CurrentWeatherView(viewModel: viewModel)
  }
}

이 요소(entity)는 WeeklyWeatherVeiw에서 탐색(navigating)할때 필요한 화면을 만드는 공장(factory) 역할을 합니다.

WeeklyWeatherViewModel.swift을 열고 파일의 하단에 다음에 오는 것을 추가해서 빌더(builder)를 사용하세요.

extension WeeklyWeatherViewModel {
  var currentWeatherView: some View {
    return WeeklyWeatherBuilder.makeCurrentWeatherView(
      withCity: city,
      weatherFetcher: weatherFetcher
    )
  }
}

마지막으로, WeeklyWeatherView.swift를 열고 cityHourlyWeatherSection 프로퍼티 구현을 다음에 오는 것으로 바꾸세요.

var cityHourlyWeatherSection: some View {
  Section {
    NavigationLink(destination: viewModel.currentWeatherView) {
      VStack(alignment: .leading) {
        Text(viewModel.city)
        Text("Weather today")
          .font(.caption)
          .foregroundColor(.gray)
      }
    }
  }
}

여기에서 중요한 부분은 viewModel.currentWeatherView입니다. WeeklyWeatherView는 WeeklyWeatherViewModel에게 다음으로 이동해야할 뷰를 요청합니다. WeeklyWeatherViewModel은 필요한 View를 제공하기 위해서 WeeklyWeatherBuilder를 사용합니다. 이것는 책임져야 할것들을 멋지게 분리해주며, 동시에 그것들 간의 전반적인 관계를 쉽게 유지할 수 있습니다.

네비게이션 문제를 해결하는 다른 많은 방법이 있습니다. 일부 개발자는 View 레이어가 탐색하는 곳 또는 어떻게 탐색하는지(modally 또는 pushed)를 알고 있어서는 안된다고 주장할 것입니다. 이런 주장을 하는 경우에, Apple이 제공하는 NavigationLink를 더 이상 사용하지 않는것이 좋습니다. 실용성과 확장성간의 균형을 유지하는 것이 중요합니다. 이 튜토리얼은 전자(former)에 속합니다.

프로젝트를 빌드하고 실행합니다. 모든 것이 예상대로 동작합니다!
날씨 앱을 만든 것을 축하합니다 :]

여기에서 어디로 가야하나요?(Where to Go From Here?)

Download Materials

이 튜토리얼의 상단 또는 하단에서 Download Materials를 클릭해서 완성된 프로젝트를 다운로드 받을 수 있습니다.

이 튜토리얼에서 MVVM, Combine, Swift에 대한 많은 부분을 다뤘습니다. 이러한 각각의 주제들에 대한 튜토리얼이 필요하며, 오늘의 목표는 iOS 개발의 미래를 엿보게 하는 것이었습니다.

오늘 다뤘던 주제에 대해 자세히 보려면 앞으로 업데이트될 Advanced iOS App Architecture를 참고하세요. 

그리고 Combine 사용에 대한 자세한 내용은 새로운 책 Combine: Asynchronous Programming with Swift을 확인해보세요.

반응형
Posted by 까칠코더
,