SwiftUI 学习(七)

254 阅读8分钟

「这是我参与2022首次更文挑战的第7天,活动详情查看:2022首次更文挑战

MVVM,它是 Model View View-Model 的首字母缩写词。 什么是 MVVM 没有单一的定义,你会发现网上各种各样的人都在争论它,但这没关系——在这里我们将保持简单,并使用 MVVM 作为获取我们的一些程序的一种方式 我们视图结构中的状态和逻辑。

首先,创建一个符合 ObservableObject 协议的新类,这样我们就能够将更改报告回任何正在观看的 SwiftUI 视图:

class ViewModel: ObservableObject {
}

其次,将该新类放在 ContentView 的扩展中,如下所示:

extension ContentView {
    class ViewModel: ObservableObject {
    }
}

最后一个小改动是向整个类添加一个新属性 @MainActor,如下所示:

extension ContentView {
    @MainActor class ViewModel: ObservableObject {
    }
}

mainActor负责运行所有用户界面更新,并将该属性添加到类意味着我们希望它的所有代码——任何时候运行任何东西,除非我们特别要求——运行在该主要参与者上。 这很重要,因为它负责进行 UI 更新,而这些更新必须发生在主要参与者身上。 在实践中,这并不是那么容易,但我们稍后会谈到。

现在,我们之前使用过 ObservableObject 类,但没有 @MainActor——它们是如何工作的? 好吧,每当我们使用 @StateObject 或 @ObservedObject 时,Swift 都会在幕后默默地为我们推断 @MainActor 属性——它知道这两者都意味着 SwiftUI 视图依赖于外部对象来触发其 UI 更新,因此它会让 确保所有工作都会自动发生在主角身上,而无需我们要求。

但是,这并不能提供 100% 的安全性。 是的,当从 SwiftUI 视图中使用时,Swift 会推断出这一点,但是如果你从其他地方访问你的类——例如,从另一个类中访问你的类怎么办? 然后代码可以在任何地方运行,这是不安全的。 因此,通过在此处添加 @MainActor 属性,我们采用了一种“带大括号”的方法:我们告诉 Swift 这个类的每个部分都应该在主要角色上运行,因此更新 UI 是安全的,无论它在哪里 用过的。

现在我们已经有了我们的类,我们可以选择视图中的哪些状态应该移动到视图模型中。 有些人会告诉你移动所有它,其他人会更有选择性,这没关系——再说一次,没有单一的 MVVM 看起来像什么,所以我将为你提供工具和知识来进行自己的实验。

让我们从简单的事情开始:将 ContentView 中的所有三个 @State 属性移到其视图模型中,将 @State 私有切换为仅 @Published ——它们不能再私有了,因为它们明确需要与 ContentView 共享:

extension ContentView {
    @MainActor class ViewModel: ObservableObject {
        @Published var mapRegion = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 50, longitude: 0), span: MKCoordinateSpan(latitudeDelta: 25, longitudeDelta: 25))
        @Published var locations = [Location]()
        @Published var selectedPlace: Location?
    }
}

现在我们可以将 ContentView 中的所有这些属性替换为一个:

@StateObject private var viewModel = ViewModel()

这当然会破坏很多代码,但修复很容易——只需在不同的地方添加 viewModel 即可。因此,mapRegion变成了mapRegion 变成了 viewModel.mapRegion,locations 变成了 viewModel.locations,以此类推。

一旦你添加了所有需要它的地方,你的代码将再次编译,但你可能想知道这有什么帮助——我们不是刚刚将代码从一个地方移动到另一个地方吗?嗯,是的,但是随着你技能的提高,有一个重要的区别会变得更加清晰:将所有这些功能放在一个单独的类中可以更容易地为你的代码编写测试。

视图在处理数据表示时效果最好,这意味着对数据的操作是代码进入视图模型的一个很好的候选者。考虑到这一点,如果您查看您的 ContentView 代码,您可能会注意到我们的视图在两个地方做的工作比它应该做的多:添加新位置和更新现有位置,这两者都根植于我们的内部数据中视图模型。

从视图模型的属性中读取数据数据通常很好,但编写它不是因为这个练习的重点是将逻辑与布局分开。如果我们限制写入视图模型数据,您可以立即找到这两个位置 - 将视图模型中的位置属性修改为:

@Published private(set) var locations = [Location]()

现在我们已经说过读取位置很好,但只有类本身可以写入位置。 Xcode 会立即指出我们需要将代码移出视图的两个位置:添加新位置和更新现有位置。

因此,我们可以先向视图模型添加一个新方法来处理添加新位置:

func addLocation() {
    let newLocation = Location(id: UUID(), name: "New location", description: "", latitude: mapRegion.center.latitude, longitude: mapRegion.center.longitude)
    locations.append(newLocation)
}

然后可以从 ContentView 中的 + 按钮使用它:

Button {
    viewModel.addLocation()
} label: {
    Image(systemName: "plus")
}

第二个有问题的地方是更新位置,所以如果让索引检查到剪贴板,我希望你将整个剪切,然后将其粘贴到视图模型中的新方法中,添加检查我们有选择的工作地点 :

func update(location: Location) {
    guard let selectedPlace = selectedPlace else { return }

    if let index = locations.firstIndex(of: selectedPlace) {
        locations[index] = location
    }
}

确保并从那里删除两个 viewModel 引用——它们不再需要了。

现在 ContentView 中的 EditView 工作表可以将其数据传递给视图模型:

EditView(location: place) {
    viewModel.update(location: $0)
}

此时视图模型已经接管了 ContentView 的所有方面,这很棒:视图用于呈现数据,视图模型用于管理数据。拆分并不总是那么干净,尽管您可能会在网上其他地方听到什么,但这没关系——一旦您进入更高级的项目,您会发现“一刀切”的方法通常不适合任何人,所以我们尽我们所能。

无论如何,在这种情况下,既然我们已经设置了我们的视图模型,我们可以升级它以支持数据的加载和保存。这将在文档目录中查找特定文件,然后使用 JSONEncoder 或 JSONDecoder 对其进行转换以供使用。

之前我向您展示了如何使用可重用功能找到我们应用程序的文档目录,但这里我们将其打包为 FileManager 上的扩展,以便在任何项目中访问。

创建一个名为 FileManager-DocumentsDirectory.swift 的新 Swift 文件,然后给它以下代码:

extension FileManager {
    static var documentsDirectory: URL {
        let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
        return paths[0]
    }
}

现在我们可以在文档目录中的任何位置创建文件的 URL,但是我不想在加载和保存文件时这样做,因为这意味着如果我们更改保存位置,我们需要记住更新这两个位置 .

因此,一个更好的想法是向我们的视图模型添加一个新属性来存储我们要保存到的位置:

let savePath = FileManager.documentsDirectory.appendingPathComponent("SavedPlaces")

有了这些,我们可以创建一个新的初始化程序和一个新的 save() 方法,以确保我们的数据自动持久化。 首先将其添加到视图模型中:

init() {
    do {
        let data = try Data(contentsOf: savePath)
        locations = try JSONDecoder().decode([Location].self, from: data)
    } catch {
        locations = []
    }
}

至于保存,之前我向您展示了如何将字符串写入磁盘,但 Data 版本更好,因为它让我们只需一行代码就可以完成一些非常惊人的事情:我们可以要求 iOS 确保文件是加密写入的 这样只有在用户解锁设备后才能读取它。 除了请求原子写入之外,iOS 几乎为我们完成了所有工作。

现在将此方法添加到视图模型:

func save() {
    do {
        let data = try JSONEncoder().encode(locations)
        try data.write(to: savePath, options: [.atomic, .completeFileProtection])
    } catch {
        print("Unable to save data.")
    }
}

是的,确保文件以强加密方式存储所需要做的就是将 .completeFileProtection 添加到数据写入选项中。

使用这种方法,我们可以在任意数量的文件中写入任意数量的数据——它比 UserDefaults 灵活得多,并且如果我们需要它还允许我们根据需要加载和保存数据,而不是像使用 UserDefaults 在应用程序启动时立即加载和保存数据。

在我们完成这一步之前,我们需要对我们的视图模型进行一些小的更改,以便使用我们刚刚编写的代码。

首先,位置数组不再需要初始化为空数组,因为这是由初始化程序处理的。 将其更改为:

@Published private(set) var locations: [Location]

其次,我们需要在添加新位置或更新现有位置后调用 save() 方法,因此将 save() 添加到这两个方法的末尾。

现在继续运行应用程序,您应该会发现可以自由添加项目,然后重新启动应用程序即可看到它们恢复原状。

这总共花费了相当多的代码,但最终的结果是我们已经很好地完成了加载和保存:

所有的逻辑都在视图之外处理,所以以后当你学习编写测试时,你会发现视图模型更容易使用。 当我们写入数据时,我们会让 iOS 对其进行加密,因此在用户解锁设备之前无法读取或写入文件。 加载和保存过程几乎是透明的——我们添加了一个修改器并更改了另一个修改器,仅此而已。