[SwiftUI 100天] Bucket List - part4

貓克杯發表於2020-03-17
譯自 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 {
        MapViewCircleVStack…
    } 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 完成了 —— 幹得漂亮!


相關文章:

[SwiftUI 100 天] Bucket List - part1

[SwiftUI 100 天] Bucket List - part2

[SwiftUI 100 天] Bucket List - part3


我的公眾號 這裡有Swift及計算機程式設計的相關文章,以及優秀國外文章翻譯,歡迎關注~

[SwiftUI 100天] Bucket List - part4


相關文章