iOS开发攻城狮的集散地Swift学习

Combine框架详细解析(二) —— Combine 与MVV

2019-09-16  本文已影响0人  刀客传奇

版本记录

版本号 时间
V1.0 2019.09.16 星期一

前言

最近苹果多了一个框架Combine,这里我们就一起来看一下这个框架。感兴趣的可以看下面几篇文章。
1. Combine框架详细解析(一) —— 基本概览(一)

开始

首先看一下主要内容

学习如何开始使用Combine框架和SwiftUI来使用MVVM模式构建应用程序

下面看下写作环境

Swift 5, iOS 13, Xcode 11

本教程需要macOS Catalina beta 6或更高版本以及Xcode 11 beta 6或更高版本。

Apple的最新框架CombineSwiftUI一起,风靡WWDCCombine是一个框架,它提供逻辑数据流,可以发出值,然后可选地以成功或错误结束。 这些流是Functional Reactive Programming (FRP)的核心,近年来它已经变得流行。 很明显,Apple正在向前发展,不仅使用SwiftUI创建接口的声明方式,而且还使用Combine来管理状态。 在这个MVVM with Combine教程中,您将创建一个利用SwiftUICombineMVVM作为架构模式的天气应用程序。 到最后,你会得到:

在本教程结束时,您的应用应如下所示:

您还将探索这种特定方法的优缺点,以及如何以不同方式解决问题。 这样你就可以为自己的方式做好准备!

打开位于CombineWeatherApp-Starter文件夹内的项目。

在您看到任何天气信息之前,您必须在OpenWeatherMap注册并获取API密钥。 这个过程不应该花费你几分钟,最后,你会看到一个类似于这个的页面:

打开WeatherFetcher.swift。 然后使用结构OpenWeatherAPI中的键更新WeatherFetcher.OpenWeatherAPI,如下所示:

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

完成后,构建并运行项目。 主屏幕显示一个按钮:

点击Best weather app将显示更多详细信息:

现在它看起来并不像,但是在本教程结束时,它看起来会好很多。


An Introduction to the MVVM Pattern

Model-View-ViewModel(MVVM)模式是一种UI设计模式。 它是更大的模式系列的成员,统称为MV *,包括 Model View Controller(MVC),Model View Presenter(MVP)和许多其他模式。

这些模式中的每一个都将UI逻辑与业务逻辑分开,以便使应用程序更易于开发和测试。

MVC是第一个UI设计模式,其起源可追溯到20世纪70年代的Smalltalk语言 Smalltalk language of the 1970s。 下图说明了MVC模式的主要组成部分:

此模式将UI分为表示应用程序状态的Model,由UI控件组成的View和处理用户交互并相应更新模型的Controller

MVC模式的一个大问题是它非常令人困惑。 概念看起来很好,但是当人们开始实现MVC时,上面所示的看似循环的关系会导致模型,视图和控制器成为一个巨大而可怕的混乱。

最近,Martin Fowler介绍了一种名为Presentation ModelMVC模式的变体,它被微软以MVVM的名义采用并推广。

此模式的核心是ViewModel,它是一种特殊类型的模型,表示应用程序的UI状态。它包含详细说明每个UI控件状态的属性。例如,text field的当前文本,或者是否启用了特定按钮。它还公开了视图可以执行的操作,例如按钮点击或手势。

ViewModel视为model-of-the-view可能会有所帮助。

遵循以下严格规则,MVVM模式的三个组件之间的关系比对应的MVC更简单:

如果你打破这些规则,那你就错误地做了MVVM

这种模式的几个直接优势是:

注意:测试视图是非常困难的,因为测试运行的是小的,包含的代码块。通常,控制器会向依赖于其他应用状态的场景添加和配置视图。这意味着运行小型测试可能会成为一个脆弱而繁琐的主张。

此时,您可能已发现问题。如果View具有对ViewModel的引用但不是反之亦然,ViewModel如何更新View

啊,哈!这就是MVVM模式的秘密来源。


MVVM and Data Binding

Data Binding - 数据绑定允许您将View连接到其ViewModel。 在今年的WWDC之前,你将不得不使用类似于RxSwift(通过RxCocoa)或ReactiveSwift(通过ReactiveCocoa)的东西。 在本教程中,您将探索如何使用SwiftUICombine实现此连接。

1. MVVM With Combine

实际上并不需要Combine绑定,但这并不意味着你无法利用它的力量。 您可以单独使用SwiftUI来创建绑定。 但使用Combine可以提供更多能力。 正如您在整个教程中所看到的,一旦您在ViewModel端,使用Combine成为自然选择。 它允许您清晰地定义从UI开始到网络调用的链。 通过组合SwiftUICombine,您可以轻松实现所有这些功能。 可以使用另一种通信模式(例如代理),但通过这样做,您将交换SwiftUI设置的声明性方法及其绑定,用于命令式(imperative)方法。


Building the App

注意:如果您是SwiftUICombine之类的新手,您可能会对某些代码段感到困惑。 不要担心,如果是这样的话! 这是一个高级主题,需要一些时间和实践。 如果某些地方不是很清楚,请运行应用程序并设置断点以查看其行为方式。

您将从模型层开始向上移动到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()
}

这使用标准的JSONDecoder来解码OpenWeatherMap API中的JSON。 您将很快找到有关mapError(_ :)eraseToAnyPublisher()的更多信息。

注意:您可以手动编写解码逻辑,也可以使用QuickType等服务。 根据经验,对于我拥有的服务,我是手工完成的。 对于第三方服务,我使用QuickType生成样板。 在此项目中,您将在Responses.swift中找到使用此服务生成的实体。

现在打开WeatherFetcher.swift。 该实体负责从OpenWeatherMap API获取信息,解析数据并将其提供给其使用者。

像一个好的Swift开发人员,你将从一个协议开始。 在导入下面添加以下内容:

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

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

您将使用第一个屏幕的第一种方法显示接下来五天的天气预报。 您将使用第二个来查看更详细的天气信息。

您可能想知道AnyPublisher是什么以及为什么它有两个类型参数。 你可以把它想象成一个计算,或者一旦你订阅它就会执行的东西。 第一个参数(WeeklyForecastResponse)引用它在计算成功时返回的类型,并且正如您可能已经猜到的那样,第二个参数指的是如果失败的类型(WeatherError)

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

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

这是这样做的:

在模型级别,您应该拥有所需的一切。构建应用程序以确保一切正常。


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

这是代码的作用:

现在,通过在初始化程序下面添加以下内容来使用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)
}

这里做了很多事,但我保证在此之后,一切都会变得更容易!

构建应用程序。一切都应该编译!现在,应用程序仍然没有做太多,因为你没有视图,所以是时候考虑它了!


Weekly Weather View

首先打开WeeklyWeatherView.swift。然后,在struct中添加viewModel属性和初始化程序:

@ObservedObject var viewModel: WeeklyWeatherViewModel

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

@ObservedObject属性代理在WeeklyWeatherViewWeeklyWeatherViewModel之间建立连接。 这意味着,当WeeklyWeatherView的属性objectWillChange发送一个值时,将通知视图数据源即将更改,从而重新呈现视图。

现在打开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)
    }
  }
}

虽然这里有相当多的代码,但只有三个主要部分:

构建并运行应用程序,您应该看到以下内容:

令人惊讶的是,没有任何反应。 原因是您尚未将city绑定连接到实际的HTTP请求。 是时候解决这个问题。

打开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。

构建并运行项目。您最终应该看到主屏幕在运行:


Navigation and Current Weather Screen

MVVM作为一种架构模式并没有深入到细节之中。有些决定由开发人员自行决定。其中之一就是您如何从一个屏幕导航到另一个屏幕,以及哪个实体拥有该责任。SwiftUI暗示了NavigationLink的用法,因此,这就是您将在本教程中使用的内容。

如果你看一下NavigationLink最基本的初始化程序:public init <V>(destination:V,label :() - > Label)其中V:View,你可以看到它希望View作为参数。这实际上将您当前的视图(源)与另一个视图(目标)联系起来。这种关系在更简单的应用程序中可能没问题但是当您有复杂的流程需要基于外部逻辑的不同目标(如服务器响应)时,您可能会遇到麻烦。

遵循MVVM配方,View应该向ViewModel询问接下来要做什么,但这很棘手,因为期望的参数是ViewViewModel应该与这些关注点无关。此问题通过FlowControllersCoordinator解决,FlowControllersCoordinator由另一个与ViewModel一起工作的实体表示,以管理跨app的路由。这种方法可以很好地扩展,但它会阻止你使用像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<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)
  }
}

CurrentWeatherViewModel模仿您之前在WeeklyWeatherViewModel中执行的操作:

现在,请关注UI。 打开CurrentWeatherView.swift并在struct顶部添加初始化程序:

@ObservedObject var viewModel: CurrentWeatherViewModel

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

这遵循您在WeeklyWeatherView中应用的相同模式,并且很可能是您在自己的项目中使用SwiftUI时将要执行的操作:在View中注入ViewModel并访问其公共API

现在,更新body计算属性:

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初始化程序。

现在您已经拥有了大部分内容,现在是时候完成导航了。 打开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并通过在文件底部添加以下内容来开始使用构建器:

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.currentWeatherViewWeeklyWeatherView要求WeeklyWeatherViewModel查看它应该导航到下一个。 WeeklyWeatherViewModel使用WeeklyWeatherBuilder提供必要的视图。 责任之间存在很好的分离,同时保持它们之间的整体关系易于遵循。

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

构建并运行项目。 一切都应该按预期工作! 恭喜您创建天气应用程序!

MVVMCombineSwift的本教程中,你一定学到了很多。 重要的是要提到这些主题中的每一个都需要自己的教程,今天的目标是让您了解并开始了解iOS开发的未来。

后记

本篇主要讲述了CombineMVVM,感兴趣的给个赞或者关注~~~

上一篇 下一篇

猜你喜欢

热点阅读