分享:现代MVVM iOS 应用程序架构

722 阅读10分钟

原文:《Modern MVVM iOS App Architecture with Combine and SwiftUI

iOS 开发人员社区调查显示,MVVM 是设计 iOS 应用的第二大最流行的架构模式。它为使用 SwiftUI 和 Combine 研究现代 MVVM 的状态提供了一个很好的理由。

在本文中,我们将介绍:

  • MVVM 的目的。
  • MVVM 模式的组件。
  • MVVM 中的数据流和依赖项。
  • 为什么我们应该使用单向数据流而不是双向绑定?
  • 如何将 UI 表示为有限状态机?

并使用 MVVM 架构模式、Combine 和 SwiftUI 框架构建 iOS 应用程序。

历史

MVVM起源于1988年在Smalltalk工程领域发明的应用程序模型模式。该模式的主要目标是将两种逻辑(表示法和业务逻辑)拆分为两个单独的对象:分别是应用程序模型域模型

2004年,Martin Fowler将Application Model更名为Presentation Model(PM)。 PM 的想法是创建一个与 UI 无关的对象表示模型,该模型从视图中提取所有状态和行为。这样,视图仅将表示模型的状态投影到屏幕上。

Microsoft 于 2006 年推出了 MVVM,用于使用 Windows Presentation Foundation (WPF) UI 框架设计和实现桌面客户端应用程序。借助 MVVM,Microsoft 追求了标准化 WPF 应用程序开发方式的目标。该模式旨在利用 WPF 框架的强大功能,例如数据绑定。

根据Microsoft的说法,MVVM是Fowler的演示模型的一个专用版本。马丁·福勒甚至声称他们是一样的

MVVM 的目的

MVVM 的目标是将业务和表示逻辑与 UI 分离。它提高了可测试性和可维护性,这通常是应用程序成功的关键因素。

为了实现其目标,MVVM 将视图中的决策设置降至最低,并将视图状态和行为移动到视图模型中。这样,视图将变为被动:

  • 视图不会从视图模型中提取数据。
  • 视图不负责从视图模型更新自身。
  • 视图的状态由视图模型管理。

这样的设计允许我们在独立于 GUI 堆栈的情况下测试表示逻辑。

MVVM 模式

MVVM 是 UI 模式。与最丰富的客户端系统一样,这是iOS应用程序代码库的很大一部分所在位置。SwiftUI 视图、UIKit 视图和视图控制器、情节提要和 xib 都属于这里。

MVVM 提供了一组以下方面的准则:

  • 如何在 UI 上显示信息。
  • 如何处理用户与应用之间的交互。
  • 如何将用户输入解释为对业务规则和数据执行的操作。

MVVM 可以分解为遵循严格依赖规则的三个组件:

image.png

依赖项按以下方式组织:

  • 视图取决于视图模型。
  • 视图模型取决于模型。
  • 模型和视图模型都不依赖于视图。

取决于均值代码依赖关系,如导入、引用、函数调用。

请注意,与依赖项的流相比,数据流是不同的:

image.png

也就是说,数据在两个方向上流动。它从用户交互开始,该交互由视图处理。接下来,视图将交互事件传递给视图模型。然后,视图模型将事件转换为对模型和数据的 CRUD(创建、读取、更新和删除)操作。

反之亦然。该模型从后端、数据库或任何其他源获取数据。接下来,模型将数据传递到视图模型。然后,视图模型以便于视图使用的形式准备数据。最后,视图将数据呈现到屏幕上。

现在,让我们来了解 MVVM 组件的角色。

视图模型

ViewModel 表示应在视图中显示的数据,并包含表示逻辑。

ViewModel 的职责是:

  • 管理 UI 行为和状态。
  • 将用户输入解释为对业务规则和数据执行的操作。通常,视图模型与模型对象保持一对多关系。
  • 准备模型中要呈现给用户的数据。视图模型以方便视图使用的方式构造数据。

Viewmodel 独立于 ui 框架。如果要在 viewmodel.swift 文件中导入 swiftui 或 uikit,请三思而后行。

视图

视图呈现 UI 并将用户交互向前传递。它没有状态,也不包含任何解释用户操作的代码隐藏。

视图的职责是:

  • 呈现 UI。
  • 执行动画。
  • 将用户交互传递到视图模型。

模型是业务概念的软件表示,这些概念可以赚钱或为您的客户带来任何其他价值。这是实际编写iOS应用程序的主要原因。

尽管 MVVM 将模型作为其名称的一部分,但 MVVM 不会对其实现做出任何假设。它可以是Redux或Clean Architecture的变体,如VIPER。

MVVM 的现代状态

以下技术塑造了我认为是 Swift 中 MVVM 的现代状态。

FRP和数据绑定

首先,使 MVVM 可行的最重要的一个方面是数据绑定。

数据绑定是一种将数据提供程序与使用者连接并同步它们的技术。

使用数据绑定技术,我们可以创建随时间变化的值流。函数式反应式编程(FRP) 是一种与数据流和变化传播有关的编程范式。在FRP中,价值观流是一等公民。这意味着我们可以在运行时构建它们,传递并存储在变量中。

Combine 和 SwiftUI 框架提供了第一方 FRP 支持,这使我们能够在视图中无缝地反映视图模型更改,并且无需在直接更新视图的视图模型中编写代码。

双向绑定上的单向数据流

MVVM 模式的许多应用程序都使用双向绑定将视图与视图模型同步。对这种方法的解释通常伴随着一个计数器应用程序的示例。尽管它适用于两个数据流(计数器增量和计数器递减),但双向绑定方法在应用于类似生产的功能时不能很好地扩展。

让我们通过示例来演示这些问题。下面是一个只有四个状态的注册屏幕:

image.png

如果我们希望使用MVVM实现它,并使用双向绑定连接视图和视图模型,它可能会如下所示:

image.png

每个箭头都表示一个值流。这些流也相互连接:

image.png

该图仍然缺少一些细节。通常,在生产应用中,您将发送网络请求,并允许用户使用身份提供商(如 Google 或 Facebook)登录。双向绑定方法很快就会失控,最终结果是这样的:

image.png

双向绑定的第二个问题是错误处理。开箱即用的Combine框架不提供永无止境的价值流的概念,就像RxSwift Relay一样。因此,当发生错误时,它将终止整个流,并可能使应用的 UI 无响应。尽管您可以在PublishSubjectCurrentValueSubject之上的 Combine 中重新创建中继,但由于下一节中解释的原因,它可能不是正确的方法。

作为状态机的 UI

从双向绑定方法中掉落的另一个重要问题是状态爆炸

什么是状态?对象的状态是指其字段中所有值的组合。因此,UI 状态的组合数随着阶乘复杂性而增长。但是,大多数此类状态都是不需要的,甚至是退化的

例如,让我们以注册示例中的isSigningUperrorMessage流为例。在isSigningUp发送trueerrorMessage发送非nil值的情况下,如何呈现UI还不清楚。我们应该显示装载指示器吗?还是带有错误消息的警报框?或者两者都有?

出现更多意外状态的问题:

  • 他们创建了许多很难详尽测试的代码路径。
  • 添加新状态的复杂性不断累积。

解决方案是找出所有可能的状态以及触发状态转换的所有可能操作,并使其显式化。有限状态机(FSM) 是将这一想法形式化的计算模型。

在任何给定时间 ,FSM可以恰好处于有限数量的状态之一。它可以从一种状态更改为另一种状态以响应外部输入;这种变化被称为过渡[1]。

UI FSM 将管理视图的状态,并通过状态转换函数处理用户输入,该函数可能包含其他副作用。状态机完全定义为其[2]:

  • 输入集。
  • 输出集。
  • 状态集。
  • 初始状态。
  • 状态转换功能。
  • 输出功能。

在本文中,我们将使用CombineFeedback库,该库适合于设计反应状态机。使用 CombineFeedback,接下来将介绍应用组件的结构:

image.png

我们来描述一下上图的核心组件。

状态表示有限状态机的状态。

事件描述系统中发生的情况。

Reduce指定状态如何响应事件而变化。

反馈是生成事件的代码与将 事件还原为新状态的代码之间的扩展点。你所有的副作用都会坐在这里。反馈允许我们将副作用与状态机本身的纯结构分开(请参阅绿色)。

ViewModel完全初始化 UI 状态机。

要设置状态机,我们需要system运算符和Feedback类型。system运算符创建一个反馈循环并引导所有依赖项:

下面的代码段基于RxFeedback 的实现。

extension Publishers {
    
    static func system<State, Event, Scheduler: Combine.Scheduler>(
        initial: State,
        reduce: @escaping (State, Event) -> State,
        scheduler: Scheduler,
        feedbacks: [Feedback<State, Event>]
    ) -> AnyPublisher<State, Never> {
        
        let state = CurrentValueSubject<State, Never>(initial)
        
        let events = feedbacks.map { feedback in feedback.run(state.eraseToAnyPublisher()) }
        
        return Deferred {
            Publishers.MergeMany(events)
                .receive(on: scheduler)
                .scan(initial, reduce)
                .handleEvents(receiveOutput: state.send)
                .receive(on: scheduler)
                .prepend(initial)
                .eraseToAnyPublisher()
        }
        .eraseToAnyPublisher()
    }
}

Feedback生成响应状态更改的事件流。它允许我们在发送事件的时刻和到达reduce函数的时刻之间执行副作用,例如IO:

下面的代码段基于CombineFeedback的实现。

struct Feedback<State, Event> {
    let run: (AnyPublisher<State, Never>) -> AnyPublisher<Event, Never>
}

extension Feedback {
    init<Effect: Publisher>(effects: @escaping (State) -> Effect) where Effect.Output == Event, Effect.Failure == Never {
        self.run = { state -> AnyPublisher<Event, Never> in
            state
                .map { effects($0) }
                .switchToLatest()
                .eraseToAnyPublisher()
        }
    }
}

最后,让我们来看看如何将所有内容放在一起。

使用 MVVM 构建应用

要遵循代码示例,您需要一些 SwiftUI 和 Combine 框架的基本知识。CombineApple SwiftUI 教程入门将帮助你快速上手。

让我们通过从头开始构建电影应用程序来探索 MVVM iOS 应用程序体系结构。以下是最终结果的外观:

image.png

实现影片列表视图模型

该应用程序有两个屏幕:

  • 热门电影列表。
  • 电影的详细信息。

我们从电影列表开始。在编写任何代码之前,我们必须设计状态机:

image.png

根据该图,在代码中表示状态和事件的列表是微不足道的。我更喜欢将它们声明为视图模型的内部类型:

final class MoviesListViewModel: ObservableObject {
    @Published private(set) var state = State.idle

    ...
}

extension MoviesListViewModel {
    enum State {
        case idle
        case loading
        case loaded([ListItem])
        case error(Error)
    }

    enum Event {
        case onAppear
        case onSelectMovie(Int)
        case onMoviesLoaded([ListItem])
        case onFailedToLoadMovies(Error)
    }
}

请注意,MoviesListViewModel实现了ObserveObject协议。这允许我们将视图绑定到视图模型。每当视图模型更新其状态时,SwiftUI将自动更新视图。

某些状态具有关联的值,以便在 UI 上绘制它们或传递到下一个状态。类似地,事件携带数据,这是我们在reduce()函数内生成新状态时的唯一信息源。

现在,我们可以实现一个定义所有可能的状态到状态转换的reduce()函数:

extension MoviesListViewModel {
    static func reduce(_ state: State, _ event: Event) -> State {
        switch state {
        case .idle:
            switch event {
            case .onAppear:
                return .loading
            default:
                return state
            }
        case .loading:
            switch event {
            case .onFailedToLoadMovies(let error):
                return .error(error)
            case .onMoviesLoaded(let movies):
                return .loaded(movies)
            default:
                return state
            }
        case .loaded:
            return state
        case .error:
            return state
        }
    }
}

在状态机图上,您可以看到除onSelectMovie之外的所有事件。这是因为onSelectMovie是用户与应用程序交互后发送的。用户输入是一个副作用,需要在反馈中处理:

extension MoviesListViewModel {
    static func userInput(input: AnyPublisher<Event, Never>) -> Feedback<State, Event> {
        Feedback { _ in input }
    }
}

然后,我们使用system运算符初始化状态机:

final class MoviesListViewModel: ObservableObject {
    @Published private(set) var state = State.idle
    private var bag = Set<AnyCancellable>()
    private let input = PassthroughSubject<Event, Never>()
    
    init() {
        // 1.
        Publishers.system(
            initial: state,
            reduce: Self.reduce,
            scheduler: RunLoop.main,
            feedbacks: [
                // 2.
                Self.whenLoading(),
                Self.userInput(input: input.eraseToAnyPublisher())
            ]
        )
        .assign(to: .state, on: self)
        .store(in: &bag)
    }
    
    deinit {
        bag.removeAll()
    }
    
    // 3.
    func send(event: Event) {
        input.send(event)
    }
}

要点是:

  1. MoviesListViewModel是功能的入口点。它连接所有依赖项并启动状态机。
  2. whenLoading()反馈处理网络。我们稍后将实现它。
  3. send()方法提供了一种传递用户输入和查看生命周期事件的方法。使用input主题,我们将事件传播到反馈循环中进行处理。

缺少的两个部分是whenLoading()反馈和ListItem类型。它们都与从网络加载电影有关。

当系统进入loading状态时,我们发起网络请求:

static func whenLoading() -> Feedback<State, Event> {
  Feedback { (state: State) -> AnyPublisher<Event, Never> in
      // 1.
      guard case .loading = state else { return Empty().eraseToAnyPublisher() }
      
      // 2.
      return MoviesAPI.trending()
          .map { $0.results.map(ListItem.init) }
          // 3.
          .map(Event.onMoviesLoaded)
          // 4.
          .catch { Just(Event.onFailedToLoadMovies($0)) }
          .eraseToAnyPublisher()
  }
}

以下是我们正在做的事情:

  1. 检查系统当前是否处于loading状态。
  2. 触发网络请求。
  3. 如果请求成功,反馈将发送一个包含电影列表的onMoviesLoaded事件。
  4. 如果发生故障,反馈将发送一个包含错误的onFailedToLoadMovies事件。

网络客户端与TMDB API 通信,以获取热门影片。我跳过了一些实现细节,以保持对主要主题的关注:

您可以在此处了解如何使用 Combine 构建基于承诺的网络层

enum MoviesAPI {
    static func trending() -> AnyPublisher<PageDTO<MovieDTO>, Error> {
        let request = URLComponents(url: base.appendingPathComponent("trending/movie/week"), resolvingAgainstBaseURL: true)?
            .addingApiKey(apiKey)
            .request
        return agent.run(request!)
    }
}

列表条目用对象表示:

struct MovieDTO: Codable {
    let id: Int
    let title: String
    let poster_path: String?
    
    var poster: URL? { ... }
}

DTO 后缀表示我们正在使用域转移对象模式

ListItemMovieDTO的映射,用于演示:

extension MoviesListViewModel {
    struct ListItem: Identifiable {
        let id: Int
        let title: String
        let poster: URL?
        
        init(movie: MovieDTO) {
            id = movie.id
            title = movie.title
            poster = movie.poster
        }
    }
}

实现影片列表视图

设计视图模型后,现在我们可以从视图的实现开始。

首先,通过@ObservedObject属性包装将视图绑定到视图模型状态更新:

struct MoviesListView: View {
    @ObservedObject var viewModel: MoviesListViewModel

    var body: some View {
        ...
    }
}

接下来,在body中,我们要向视图模型发送一个生命周期事件:

struct MoviesListView: View {
    ...
        
    var body: some View {
        NavigationView {
            content
                .navigationBarTitle("Trending Movies")
        }
        .onAppear { self.viewModel.send(event: .onAppear) }
    }
    
    private var content: some View {
        ...
    }
}

状态呈现发生在content变量中:

struct MoviesListView: View {
    ...
    
    private var content: some View {
        switch viewModel.state {
        case .idle:
            return Color.clear.eraseToAnyView()
        case .loading:
            return Spinner(isAnimating: true, style: .large).eraseToAnyView()
        case .error(let error):
            return Text(error.localizedDescription).eraseToAnyView()
        case .loaded(let movies):
            return list(of: movies).eraseToAnyView()
        }
    }
    
    private func list(of movies: [MoviesListViewModel.ListItem]) -> some View {
        ...
    }
}

以下是list(of:)方法的实现方式:

private func list(of movies: [MoviesListViewModel.ListItem]) -> some View {
    return List(movies) { movie in
        NavigationLink(
            destination: MovieDetailView(viewModel: MovieDetailViewModel(movieID: movie.id)),
            label: { MovieListItemView(movie: movie) }
        )
    }
}

MovieDetailView表示影片的详细信息。如果用户点击列表行,则该行将被推送到导航堆栈上。 MovieDetailView使用视图模型进行初始化,而视图模型又接受电影标识符。

MovieListItemView表示列表行。请注意,它接受MoviesListViewModel.ListItem类型的视图模型,而不是MovieDTO。重要的是不要将基础结构详细信息(即MovieDTO)与表示(即视图模型)混合在一起。我跳过MovieListItemView的实现,因为它与我们的主题没有直接关系。

实现影片详细信息

与热门电影列表相比,电影详细信息状态机是相同的:

image.png

以下是我们如何在代码中表示电影详细信息状态机:

final class MovieDetailViewModel: ObservableObject {
    @Published private(set) var state: State

    ...
}

extension MovieDetailViewModel {
    enum State {
        case idle(Int)
        case loading(Int)
        case loaded(MovieDetail)
        case error(Error)
    }
    
    enum Event {
        case onAppear
        case onLoaded(MovieDetail)
        case onFailedToLoad(Error)
    }

    struct MovieDetail {
        ...
    }
}

为了传递用户事件,我们会创建一个userInput反馈:

final class MovieDetailViewModel: ObservableObject {
    ...

    private let input = PassthroughSubject<Event, Never>()

    func send(event: Event) {
        input.send(event)
    }
    
    static func userInput(input: AnyPublisher<Event, Never>) -> Feedback<State, Event> {
        Feedback(run: { _ in
            return input
        })
    }
}

接下来,我们声明另一个触发网络请求的反馈:

static func whenLoading() -> Feedback<State, Event> {
    Feedback { (state: State) -> AnyPublisher<Event, Never> in
        guard case .loading(let id) = state else { return Empty().eraseToAnyPublisher() }
        return MoviesAPI.movieDetail(id: id)
            .map(MovieDetail.init)
            .map(Event.onLoaded)
            .catch { Just(Event.onFailedToLoad($0)) }
            .eraseToAnyPublisher()
    }
}

它从MoviesAPI中调用movieDetail(),以获取电影详细信息提供的电影标识符:

enum MoviesAPI {
    ...
    
    static func movieDetail(id: Int) -> AnyPublisher<MovieDetailDTO, Error> {
        let request = URLComponents(url: base.appendingPathComponent("movie/(id)"), resolvingAgainstBaseURL: true)?
            .addingApiKey(apiKey)
            .request
        return agent.run(request!)
    }
}

struct MovieDetailDTO: Codable {
    let id: Int
    let title: String
    let overview: String?
    let poster_path: String?
    let vote_average: Double?
    let genres: [GenreDTO]
    let release_date: String?
    let runtime: Int?
    let spoken_languages: [LanguageDTO]
    ...
}

MovieDetailDTO是为分析网络响应而创建的。它不应该泄漏到 UI 层中。当视图模型收到成功的网络响应时,它会将MovieDetailDTO映射到MovieDetailViewModel。后者是便于视图使用的相同数据的表示形式。

然后我们初始化状态机:

final class MovieDetailViewModel: ObservableObject {
    @Published private(set) var state: State
    private var bag = Set<AnyCancellable>()
        
    init(movieID: Int) {
        state = .idle(movieID)
        
        Publishers.system(
            initial: state,
            reduce: Self.reduce,
            scheduler: RunLoop.main,
            feedbacks: [
                Self.whenLoading(),
                Self.userInput(input: input.eraseToAnyPublisher())
            ]
        )
        .assign(to: .state, on: self)
        .store(in: &bag)
    }

    ...
}

现在我们可以实现一个视图:

struct MovieDetailView: View {
    @ObservedObject var viewModel: MovieDetailViewModel

    var body: some View {
        content
            .onAppear { self.viewModel.send(event: .onAppear) }
    }
    
    private var content: some View {
        switch viewModel.state {
        case .idle:
            return Color.clear.eraseToAnyView()
        case .loading:
            return spinner.eraseToAnyView()
        case .error(let error):
            return Text(error.localizedDescription).eraseToAnyView()
        case .loaded(let movie):
            return self.movie(movie).eraseToAnyView()
        }
    }
    
    private func movie(_ movie: MovieDetailViewModel.MovieDetail) -> some View {
        ...
    }
}

源码

您可以在此处找到最终项目

推荐

iOS技术资料|地址;感谢您的阅读!然后如果有错误的地方,也请在评论区指出,帮助我把错的地方订正回来!