第72天 项目 14 第五部分
今天是这个项目编码的最后一天,相信你已经很期待明天的挑战和评审了——这应该能让你从漫长的教程中换个口味,感觉会很不错。
不过,首先我们需要讲解两个比较复杂的主题,其中一个会非常有挑战性,因为我们要重构代码,采用MVVM设计模式。你会发现,这种模式有助于将项目中的逻辑与布局分离,但也需要多花些心思去理解——尤其是你需要掌握“主参与者”(main actor)这个概念。
在今天的学习过程中,你可能会明显感觉到难度在提升,因为我们的项目在规模和复杂度上都有所增长。借此机会,我想提醒你几件事:
- 你不是一个人在奋斗;每个人都要经历这条相同的学习曲线。
- 这是一场马拉松,不是短跑;慢慢来,你终会掌握这些知识。
- 偶尔休息一下,之后之后再回过头来看某个主题也没关系;换个新的视角往往会有所帮助。
- 没有奋斗就没有收获;如果你正在为学习某样东西而努力,最终掌握它时,相关知识会记得更牢固。
有一句广泛认为是孔子所说的话,或许能给你一些启发:“不怕慢,就怕站。”
今天你需要学习两个主题,通过这些内容,你将掌握如何安全地将数据写入磁盘,以及如何启用生物识别认证。
- 在SwiftUI项目中引入MVVM模式
- 用Face ID锁定我们的用户界面
又一个应用程序即将完成——一定要向大家分享你的进展!
在SwiftUI项目中引入MVVM模式
作者:Paul Hudson 2024年4月11日
到目前为止,我已经向你介绍了Swift和SwiftUI中的一系列概念,也分享了一些关于如何更好地组织代码的小技巧。现在,我想进一步探讨代码组织这个话题:我们将要学习一种通常被称为“软件架构”的东西,或者更宏大一点的说法——“架构设计模式”,其实本质上就是一种特定的代码结构组织方式。
我们要学习的模式叫做MVVM,这是Model(模型)、View(视图)、View-Model(视图模型)的首字母缩写。这个命名其实非常糟糕,很容易让人困惑,但遗憾的是,现在我们基本上只能沿用这个名称了。MVVM并没有一个统一的定义,你在网上会看到各种各样的人对它争论不休,但没关系——在这里,我们会把它简化,将MVVM作为一种把程序状态和逻辑从视图结构体中抽离出来的方法。实际上,就是实现逻辑与布局的分离。
我们会在学习过程中逐步展开这个定义,不过现在,先从基础开始:新建一个名为ContentView-ViewModel.swift的Swift文件,然后在文件顶部额外导入MapKit框架。我们将用这个文件创建一个新的类,这个类负责管理数据,并代表ContentView结构体处理数据操作,这样视图就不需要关心底层数据系统的具体工作方式了。
我们先从两个简单的步骤入手,然后再逐步深入。第一步,创建一个新的类,并使用@Observable宏,这样当数据发生变化时,就能向所有监听它的SwiftUI视图发送通知:
@Observable
class ViewModel {
}第二步,把这个新类放到ContentView的扩展(extension)内部,就像这样:
extension ContentView {
@Observable
class ViewModel {
}
}现在我们明确了,这个视图模型不是随便一个普通的视图模型,而是专门为ContentView服务的视图模型。之后,你需要自己为EditView添加第二个视图模型,这样就能尝试将这些概念应用到其他地方了。
提示: 经常有人问我,为什么要把视图模型放在视图的扩展里,这里我想花点时间解释一下。当前这个应用程序规模不大,但你可以想象一下,当你有10个、50个甚至500个视图时,情况会是怎样。如果像这样使用扩展,当前视图对应的视图模型就始终叫做ViewModel,而不是像EditMapLocationViewModel这样冗长的名称——这样代码更简洁,也避免了大量不同类名造成的代码混乱!
现在我们已经创建好了这个类,接下来要决定把视图中的哪些状态转移到视图模型里。有些人会说要把所有状态都移过去,也有些人会更有选择性,这都没问题——再次强调,MVVM并没有统一的标准,所以我会给你提供相关的工具和知识,让你自己去尝试和探索。
先从简单的开始:把ContentView中的两个@State属性都转移到它的视图模型中,同时去掉@State private修饰符,因为现在已经不需要它们了:
extension ContentView {
@Observable
class ViewModel {
var locations = [Location]()
var selectedPlace: Location?
}
}然后,我们可以用一个属性来替代ContentView中原来的那些属性:
@State private var viewModel = ViewModel()提示: 这就是把视图模型放在扩展里的好处之一——我们只需写ViewModel,就能自动获取当前视图对应的正确视图模型类型。
当然,这样修改会导致很多代码报错,但修复起来很简单——只需在相应的地方加上viewModel即可。比如,locations要改成$viewModel.locations,selectedPlace要改成$viewModel.selectedPlace。
把这些修改都完成后,代码就能重新编译运行了,但你可能会疑惑,这样做到底有什么用——我们不就是把代码从一个地方移到了另一个地方吗?从表面上看确实是这样,但这里有一个重要的区别,随着你技能的提升,你会对这一点有更深刻的理解:将所有这些功能放在一个独立的类中,会让我们更容易为代码编写测试。
视图的核心作用是展示数据,所以数据的处理逻辑非常适合转移到视图模型中。考虑到这一点,你可以仔细查看一下ContentView的代码,会发现有两个地方视图承担了过多它本不该做的工作:添加新位置和更新现有位置,这两个操作都需要直接操作视图模型的内部数据。
通常来说,从视图模型的属性中“读取”数据是没问题的,但“写入”数据就不合适了,因为我们这么做的核心目的就是要实现逻辑与布局的分离。如果我们限制对视图模型数据的写入操作,就能立刻找到这两个需要修改的地方——把视图模型中的locations属性修改成这样:
private(set) var locations = [Location]()现在我们设定,读取locations是允许的,但只有这个类本身能“写入”locations数据。这样一来,Xcode会立刻指出代码中需要修改的两个地方:添加新位置和更新现有位置的逻辑需要从视图中抽离出来。
首先,我们在视图模型中添加一个新方法,用于处理添加新位置的操作。首先,在文件顶部导入CoreLocation框架,然后在类中添加这个方法:
func addLocation(at point: CLLocationCoordinate2D) {
let newLocation = Location(id: UUID(), name: "New location", description: "", latitude: point.latitude, longitude: point.longitude)
locations.append(newLocation)
}之后,在ContentView的点击手势(tap gesture)中就可以调用这个方法了:
.onTapGesture { position in
if let coordinate = proxy.convert(position, from: .local) {
viewModel.addLocation(at: coordinate)
}
}第二个需要修改的地方是更新位置的逻辑,你可以把那段完整的if let index判断代码复制到剪贴板,然后粘贴到视图模型的一个新方法中,同时添加一个检查,确保存在选中的位置可供操作:
func update(location: Location) {
guard let selectedPlace else { return }
if let index = locations.firstIndex(of: selectedPlace) {
locations[index] = location
}
}你需要对代码做一些细微的调整,包括确保移除代码中原来的两个viewModel引用——现在已经不需要它们了。
现在,ContentView中的EditView弹窗(sheet)只需把数据传递给视图模型即可:
EditView(location: place) {
viewModel.update(location: $0)
}到这里,视图模型已经接管了ContentView的所有核心功能,这非常好:视图只负责展示数据,而视图模型负责“管理”数据。尽管你在网上可能会看到一些说法,但实际上,逻辑与视图的分离并非总能做到如此彻底,不过这也没关系——当你接触更复杂的项目时就会发现,“一刀切”的方案往往并不适用,所以我们只需在现有条件下做到最好就行。
无论如何,既然我们已经搭建好了视图模型,现在就可以对它进行升级,让它支持数据的加载和保存功能。这个功能会在文档目录中查找特定的文件,然后使用JSONEncoder或JSONDecoder对数据进行转换,以便后续使用。
之前我已经向你展示过如何找到应用程序的文档目录,并在其中创建文件名,但我不希望在加载和保存文件时都重复编写这段代码——因为如果将来我们需要修改保存路径,就必须记得在两个地方都进行更新。
所以,一个更好的做法是在视图模型中添加一个新属性,用于存储数据的保存路径:
let savePath = URL.documentsDirectory.appending(path: "SavedPlaces")有了这个属性后,我们就可以创建一个新的初始化器(initializer)和一个新的save()方法,确保数据能自动保存。首先,在视图模型中添加初始化器:
init() {
do {
let data = try Data(contentsOf: savePath)
locations = try JSONDecoder().decode([Location].self, from: data)
} catch {
locations = []
}
}至于保存功能,之前我向你展示过如何将字符串写入磁盘,而使用Data类型会更好,因为它能让我们用一行代码实现一个非常强大的功能:我们可以让iOS确保文件在写入时会进行加密处理,这样只有用户解锁设备后才能读取该文件。这是在“原子写入”(atomic writes)基础上的额外安全保障——iOS会帮我们完成大部分工作。
现在在视图模型中添加这个save()方法:
func save() {
do {
let data = try JSONEncoder().encode(locations)
try data.write(to: savePath, options: [.atomic, .completeFileProtection])
} catch {
print("无法保存数据。")
}
}没错,只需在数据写入选项中添加.completeFileProtection,就能确保文件以强加密方式存储。
通过这种方法,我们可以写入任意大小的数据,也可以创建任意数量的文件——它比UserDefaults灵活得多,而且还能让我们根据需要加载和保存数据,而不是像UserDefaults那样在应用启动时就立即加载所有数据。
在完成这一步之前,我们还需要对视图模型做一些小修改,以确保它能正确使用我们刚才编写的代码。
首先,locations数组不再需要初始化为空数组,因为初始化器已经处理了这个情况。把它修改成这样:
private(set) var locations: [Location]其次,我们需要在添加新位置或更新现有位置后调用save()方法,所以在这两个方法的末尾都加上save()。
现在可以运行应用程序了,你会发现可以随意添加位置,重新启动应用后,之前添加的位置也会被完整保留下来。
总的来说,我们写了不少代码,但最终的结果是,数据的加载和保存功能实现得非常完善:
- 所有逻辑都在视图之外处理,所以之后当你学习编写测试时,会发现视图模型更容易操作。
- 写入数据时,我们让iOS对文件进行加密,这样只有用户解锁设备后,才能读取或写入该文件。
- 加载和保存的过程几乎是透明的——视图完全不需要知道这个过程的存在。
有时候会有人问我,为什么不在课程早期就引入MVVM模式,主要有两个原因:
- 至少在目前,它与SwiftData的兼容性非常差。未来这种情况可能会改善,但现在将SwiftData与MVVM结合使用基本是不可能的。
- 项目结构的组织方式有很多种,MVVM只是其中之一。你应该多花些时间尝试不同的方式,而不是一开始就局限于某一种思路。
当然,我们的应用程序目前还不是真正的安全——我们虽然确保了数据文件在保存时会被加密,只有设备解锁后才能读取,但这并不能阻止其他人在之后读取这些数据。
用Face ID锁定用户界面
作者:Paul Hudson 2024年1月8日
为了完成这个应用程序,我们要做最后一个重要的修改:要求用户通过Touch ID或Face ID进行身份验证后,才能查看他们在应用中标记的所有位置。毕竟,这些是用户的私人数据,我们应该尊重用户的隐私,而且这也能让你在实际场景中掌握一项重要的技能!
首先,我们需要在视图模型中添加一个新的状态属性,用于跟踪应用程序是否处于解锁状态。所以,先添加这个属性:
var isUnlocked = false其次,我们需要在项目配置选项中添加Face ID权限请求键,向用户说明我们为什么要使用Face ID。如果你还没有添加这个键,请现在进入目标(target)选项,选择“Info”标签页,然后右键点击任意现有行,添加“Privacy - Face ID Usage Description”(隐私 - Face ID使用说明)键。你可以输入任何合适的描述,比如“请进行身份验证以解锁你的位置”就很不错。
第三,在视图模型文件的顶部添加import LocalAuthentication,这样我们就能使用苹果的认证框架了。
接下来就是比较复杂的部分了。如果你还记得,生物识别认证的代码因为带有Objective-C的痕迹,所以写起来会有点繁琐,因此最好把它和简洁的SwiftUI代码分离开来。所以,我们要编写一个专门的authenticate()方法,来处理所有与生物识别相关的工作:
- 创建一个LAContext实例,用于检查和执行生物识别认证。
- 检查当前设备是否支持生物识别认证。
- 如果支持,发起认证请求,并提供一个在认证完成后执行的闭包(closure)。
- 认证请求完成后,检查结果。
- 如果认证成功,就将isUnlocked设为true,这样应用就能正常运行了。
现在在视图模型中添加这个方法:
func authenticate() {
let context = LAContext()
var error: NSError?
if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
let reason = "Please authenticate yourself to unlock your places."
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, authenticationError in
if success {
self.isUnlocked = true
} else {
// 处理错误情况
}
}
} else {
// 设备不支持生物识别
}
}记住,代码中的这个字符串用于Touch ID,而Info.plist中的字符串用于Face ID。
接下来,我们需要做一个调整,从文字描述上看可能很简单,但如果只是阅读文字而不是观看视频,可能很难想象具体的操作。需要把body属性内部的所有内容缩进一级,然后在最前面添加以下代码:
if viewModel.isUnlocked {然后在body属性的末尾添加以下代码,以闭合这个条件语句,并为解锁按钮留出位置:
} else {
// 这里放按钮
}现在,我们只需要把“// 这里放按钮”这个注释替换成一个真正的按钮,点击该按钮时触发authenticate()方法即可。你可以根据自己的喜好设计按钮样式,不过像下面这样的按钮就足够用了:
Button("Unlock Places", action: viewModel.authenticate)
.padding()
.background(.blue)
.foregroundStyle(.white)
.clipShape(.capsule)现在可以再次运行应用程序了,因为我们的代码基本已经完成。如果这是你第一次在模拟器中使用Face ID,需要先进入“Features”(功能)菜单,选择“Face ID” > “Enrolled”(已注册)。之后重新启动应用程序,就可以通过“Features” > “Face ID” > “Matching Face”(匹配面部)来进行认证了。
到这里,我们的代码就全部完成了,又一个应用程序制作完毕——做得好!