[SwiftUI 100天] Bucket List - part4

2020/3/17 23:01:30

本文主要是介绍[SwiftUI 100天] Bucket List - part4,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

译自 www.hackingwithswift.com/books/ios-s…
喜欢文章,不如来点赞关注吧

让别人的类遵循 Codable

任何需要用户输入数据的 app ,通常最好是把数据存起来,不过在 Apple 的 framework 里,这件事说起来比做起来容易。

我们的 app 使用 MKPointAnnotation 来存储用户有兴趣游览的地点,而我们希望使用 iOS 存储来永久保存它们。创建一个新的 Swift 文件,名叫 MKPointAnnotation-Codable.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 类,也就意味着其他类可以继承它。我们可能能够为这个类实现 Codable 协议,但这意味着所有的子类也必须遵循 Codable ,而这是我们无法保证的。

对于这个问题有几个解决方案:

  • MKPointAnnotation 是一个实现 MKAnnotation 协议的类,所以我们可以创建自己的类,并实现相同的协议。
  • 我们还可以创建一个 MKPointAnnotation 的子类,并实现 Codable ,这样可以有效地避免MKPointAnnotation 触及 Codable 。因为这个类属于我们,所以我们可以强制它遵循Codable 协议。
  • 我们还可以创建一个 wrapper 结构体在类的外层,让结构体遵循 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在项目中几个地方都有用到,但我们只需要在两个地方修改。首先,修改 locations属性,它位于 ContentView

@State private var locations = [CodableMKPointAnnotation]()复制代码

然后修改 ContentView 里的 + 按钮,让 newLocation也使用新类:

let newLocation = CodableMKPointAnnotation()复制代码

我们不需要修改其他地方,因为 CodableMKPointAnnotationMKPointAnnotation的子类,所以我们声明MKPointAnnotation 的地方都可以传入CodableMKPointAnnotation。技术上这被称为 “行为亚型” (behavioral subtyping) , 更常见的叫法是里 里氏替换原则(Liskov Substitution principle。如果你听说过术语 “SOLID”,那么这个原则就是里面的 “L” 。

言归正传,接下来是有趣的地方,我们需要加载和存储数据,但这一次我们不用 UserDefaults,取而代之的是,我们会写入 JSON 到 iOS 的文件系统,这样一来我们就按照需求写入尽可能多的数据。

之前我向你演示过如何获取 app 的文档目录,这里也是先从这一步开始,把下面的方法加到 ContentView:

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

上面方法就位了,我们就可以用getDocumentsDirectory().appendingPathComponent()来创建新的 URL ,指向文档目录里的特定文件。一旦构建好 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("无法加载保存的数据")
    }
}复制代码

采用上面这种方法,我们可以写入任意数量的数据,以任意的文件数量 —— 这比UserDefaults灵活多了,另外借助这种方式我们可以按需加载和保存数据,不用像使用 UserDefaults 那样在 app 一启动时就执行操作。

另外,这个方法还有一个好处,它存在于我们写入数据的过程。当然,我们还会用到getDocumentsDirectory()JSONEncoder ,不过这时候是用 write(to:) 方法来保存数据到磁盘,写入到特定的 URL 。

之前我向你演示过字符串的方法,不过 Data 版本更酷,因为它能让我们用一行代码就完成令人惊讶的事情。我们可以要求 iOS 确保文件以加密的方式写入(磁盘),以便它只能在用户解锁设备的情况下被读取。这其中额外地引入原子写入的需求,而 iOS 几乎为我们完成了所有的工作。

把下面这个方法添加到 ContentView

func saveData() {
    do {
        let filename = getDocumentsDirectory().appendingPathComponent("SavedPlaces")
        let data = try JSONEncoder().encode(self.locations)
        try data.write(to: filename, options: [.atomicWrite, .completeFileProtection])
    } catch {
        print("无法加载保存的数据")
    }
}复制代码

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

目前为止一切顺利,我们需要做的最后一件事是把这些方法和 SwiftUI 连起来使用,以便所有的东西能自动加载和保存。

加载数据时,我们只需要在 ContentView 的 ZStack 的 onAppear() modifier 里执行下面代码:

.ondAppear(perform: loadData)复制代码

保存数据时,我们在 sheet() 的 onDismiss参数上使用相同参数,在上一个项目中介绍过。这意味着每当 EditView 被 dismiss 的时候,我们把新建项或者编辑项保存起来。

把 ContentView 的 sheet() modifier 改成这样:

.sheet(isPresented: $showingEditScreen, onDismiss: saveData) {复制代码

现在再运行 app ,你会发现你可以自由添加新条目,并且重启 app 之后它们都还在。

实现这些东西花费了不少代码,不过我们总归是很好地实现了加载和保存:

  • Codable 实现单独位于一个文件中,这样 SwiftUI 不需要关心它。
  • 当我们写入数据时,让 iOS 加密文件,以便设备未解锁不能被读取或者写入。
  • 加载和保存过程几乎透明 —— 我们只需要新增一个 modifier ,修改另一个就完事了。

当然,我们的 app 并非就真的安全了:我们已经确保我们的数据以加密方式保持以便只能在设备解锁情况下被读取,但在解锁之后就没有机制阻止其他人来读取数据了。

把我们的 UI 锁在 Face ID 之后

为了完成我们的 app ,我们还要做出最后一项重要的修改:我们要请求用户用 Touch ID 或者 Face ID 认证自己,然后才能查看 app 中标记的地点。毕竟,这些数据属于他们的隐私 我们应当尊重他们。因而这里我有机会向你演示在实践上很重要的一项技术。

首先,我们需要在 ContentView 添加新状态,以便跟踪我们的 app 是否解锁。先添加下面这个新属性:

@State private var isUnlocked = false复制代码

其次,我们需要添加 “Privacy - Face ID Usage Description” 键到 Info.plist ,向用户解释为什么我们需要使用 Face ID 。这里的值你可以输入任何你想要的内容,不过 “Please authenticate yourself to unlock your places” 看起来是个不错的选项。

第三,我们需要在 ContentView.swift 头部添加import LocalAuthentication ,这样才能访问 Apple 的 鉴权框架。

接下来是难点。你回忆一下,生物指纹鉴权的代码有点令人不爽,因为它来自 Objective-C r所以处于保持 SwiftUI 代码整洁的考虑,最好让这些代码离远一点。所以我们将写一个专门的authenticate() 方法,负责处理生物指纹的工作:

  1. 创建一个 LAContext ,我们用来检查和执行生物指纹鉴权。
  2. 查询当前设备是否支持生物指纹鉴权。
  3. 如果支持,启动鉴权请求并且提供一个闭包,在鉴权完成时执行。
  4. 当请求完成时,把我们的工作推回主线程,检查结果。
  5. 如果鉴权成功,我们将设置 isUnlocked 为 true ,然后就可以正常运行 app 了。

把下面这个方法添加到 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 {
                    // error
                }
            }
        }
    } else {
        // 没有生物指纹识别
    }
}复制代码

记住,代码中的字符串是给 Touch ID 用的,而 Info.plist 中的字符串是给 Face ID 用的。

接下来我们需要做一个很小的调整,不过只阅读文章而不看视频教程的话你可能注意不到这一点。 ZStack中所有的东西都需要缩进一级,然后在头部加上这个:

if isUnlocked {复制代码

然后在 ZStack 后面加上这个:

} else {
    // button here
}复制代码

看起来就像这样:

ZStack {
    if isUnlocked {
        MapView…
        Circle…
        VStack…
    } else {
        // 按钮
    }
}
.alert(isPresented: $showingPlaceDetails) {复制代码

接下来我们要做的就是把// button here 注释换成实际的按钮,按钮被点击时触发 authenticate() 方法,你可以任意设计,不过我想像下面这样的代码应该够用了:

Button("解锁地点") {
    self.authenticate()
}
.padding()
.background(Color.blue)
.foregroundColor(.white)
.clipShape(Capsule())复制代码

代码完成,再次运行 app 。由于这可能是你第一次在模拟器中使用 Face ID ,你需要到 Hardware 菜单,选择 Face ID > Enrolled ,重启 app 后你就可以用 Hardware > Face ID > Matching Face 来完成身份验证了。

又一个 app 完成了 —— 干得漂亮!

我的公众号 这里有Swift及计算机编程的相关文章,以及优秀国外文章翻译,欢迎关注~





这篇关于[SwiftUI 100天] Bucket List - part4的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程