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

版本记录

版本号 时间
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作为架构模式的天气应用程序。 到最后,你会得到:

  • 使用Combine来管理状态。
  • 使用SwiftUIUIViewMode之间创建绑定。
  • 了解所有这三个概念如何结合在一起。

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

Combine框架详细解析(二) —— Combine 与MVVM(一)_第1张图片

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

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

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

Combine框架详细解析(二) —— Combine 与MVVM(一)_第2张图片

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

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

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

Combine框架详细解析(二) —— Combine 与MVVM(一)_第3张图片

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

Combine框架详细解析(二) —— Combine 与MVVM(一)_第4张图片

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


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模式的主要组成部分:

Combine框架详细解析(二) —— Combine 与MVVM(一)_第5张图片

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

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

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

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

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

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

  • 1) View具有对ViewModel的引用,但不是反之亦然。
  • 2) ViewModel具有对Model的引用,但不是反之亦然。
  • 3) View没有引用Model,反之亦然。

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

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

  • 1) Lightweight Views - 轻量级视图:所有UI逻辑都驻留在ViewModel中,从而产生非常轻量级的视图。
  • 2) Testing - 测试:您可以在不使用View的情况下运行整个应用程序,从而大大增强其可测试性。

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

此时,您可能已发现问题。如果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(_ 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()的更多信息。

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

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

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

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

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

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

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

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

// 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值中的错误。然后,将其类型擦除到AnyPublisher,因为这是方法的返回类型。
  • 2) 使用新的URLSession方法dataTaskPublisher(for :)来获取数据。此方法接受URLRequest的实例并返回元组(Data,URLResponse)URLError
  • 3) 因为该方法返回AnyPublisher ,所以将错误从URLError映射到WeatherError
  • 4) flatMap的用法值得拥有自己的post。在这里,您可以使用它将来自服务器的数据作为JSON转换为完全成熟的对象。您使用decode(_ :)作为辅助函数来实现此目的。由于您只对网络请求发出的第一个值感兴趣,因此设置.max(1)
  • 5) 如果不使用eraseToAnyPublisher(),则必须继承flatMap返回的完整类型:Publishers.FlatMap ,Publishers.MapError >。作为API的使用者,您不希望承担这些细节。因此,要改进API人体工程学,请将类型删除为AnyPublisher。这也很有用,因为添加任何新转换(例如filter)会更改返回的类型,从而泄漏实现细节。

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


Diving Into the ViewModels

接下来,您将使用为每周预测屏幕提供支持的ViewModel

Combine框架详细解析(二) —— Combine 与MVVM(一)_第6张图片

打开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()

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

这是代码的作用:

  • 1) 使WeeklyWeatherViewModel遵守ObservableObjectIdentifiable。遵守这些意味着WeeklyWeatherViewModel的属性可以用作绑定。到达View图层后,您将看到如何创建它们。
  • 2) 合适的代理@Published修饰符可以观察city属性。你会马上看到如何利用它。
  • 3) 您将View的数据源保留在ViewModel中。这与您在MVC中可能习惯的情况形成对比。由于该属性标记为@Published,因此编译器会自动为其synthesizes一个publisher。当您更改属性时,SwiftUI会订阅该publisher并重新绘制屏幕。
  • 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获取信息的新请求。将城市名称作为参数传递。
  • 2) 将响应(·WeeklyForecastResponse·对象)映射到DailyWeatherRowViewModel对象的数组。此实体表示列表中的单个行。您可以检查DailyWeatherRowViewModel.swift中的实现。使用MVVMViewModel层最重要的是向View公开它需要的数据。直接暴露给ViewWeeklyForecastResponse是没有意义的,因为这会强制View层格式化模型以便使用它。最好让View尽可能简单,只关注渲染。
  • 3) OpenWeatherMap API会根据一天中的时间返回同一天的多个温度,因此请删除重复项。你可以检查Array + Filtering.swift来看看它是如何完成的。
  • 4) 虽然从服务器获取数据或解析一小块JSON,但是在后台队列上发生,更新UI必须在主队列上进行。使用receive(on :),可以确保在步骤5,6和7中执行的更新发生在正确的位置。
  • 5) 通过sink(receiveCompletion:receiveValue :)启动publisher。这是您相应地更新dataSource的地方。重要的是要注意处理完成 - 无论是成功还是失败 - 都与处理值分开进行。
  • 6) 如果发生失败,请将dataSource设置为空数组。
  • 7) 新预测到达时更新dataSource
  • 8) 最后,将可取消的引用添加到disposables集中。如前所述,在不保持此引用存活的情况下,network publisher将立即终止。

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


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

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

  • 1) 你的第一个绑定! $ viewModel.city在您在TextFieldWeeklyWeatherViewModelcity属性中键入的值之间建立连接。 使用$可以将city属性转换为Binding 。 这是唯一可能的,因为WeeklyWeatherViewModel符合ObservableObject并使用@ObservedObject属性包装器声明。
  • 2) 使用自己的ViewModel初始化每日天气预报行。 打开DailyWeatherRow.swift看看它是如何工作的。
  • 3) 您仍然可以使用和访问WeeklyWeatherViewModel属性,而无需任何花哨的绑定。 这只是在Text中显示城市名称。

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

Combine框架详细解析(二) —— Combine 与MVVM(一)_第7张图片

令人惊讶的是,没有任何反应。 原因是您尚未将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。

  • 1) 添加scheduler参数,以便指定HTTP请求将使用的队列。
  • 2) city属性使用@Published属性代理,因此它的行为与任何其他Publisher一样。这意味着可以观察它,也可以使用Publisher可用的任何其他方法。
  • 3) 创建观察后,$ city会发出第一个值。由于第一个值是空字符串,因此您需要跳过它以避免意外的网络调用。
  • 4) 使用debounce(for:scheduler :)来提供更好的用户体验。如果没有它,fetchWeather会为每个输入的字母发出一个新的HTTP请求。 debounce通过等待半秒(0.5)来工作,直到用户停止输入并最终发送一个值。您可以在RxMarbles中找到这种行为的精彩可视化。您还将scheduler作为参数传递,这意味着发出的任何值都将在该特定队列上。经验法则:您应该在后台队列上处理值并将它们传递到主队列。
  • 5) 最后,您通过sink(receiveValue :)观察这些事件,并使用之前实现的fetchWeather(forCity :)处理它们。

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

Combine框架详细解析(二) —— Combine 与MVVM(一)_第8张图片

Navigation and Current Weather Screen

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

如果你看一下NavigationLink最基本的初始化程序:public init (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()

  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中执行的操作:

  • 1) 使CurrentWeatherViewModel符合ObservableObjectIdentifiable
  • 2) 公开可选的CurrentWeatherRowViewModel作为数据源。
  • 3) 将新值转换为CurrentWeatherRowViewModel,因为它们以CurrentWeatherForecastResponse的形式出现。

现在,请关注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就不再有意义了。 在实用主义和可扩展性之间取得平衡非常重要。 本教程倾向于前者。

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

Combine框架详细解析(二) —— Combine 与MVVM(一)_第9张图片

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

后记

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

Combine框架详细解析(二) —— Combine 与MVVM(一)_第10张图片

你可能感兴趣的:(Combine框架详细解析(二) —— Combine 与MVVM(一))