架构之路 (七) —— 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
教程假设您已经掌握了 Swift
和 iOS
的基础知识。
打开入门项目。解压缩它并在 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
。 这是应用程序中的第一个视图,您可以在其中选择要显示的费用报告类型:每日或每月。
假设您想添加本周的报告。使用此设置,您需要创建一个新的报告屏幕以匹配 DailyExpensesView
和 MonthlyExpensesView
。然后,您将使用新的列表项更改 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
}()
preview
是 PersistenceController
的另一个类似于 shared
的实例,但 preview
中的container
不从数据库文件中读取。相反,它包含五个硬编码并存储在内存中的费用条目。
previewItem
是 ExpenseModel
的单个存根实例,与您从 AppMain.swift
中删除的实例相同。
为什么要做这一切?目前,您应用的所有类都直接使用 ExpenseModel
。您不能在不定义持久容器的情况下创建此类的实例。最好将与 Core Data
设置和预览相关的属性组合在一起。
在重构的后期,您将能够完全删除这些预览支持对象,并用更有条理的内容替换它们。
注意:
static
属性默认是惰性的。在您使用它们之前,它们永远不会被分配到内存中。因为您只在预览中使用它们,所以您根本不必担心它们存在于内存中。
1. Using the New Persistence
现在您已将 Core Data
设置与 AppMain.swift
分开,您需要修复五个位置。
在 DailyReportsDataSource.swift
和 MonthlyReportsDataSource.swift
中,将 init(viewContext:)
中的默认参数更改为 PersistenceController.shared.container.viewContext
,如下所示:
init(viewContext: NSManagedObjectContext
= PersistenceController.shared.container.viewContext
) {
self.viewContext = viewContext
prepare()
}
然后,在 DailyExpensesView.swift
和 MonthlyExpensesView.swift
中找到 SwiftUI
预览代码。 将您在previews
中发送到报告数据源的参数更改为 PersistenceController.preview.container.viewContext
,如下所示:
let reportsDataSource = DailyReportsDataSource(
viewContext: PersistenceController.preview.container.viewContext)
和
let reportsDataSource = MonthlyReportsDataSource(
viewContext: PersistenceController.preview.container.viewContext)
最后,在 ExpenseItemView.swift
的previews
中,使用预览项 PersistenceController.previewItem
而不是您从 AppMain
中删除的项:
ExpenseItemView(expenseItem: PersistenceController.previewItem)
DailyExpensesView
和 MonthlyExpensesView
的预览是相同的,不受重构的影响。 这同样适用于 ExpenseItemView
的预览。
构建并运行。 打开报告以确保您的更改没有破坏任何内容。
Implementing the Open-Closed Principle
第二个原则是关于以不需要您在类中进行深入修改以添加新功能的方式构建您的代码。如何不这样做的一个完美例子是每日和每周报告的实施。
查看 DailyReportsDataSource.swift
和 MonthlyReportsDataSource.swift
,您可以看到它们是相同的,除了获取请求使用的日期。
DailyExpensesView.swift
和 MonthlyExpensesView.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().startOfDay
和 Date().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 []
}
您在方法中声明了两个新变量,startDate
和 endDate
,您将在日期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
中只剩下几个错误。 转到该文件并开始修复它们。
完全删除两个计算属性,dailyReport
和monthlyReport
,并添加这个新方法:
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
中存在的所有值的数组,从而使您可以轻松地遍历它们。 对于每个枚举案例,您将创建一个新的导航链接。
最后,检查 ContentView
和 ExpensesView
的预览以确保您的重构没有破坏任何内容。
构建并运行,然后检查您之前保存的报告。
4. Adding Weekly Reports
在这些更改之后,添加另一种报告类型很容易。 通过添加每周报告来尝试一下。
打开 ReportRange.swift
并在每天和每月之间的枚举中添加一个新的每周值:
case weekly = "This Week"
在 timeRange()
中,添加为此值返回的日期:
case .weekly:
return (now.startOfWeek, now.endOfWeek)
构建并运行。 您将立即在列表中看到新项目。
添加报告类型现在很简单,只需最少的努力。这是可能的,因为您的对象是智能的。您不需要修改 ContentView
或 ExpensesView
的任何内部实现。这证明了开闭原则是多么强大。
对于其余的原则,您将以不同的顺序浏览它们,以使它们更易于应用。请记住,当您重构现有项目时,按顺序遵循 SOLID
并不重要。正确地做这件事很重要。
Applying Dependency Inversion
对于下一步,您将通过将依赖项分解为协议来应用依赖项倒置。当前项目有两个具体的依赖项需要打破:
-
ExpensesView
直接使用ReportsDataSource
。 -
Core Data
管理的对象ExpenseModel
间接地使使用此类的所有内容都依赖于Core Data
。
您无需依赖这些依赖项的具体实现,而是通过为每个依赖项创建协议来将它们抽象出来。
在项目导航器中,创建一个名为 Protocols
的新组,并在其中添加两个 Swift
文件:ReportReader.swift
和 ExpenseModelProtocol.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
使用Combine
的ObservableObject
将其发布的属性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
并将 $isAddPresented
的full 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.PreviewSaveHandler
在AddExpenseView.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
方法返回 false
,AddExpenseView
不会关闭。
通过将行 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 App
的SOLID
原则,感兴趣的给个赞或者关注~~~