1. iOS应用的蓝图

1.1. 我们到底为什么需要设计模式?

在构建iOS应用时,你需要考虑许多任务。
最常见的,你可以在任何应用中找到的是:

  • Representing data - 数据展示
  • Building the user interface - 构建用户界面
  • Implementing the app's logic - 实现应用程序的逻辑
  • Storing data on the disk - 在磁盘上存储数据
  • Connecting to the network - 连接网络
  • Reading data from the device sensors - 从设备传感器读取数据

我最喜欢的简单易懂的比喻是盖房子。虽说有许多风格你可以遵循,所有的房子都有相同的结构ture:基础,墙壁,门,窗户,和一个屋顶。不管房子是昨天建的或者两千年前建的都没有关系。它的蓝图是保持一样的,因为这是唯一说得通的。如果你偏离, 你的房子会倒塌的。

这同样适用于软件开发。你不能以任何你想要的方式组织你的代码。只有一组预先设计好的蓝图才能做到有意义的。

有些开发者喜欢说你的应用的架构取决于细节。 我不同意。当然,你有一些灵活性,但如果你走得太远,你的房子就会在它的重压下倒塌(好比承重墙承担的重量超过了设计的标准)。

在软件开发中,这些蓝图称为设计模式。他们的出现是因为开发人员开始使用标准的图表为任何软件工作。他们已经为你做了,所以你不需要经历同样的尝试和错误的过程。

有许多体系结构设计模式告诉您如何构建构建你的应用,都有不同的且深奥的名字。遵循任何模式 总比没有任何模式好。但是,如果你仔细的看看它们,你会发现它们都有相同的结构,就像房子一样。

这不是巧合。最流行的设计模式就是MVC。以及一些从MVC衍生出的,包括MVVM, MVA, MVP,设置VIPER。

1.2. 香草MVC模式和它的iOS进化

MVC模式将任何应用程序的结构划分为三层, 每一个都有其明确的职责:

  • The model layer represents the data of the app and encapsulates
    the domain business logic. - 模型层表示应用程序的数据并进行封装部分业务逻辑。
  • The view layer is the user interface of the app. It shows data to
    the user and allows interaction. - 视图层是应用程序的用户界面。它显示数据给用户并允许交互。
  • Finally, the controller layer acts as a bridge between the other two
    layers and implements the app’s business logic. - 最后,控制器层作为其他两个层之间的桥梁层和实现应用程序的业务逻辑。
avatar

为了避免大型复杂的代码,并保持我们的应用程序的良好结构,我们可以这样做 - 添加一个额外的层来承担这些责任。 在UIKit中,那是view controllers的角色。在SwiftUI中,类似的代码角色消失了,所以我们需要一个类似的层。我把它命名为 root layer。

avatar

1.3. 关于MVVM的一些话

avatar

在继续如果通过MVC蓝图来构建SiwftUI应用之前,必须就MVVM模式多说几句,并解决一些误解。

简而言之:MVC和MVVM实际上一直是相同的 设计模式,有一些细微的差别。在SwiftUI中这些已不复存在

这就是你需要知道的,所以你可以跳过这一部分。

很容易看清楚MVVM的view model layer 就是 MVC的 controller layer。 唯一的区别是视图和视图模型层通过绑定器连接声明性数据绑定技术,而不是标准编程在MVC中这样的实践。

这是,或者更好,唯一的区别。在UIKit中,MVC应用使用视图控制器的生命周期事件,委托和回调确保视图和控制器层之间传输数据。MVVM相反,它依赖于开源的事件驱动的函数响应式编程框架,如RxSwift。

这种差异在SwiftUI中消失了。传输数据的唯一方式视图层是通过使用带有@Published属性的可观察对象来实现的。SwiftUI视图使用@ObservedObject和@EnvironmentObject属性包装器连接到这些对象。

这是一个事件驱动的机制。每次发布的属性更新后,用户界面自动刷新,删除了MVC和MVVM模式之间的唯一区别。

UIKit应用程序是由视图控制器管理的,这是框架中不可避免的一部分。因此,MVVM的支持者们大行横道
把视图控制器放在地毯下面,把它们放在视图层里假装他们不存在。

avatar

2. 管理模型类型中的数据和业务逻辑

现在我们将通过一个具体的例子来展示如何使用MVC在实际应用中的抽象概念。我们将看到一个简单的银行应用程序,管理多个帐户。 用户将能够在应用程序中创建新帐户并执行 每个账户的交易。

这是最终的应用程序的样子:

avatar

这也将使代码尽可能小,所以我们将能够专注于应用程序的结构,没有额外的干扰。

2.1. 展示应用程序的数据

模型层是我们的应用程序的基础。许多开发者喜欢从应用程序的用户界面开始,这是一个很好的方法。但是我喜欢从模型类型开始。

模型层独立于应用程序的其他部分,只关注数据,不需要担心存储,网络,或者用户界面。这使得它们易于编写和测试。

尽管它们很简单,但许多驱动一个结构良好的应用程序的代码都位于模型层。模型层中的bug可以影响应用程序的任何部分或所有部分。另一方面,一个可靠的模型层将帮助您显著减少MVC不同层中的代码。所以正确的模型类型是很重要的。

我们的应用程序需要处理银行账户和交易,所以我们可以创建几个类型来处理这些。

struct Transaction {
    let amount: Int
    let beneficiary: String
    let date: Date
}
struct Account {
    let name: String
    let iban: String
    let kind: Kind
    var transactions: [Transaction] 
}
extension Account {
    enum Kind: String, Codable, CaseIterable {
        case checking
        case savings
        case investment
    } 
}

应用程序的模型层可以用Swift的值类型更好地体现出来,即结构和枚举。值类型被复制,所以每个代码段可以处理不受影响的局部值一切。访问相同资源的并行代码是任何软件中重要的错误来源。对于值类型,我们保持我们的代码尽可能独立。

(附注:我使用Int作为Transaction的amount属性类型而不是Float,因为你永远不应该使用浮点数精确的数字,比如金融交易。钱金额通常用分表示,使用整数)。

2.2. 在模型层中实现域业务逻辑

开发人员犯的最严重的错误是放置区域业务逻辑在MVC的其他层中。

域业务逻辑是任何处理我们的应用程序相关域及其规则的逻辑。在我们的案例中,这个领域是银行业。因此,所有处理银行业法律的法典属于该领域逻辑。

许多开发人员停留在上面的代码上,导致模型层很轻。他们只使用模型类型来表示应用程序中的数据。但是在MVC中,模型层应该承担更多的工作。模型类型也应该:

  • encapsulate the domain business logic; - 封装领域业务逻辑;
  • transform data from one format to another. - 将数据从一种格式转换为另一种格式。

在帐户和交易结构中,我们期望银行账户和交易做的更多。例如:

  • A customer can only open an account by putting into it a
    €2.000 deposit. - 客户只有在账户里存入现金€2.000 才能开户 。
  • The balance of an account needs to match the sum of all its
    transactions. - 一个帐户的余额需要与所有它的交易总和相符。
  • Each transaction is final, and it should not be possible to delete
    it from an account. - 每个交易都是最终的,不应该从一个账户被删除。

由于这些规则属于域业务逻辑,因此它们的代码也应该进入模型层。

struct Transaction: Identifiable, Codable {
    let id = UUID()
    let amount: Int
    let beneficiary: String
    let date: Date
}
struct Account: Codable, Identifiable {
    let name: String
    let iban: String
    let kind: Kind
    private(set) var transactions: [Transaction]
    var id: String { iban }
    var balance: Int {
        var balance = 0
        for transaction in transactions {
            balance += transaction.amount
        }
        return balance
    }
    init(name: String, iban: String, kind: Kind) {
        self.name = name
        self.kind = kind
        self.iban = iban
        transactions = [
            Transaction(amount: 2000_00, 
                beneficiary: "Initial Balance", date: Date())
        ] 
    }
    mutating func add(_ transaction: Transaction) {
        transactions.append(transaction)
    } 
}
extension Account {
    enum Kind: String, Codable, CaseIterable {
        case checking
        case savings
        case investment
    } 
}

我们的模型类型现在更丰富了,并执行了我列出的所有以上规则。对于初学者,Account结构的初始化器将initial交易设置为€2,000。(我在这里简化。创建的请求账户通常是通过客户所在的银行开立的
会带来实际的金额)。

余额是计算出的属性,它将所有交易加在一起的金额。交易属性可以是只读的,仅由Account结构通过其add(_:)方法修改。这可以防止任何外部代码删除现有交易。

我们的模型类型还处理数据转换。所有类型都采用Identifiable和 Codable协议,该协议允许我们组织、存储和发送数据。如果我们对编码和解码数据有更复杂的规则,这些规则也将限制在Transaction和Account结构中。

通常,所有这些逻辑都结束在控制器中,或者更糟的是,结束在视图中。这是一个错误。它将业务逻辑传播到整个应用程序,通常重复功能,使其难以管理。将其保存在模型类型中使其编写和测试更简单。由于我们的整个应用程序将依赖于此业务逻辑,因此这种方法产生的bug更少。

3. 实现应用程序的状态和存储业务逻辑

向上移动MVC模式,我们遇到的下一层是控制器层。在这里,我们还发现开发人员经常将许多职责放在 SwiftUI视图中。

模型层包含域业务逻辑,即控制器层包含应用程序的业务逻辑,包括:

  • keeping the state for the entire app; - 保持整个应用程序的状态;
  • storing data on disk; - 在磁盘上存储数据;
  • transmitting data over the network; - 通过网络传送数据;
  • dealing with data and events coming from the device sensors (GPS, accelerometer, gyroscope, etc.) - 处理来自设备传感器的数据和事件 (GPS、加速度计、陀螺仪等)

3.1. 存储数据并保持应用程序的逻辑隔离在控制器内

我们将从在磁盘上保存和读取数据开始,这是许多应用程序需要的。为此,我们将使用iOS文件系统,将数据保存在documents目录中。

class StorageController {
    private let documentsDirectoryURL = FileManager.default
    .urls(for: .documentDirectory, in: .userDomainMask) .first!
    private var accountsFileURL: URL {
        return documentsDirectoryURL
            .appendingPathComponent("Accounts") .appendingPathExtension("json") 
    }
    func save(_ accounts: [Account]) {
        let encoder = JSONEncoder()
        guard let data = try? encoder.encode(accounts) else { return }
        try? data.write(to: accountsFileURL) 
    }
    
    func fetchAccounts() -> [Account] {
        guard let data = try? Data(contentsOf: accountsFileURL) else { return [] }
        let decoder = JSONDecoder()
        let accounts = try? decoder.decode([Account].self, from: data)
        return accounts ?? []
    } 
}

首先要注意的是,我们使用的是类而不是结构。控制器需要在整个应用中共享,并提供唯一的接入点,所以它们需要是对象。

StorageController类的核心功能是 save(_:)和fetchAccounts()方法。前者编码模型类型中的数据并将其存储在文件中。后者读取该文件并对其数据进行解码,以在每次用户访问时恢复模型类型。由于每个账户都包含各自的交易,这足以保存我们所有的数据。

虽然这个类做的事情不多,但是将其代码从剩余中分离出来是至关重要的。在现实世界的应用程序中,事情很少这么简单。你的应用中的任何代码都可能随着时间的推移而增长,所以从一开始就把责任分开。

3.2. 将应用程序的状态与其业务逻辑保持在一起

几乎任何应用程序都需要在某个地方存储其全局状态。

保存应用程序的状态是另一个需要更多代码的任务,这比乍一看可能需要的更多。许多开发人员只是将表示当前应用状态的数据存储在一个可观察对象中,然后就结束了。

class StateController: ObservableObject {
    @Published var accounts: [Account] 
}

当您看到一个存储了属性但没有代码的类型时,您应该感到怀疑。虽然这种情况有时会发生,但无论如何也不会持续太久。

回想一下,控制器层必须包含应用程序的业务逻辑, 它定义了:

  • how to get an IBAN for new accounts; - 如何为新账户申请IBAN;
  • where to place new accounts relatively to existing ones; - 新账户相对于现有账户的放置位置;
  • setting the current date for new transactions; - 为新交易设定当前日期;
  • when to save and read data. - 何时保存和读取数据。

记住:如果你不把应用程序的逻辑放在控制器中,它会扩散到你的SwifitUI视图中。那不是合适的地方。这些是全局规则,影响整个应用程序的行为。把这样的代码放在视图中会导致重复,污染视图与他们不应该有的责任。

首先,我们需要一种为新账户生成IBAN代码的方法。这是银行通常承担的另一项任务。真正的应用程序会从网络中获取它,但为了简单起见,我们将在应用程序中生成IBAN代码。

extension String {
    static func generateIban() -> String {
        func randomString(length: Int, from characters: String) -> String {
            String((0 ..< length).map { _ in characters.randomElement()! })
        }
        let letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
        let digits = "0123456789"
        return randomString(length: 2, from: letters)
            + randomString(length: 2, from: digits)
            + randomString(length: 4, from: letters)
            + randomString(length: 12, from: digits)
    } 
}

IBAN代码必须遵循精确的算法,但在这里我们不关心,所以我只是生成了一个看起来像IBAN的随机字符串。我将其放入String扩展中,因为这仍然是域业务逻辑的一部分。

我们现在可以扩展状态控制器来执行我列出的所有以上任务。

class StateController: ObservableObject {
    @Published var accounts: [Account]
    private let storageController = StorageController()
    init() {
        self.accounts = storageController.fetchAccounts()
    }
    func addAccount(named name: String, withKind kind: Account.Kind) {
        let account = Account(name: name, iban: String.generateIban(), kind: kind)
        accounts.append(account)
        storageController.save(accounts) 
    }
    func addTransaction(withAmount amount: Int, beneficiary: String, 
    to account: Account) {
        guard let index = accounts.firstIndex(where: { $0.id == account.id }) 
        else { return }
        let transaction = Transaction(amount: amount, beneficiary: beneficiary, 
        date: Date())
        accounts[index].add(transaction)
        storageController.save(accounts) 
    } 
}

注意我们现在有多少代码。如果不是在这里收集,这段代码会在视图中结束。

addAccount(named:withKind:)和addTransaction(withAmount:beneficiary:to:)方法接受简单类型的参数,并在内部创建Account和Transaction值。这使得应用程序的业务逻辑对视图是隐藏的,而视图则只管理用户输入。

这里的另一个重要部分是StateController管理StorageController。这是因为前者保存应用程序的状态,所以它有责任决定何时保存或读取数据。

为了简单起见,每次数据改变时都会发生这种情况。这适用于少量数据,因为对磁盘的访问可以保持相对较快的速度。在一个数据量更大的应用程序中,可能需要一个更复杂的策略,例如,将磁盘access分派到一个异步后台线程。

我们正在缓慢但坚定地建造我们的房子,把每一种新类型都盖上 坚实的基础。

avatar

4. 向用户显示信息并通过视图支持交互

现在我们将跳过root layer,跳到view layer。原因很简单:root views 是控制器和视图之间的桥梁,所以当另外两个存在时更容易实现它们。

视图层只有两个职责:

  • showing data to the user; - 向用户显示数据;
  • allowing user interaction. - 允许用户交互。

与其他层不同的是,在这里视图中有多少责任并不重要,重要的是有多责任。这里的错误是在视图中放入了太多代码。

我们把所有关于应用导航结构和与数据交互的考虑都放到了根层。这大大简化了我们的视图代码。

让我们再次看看我们想要实现的最终结果:

avatar

在继续之前,我们需要为Xcode预览创建一些数据。将这样的数据放在单独的结构中是一个很好的做法,所以我们不需要在每次预览中重新创建它。

struct TestData {
    static let account = 
    Account(name: "Test account", iban: String.generateIban(), kind: .checking)
    static let transaction = 
    Transaction(amount: 123456, beneficiary: "Salary", date: Date())
}

4.1. 将视图代码组织成小型模块化类型

虽然视图的一般职责很少,但它们的代码往往增长得非常快。构建SwiftUI视图的一个重要部分是保持它们的小型和可重用性。没有什么可以阻止您将整个屏幕的代码放在单个视图中。但这些代码很快就会变得难以管理。

这适用于任何代码,而不仅仅是SwiftUI。无论编写命令式代码还是声明式代码,扩展函数都很难阅读,而且很容易出错。

当考虑我们应该如何分割视图代码时,有些情况比其他情况更明显。一般来说,任何重复多次的观点都是很好的候选观点。

一个简单的例子是在各自屏幕的底部添加帐户和交易的两个按钮。因为唯一的区别是它们的标题,很明显它们的代码应该在一个可重用的单一视图中。

struct AddButton: View {
    let title: String
    let action: () -> Void
    var body: some View {
        HStack {
            Spacer()
            Button(action: action) {
                HStack {
                    Image(systemName: "plus.circle.fill")
                    Text(title) 
                }
                .font(.headline) 
            }
            .padding(.trailing, 20.0) 
        }
    }
}

struct AddButton_Previews: PreviewProvider {
    static var previews: some View {
        VStack {
            Spacer()
            AddButton(title: "Add item", action: {})
        }
    }
}

avatar

在这里,我们找到了视图最常用的方式,从上到下与它们的子视图通信:存储属性。AddButton结构的title属性有一个简单的String类型,而action property接受一个传递给Button视图的函数。

表视图的行也是可重用视图的另一个突出例子。我们在Accounts屏幕中发现了一个实例。

extension AccountsView.Content {
    struct Row: View {
        let account: Account
        var body: some View {
            VStack(alignment: .leading, spacing: 4.0) {
                HStack {
                    Text(account.name) .font(.headline)
                    Spacer()
                    Text(account.balance.currencyFormat) .font(.headline) 
                }
                Text("\(account.kind.rawValue.capitalized) account") 
                    .font(.subheadline) 
                    .foregroundColor(.secondary)
                Text(account.iban.ibanFormat) 
                    .font(.caption)
                    .foregroundColor(.secondary) 
            }.padding(.vertical, 8.0) 
        } 
    } 
}

我喜欢在它们的父视图中使用Swift扩展来命名我的视图,以避免过长的类型名。该视图的全称是AccountsView.Content.Row。但你不必遵循这个惯例。例如,您可以调用该视图AccountRow,并将其放置在Swift扩展之外。

格式化数据是另一个跨屏幕重复的任务,因此它的代码也应该被隔离。数据转换代码位于模型类型中,但是为用户格式化数据是视图层的一项任务。我们通过在模型类型的Swift扩展中放置格式化代码来解决这个难题。

extension Int {
    var currencyFormat: String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .currency
        return formatter.string(from: NSNumber(value: Float(self) / 100 )) ?? ""
    } 
}
extension Date {
    var transactionFormat: String {
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        return formatter.string(from: self) 
    } 
}
extension String {
    var ibanFormat: String {
        var remaining = Substring(self)
        var chunks: [Substring] = []
        while !remaining.isEmpty {
            chunks.append(remaining.prefix(4))
            remaining = remaining.dropFirst(4) 
        }
        return chunks.joined(separator: " ") 
    } 
}

我们也有一个表来显示一个帐户的交易列表, 我们找到了另一个可重用行。

extension TransactionsView.Content {
    struct Row: View {
        let transaction: Transaction
        var body: some View {
            VStack(alignment: .leading, spacing: 4.0) {
                HStack {
                    Text(transaction.beneficiary) 
                        .font(.headline)
                    Spacer()
                    Text(transaction.amount.currencyFormat) 
                        .font(.headline) 
                }
                Text(transaction.date.transactionFormat) 
                    .font(.subheadline) 
                    .foregroundColor(.secondary) 
            } 
        } 
    } 
}

虽然可重用视图是分离代码最明显的候选者,但它们不是唯一的候选者。通常,单个屏幕具有带有扩展代码的复杂部分。即使您不需要在其他地方重用此类代码,将其放在单独的类型中仍然是好的。

例如,在New Transaction屏幕中,我们有一个部分用于输入需要几行代码的交易金额。我们也可以从模块的角度分离出来。

extension NewTransactionView.Content {
    struct Amount: View {
        @Binding var amount: String
        var body: some View {
            VStack(alignment: .trailing) {
                Text("Amount") 
                    .font(.callout) 
                    .foregroundColor(.secondary)
                TextField(0.currencyFormat, text: $amount) 
                    .multilineTextAlignment(.trailing) 
                    .keyboardType(.decimalPad) 
                    .font(Font.largeTitle.bold())
            }.padding()
        } 
    } 
}

4.2. 保持视图与应用程序业务逻辑分离

多亏了我们刚刚创建的模块化视图,我们的应用屏幕代码仍然是有限的和可读的。让我们从一个帐户的交易列表开始。

extension TransactionsView {

    struct Content: View {
        let account: Account
        let newTransaction: () -> Void
        var body: some View {
            VStack {
                List(transactions) { transaction in
                    Row(transaction: transaction)
                }
                AddButton(title: "New Transaction", action: newTransaction) 
            }.navigationBarTitle(account.name) 
        }
        var transactions: [Transaction] {
            account.transactions.sorted(by: { $0.date >= $1.date })
        }   
    }   
}

这里需要注意的重要一点是,TransactionsView如何。-内容视图尊重视图层的两个职责,而把其他的留在外面。

首先,它为整个屏幕布局用户界面,并设置导航栏标题。它还按日期对交易进行排序,以便最近的交易排在最前面。这是属于视图层的用户界面逻辑的一部分。这不会影响StateController中的应用程序的全局状态,它可以按任何有意义的顺序排序和存储transactions。

最后,Content视图允许用户通过New Transaction按钮进行交互。在这里,我们的观点没有做的事情也很重要。Content结构不能决定当用户点击New Transaction按钮时发生什么。这是应用导航结构的一部分,属于根层。相反,AddButton的操作是由祖先通过Content类型的newTransaction属性提供给视图的。

同样的情况也发生在我们的应用的所有其他视图中。

extension NewTransactionView {
    struct Content: View {
        @Binding var amount: String
        @Binding var beneficiary: String
        let cancel: () -> Void
        let send: () -> Void
        var body: some View {
            List {
                Amount(amount: $amount)
                TextField("Beneficiary name", text: $beneficiary) 
            }
            .navigationBarTitle("New Transaction") 
            .navigationBarItems(leading: cancelButton, trailing: sendButton) 
        }
        var cancelButton: some View {
            Button(action: cancel) {
                Text("Cancel") 
            } 
        }
        var sendButton: some View {
            Button(action: send) {
                Text("Send") .bold()
            } 
        } 
    } 
}

再次,NewTransactionView.Content布局整个用户界面,为导航栏提供标题和按钮。但是它没有实现取消或发送事务的逻辑。它只是通过两个@Binding属性将用户输入的数据传递给祖先。cancelButton和createButton的动作再次通过两个存储functions的属性来实现。

该视图不涉及创建新的交易,也不以任何方式与StateController交互。当用户操作时,它也不会关闭屏幕。

保持视图代码与控制器层的分离也使创建预览更简单。

struct NewTransactionView_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            NewTransactionView.Content(amount: .constant(""), beneficiary: .constant(""), cancel: {}, send: {})
        } 
    } 
}
avatar

因为Content视图不与对象交互,所以我们可以在预览中将简单的值作为参数传递。添加一个NavigationView可以使导航栏可见,因此我们可以检查整个屏幕是否如预期的那样。

创建新帐户的视图以同样的方式工作。

extension NewAccountView {
    struct Content: View {
        @Binding var name: String
        @Binding var kind: Account.Kind
        let create: () -> Void
        let cancel: () -> Void
        var body: some View {
            List {
                TextField("Account name", text: $name)
                Picker("Kind", selection: $kind) {
                    ForEach(Account.Kind.allCases, id: \.self) { kind in
                        Text(kind.rawValue).tag(kind)
                    } 
                }
                Spacer()
            }.padding(.top) 
            .navigationBarTitle("New Account")
            .navigationBarItems(leading: cancelButton, trailing: createButton) 
        }
        var cancelButton: some View {
            Button(action: cancel) {
                Text("Cancel") 
            } 
        }
        var createButton: some View {
            Button(action: create) {
                Text("Create") .bold()
            } 
        } 
    } 
}
struct NewAccountView_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            NewAccountView.Content(name: .constant(""), kind: .constant(.checking),
            create: {}, cancel: {})
        } 
    } 
}
avatar

同样,这个视图包含交互式视图,如TextField和Picker 设置导航栏按钮,但不处理任何应用程序的业务逻辑。

最后,我们有了账户列表。

extension AccountsView {
    struct Content: View {
        @Binding var accounts: [Account]
        let newAccount: () -> Void
        var body: some View {
            VStack {
                List {
                    ForEach(accounts) { account in
                        NavigationLink(destination: 
                            TransactionsView(account: account)) {
                                Row(account: account)
                        } 
                    }.onMove(perform: move(fromOffsets:toOffset:))
                }
                AddButton(title: "New Account", action: newAccount) 
            }.navigationBarTitle("Accounts") 
            .navigationBarItems(trailing: EditButton())
        }
        func move(fromOffsets source: IndexSet, toOffset destination: Int) {
            accounts.move(fromOffsets: source, toOffset: destination)
        } 
    } 
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            AccountsView.Content(accounts: .constant([TestData.account]),
                newAccount: {})
        } 
    } 
}
avatar

这个视图包含更多的用户界面逻辑,允许用户在屏幕上重新排序帐户。但同样,它不与存储这些帐户的StateController交互。相反,它通过@Binding属性接收帐户数组,用于将数据更改发送到祖先视图。

如果你想要更复杂的方法,你甚至可以通过一个函数属性让视图接收move(fromffsets:toOffset:),但这对于我们的应用来说已经足够了。

这是我们到目前为止所创造的。

avatar

我们的应用的底层和上层仍然是断开连接的。将它们聚集在一起的是根层,我们将在本指南的下一章,也就是最后一章中讨论它。

5. 将整个应用程序放在根层中

我们终于到了MVC模式的最后一层:根层。注意,这个命名是我的,所以您可能不会发现任何其他开发人员使用这个名称来引用它。除非这个想法流行起来。

这里我们找到了应用程序的所有剩余职责:

  • structuring the navigation flow; - 构建导航流程;
  • connecting views to controllers; - 将视图连接到控制器;
  • interpreting user action. - 解释用户操作。

5.1. 使用架构视图和model presentation 管理应用程序的导航结构

首先,我们需要一个StateController的单一实例,它可以被应用程序中的所有屏幕访问。我们在应用程序的SceneDelegate中创建它,然后将它添加到用户界面的环境中。

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
    let stateController = StateController()
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, 
    options connectionOptions: UIScene.ConnectionOptions) {
        let contentView = AccountsView()
            .environmentObject(stateController)
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView:contentView)
            self.window = windows
            window.makeKeyAndVisible()
        } 
    } 
}

一旦StateController实例在环境中,任何根视图都可以使用@EnvironmentObject属性包装器访问它。让我们从应用程序的第一个屏幕开始。

struct AccountsView: View {
    @EnvironmentObject private var stateController: StateController
    @State private var addingAccount = false
    var body: some View {
        NavigationView {
            Content(accounts: $stateController.accounts, 
            newAccount: { self.addingAccount = true })
        }
        .sheet(isPresented: $addingAccount) {
            NavigationView {
                NewAccountView()
            }.environmentObject(self.stateController) 
        }
    }
}

这是我们的第一个根视图。它的代码完全是结构化的。它将Content视图的accounts属性绑定到StateController的accounts属性。

它还模式地显示了New Account屏幕,并提供了两个导航视图。第一个允许应用程序的向下一级导航,它会指向所选帐户的交易列表。第二个导航视图只需要在New Account屏幕中显示一个导航栏,即使导航没有继续进行。

struct TransactionsView: View {
    let account: Account
    @EnvironmentObject private var stateController: StateController
    @State private var addingTransaction: Bool = false
    var body: some View {
        Content(account: account, newTransaction: { self.addingTransaction = true })
            .sheet(isPresented: $addingTransaction) {
                NavigationView {
                    NewTransactionView(account: self.account) 
                    .environmentObject(self.stateController) 
                } 
        }
    } 
}

TransactionView结构的工作原理相同。它将它的Content视图连接到StateController的共享实例,并管理NewTransactionView的模式表示。

5.2. 将用户操作映射到应用程序的业务逻辑中

我们只剩下创建新帐户和交易的视图。

struct NewAccountView: View {
    @EnvironmentObject private var stateController: StateController
    @Environment(\.presentationMode) private var presentationMode
    @State private var name: String = ""
    @State private var kind: Account.Kind = .checking
    var body: some View {
        Content(name: $name, kind: $kind, create: create, cancel: dismiss) 
    }
    func create() {
        stateController.addAccount(named: name, withKind: kind)
        dismiss()
    }
    func dismiss() {
        presentationMode.wrappedValue.dismiss()
    } 
}

这里我们找到了将用户操作映射到应用程序业务逻辑的第一个例子。NewAccountView结构将从Content视图接收到的数据收集到两个@State属性中。然后,当用户点击Create按钮时,它通过调用StateController的addAccount(named:withKind:)方法创建一个新帐户。

注意,我们是直接访问StateController,而不是使用绑定将数据传递到视图层次结构中。创建一个新的账户是新帐户屏幕的责任。如果我们将数据向上传递到视图层次结构中,我们将把适用的代码移到AccountView中,这应该与创建帐户没有任何关系。

最后,我们的NewAccountView还管理导航,当用户创建帐户或取消时dimiss显示屏幕。NewTransactionView中也发生了同样的情况,它还通过StateController向帐户添加新交易,StateController是所有应用程序业务逻辑的最终存储库。

struct NewTransactionView: View {
    let account: Account
    @EnvironmentObject private var stateController: StateController
    @Environment(\.presentationMode) private var presentationMode
    @State private var amount: String = ""
    @State private var beneficiary: String = ""
    var body: some View {
        Content(amount: $amount, beneficiary: $beneficiary, cancel: dismiss,
        send: send)
    }
    func send() {
        let amount = (Int(self.amount) ?? 0) * 100
        stateController.addTransaction(withAmount: amount, beneficiary: beneficiary, to: account)
        dismiss()
    }
    func dismiss() {
        presentationMode.wrappedValue.dismiss()
    } 
}

我们最终覆盖了整个应用程序。在根层中,我们提供了它的所有导航流,并将所有屏幕连接到中央状态。

avatar

感谢MVC模式,我们有一个结构良好的下的稳定的应用程序。每一种类型,无论是一个模型类型,一个控制器,或一个视图,都是小的和可管理的,只包含有限的响应功能。

此外,我们有一个地图,可以让我们快速地找到我们正在寻找的任何特定代码。最后,我们可以添加新的功能和屏幕到我们的应用程序,知道在哪里放置任何新代码,保持项目的管理,随着它的增长。

完整代码