[SwiftUI 100天] Bucket List - part1

貓克杯發表於2020-02-29
譯自 Bucket List: Introduction
更多內容歡迎關注公眾號 「Swift花園」

在這個工程中我們將構建一個可以讓使用者基於地圖建立他們想去的地方的願望清單的 app 。想去的地方包含地點描述,附近有趣的地方,還可以儲存起來之後訪問。

為了搞定這個 app ,你需要用到之前學到的技能,包括 form ,sheets ,CodableURLSession,並且我還將教給你新的技能:如何在 SwiftUI app 裡嵌入地圖,如何安全地儲存資料 (只有鑑定的使用者才能訪問),如何在UserDefaults之外讀寫資料,等等。

新建工程,用 Single View App 模板,起名 BucketList 。接下來,先介紹我們用到各種技術

譯自 Adding conformance to Comparable for custom types

為自定義型別適配 Comparable 協議

如果你曾思考過,你一定知道在我們寫 Swift 程式碼時,編譯器為我們做了許多事。舉個例子,我們寫 4 < 5,期望返回 true —— Swift 語言的開發者 (包括 LLVM,Swift 背後整個編譯器的專案組) 完成了大量艱苦的工作,以確定這個計算的結果。

然而,Swift 更棒的地方在於它通過協議和協議擴充套件把功能擴充套件方方面面。舉個例子,我們知道 4 < 5 是 true ,因為我們懂的怎麼比較兩個整數並且確定哪個在前,哪個在後。Swift 把這種功能擴充套件到整數陣列:我們可以比較一個陣列裡所有的整數,以確定每個數的順序。這就是排序。

在 Swift 中,我們期望下面的程式碼可以正常執行:

struct ContentView: View {  
    let values = [1, 5, 3, 6, 2, 9].sorted()

    var body: some View {
        List(values, id: \.self) {
            Text(String($0))
        }
    }
}複製程式碼

我們不需要向編譯器解釋 sorted()如何工作,因為它知道整數陣列如何工作。

現在來看一個結構體:

struct User: Identifiable {
    let id = UUID()
    let firstName: String
    let lastName: String
}複製程式碼

我們可以建立包含一組使用者的陣列,然後在 List 中使用它們:

struct ContentView: View {
    let users = [
        User(firstName: "Arnold", lastName: "Rimmer"),
        User(firstName: "Kristine", lastName: "Kochanski"),
        User(firstName: "David", lastName: "Lister"),
    ]

    var body: some View {
        List(users) { user in
            Text("\(user.lastName), \(user.firstName)")
        }
    }
}複製程式碼

上面的程式碼也能正常工作,因為 User結構體遵循 Identifiable 協議。

但如果我們想要這些 users 按照順序來顯示呢? 我們可能會把程式碼改成下面這樣:

let users = [
    User(firstName: "Arnold", lastName: "Rimmer"),
    User(firstName: "Kristine", lastName: "Kochanski"),
    User(firstName: "David", lastName: "Lister"),
].sorted()複製程式碼

但 Swift 不理解這裡的 sorted() 是什麼意思,因為它不知道怎麼通過 first name ,last name,或者是兩者一起,又或者其他東西來給 users 排序。

之前我向你演示過給 sorted() 配置一個閉包,用來排序,我們可以把它用在這裡:

let users = [
    User(firstName: "Arnold", lastName: "Rimmer"),
    User(firstName: "Kristine", lastName: "Kochanski"),
    User(firstName: "David", lastName: "Lister"),
].sorted {
    $0.lastName < $1.lastName
}複製程式碼

這樣程式碼就可以編譯了,但是這個解決方案不夠理想。原因有二。

首先,這是模型的資料。在一個設計良好的應用裡,我們不會希望告訴模型它應該如何在 SwiftUI 裡表現自己的行為。SwiftUI 呈現我們的檢視,例如我們的佈局,如果我們把模型相關的程式碼放在那裡,事情會變得令人困惑。

其次,假如我們想在多個地方排序我們的 User 陣列呢?你可能需要複製貼上閉包程式碼一次或者兩次。不過當你發現程式碼有問題時,你需要更新好多個地方的程式碼。

Swift 有更好的解決方案。整數陣列有簡單 sorted() 方法,不需要引數,因為 Swift 知道如何比較兩個整數。以程式設計的術語來說,Int 遵循 Comparable 協議,表明它定義了一個函式,這個函式接收兩個整數,當第一個數要排在第二個數之前時返回 true 。

我們也可以讓自定義的型別遵循 Comparable,這樣一來就能得到一個無參的sorted()方法。一共分兩步:

  1. 新增 Comparable 協議到 User 的定義
  2. 新增一個叫 < 的方法,接收兩個 user 作為引數,當第一個應當排在第二個之前時返回 true 。

程式碼如下:

struct User: Identifiable, Comparable {
    let id = UUID()
    let firstName: String
    let lastName: String

    static func < (lhs: User, rhs: User) -> Bool {
        lhs.lastName < rhs.lastName
    }
}複製程式碼

程式碼不多,但是有不少東西可以拆解。

首先,這個方法名就叫 <,它是 “小於” 操作符。這個方法的工作是確定一個使用者是否 “小於” 另一個使用者 (排序意義上)。所以我們其實是在已經存在的操作符上新增功能。這個動作叫做 操作符過載 。它可能是一把雙刃劍。

其次, lhsrhs 是 “左邊” 和 “右邊 的編碼約定,之所以這麼叫是因為 < 操作符左邊和右邊各有一個運算元。

第三,這個方法返回一個布林型,也就是說我們需要確定哪個物件應該被排在前面。不存在 “它們是相等的” 的餘地 —— 那個由另一個叫 Equatable 的協議來處理。

第四,方法需要標記為 static,也就說這方法是直接基於 User 結構體呼叫,而不是基於這個結構體的某個例項呼叫。

最後,我們這裡的邏輯相當簡單:我們直接其中的一個屬性,讓 Swift 用< 來確定兩個姓的順序。當然,你可以自己新增更多邏輯,想比較幾個屬性由你決定,但最終必須要返回 true 或者 false 。

提示: 遵循 Comparable 給我們帶來的一個東西,你可能沒有留意到,即時它也給了我們訪問 > 操作符 —— 大於,它是 < 的反面。基於它可以反轉 true 和 false。

一旦我們的 User 結構體遵循了Comparable 協議, 我們就自動獲得了無參版本的 sorted()方法,下面的程式碼就可以編譯通過了:

let users = [
    User(firstName: "Arnold", lastName: "Rimmer"),
    User(firstName: "Kristine", lastName: "Kochanski"),
    User(firstName: "David", lastName: "Lister"),
].sorted()複製程式碼

這樣就解決了我們之前的問題:我們現在把模型功能獨立在結構體本身,不再需要複製貼上閉包程式碼。我們在任何地方用這個類的sorted() 方法,而不必擔心排序演算法發生變化。

譯自 Writing data to the documents directory

往文件目錄寫入資料

前面我們已經學習過如何讀寫 UserDefaults,它對於使用者設定項或者數量較小的 JSON 資料很方便。不過,通常我們不用它來存放資料,尤其是你認為未來很可能會存更多東西的情況下。

在這個 app 中我們將允許使用者任意建立資料,也就意味著我們需要一個更好的儲存解決方案。 幸運的是,iOS 使得從裝置儲存上讀寫資料這件事變得很容易。實際上,所有的 app 都有一個目錄,用以存放任何我們想要的文件。這些檔案會自動藉助 iCloud 備份,如果使用者換了新裝置,我們的資料也會同其他系統資料一同被恢復 —— 我們甚至不需要考慮這個細節。

有一個事實要注意 —— 所有的 iOS 應用都處於沙盒環境中,也就是說,它們執行在自己的容器中,外部很難猜測目錄的檔案。由此,我們無法 —— 當然也不應該試圖猜測應用安裝目錄,而應該信賴 Apple 的 API ,藉助它們來查詢我們的文件目錄。

這些動作沒有可以優化的空間,所以我幾乎總是複製貼上相同的輔助方法到我的各個工程裡。這裡我們要做的事情也一樣。其中用到了一個新類叫 FileManager,它可以提供給我們當前使用者的文件目錄。理論上,它返回好幾個 URL 路徑,但這裡我們只關心第一個。

ContentView 中新增下面的程式碼:

func getDocumentsDirectory() -> URL {
    // find all possible documents directories for this user
    let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)

    // just send back the first one, which ought to be the only one
    return paths[0]
}複製程式碼

這個文件目錄是屬於 app 的,所以我們任意操作,但由於它屬於 app ,所以當 app 被解除安裝時它也會被自動刪除。除了物理裝置的限制外,存放資料量並沒有其他的限制。不過使用者是可以通過設定 app 檢視你的 app 佔用了多少空間 —— 所以你需要謹慎使用空間。

得到了文件目錄,我們可以自由地讀寫檔案了。你之前應該已經見過String(contentsOf:) 方法和 Data(contentsOf:) 方法,它們用於讀取資料。但對於寫入資料,我們需要用到下面的方法,有三個引數:

  1. 一個要寫入的 URL
  2. 是否令寫入動作原子化,它指的是 “一次完成”
  3. 我們想採用的字元編碼

第一個引數可以通過組合文件目錄的 URL 加上一個檔名來得到,例如 myfile.txt 。

第二個引數一般總是設定為 true 。如果設定為 false 的話,當我們在寫入大檔案的時候,有可能 app 的其他部分正在嘗試讀取這個檔案。雖然不會引起崩潰,但讀取的那部分只能讀取到部分資料,因為還沒寫完。原子化寫入使得系統每次將檔案內容放在一個臨時的地方,當寫入完成時才重新命名為我們的目標檔案。也就是說,目標檔案要麼不存在,要麼就是完整的。

第三個引數的存在是因為我們需要在 Objective-C API 的中使用 Swift 的字串。Objective-C 用的是 UTF-16 編碼,而 Swift 原生是採用 UTF-8 編碼,所以我們需要轉換編碼。

把上面所有的知識點翻譯成程式碼實踐 —— 我們可以在預設的文字檢視模板程式碼裡測試寫入一個字串到文件目錄的一個檔案中,然後再把字串讀回一個新的字串。這樣就實現了一個完整的讀寫迴圈。

把 ContentView 的 body 屬性改成下面這樣:

Text("Hello World")
    .onTapGesture {
        let str = "Test Message"
        let url = self.getDocumentsDirectory().appendingPathComponent("message.txt")

        do {
            try str.write(to: url, atomically: true, encoding: .utf8)
            let input = try String(contentsOf: url)
            print(input)
        } catch {
            print(error.localizedDescription)
        }
    }複製程式碼

執行 app ,點選文字標籤,你應該會在 Xcode 的除錯輸出區域看到 “Test message” 的訊息。

相關文章:

[SwiftUI 100 天] Bucket List - part2

[SwiftUI 100 天] Bucket List - part3

[SwiftUI 100 天] Bucket List - part4


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

[SwiftUI 100天] Bucket List - part1


相關文章