MVVM with Combine Tutorial for iOS

翻译自 MVVM with Combine Tutorial for iOS

点击下载本教程素材/Demo

本教程将介绍如何使用 Combine + SwiftUI + MVVM架构模式 来构建一个简单的天气App。

Version:

Swift 5, iOS 13, Xcode 11

今年WWDC最大的亮点无疑是苹果新发布的 Combine 框架和 SwiftUI。Combine 是一个 函数式响应式编程框架(Functional Reactive Programming),它可以发送逻辑数据流,并以成功或失败告终。SwiftUI 是一个声明式的UI框架,它可以结合 Combine 来处理应用的状态。教程将演示如何使用 SwiftUI + Combine + MVVM架构模式 创建一个简单的天气App。读完本教程,您讲了解到:

  • 使用 Combine 来管理状态
  • 在 SwiftUI 中为 View 和 ViewModel 建立绑定关系
  • 理解 SwiftUI, Combine, MVVM 这三个概念是怎样结合到一起的

界面最终效果如下:


MVVM with Combine Tutorial for iOS_第1张图片
weather_final-1.gif

Getting Started

  1. 首先 下载本教程所需素材/Demo,打开文件夹 CombineWeatherAppStarter。
  2. 为了能看到天气信息,您必须先花两分钟去 OpenWeatherMap 网站上注册并获取一个 API key,如图:
MVVM with Combine Tutorial for iOS_第2张图片
OpenWeatherInterface-650x294.png
  1. 打开 WeatherFetcher.swift,更新 WeatherFetcher.OpenWeatherAPI.key,如下:

    struct OpenWeatherAPI {
      ...
      static let key = "" // Replace with your own API Key
    }
    
  2. build and run,主屏幕会展示一个可点击的按钮:

    MVVM with Combine Tutorial for iOS_第3张图片
    MainScreen_start.png

点击 "Best weather app" 将展示更多细节:

MVVM with Combine Tutorial for iOS_第4张图片
DetailScreen_finished.png

此时界面上还是空的,接下来让我们逐渐完善它。

An Introduction to the MVVM Pattern

Model-View-ViewModel 是一种 UI 设计模式,它也是 MV* 模式系列中的一员,包括 Model View Controller(MVC), Model View Presenter(MVP).

每种模式都是为了分离 界面逻辑和业务逻辑,以便更容易开发和测试App。

Note: Design Patterns by Tutorials 和 Advanced iOS App Architecture 这两本书介绍了更多关于设计模式和iOS架构相关的知识。

回顾 MVVM 的起源有助于更好的理解该模式。

MVC 是最早的 UI 设计模式,它的起源可以追溯到 Smalltalk language of the 1970s. 下图展示了 MVC 模式的主要组件:

MVVM with Combine Tutorial for iOS_第5张图片
MVCPattern-2.png

这种模式将UI分成三部分:

  1. Model - 代表应用程序状态的模型
  2. View - 由UI控件组成
  3. Controller,处理用户交互,并相应的更新模型状态

这些概念看起来不错,但是通常当人们开始实现MVC时,上面说明的看似循环的关系导致Model,View 和 Controller 变得巨大而混乱。

后来,Martin Flowler 引入了称为 Presentation Model 的MVC模式的一种变体,该模型被 Microsoft 以 MVVM 的名义采用和普及。

MVVMPattern.png

MVVM 的核心是 ViewModel,ViewModel 是一种代表了 UI状态的 Model类型,它包含详细描述每个UI控件状态的属性。如 输入框当前的文本内容、某个按钮是否可用。它也对外公开了视图可以执行的操作,比如按钮点击或手势。

将 ViewModel 看作是 View的Model,也许会更容易理解。

MVVM模式遵循以下规则:
  1. View 引用了 ViewModel,反之不会。
  2. ViewModel 引用了 Model,反之不会。
  3. View 和 Model 之间无引用关系。
MVVM模式的几个优点是:
  1. 轻量级的 View:所有的UI逻辑都在 ViewModel 中,从而产生了非常轻量级的 View。
  2. 容易测试:可以在没有View的情况下运行整个应用程序,从而大大增强了其可测试性。

此时,您可能已经发现了一个问题。如果 View 引用了 ViewModel,但 ViewModel 没有引用 View,那 ViewModel 如何更新 View 呢?

这就是MVVM模式的秘诀所在。

MVVM and Data Binding

数据绑定(Data Binding) 可以在 View 和 ViewModel 之间建立一种关联。在今年的WWDC之前,不得不使用 RxSwift (via RxCocoa) 或者 ReactiveSwift (via ReactiveCocoa) 来做数据绑定。在本教程中,将使用 SwiftUI 和 Combine。

MVVM With Combine

虽然 Combine 并不是做绑定的唯一方式,但在 SwiftUI 和 ViewModel 中使用 Combine 是十分自然的选择。它使您可以清晰地定义从UI到网络调用的链,并轻松实现这些功能。使用其他通信模式(如代理、回调) 也可以达到效果,但这样做会将 SwiftUI 的声明式方法及其绑定 换成命令式的。

Building the App

先看Model层,再看UI层。

我们可以创建一个通用的方法,将 OpenWeatherMap 接口返回的数据解码成我们需要的对象,打开 Parsing.swift 并添加以下代码:

import Foundation
import Combine

func decode(_ data: Data) -> AnyPublisher {
  let decoder = JSONDecoder()
  decoder.dateDecodingStrategy = .secondsSince1970

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

它使用标准的 JSONDecoder 来解码 OpenWeatherMap API 返回的JSON数据。后面我们再说说 mapError(_:)eraseToAnyPublisher()

打开 WeatherFetcher.swift, 该实体负责从 OpenWeatherMap API 中获取数据并提供给消费者。

从协议开始,添加如下代码:

protocol WeatherFetchable {
  func weeklyWeatherForecast(
    forCity city: String
  ) -> AnyPublisher

  func currentWeatherForecast(
    forCity city: String
  ) -> AnyPublisher
}

第一个方法,用来获取第一个界面要展示的后续5天的天气信息。第二个方法用来查看某天的天气信息详情。

AnyPublisher 将在被订阅后执行。第一个参数 WeeklyForecastResponse 指的是计算成功后返回的类型,第二个参数是计算失败时的错误类型。

通过在类声明下面添加以下代码来实现这两种方法:

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

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

  private func forecast(
    with components: URLComponents
  ) -> AnyPublisher 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 中的错误,并将 Fail 类型擦除为方法的返回类型AnyPublisher
  2. 使用新的URLSession 方法 dataTaskPublisher(for:) 来获取数据,此方法使用 URLRequest 的实例做请求,返回一个 (Data, URLResponse) 类型的元组 或是 一个 URLError
  3. 因为这个方法返回类型是 AnyPublisher,因此需要将 URLError 转换为 WeatherError
  4. flatMap 中使用 decode(_:) 方法 将服务器返回的json数据转换为所需的对象,因为我们只需要取一次网络请求的值,所以设置 .max(1)
  5. 由于这个函数可能返回 Publishers.FlatMapPublishers.MapError。因此我们需要使用 eraseToAnyPublisher() 来擦除它的类型,否则我们需要在返回值中包含所有可能出现的类型,如果将来又增加新的类型,就更难处理了。而且,作为API的使用者,我们并不关心这些返回值的类型细节。

Diving Into the ViewModels

接下来,我们将使用 ViewModel 来处理 每周天气预报页:

MVVM with Combine Tutorial for iOS_第6张图片
weekly_forecast.gif

打开 WeeklyWeatherViewModel.swift 并且添加如下代码:

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

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

  private let weatherFetcher: WeatherFetchable

  // 4
  private var disposables = Set()

  init(weatherFetcher: WeatherFetchable) {
    self.weatherFetcher = weatherFetcher
  }
}
看看这段代码做了些什么:
  1. WeeklyWeatherViewModel 遵守 ObservableObjectIdentifiable 协议,这使得 WeeklyWeatherViewModel 的属性可以被用来做数据绑定。我们后续在 View 层会看到。
  2. 属性包装器 @Published,使得 属性 city 在发生改变时可以被其他对象观察到。
  3. 在 ViewModel 中引用一份 View 的数据源 dataSource,这与我们在 MVC模式中的处理方式不同。由于这个属性被标记为 @Published,所以编译器会自动为 属性 dataSource 合成 publisher 的相关方法。SwiftUI 将会订阅这个 publisher,当属性 dataSource 发生改变时,SwiftUI 会自动刷新UI。
  4. disposables 当做所有请求的集合,作为一个全局属性,它保留了所有请求的强引用。如果没有这些引用,您所做的请求出了作用域就立刻会被释放掉,也就无法接收后续从服务器返回的数据。

现在,在 WeeklyWeatherViewModel 的初始化器中初始化 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. 通过 WeatherFetcher 从 OpenWeatherMapAPI 获取 city 的天气数据。
  2. 将 请求的回复对象 WeeklyForecastResponse 转换为界面所需的 DailyWeatherRowViewModel 数组。一个 DailyWeatherRowViewModel 表示列表中一行所展示的数据。您可以在 DailyWeatherRowViewModel.swfit 文件中查看它的具体实现。在MVVM模式中,在ViewModel层应该提供 View 所需展示的数据,而不是直接将服务器返回的数据交由 View 去处理。比如,不应该让 View 来格式化所要展示的数据。View 尽量只关心界面的展示渲染,而不处理额外的业务逻辑。
  3. OpenWeatherMap API 会根据一天中的时间返回同一天的多个温度,我们去掉其中重复的部分。关于 Array.removeDuplicates 方法的实现细节请看 Array+Filtering.swfit 文件。
  4. 尽管从服务器获取、解析JSON数据发生在后台线程,但是应该在主线程刷新UI。方法 receive(on: DispatchQueue.main) 可以确保接下来的步骤 5, 6, 7 在主线程执行。
  5. 通过 sink(receiveCompletion:receiveValue:) 方法来订阅 publisher 并接收事件。我们也相应的更新 dataSource。值得注意的是,接收到订阅的值values 和 订阅结束(成功 or 失败) 的事件,是分开处理的。
  6. 如果接收到的事件是 failure,设置 dataSource 为一个空数组。
  7. 每当接收到一个新值时就更新 dataSource。
  8. 最后,将返回的 cancellable 加入到 disposables 中。如前文提及到的,如果不保留对它的引用,发起网络请求的 publisher 就会立即终止。

现在,App应该可以成功 Build。接下来我们开始编写 View。

Weekly Weather View

首先打开 WeeklyWeatherView.swift,在这个 struct 中添加并初始化属性 viewModel

@ObservedObject var viewModel: WeeklyWeatherViewModel

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

通过属性包装器 ObservedObject,在 WeeklyWeatherViewWeeklyWeatherViewModel 之间建立连接。这意味着,当 WeeklyWeatherView 的属性 objectWillChange 发送一个值时,该 View 将被通知该数据源将要修改,并刷新界面。

现在,打开 SceneDelegate.swift,用下面的代码替换掉旧的属性 weeklyView

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

再次 Build,确保可以编译通过。

返回 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)
    }
  }
}

这段代码主要分为三个部分:

  1. 第一个绑定对象, $viewModel.city 在你在 TextField 中输入的值和 WeeklyWeatherViewModelcity 属性之间建立起绑定关系。使用 $ 可以将 city 属性转换为 Binding,这样做的前提是 WeeklyWeatherViewModel 遵守了 ObservableObject 协议,并且 city 已被 @ObservedObject 属性包装器所修饰。
  2. 使用各自的 ViewModel 初始化每日天气预报行,打开 DailyWeatherRow.swift 以查看其实现。
  3. 你依然可以像 Text(viewModel.city) 中一样使用 WeeklyWeatherViewModel 中的属性,而不涉及任何绑定。

Build and Run the app,你应该可以看到如下界面:


MVVM with Combine Tutorial for iOS_第7张图片
Simulator-Screen-Shot-iPhone-8-2019-07-06-at-17.36.58.png

然而,输入内容时界面却没有任何反应,这是由于我们还未将 city 与 真实的网络请求关联起来。

打开 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 请求在哪一个队列执行。
  2. 被属性包装器 @Published 修饰的 属性 city ,便具备了 Publisher 相同的功能。这意味着 city 既可以被观察,也可以使用 Publisher 的任何方法。
  3. 只要订阅了这个事件流, $city 就会发送它的第一个值。由于第一个值是一个空字符串,我们需要跳过它以避免发送一次无意义的网络请求。
  4. 使用 debounce(for:scheduler:) 来提供更好的用户体验。如果没有 debounce,每输入一个字母时, fetchWeathe 都将发起一个新的 HTTP 请求。debounce(for: .seconds(0.5)) 通过等待0.5秒,直到用户停止键入才会发送一个值。你可以在 RxMarbles 上找到该行为的可视化图解。你也可以传递一个 scheduler 参数,表示发出的任何值都将在该特定队列上。经验法则:你应该在后台队列中处理值,在主队列中交付值。
  5. 最后,通过 sink(receiveValue:) 方法来观察这些事件,并使用已实现的 fetchWeather(forCity:) 方法来处理它们。

编译并运行项目,效果如下:

MVVM with Combine Tutorial for iOS_第8张图片
weekly_forecast-1.gif

Navigation and Current Weather Screen

MVVM 作为一种架构模式,并未涉及到所有的实现细节,有些部分可由开发者自行决定。例如,如何从一个界面导航到另一个界面,以及哪个实体应承担此责任。SwiftUI 暗示了 NavigationLink 的用法,这也是本教程将使用的做法。

如果你查看 NavigationLink 最基础的初始化器: public init(destination: V, lael: () -> Label) where V: View, 你会发现,它期待一个 View 作为参数。这实质上是将您当前的视图 与 目标视图 联系了起来。在较简单的App中,这样做没有问题。但是当有复杂的流程需要根据外部逻辑(例如服务器响应)导航到不同的目标视图时,就行不通了。

按照MVVM的常规做法,View 应该询问 ViewModel 下一步该怎么做,但 NavigationLink 期望的参数是 View,这就难以处理了。而且 ViewModel 应该与这些导航问题无关才对。这个问题可以 FlowControllers 或 Coordinator 来解决,它们由另一个与 ViewModel 一起管理应用程序路由 的实体表示。这种方法可以很好的扩展,但是会阻止我们使用 NavigationLink 之类的方法。

但是,这并不在本教程的讨论范围之内,因此,我们暂时会使用一个实用的混合方法。

在进入导航之前,先更新 CurrentWeatherViewCurrentWeatherViewModel 。打开 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()

  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)
  }
}

CurrentWeatherViewModel 模仿前面在 WeekliyWeatherViewModel 中所做的工作:

  1. CurrentWeatherViewModel 遵守 ObservableObjectIdentifiable 协议。
  2. 暴露一个可选的 CurrentWeatherRowViewModel 作为数据源。
  3. 当收到 CurrentWeatherForecastResponse 值时,将其转换为 CurrentWeatherRowViewModel 格式。

现在,开始处理界面。打开 CurrentWeatherView.swift 并添加以下初始化代码:

@ObservedObject var viewModel: CurrentWeatherViewModel

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

这和在 WeeklyWeatherView 中的做法一样。您在自己的项目中很可能也会这样做:在 View 中注入 ViewModel 并访问其公共API。

现在,更新 body:

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

你会注意到 onAppear(perform:) 方法的使用,它具有 () -> Void 类型的参数,并在视图出现时执行。此时,你可以调用 viewModel.refresh 方法刷新数据源。

最后,在文件底部添加以下UI代码:

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)
  }
}

此时项目还不能编译通过,因为改变了 CurrentWeatherView 的初始化方法。

现在已完成了大部分的工作,是时候编写导航了。打开 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)
  }
}

该实体将 创建从 WeeklyWeatherView 导航时的目标界面。

打开 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.currentWeatherWeeklyWeatherView 询问 WeeklyWeatherViewModel 应该导航到下一个视图。 WeeklyWeatherViewModel 使用 WeeklyWeatherBuilder 提供必要的视图。职责之间有很好的隔离,同时使职责之间的整体关系易于遵循。

还有许多其他方法可以解决导航问题。一些开发人员会争辩说,视图层不应该知道要导航的位置,甚至不应该知道导航应该如何发生(模态或推送)。如果这是一个争论点,那么苹果提供的 NavigationLink 便不再有意义。在实用主义和可扩展性之间取得平衡非常重要,本教程倾向于前者。

至此,项目可以完整运行了,恭喜您创建了天气应用! :]

MVVM with Combine Tutorial for iOS_第9张图片
weather_final-1-1.gif

你可能感兴趣的:(MVVM with Combine Tutorial for iOS)