Hacking with iOS: SwiftUI Edition - 愿望清单项目(三)

使其他用户输入数据类支持 Codable

任何要求用户输入数据的应用程序在其能存储对应数据时通常能有更好的体验,但是在使用Apple框架时,说起来容易做起来难。

在我们的应用中,我们使用MKPointAnnotation存储用户想要访问的有趣地点,并且我们想使用 iOS 存储将其永久保存。创建一个名为 MKPointAnnotation-Codable.swift 的新Swift文件,导入 MapKit,然后为其提供以下代码:

extension MKPointAnnotation: Codable {
    public required init(from decoder: Decoder) throws {

    }

    public func encode(to encoder: Encoder) throws {

    }
}

这是遵守Codable的协议的需要实现的方法,但它无能为力。如果您尝试构建,则会看到错误“ 'required' initializer must be declared directly in class 'MKPointAnnotation' (not in an extension)"

让我说清楚:在Swift中无法完成这项工作。

不需要您了解为什么这是不可能的,但是我确实认为这为Swift的工作原理提供了一些启示。

MKPointAnnotation不是最终类(final class),这意味着其他类也可以从中继承。我们也许可以为这一类实现Codable协议的支持,但是这样做还意味着所有子类也应该是Codable,这不是我们可以遵守的承诺。

这有一些解决方案:

  • MKPointAnnotation是实现MKAnnotation协议的类,因此我们可以创建自己的符合相同协议的类。
  • 我们可以创建MKPointAnnotation的子类并在那里实现Codable,从而有效地使MKPointAnnotation免受使用Codable的影响。这是我们的类,因此我们可以强制子类遵循Codable
  • 我们可以围绕该类创建一个包装器结构体,使该结构体符合Codable并在内部存储MKPointAnnotation

所有这三个都是不错的选择,您可以在这里轻松地确定其中任何一个都是正确的选择。但是,最简单的选项是子类,因为我们可以在单个文件中实现它,然后仅更改MKPointAnnotation的两个实例即可使其与我们的其余代码一起使用。

首先,代码。我们将创建一个名为CodableMKPointAnnotation的新类,该类继承自MKPointAnnotation并符合Codable。我们确实需要提供一个自定义的Codable实现,以便保存所有数据,而且这非常简单——唯一的不足是CLLocationCoordinate2D尚未符合Codable,因此我们将其保存为纬度和经度。

除此之外,这里没有什么特别的,因此,将 MKPointAnnotation-Codable.swift 中的所有内容替换为:

class CodableMKPointAnnotation: MKPointAnnotation, Codable {
    enum CodingKeys: CodingKey {
        case title, subtitle, latitude, longitude
    }

    override init() {
        super.init()
    }

    public required init(from decoder: Decoder) throws {
        super.init()

        let container = try decoder.container(keyedBy: CodingKeys.self)
        title = try container.decode(String.self, forKey: .title)
        subtitle = try container.decode(String.self, forKey: .subtitle)

        let latitude = try container.decode(CLLocationDegrees.self, forKey: .latitude)
        let longitude = try container.decode(CLLocationDegrees.self, forKey: .longitude)
        coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
    }

    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(title, forKey: .title)
        try container.encode(subtitle, forKey: .subtitle)
        try container.encode(coordinate.latitude, forKey: .latitude)
        try container.encode(coordinate.longitude, forKey: .longitude)
    }
}

MKPointAnnotation类在我们项目的多个位置使用,但是我们只需要在两个位置进行更改。首先,将ContentView中的locations属性更改为此:

@State private var locations = [CodableMKPointAnnotation]()

现在更改ContentView+ 按钮的操作,以便newLocation也使用我们的新子类:

let newLocation = CodableMKPointAnnotation()

我们不需要更改其他位置,因为CodableMKPointAnnotationMKPointAnnotation的子类,这意味着我们使用MKPointAnnotation的任何地方都可以在CodableMKPointAnnotation中发送。这在技术上被称为行为亚型,但在它的创建者芭芭拉·利斯科夫(Barbara Liskov)的名字之后,您会更常听到它称为“利斯科夫替代原则”。如果您曾经听过“ SOLID”一词,那就是“ L”!

无论如何,有趣的是我们如何加载和保存数据,因为这次我们不会使用UserDefaults。取而代之的是,我们将JSON写入 iOS 文件系统,因此我们可以根据需要写入任意数量的数据。

之前,我向您展示了如何查找应用程序的文档目录,因此,首先将这种方法添加到ContentView中:

func getDocumentsDirectory() -> URL {
    let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
    return paths[0]
}

有了这个,我们现在可以使用 getDocumentsDirectory().appendingPathComponent() 创建指向文档目录中特定文件的新URL。有了这些功能后,就如同使用Data(contentsOf: )JSONDecoder()加载数据一样简单——之前我们都使用过。

因此,将此loadData()方法添加到ContentView中:

func loadData() {
    let filename = getDocumentsDirectory().appendingPathComponent("SavedPlaces")

    do {
        let data = try Data(contentsOf: filename)
        locations = try JSONDecoder().decode([CodableMKPointAnnotation].self, from: data)
    } catch {
        print("Unable to load saved data.")
    }
}

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

是的,要确保文件以强加密方式存储,只需在数据写入选项中添加.completeFileProtection

完成所有这些工作之后,我们要做的最后一件事实际上是将这些方法连接到 SwiftUI,以便自动加载和保存所有内容。

为了加载数据,我们只需要在ContentViewZStack中添加一个onAppear()修饰符即可:

.onAppear(perform: loadData)

为了进行保存,我们可以使用与项目13中引入的sheet()相同的onDismiss参数。这意味着每次关闭EditView时,我们都会保存数据,这意味着我们既保存新项目,也保存已编辑项目。

因此,将ContentView中的sheet()修改器更改为此:

.sheet(isPresented: $showingEditScreen, onDismiss: saveData) {

继续并立即运行该应用程序,您应该发现可以自由添加项目,然后重新启动该应用程序以查看它们是否恢复了原来的状态。

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

  • Codable协议的支持 全部隔离在一个文件中,因此SwiftUI不必关心它。
  • 写入数据时,我们正在对iOS进行加密,以便在用户解锁设备之前无法读取或写入文件。
  • 加载和保存过程几乎是透明的——我们添加了一个修改器并更改了另一个修改器,仅此而已。

当然,我们的应用程序还不是真正安全的:我们已确保使用加密将数据文件保存下来,以便仅在设备解锁后才能读取它,但是并没有阻止其他人随后读取数据的方法。

Face ID 锁定 UI

为了完成我们的应用程序,我们将做最后一个重要更改:我们将要求用户使用Touch IDFace ID进行身份验证,以便查看他们在应用程序上标记的所有位置。毕竟,这是他们的私人数据,我们应该对此予以尊重,当然,这使我有机会让您在实际情况下使用重要技能!

首先,我们需要ContentView中的一些新状态来跟踪应用程序是否已解锁。因此,首先添加以下新属性:

@State private var isUnlocked = false

其次,我们需要在Info.plist中添加“Privacy - Face ID Usage Description” Key,向用户说明为什么要使用面部ID。您可以输入自己喜欢的内容,但是“请进行身份验证以解锁自己的位置”似乎是一个不错的选择。

第三,我们需要在 ContentView.swift 的顶部添加import LocalAuthentication,以便可以访问Apple的身份验证框架。

现在是困难的部分。回想一下,由于生物识别认证的代码源于Objective-C,因此有点令人不快,因此保存SwiftUI的简洁性始终是一个好主意。因此,我们将编写一个专用的authenticate()方法来处理所有生物识别工作:

  1. 创建一个LAContext,以便我们可以检查并执行生物特征认证。
  2. 询问当前设备是否能够进行生物特征认证。
  3. 如果支持,请启动该请求并提供一个闭包以在完成时运行。
  4. 请求完成后,将我们的工作返回到主线程并检查结果。
  5. 如果成功,我们将isUnlocked设置为true,以便我们可以正常运行应用程序。

立即将此方法添加到ContentView

func authenticate() {
    let context = LAContext()
    var error: NSError?
    // 检查生物特征认证是否可用
    if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
        // 可用,所以继续使用它
        let reason = "请验证您的身份以解锁您的位置。"
        context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, authenticationError in
            // 身份验证现已完成
            DispatchQueue.main.async {
                if success {
                    // 认证成功
                    self.isUnlocked = true
                } else {
                    // 发生的异常
                }
            }
        }
    } else {
        // 没有生物识别
    }
}

请记住,我们代码中的字符串用于 Touch ID,而Info.plist中的字符串用于 Face ID。至于为什么,那只有 Apple 自己知道。

现在,我们需要进行实际上很小的调整,但是如果您正在阅读而不是观看视频,则可能难以想象。ZStack内部的所有内容都必须缩进一个级别,并将其放在前面:

if isUnlocked {

ZStack结束之前添加以下代码:

} else {
    // button here
}

因此,它看起来应该像这样:

ZStack {
    if isUnlocked {
        MapView…
        Circle…
        VStack…
    } else {
        // button here
    }
}
.alert(isPresented: $showingPlaceDetails) {

因此,现在我们需要做的就是在 // button here中用触发authenticate()方法的实际按钮填充注释。您可以设计任何您想要的东西,但是像这样就足够了:

Button("请解锁设备") {
    self.authenticate()
}
.padding()
.background(Color.blue)
.foregroundColor(.white)
.clipShape(Capsule())

现在,您可以继续并再次运行该应用程序,因为我们的代码已完成。如果这是您第一次在模拟器中使用Face ID,则需要转到 “Features” 菜单,然后选择“ Face ID”> “Enrolled”,但是一旦重新启动应用程序,则可以使用“ Features”>“ Face ID”>“ Matching Face”进行身份验证。

那是又一个完成的应用程序——做得好!

译自
Making someone else’s class conform to Codable
Locking our UI behind Face ID

你可能感兴趣的:(Hacking with iOS: SwiftUI Edition - 愿望清单项目(三))