架构之路 (七) —— iOS App的SOLID原则(一)

版本记录

版本号 时间
V1.0 2020.07.04 星期日

前言

前面写了那么多篇主要着眼于局部问题的解决,包括特定功能的实现、通用工具类的封装、视频和语音多媒体的底层和实现以及动画酷炫的实现方式等等。接下来这几篇我们就一起看一下关于iOS系统架构以及独立做一个APP的架构设计的相关问题。感兴趣的可以看上面几篇。
1. 架构之路 (一) —— iOS原生系统架构(一)
2. 架构之路 (二) —— APP架构分析(一)
3. 架构之路 (三) —— APP架构之网络层分析(一)
4. 架构之路 (四) —— APP架构之工程实践中网络层的搭建(二)
5. 架构之路 (五) —— VIPER架构模式(一)
6. 架构之路 (六) —— VIPER架构模式(二)

开始

首先看下主要内容:

SOLID 是一组原则,可引导您编写清晰有序的代码,而无需额外的努力。 了解如何将其应用于您的 SwiftUI iOS 应用程序。内容来自翻译

接着看下写作环境:

Swift 5, iOS 14, Xcode 12

下面就是正文了。

要编写出色的应用程序,您不仅需要提出一个好主意,还需要考虑未来。快速有效地适应、改进和扩展应用程序功能的灵活性至关重要。无论您是在团队中工作还是独自工作,从长远来看,您编写和组织代码的方式将对维护您的代码产生巨大影响。这就是 SOLID 原则的用武之地。

想象一下,你的桌子上有一堆纸。您可能能够快速找到任何给定的论文,但是当其他人在寻找某些东西时,就很难找到他们需要的东西。你的代码很像你的办公桌,只是其他人更有可能需要它的东西。

另一方面,如果你的办公桌整洁有序,那么你就会拥有开发人员所说的干净代码:代码清楚地知道它的作用,可维护且易于他人理解。 SOLID 是一组可帮助您编写干净代码的原则。

在本教程中,您将:

  • 学习 SOLID 的五个原则。
  • 审计一个没有遵循他们的工作项目。
  • 更新项目,看看 SOLID 有多大的不同。

由于您的目标是学习如何改进现有代码,因此本 SOLID 教程假设您已经掌握了 SwiftiOS 的基础知识。

打开入门项目。解压缩它并在 starter 文件夹中打开 ExpenseTracker.xcodeproj

该应用程序允许用户存储他们的开支,以便他们可以跟踪他们每天或每月花费的金额。

构建并运行应用程序。 尝试自己添加一些条目:

该应用程序起作用了,但不是最佳状态,也不遵循 SOLID 原则。 在您审核项目以识别其缺点之前,您应该了解这些原则是什么。


Understanding SOLID’s Five Principles

SOLID 的五个原则并不直接相关,但它们都服务于同一个目的:保持代码简单明了。

这些是五个 SOLID 原则:

  • Single Responsibility - 单一职责
  • Open-Closed - 开闭
  • Liskov Substitution - 里氏替代
  • Interface Segregation - 接口隔离
  • Dependency Inversion - 依赖倒置

以下是每个原则含义的概述:

1. Single Responsibility

一个类应该有一个,而且只有一个。

您定义的每个类或类型应该只有一项工作要做。这并不意味着你只能实现一种方法,而是每个类都需要有一个专注的、专门的角色。

2. Open-Closed

软件实体,包括类、模块和函数,应该对扩展开放,对修改关闭。

这意味着您应该能够扩展您的类型的功能,而无需大幅更改它们以添加您需要的内容。

3. Liskov Substitution

程序中的对象应该可以用它们的子类型的实例替换,而不会改变该程序的正确性。

换句话说,如果您将一个对象替换为另一个子类,并且此替换可能会破坏受影响的部分,那么您就没有遵循这一原则。

4. Interface Segregation

不应强迫客户依赖他们不使用的接口。

在设计将在代码中的不同位置使用的协议时,最好将该协议分解为多个较小的部分,每个部分都有特定的作用。这样,客户端只依赖于他们需要的协议部分。

5. Dependency Inversion

依赖于抽象,而不是具体。

代码的不同部分不应依赖于具体的类。他们不需要了解这些。这鼓励使用协议而不是使用具体的类来连接应用程序的各个部分。

注意:当您重构现有项目时,按顺序遵循 SOLID 原则并不重要。相反,正确使用它们很重要。


Auditing the Project

启动项目打破了所有五个原则。 它确实工作了,而且乍一看并不觉得很复杂,或者似乎需要很多努力来维护。 然而,如果你仔细观察,你会发现这不是真的。

发现被破坏的最简单的原则是依赖倒置(dependency inversion)。 项目中根本没有协议,这意味着也没有要隔离的接口。

打开 AppMain.swift。 所有 Core Data 设置都在那里发生,这听起来根本不像是一个单一的职责。 如果您想在不同的项目中重用相同的 Core Data 设置,您会发现自己使用的是代码片段而不是整个文件。

接下来,打开 ContentView.swift。 这是应用程序中的第一个视图,您可以在其中选择要显示的费用报告类型:每日或每月。

假设您想添加本周的报告。使用此设置,您需要创建一个新的报告屏幕以匹配 DailyExpensesViewMonthlyExpensesView。然后,您将使用新的列表项更改 ContentView并创建一个新的 DailyReportsDataSource

只是为了添加您已经拥有的功能的变体,这非常混乱并且需要做很多工作。可以肯定地说,这违反了开闭(open-closed)原则。

添加单元测试并不容易,因为几乎所有模块都已连接。

此外,如果在某个时候您想删除 CoreData 并将其替换为其他内容,则您需要更改此项目中的几乎每个文件。原因很简单,因为一切都在使用 ManagedObject 子类 ExpenseModel

总体而言,该项目提供了最小的改动空间。它侧重于初始要求,并且不允许在不对整个项目进行重大更改的情况下进行任何未来的添加。

现在,您将了解如何应用每个原则来清理项目,并了解重构为您的应用程序带来的好处。


Invoking the Single Responsibility Principle

再次打开 AppMain.swift 并查看代码。 它有四个主要属性:

  • 1) container:应用程序的主要持久性容器。
  • 2)previewContainer:用于 SwiftUI 预览的preview/mock容器。 这消除了对实际数据库的需要。
  • 3)previewItem:这是在 ExpenseItemView 中预览的单个项目。
  • 4)body:应用程序本身的主体。 这是 AppMain 的主要职责。

你真正需要在这里拥有的唯一属性是body —— 其他三个不合适。 删除它们并在 Storage 组中创建一个名为 Persistence.swift 的新 Swift文件。

在新文件中,定义一个名为 PersistenceController 的新结构:

import CoreData

struct PersistenceController {
  static let shared = PersistenceController()
}

这个持久化控制器负责存储和检索数据。shared 是您将在整个应用程序中使用的共享实例。

在新结构中,添加此属性和初始化程序:

let container: NSPersistentContainer

init(inMemory: Bool = false) {
  container = NSPersistentContainer(name: "ExpensesModel")
  if inMemory {
    container.persistentStoreDescriptions.first?.url = URL(
      fileURLWithPath: "/dev/null")
  }
  container.loadPersistentStores { _, error in
    if let error = error as NSError? {
      fatalError("Unresolved error \(error), \(error.userInfo)")
    }
  }
}

初始值设定项中的参数定义容器是内存中的临时容器还是具有存储在设备上的数据库文件的实际容器。 你需要内存存储来在 SwiftUI 预览中显示虚假数据。

接下来,定义两个将用于 SwiftUI 预览的新属性:

static var preview: PersistenceController = {
  let result = PersistenceController(inMemory: true)
  let viewContext = result.container.viewContext
  for index in 1..<6 {
    let newItem = ExpenseModel(context: viewContext)
    newItem.title = "Test Title \(index)"
    newItem.date = Date(timeIntervalSinceNow: Double(index * -60))
    newItem.comment = "Test Comment \(index)"
    newItem.price = Double(index + 1) * 12.3
    newItem.id = UUID()
  }
  do {
    try viewContext.save()
  } catch {
    let nsError = error as NSError
    fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
  }
  return result
}()

static let previewItem: ExpenseModel = {
  let newItem = ExpenseModel(context: preview.container.viewContext)
  newItem.title = "Preview Item Title"
  newItem.date = Date(timeIntervalSinceNow: 60)
  newItem.comment = "Preview Item Comment"
  newItem.price = 12.34
  newItem.id = UUID()
  return newItem
}()

previewPersistenceController 的另一个类似于 shared 的实例,但 preview 中的container不从数据库文件中读取。相反,它包含五个硬编码并存储在内存中的费用条目。

previewItemExpenseModel 的单个存根实例,与您从 AppMain.swift 中删除的实例相同。

为什么要做这一切?目前,您应用的所有类都直接使用 ExpenseModel。您不能在不定义持久容器的情况下创建此类的实例。最好将与 Core Data 设置和预览相关的属性组合在一起。

在重构的后期,您将能够完全删除这些预览支持对象,并用更有条理的内容替换它们。

注意:static属性默认是惰性的。在您使用它们之前,它们永远不会被分配到内存中。因为您只在预览中使用它们,所以您根本不必担心它们存在于内存中。

1. Using the New Persistence

现在您已将 Core Data 设置与 AppMain.swift分开,您需要修复五个位置。

DailyReportsDataSource.swiftMonthlyReportsDataSource.swift 中,将 init(viewContext:) 中的默认参数更改为 PersistenceController.shared.container.viewContext,如下所示:

init(viewContext: NSManagedObjectContext
  = PersistenceController.shared.container.viewContext
) {
  self.viewContext = viewContext
  prepare()
}

然后,在 DailyExpensesView.swiftMonthlyExpensesView.swift 中找到 SwiftUI 预览代码。 将您在previews中发送到报告数据源的参数更改为 PersistenceController.preview.container.viewContext,如下所示:

let reportsDataSource = DailyReportsDataSource(
  viewContext: PersistenceController.preview.container.viewContext)

let reportsDataSource = MonthlyReportsDataSource(
  viewContext: PersistenceController.preview.container.viewContext)

最后,在 ExpenseItemView.swiftpreviews中,使用预览项 PersistenceController.previewItem 而不是您从 AppMain 中删除的项:

ExpenseItemView(expenseItem: PersistenceController.previewItem)

DailyExpensesViewMonthlyExpensesView的预览是相同的,不受重构的影响。 这同样适用于 ExpenseItemView 的预览。

构建并运行。 打开报告以确保您的更改没有破坏任何内容。


Implementing the Open-Closed Principle

第二个原则是关于以不需要您在类中进行深入修改以添加新功能的方式构建您的代码。如何不这样做的一个完美例子是每日和每周报告的实施。

查看 DailyReportsDataSource.swiftMonthlyReportsDataSource.swift,您可以看到它们是相同的,除了获取请求使用的日期。

DailyExpensesView.swiftMonthlyExpensesView.swift 也是如此。除了使用的报表数据源类之外,它们也相同。

这两种情况都使用了大量重复代码——必须有更好的方法!

一种选择是定义一个单一的数据源类,它使用一系列日期来获取条目,然后有一个单一的视图来显示这些条目。

为了使它更清晰,请使用枚举enum来表示这些范围,然后让 ContentView 循环遍历枚举中的值以填充可用选项列表。

使用此方法,添加新报告类型所需要做的就是创建一个新枚举。其他一切都会正常工作。接下来您将实施此解决方案。

1. Creating the Enum

在您的项目导航器中,创建一个名为 Enums 的新组。在其中创建一个名为 ReportRange.swift的新文件。

在新文件中,创建一个新的枚举类型:

enum ReportRange: String, CaseIterable {
  case daily = "Today"
  case monthly = "This Month"
}

CaseIterable 允许您迭代刚刚定义的枚举的可能值。 稍后清理 ContentView 时将使用此选项。

接下来,在枚举的定义中添加以下内容:

func timeRange() -> (Date, Date) {
  let now = Date()
  switch self {
  case .daily:
    return (now.startOfDay, now.endOfDay)
  case .monthly:
    return (now.startOfMonth, now.endOfMonth)
  }
}

timeRange()返回表示range的元组中的两个日期。 第一个是下边界,第二个是上边界。 根据枚举的值,它将返回一个适合一天或一个月的范围。

2. Cleaning up the Reports

下一步是合并重复的类。

完全删除 MonthlyReportsDataSource.swift,然后将 DailyReportsDataSource.swift 重命名为 ReportsDataSource.swift。 此外,重命名其中的类以匹配文件名。

要让 Xcode 完成所有工作,请打开 DailyReportsDataSource.swift并右键单击类名。 从弹出菜单中选择Refactor ▸ Rename...。 当您在一处编辑名称时,Xcode 会更改它出现的其他任何地方,包括文件名。 完成名称编辑后,单击右上角的Rename

class ReportsDataSource: ObservableObject

在类中添加一个新属性来存储您希望此实例使用的日期范围:

let reportRange: ReportRange

然后,通过将当前初始化程序替换为以下初始化程序,将此值传递给初始化程序:

init(
  viewContext: NSManagedObjectContext =
    PersistenceController.shared.container.viewContext,
  reportRange: ReportRange
) {
  self.viewContext = viewContext
  self.reportRange = reportRange
  prepare()
}

目前,获取请求使用 Date().startOfDayDate().endOfDay。 它应该使用枚举中的日期。 将 getEntries() 的实现更改为以下内容:

let fetchRequest: NSFetchRequest<ExpenseModel> =
  ExpenseModel.fetchRequest()
fetchRequest.sortDescriptors = [
  NSSortDescriptor(
    keyPath: \ExpenseModel.date,
    ascending: false)
]
let (startDate, endDate) = reportRange.timeRange()
fetchRequest.predicate = NSPredicate(
  format: "%@ <= date AND date <= %@",
  startDate as CVarArg,
  endDate as CVarArg)
do {
  let results = try viewContext.fetch(fetchRequest)
  return results
} catch let error {
  print(error)
  return []
}

您在方法中声明了两个新变量,startDateendDate,您将在日期range枚举内返回它们。 然后使用这些日期来过滤 Core Data 数据库中所有存储的费用。 这样,显示的费用会适应您在类的初始值设定项中传递的日期范围的值。

与您对数据源文件所做的类似,删除文件 MonthlyExpensesView.swift 并将 DailyExpensesView.swift 重命名为 ExpensesView.swift。 重命名文件中的类以匹配文件名:

struct ExpensesView: View {

如果上面没有选择使用 Xcode 的重构能力,请将 dataSource 的类型更改为 ReportsDataSource

@ObservedObject var dataSource: ReportsDataSource

在这里,您使用刚刚创建的更通用的数据源。

最后,将所有 SwiftUI 预览代码更改为以下内容:

struct ExpensesView_Previews: PreviewProvider {
  static var previews: some View {
    let reportsDataSource = ReportsDataSource(
      viewContext: PersistenceController.preview
        .container.viewContext,
      reportRange: .daily)
    ExpensesView(dataSource: reportsDataSource)
  }
}

您向数据源的初始值设定项添加了一个 reportRange 参数,因此您在预览中设置了它。 对于 SwiftUI 预览,您将始终显示日常开支。

只需更改数据源类型,您就可以使视图更加通用。 这显示了这两个文件中有多少代码重复。

现在,即使您创建了一般视图,您仍然没有在任何地方使用它。 你很快就会解决这个问题。

3. Updating ContentView.swift

此时,您在 ContentView.swift 中只剩下几个错误。 转到该文件并开始修复它们。

完全删除两个计算属性,dailyReportmonthlyReport,并添加这个新方法:

func expenseView(for range: ReportRange) -> ExpensesView {
  let dataSource = ReportsDataSource(reportRange: range)
  return ExpensesView(dataSource: dataSource)
}

这将为给定的日期范围创建适当的费用视图。

SwiftUI 列表具有用于两种报告类型的两个硬编码 NavigationLink 视图。 如果要添加新类型的报告,例如 每周报告,您必须在此处和 ReportRange中更改代码。

这是低效的。 您希望使用 ReportRange 的所有可能值来填充列表,而不必更改其他地方的代码。

删除 List 的内容并将其替换为以下内容:

ForEach(ReportRange.allCases, id: \.self) { value in
  NavigationLink(
    value.rawValue,
    destination: expenseView(for: value)
      .navigationTitle(value.rawValue))
}

通过使您的枚举符合 CaseIterable,您可以访问合成属性 allCases。 它为您提供了 ReportRange 中存在的所有值的数组,从而使您可以轻松地遍历它们。 对于每个枚举案例,您将创建一个新的导航链接。

最后,检查 ContentViewExpensesView 的预览以确保您的重构没有破坏任何内容。

构建并运行,然后检查您之前保存的报告。

4. Adding Weekly Reports

在这些更改之后,添加另一种报告类型很容易。 通过添加每周报告来尝试一下。

打开 ReportRange.swift 并在每天和每月之间的枚举中添加一个新的每周值:

case weekly = "This Week"

timeRange()中,添加为此值返回的日期:

case .weekly:
  return (now.startOfWeek, now.endOfWeek)

构建并运行。 您将立即在列表中看到新项目。

添加报告类型现在很简单,只需最少的努力。这是可能的,因为您的对象是智能的。您不需要修改 ContentViewExpensesView 的任何内部实现。这证明了开闭原则是多么强大。

对于其余的原则,您将以不同的顺序浏览它们,以使它们更易于应用。请记住,当您重构现有项目时,按顺序遵循 SOLID 并不重要。正确地做这件事很重要。


Applying Dependency Inversion

对于下一步,您将通过将依赖项分解为协议来应用依赖项倒置。当前项目有两个具体的依赖项需要打破:

  • ExpensesView 直接使用 ReportsDataSource
  • Core Data 管理的对象 ExpenseModel 间接地使使用此类的所有内容都依赖于 Core Data

您无需依赖这些依赖项的具体实现,而是通过为每个依赖项创建协议来将它们抽象出来。

在项目导航器中,创建一个名为 Protocols 的新组,并在其中添加两个 Swift 文件:ReportReader.swiftExpenseModelProtocol.swift

1. Removing the Core Data Dependency

打开 ExpenseModelProtocol.swift 并创建以下协议:

protocol ExpenseModelProtocol {
  var title: String? { get }
  var price: Double { get }
  var comment: String? { get }
  var date: Date? { get }
  var id: UUID? { get }
}

接下来,在 Storage 组中,创建一个名为 ExpenseModel+Protocol.swift 的新文件,并使 ExpenseModel 符合新协议:

extension ExpenseModel: ExpenseModelProtocol { }

请注意,ExpenseModel 与协议具有相同的属性名称,因此您只需添加一个扩展即可符合该协议。

现在,您需要更改使用 ExpenseModel 的代码实例以使用新协议。

打开 ReportsDataSource.swift并将 currentEntries 的类型更改为 [ExpenseModelProtocol]

@Published var currentEntries: [ExpenseModelProtocol] = []

然后把getEntries()的返回类型改成[ExpenseModelProtocol]

private func getEntries() -> [ExpenseModelProtocol] {

接下来,打开 ExpenseItemView.swift 并将expenseItem的类型更改为 ExpenseModelProtocol

let expenseItem: ExpenseModelProtocol

构建并运行。 打开任何报告并确保您的应用程序中没有任何问题。

2. Seeing Your Changes in Action

使用此重构获得的第一个好处是无需使用 PersistenceController.previewItem 即可模拟费用项目。 打开 Persistence.swift 并删除该属性。

现在,打开 ExpenseItemView.swift 并将 SwiftUI 预览代码替换为以下内容:

struct ExpenseItemView_Previews: PreviewProvider {
  struct PreviewExpenseModel: ExpenseModelProtocol {
    var title: String? = "Preview Item"
    var price: Double = 123.45
    var comment: String? = "This is a preview item"
    var date: Date? = Date()
    var id: UUID? = UUID()
  }

  static var previews: some View {
    ExpenseItemView(expenseItem: PreviewExpenseModel())
  }
}

以前,要显示模拟费用,您必须设置一个虚假的 Core Data 上下文,然后在该上下文中存储一个模型。这是一个相当复杂的努力,只是为了显示一些属性。

现在,视图依赖于一个抽象协议,您可以使用 Core Data 模型或简单的旧结构来实现它。

此外,如果您决定放弃 Core Data 并使用其他一些存储解决方案,依赖倒置将让您轻松更换底层模型实现,而无需更改视图中的任何代码。

当您想要创建单元测试时,同样的概念也适用。您可以设置假模型,以确保您的应用在各种不同的费用下都能按预期运行。

下一部分将允许您消除用于预览报告的预览视图上下文。

3. Simplifying the Reports Datasource Interface

ReportReader.swift 中实现协议之前,您应该注意一些事情。

打开 ReportsDataSource.swift 并检查类的声明及其成员属性 currentEntries 的声明

class ReportsDataSource: ObservableObject {
  @Published var currentEntries: [ExpenseModelProtocol] = []
}

每当添加新条目时,ReportsDataSource 使用CombineObservableObject 将其发布的属性currentEntries 通知任何观察者。 使用@Published 需要一个类; 它不能在协议中使用。

打开 ReportReader.swift 并创建此协议:

import Combine

protocol ReportReader: ObservableObject {
  @Published var currentEntries: [ExpenseModelProtocol] { get }
  func saveEntry(title: String, price: Double, date: Date, comment: String)
  func prepare()
}

Xcode 会报错:

Property 'currentEntries' declared inside a protocol cannot have a wrapper.

但是如果你把这个类型改成一个类,Xcode 就不会再报错了:

class ReportReader: ObservableObject {
  @Published var currentEntries: [ExpenseModelProtocol] = []
  func saveEntry(
    title: String,
    price: Double,
    date: Date,
    comment: String
  ) { }

  func prepare() {
    assertionFailure("Missing override: Please override this method in the subclass")
  }
}

注意:由于您删除了 Core Data,因此每个报告阅读器实例在创建时都将拥有自己的数据快照。 这意味着当您从Today添加费用时,除非您创建新的报表实例,否则您不会在每月Monthly中看到它。 断言确保您不会在子类中覆盖此方法,并且不会意外调用父方法。

您将创建一个抽象类,而不是创建一个具体实现符合的协议,更具体的实现需要子类化该抽象类。 它实现了相同的目标:您可以轻松地交换底层实现,而无需更改任何视图。

打开 ReportsDataSource.swift 并将类的声明更改为子类 ReportReader,而不是遵循 ObservableObject

class ReportsDataSource: ReportReader {

接下来,删除 currentEntries 的声明。 您不再需要它,因为您在超类中定义了它。 另外,为 saveEntry(title:price:date:comment:)prepare()添加关键字override

override func saveEntry(
  title: String, price: Double, date: Date, comment: String) {
override func prepare() {

然后,在 init(viewContext:reportRange:) 中,在调用 prepare() 之前添加对 super.init()的调用:

super.init()

导航到 ExpensesView.swift,您将看到 ExpenseView 使用 ReportsDataSource 作为其数据源的类型。 将此类型更改为您创建的更抽象的类 ReportReader

@ObservedObject var dataSource: ReportReader

通过像这样简化您的依赖项,您可以安全地清理 ExpenseView 的预览代码。

4. Refactoring ExpensesView

ExpensesView_Previews 中添加一个新的结构定义:

struct PreviewExpenseEntry: ExpenseModelProtocol {
  var title: String?
  var price: Double
  var comment: String?
  var date: Date?
  var id: UUID? = UUID()
}

与您之前在 ExpenseItemView 中定义的类似,这是一个基本模型,您可以将其用作模拟费用项目。

接下来,在刚刚添加的结构下方添加一个类:

class PreviewReportsDataSource: ReportReader {
  override init() {
    super.init()
    for index in 1..<6 {
      saveEntry(
        title: "Test Title \(index)",
        price: Double(index + 1) * 12.3,
        date: Date(timeIntervalSinceNow: Double(index * -60)),
        comment: "Test Comment \(index)")
    }
  }

  override func prepare() {
  }

  override func saveEntry(
    title: String,
    price: Double,
    date: Date,
    comment: String
  ) {
    let newEntry = PreviewExpenseEntry(
      title: title,
      price: price,
      comment: comment,
      date: date)
    currentEntries.append(newEntry)
  }
}

这是将所有记录保存在内存中的简化数据源。 它从几条记录开始而不是空记录,就像 ReportsDataSource 一样,但它消除了对 Core Data 和初始化预览上下文的需要。

最后,将预览的实现更改为以下内容:

static var previews: some View {
  ExpensesView(dataSource: PreviewReportsDataSource())
}

在这里,您告诉预览使用您刚刚创建的数据源。

最后,打开 Persistence.swift 并通过删除preview来删除预览对象的最后痕迹。 您的视图不再与 Core Data 相关联。 这不仅可以让您删除在此处编写的代码,还可以让您轻松地为测试中的视图提供模拟数据源。

构建并运行。 您会发现一切仍然完好无损,预览现在会显示您的模拟费用。


Adding Interface Segregation

查看 AddExpenseView,您会看到它需要一个闭包来保存条目。目前,ExpensesView现在提供了这个闭包。它所做的只是调用 ReportReader 上的一个方法。

另一种方法是将数据源传递给 AddExpenseView,以便它可以直接调用该方法。

两种方法之间的明显区别是: ExpensesView 负责通知 AddExpenseView如何执行保存。

如果修改要保存的字段,则需要将此更改传播到两个视图。但是,如果您直接传递数据源,则列表视图将不负责有关如何保存信息的任何详细信息。

但是这种方法将使由 ReportReader 提供的其他功能对 AddExpenseView 可见。

接口隔离的 SOLID 原则建议您将接口分成更小的部分。这使每个客户都专注于其主要责任并避免混淆。

在这种情况下,原则表明您应该将 saveEntry(title:price:date:comment:)分成自己的协议,然后让 ReportsDataSource 符合该协议。

1. Splitting up Protocols

Protocols 组中,创建一个新的 Swift 文件并将其命名为 SaveEntryProtocol.swift。将以下协议添加到新文件中:

protocol SaveEntryProtocol {
  func saveEntry(
    title: String,
    price: Double,
    date: Date,
    comment: String)
}

打开 ReportReader.swift 并删除 saveEntry(title:price:date:comment:)

接下来,打开 ReportsDataSource.swift 并更改类的声明以符合您的新协议:

class ReportsDataSource: ReportReader, SaveEntryProtocol {

由于您现在正在实现协议方法而不是从超类覆盖该方法,因此请从 saveEntry(title:price:date:comment) 中删除 override 关键字。

ExpensesView.swift中的 PreviewReportsDataSource 中执行相同的操作。 首先,添加遵循:

class PreviewReportsDataSource: ReportReader, SaveEntryProtocol {

然后,像以前一样删除 override 关键字。

您的两个数据源现在都符合您的新协议,该协议非常具体地说明了它的作用。 剩下的就是更改其余代码以使用此协议。

打开 AddExpenseView.swift 并将 saveClosure 替换为:

var saveEntryHandler: SaveEntryProtocol

现在,您使用的是协议而不是闭包。

saveEntry()中,用您刚刚添加的新属性替换对 saveClosure 的调用:

saveEntryHandler.saveEntry(
  title: title,
  price: numericPrice,
  date: time,
  comment: comment)

更改 SwiftUI 预览代码以匹配您的更改:

struct AddExpenseView_Previews: PreviewProvider {
  class PreviewSaveHandler: SaveEntryProtocol {
    func saveEntry(title: String, price: Double, date: Date, comment: String) {
    }
  }
  static var previews: some View {
    AddExpenseView(saveEntryHandler: PreviewSaveHandler())
  }
}

最后,打开 ExpensesView.swift 并将 $isAddPresentedfull screen cover更改为以下内容:

.fullScreenCover(isPresented: $isAddPresented) { () -> AddExpenseView? in
  guard let saveHandler = dataSource as? SaveEntryProtocol else {
    return nil
  }
  return AddExpenseView(saveEntryHandler: saveHandler)
}

现在,您正在使用更明确、更具体的协议来节省开支。如果您继续在此项目上工作,您几乎肯定会想要更改并添加保存行为。例如,您可能想要更改数据库框架、添加跨设备同步或添加服务器端组件。

拥有这样的特定协议将使将来更改功能变得容易,并使测试这些新功能变得更加容易。当你有少量代码时,最好现在就这样做,而不是等到项目变得太大而变的棘手。


Implementing Liskov Substitution

目前,AddExpenseView 期望任何保存处理程序都能够保存。此外,它不希望保存处理程序执行任何其他操作。

如果您将 AddExpenseView 与另一个符合 SaveEntryProtocol 的对象一起提供,但在存储条目之前执行一些验证,它将影响应用程序的整体行为,因为 AddExpenseView 不期望这种行为。这违反了 Liskov Substitution 替换原则。

这并不意味着您最初的 SaveEntryProtocol 设计不正确。这种情况很可能随着您的应用程序的增长和更多需求的出现而发生。但是随着它的增长,您应该了解如何以不允许其他实现违反使用它的对象的期望的方式重构您的代码。

对于这个应用程序,你需要做的就是让 saveEntry(title:price:date:comment:)返回一个布尔值来确认它是否保存了该值。

打开SaveEntryProtocol.swift 并将返回值添加到方法的定义中:

func saveEntry(
  title: String,
  price: Double,
  date: Date,
  comment: String
) -> Bool

更新 ReportsDataSource.swift 以匹配协议中的更改。 首先,在 saveEntry(title:price:date:comment:)中添加返回类型:

func saveEntry(
  title: String,
  price: Double,
  date: Date,
  comment: String
) -> Bool {

接下来,在方法结束时返回 true

return true

在以下位置再次执行这两个步骤:

  • 1) AddExpenseView_Previews.PreviewSaveHandlerAddExpenseView.swift
  • 2) ExpensesView.swift 中的 ExpensesView_Previews

接下来,在 AddExpenseView.swift 中,将 saveEntry()中的 saveEntryHandler 方法调用替换为以下内容:

guard saveEntryHandler.saveEntry(
  title: title,
  price: numericPrice,
  date: time,
  comment: comment)
else {
  print("Invalid entry.")
  return
}

如果条目验证失败,您将提前退出该方法,绕过关闭视图。 这样,如果 save 方法返回 falseAddExpenseView 不会关闭。

通过将行 saveEntry(更改为下面以消除最后的警告:

_ = saveEntry(

这会丢弃未使用的返回值。


Auditing the App Again

再看看你的应用程序。通过您所做的更改,您解决了在第一轮中发现的所有问题:

  • 1) Core Data设置不再在 AppMain 中,您将其分开。
  • 2) 您的应用程序不依赖于 Core Data。它现在可以自由使用任何类型的存储,只需对您的代码进行最少的更改。
  • 3) 添加新报告类型是在枚举中添加新值的问题。
  • 4) 创建预览和测试比以前容易得多,而且您不再需要任何复杂的模拟对象。

项目开始之前的情况和现在的情况之间有很大的改进。它不需要太多努力,并且您减少了代码量作为附带好处。

遵循 SOLID 与执行一组规则或架构设置无关。相反,SOLID 为您提供了一些指导方针,帮助您以更有条理的方式编写代码。

它使修复bug更安全,因为您的对象不会纠缠在一起。编写单元测试更容易。即使将您的代码从一个项目重用到另一个项目也毫不费力。

编写干净且有组织的代码是一个总能得到回报的目标。如果你说,“我稍后会清理它”,当那个时刻到来时,事情通常会太复杂而无法真正清理。

在代码中使用设计模式为看似复杂的问题提供了简单的解决方案。 无论您是否了解基本的 iOS 设计模式,刷新您对它们的内存总是好的。 我们的 Fundamental iOS Design Patterns tutorial 可以提供帮助。

单元测试是软件开发的一个关键方面。 您的测试需要关注代码的一小部分。 了解有关Dependency Injection的所有知识以编写出色的单元测试。

另一个可以改善您编写应用程序的方式的有趣概念是Defensive Programming。 这是关于让您的代码预测可能会出错的地方,这样您的应用程序就不会脆弱,并且在收到意外输入时不会崩溃。

防御性编码(defensive coding)的一个简单示例是在处理可选项时使用 guard let 而不是强制解包。 了解这些主题可以提高您的工作质量,而无需任何额外的努力。

后记

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

推荐阅读更多精彩内容