Start Developing iOS Apps (Swift)->持久化數據

在本課中,你將在FoodTracker應用會話中保存菜品列表。理解并實現數據持久化是iOS應用開發的重要組成部分。iOS有多種持久化數據的存儲解決方案;在本課中,你將使用NSCoding作為FoodTracker應用數據持久化的機制。NSCoding是一個協議,它會激活一個針對歸檔對象和其他結構體的輕量級解決方案。歸檔對象(Archived objects)能夠被存儲在磁盤上也能在以后從磁盤取回。

學習目標

在本課結束的時候,你將能夠:

  • 創建一個用來存儲字符串常量的結構
  • 理解靜態屬性和實例屬性之間的區別
  • 使用NSCoding協議來讀寫數據

保存和加載Meal對象

在這一步中,你將在Meal類中實現保存和加載菜品的行為。使用NSCoding方法,讓Meal類負責存儲和加載它的每個屬性。它需要通過分配每個屬性值到特定的鍵來存儲它的數據。然后它通過查找和這鍵有關聯的信息來加載數據。

一個鍵就是一個簡單的字符串。你基于在應用中最有意義的方針選擇你自己的鍵。例如,你或許使用鍵 name 來存儲name屬性的值。

要清楚編碼鍵對應的每個數據,創建一個結構來存儲鍵字符串。這樣,當你需要在代碼的多個地方使用這個鍵時,你可以使用常量來替代反復輸入字符串(這增加了錯誤的可能性)。

實現編碼鍵結構

  1. 打開 Meal.swift。
  2. 在 Meal.swift中,在//MARK: Properties部分下面,添加如下結構:
        //MARK: Types
         
        struct PropertyKey {
        }   
  1. 在這個PropertyKey結構中,添加如下屬性:
        static let name = "name"
        static let photo = "photo"
        static let rating = "rating"

每個常量對應一個Meal的三個屬性之一。這個static關鍵字表示這些常量屬于結構自己,而不是結構的實例。你可以使用結構名來訪問這些常量(例如,PropertyKey.name)。

你的PropertyKey結構看上去時這樣的:

        struct PropertyKey {
            static let name = "name"
            static let photo = "photo"
            static let rating = "rating"
        }

為了能夠編碼和解碼它們自己和它們的屬性,Meal類需要符合NSCoding協議。為了附和NSCoding,Meal需要子類化NSObject。NSObject 被認為是一個基本類,它定義了運行時系統的基本接口。

子類化NSObject并遵守NSCoding

  1. 在Meal.swift,找到class行:
        class Meal {
  1. 在Meal后面,添加冒號和NSObject來從NSObject子類化一個類
        class Meal: NSObject {
  1. 在NSObject后面,添加逗號和NSCoding來采用NSCoding協議:
        class Meal: NSObject, NSCoding {

現在,Meal是NSObject的子類,Meal類的初始化器必須調用一個NSObject類的指定初始化器。因為NSObject類的唯一初始化器是 init(),Swift編譯器自動添加這個調用,所以你無需改變代碼;但是,如果你愿意,你可以隨時添加super.init()來進行調用。

進一步探索
更多關于Swift初始化規則的信息,查看Class Inheritance and Initialization和The Swift Programming Language (Swift 4)中的初始化。

NSCoding協議聲明了兩個方法,任何采用了它的類都必須實現,這樣這些類的實例就可以編碼和解碼:

        encode(with aCoder: NSCoder)
        init?(coder aDecoder: NSCoder)

encode(with:)方法準備用來歸檔的類的信息,并且初始化器在類被創建的時候解檔這些數據。你需要同時實現encode(with:) 方法和用來正確存儲和加載數據的初始化器。

實現encodeWithCoder NSCoding方法

  1. 在Meal.swift中,在結束花括號之前,添加如下注釋:
        //MARK: NSCoding
  1. 在這個注釋的下面,添加如下方法:
        func encode(with aCoder: NSCoder) {
        }
  1. 在encode(with:)方法中,添加如下代碼:
        aCoder.encode(name, forKey: PropertyKey.name)
        aCoder.encode(photo, forKey: PropertyKey.photo)
        aCoder.encode(rating, forKey: PropertyKey.rating)

這個NSCoder類定義了幾個encode(_:forKey:)方法,每個方法的第一個參數都有不同的類型。每個方法編碼給定類型的數據。在上面顯示的代碼中,前兩行傳遞的是String參數,而第三行傳遞一個Int參數。這些行對每個Meal類的屬性的值進行了編碼,并使用它們相應的鍵存儲它們。

encode(with:)方法應該是這樣的:

        func encode(with aCoder: NSCoder) {
            aCoder.encode(name, forKey: PropertyKey.name)
            aCoder.encode(photo, forKey: PropertyKey.photo)
            aCoder.encode(rating, forKey: PropertyKey.rating)
        }

使用編碼方法寫入,實現初始化方法來解碼這些編碼的數據。

實現初始化方法類加載菜品

  1. 在文件的頂部,導入統一日志系統,在導入UIKit的下方位置。
        import os.log
  1. 在encodeWithCoder(_:)方法下方,添加下面的初始化方法:
        required convenience init?(coder aDecoder: NSCoder) {
        }

required修飾詞意味著如果子類定義了一個自己的初始化器,這個初始化器在每一個子類中必須被實現。
convenience修飾詞意味著這是一個次要的初始化器,并且它必須從同一個類里調用指定初始化器。
問號意味著這時一個可失敗初始化器,它可以返回nil。

  1. 在初始化器里面添加如下代碼:
        // The name is required. If we cannot decode a name string, the initializer should fail.
        guard let name = aDecoder.decodeObject(forKey: PropertyKey.name) as? String else {
            os_log("Unable to decode the name for a Meal object.", log: OSLog.default, type: .debug)
            return nil
        }

decodeObject(forKey:)方法解碼被編碼的信息。decodeObjectForKey(_:)的返回值是 Any?可選類型。這個guard語句在分配它到name常量之前,解包這個可選類型并把它降級為String類型。如果這些操作失敗,整個初始化失敗。

  1. 緊跟著前面的代碼,添加下面的代碼:
        // Because photo is an optional property of Meal, just use conditional cast.
        let photo = aDecoder.decodeObjectForKey(PropertyKey.photo) as? UIImage

你把decodeObject(forKey:)的返回值降級為UIImage,并把它分配給photo常量。如果降級失敗,它會給photo屬性分配nil。這里沒有必要使用guard語句,因為photo屬性本身就是可選類型。

  1. 接著添加代碼:
        let rating = aDecoder.decodeIntegerForKey(PropertyKey.rating)

decodeIntegerForKey(_:)方法解歸檔一個整型。因為decodeIntegerForKey返回值是一個Int,這里不需要降級這個解碼值,這里沒有可選類型需要解包。

  1. 在這個實現方法的最后添加如下代碼:
        // Must call designated initializer.
        self.init(name: name, photo: photo, rating: rating)

作為一個方便初始化器,這個初始化器在完成之前必須要調用它的類指定初始化方法。作為這個初始化器的參數,你需要給他傳遞一個常量的值,這個常量是你在歸檔保存數據的時候創建的。

這個新的init?(coder:)初始化方法看上去是這樣的:

        required convenience init?(coder aDecoder: NSCoder) {
            
            // The name is required. If we cannot decode a name string, the initializer should fail.
            guard let name = aDecoder.decodeObject(forKey: PropertyKey.name) as? String else {
                os_log("Unable to decode the name for a Meal object.", log: OSLog.default, type: .debug)
                return nil
            }
            
            // Because photo is an optional property of Meal, just use conditional cast.
            let photo = aDecoder.decodeObject(forKey: PropertyKey.photo) as? UIImage
            
            let rating = aDecoder.decodeInteger(forKey: PropertyKey.rating)
            
            // Must call designated initializer.
            self.init(name: name, photo: photo, rating: rating)
            
        }
        required convenience init?(coder aDecoder: NSCoder) {
            
            // The name is required. If we cannot decode a name string, the initializer should fail.
            guard let name = aDecoder.decodeObject(forKey: PropertyKey.name) as? String else {
                os_log("Unable to decode the name for a Meal object.", log: OSLog.default, type: .debug)
                return nil
            }
            
            // Because photo is an optional property of Meal, just use conditional cast.
            let photo = aDecoder.decodeObject(forKey: PropertyKey.photo) as? UIImage
            
            let rating = aDecoder.decodeInteger(forKey: PropertyKey.rating)
            
            // Must call designated initializer.
            self.init(name: name, photo: photo, rating: rating)
            
        }

接下來,你需要在文件系統中的持久化路徑,數據將在這個位置被讀寫,因此你要知道在哪里查找。

創建數據的文件路徑

在 Meal.swift中,在//MARK: Properties部分下方,添加如下代碼:

        //MARK: Archiving Paths
         
        static let DocumentsDirectory = FileManager().urls(for: .documentDirectory, in: .userDomainMask).first!
        static let ArchiveURL = DocumentsDirectory.appendingPathComponent("meals")

你標記這些常量使用static關鍵字,這意味著它們屬于這個類,而不是這個類的實例。在Meal類外部,你訪問這個路徑需要使用語法Meal.ArchiveURL.path。不用擔心創建了多少Meal類的實例,這里將只有這些屬性的一個副本。

    DocumentsDirectory常量使用文件管理器的urls(for:in:)方法來查找應用的文件目錄的URL。這是應用能夠存儲用戶數據的目錄。這個方法返回一個URL的數組,第一個項(first!)返回一個包含數組中第一個URL的可選類型;但是,只要枚舉是正確的,返回的數組必然包含一個匹配項。因此,強制解包可選類型是安全的。確定文檔目錄的URL后,你使用這個URL創建你的應用存儲數據的URL。這里,你通過添加meals到文檔URL末尾來創建的文件URL。

進一步探索
更多與iOS文件系統的交互信息,參見File System Programming Guide.。

檢查點:使用Command-B構建應用。它應該沒有任何錯誤。

保存和加載菜品列表

想要能保存和加載個人的菜品,你要在用戶添加、編輯或者刪除菜品的時候保存和加載菜品列表。

實現保存菜品列表的方法

  1. 打開MealTableViewController.swift。
  2. 在//MARK: Private Methods部分,在結束花括號之前,添加如下方法:
        private func saveMeals() {
        }
  1. 在saveMeals()方法總,添加下面的代碼:
        let isSuccessfulSave = NSKeyedArchiver.archiveRootObject(meals, toFile: Meal.ArchiveURL.path)

這個方法試圖把meals數組歸檔到一個指定位置,并在操作成功的時候返回true。方法使用你在Meal類中定義的常量Meal.ArchiveURL來指明保存信息的位置。
但是,如何快速的測試數據是否保存成功?在控制臺輸出消息來表示結果。

  1. 添加如下if語句
        if isSuccessfulSave {
            os_log("Meals successfully saved.", log: OSLog.default, type: .debug)
        } else {
            os_log("Failed to save meals...", log: OSLog.default, type: .error)
        }

如果保存成功,它會在控制臺輸出一個調試信息,如果保存失敗,則在控制臺輸出一個錯誤信息。

你的saveMeals()方法看上去應該是這樣的:

        private func saveMeals() {
            let isSuccessfulSave = NSKeyedArchiver.archiveRootObject(meals, toFile: Meal.ArchiveURL.path)
            if isSuccessfulSave {
                os_log("Meals successfully saved.", log: OSLog.default, type: .debug)
            } else {
                os_log("Failed to save meals...", log: OSLog.default, type: .error)
            }
        }

現在實現加載菜品的方法。

實現加載菜品列表的方法

  1. 在MealTableViewController.swift中,在結束花括號之前,添加下面的方法:
        private func loadMeals() -> [Meal]? {
        }

這個方法返回一個可選類型數組,數組的元素是Meal對象。這意味著它可以返回一個Meal對象的數組,或者返回nil。

  1. 在loadMeals()方法中,添加如下代碼:
        return NSKeyedUnarchiver.unarchiveObject(withFile: Meal.ArchiveURL.path) as? [Meal]

這個方法視圖解檔存儲在Meal.ArchiveURL.path路徑的對象,并把它降級為一個Meal對象的數組。這段代碼使用 as?運算符,所以在降級失敗的時候能返回nil。通常發生這種失敗的原因是數組還沒有被保存。這種情況下,unarchiveObject(withFile:)方法返回nil。試圖把nil降級為[Meal]也會失敗,它自身返回nil。

你的loadMeals()方法看上去是這樣的:

        private func loadMeals() -> [Meal]?  {
            return NSKeyedUnarchiver.unarchiveObject(withFile: Meal.ArchiveURL.path) as? [Meal]
        }

要使用這些方法,你需要添加代碼用來在用戶添加、刪除、或者編輯菜品的時候保存和加載菜品列表。

當用戶添加、刪除、或者編輯菜品的時候保存菜品列表

  1. 在MealTableViewController.swift中,找到unwindToMealList(sender:)方法:
        @IBAction func unwindToMealList(sender: UIStoryboardSegue) {
            if let sourceViewController = sender.source as? MealViewController, let meal = sourceViewController.meal {
                
                if let selectedIndexPath = tableView.indexPathForSelectedRow {
                    // Update an existing meal.
                    meals[selectedIndexPath.row] = meal
                    tableView.reloadRows(at: [selectedIndexPath], with: .none)
                }
                else {
                    // Add a new meal.
                    let newIndexPath = IndexPath(row: meals.count, section: 0)
                    
                    meals.append(meal)
                    tableView.insertRows(at: [newIndexPath], with: .automatic)
                }
            }
        }
  1. 在else分句下面,添加如下代碼:
        // Save the meals.
        saveMeals()

一旦一個新菜品添加或已存在的菜品被更新,這段代碼就會保存meals數組。確保這段代碼在外層if語句里面。

  1. 在MealTableViewController.swift中,找到tableView(_:commit:forRowAt:)方法:
        // Override to support editing the table view.
        override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
            if editingStyle == .delete {
                // Delete the row from the data source
                meals.remove(at: indexPath.row)
                tableView.deleteRows(at: [indexPath], with: .fade)
            } else if editingStyle == .insert {
                // Create a new instance of the appropriate class, insert it into the array, and add a new row to the table view
            }
        }
  1. 在meals.removeAtIndex(indexPath.row)后面,添加如下代碼:
        saveMeals()

這個代碼會在一個菜品被刪除的時候保存meals數組。

你的 unwindToMealList(_:)方法看上去是這樣的:

        @IBAction func unwindToMealList(sender: UIStoryboardSegue) {
            if let sourceViewController = sender.source as? MealViewController, let meal = sourceViewController.meal {
                
                if let selectedIndexPath = tableView.indexPathForSelectedRow {
                    // Update an existing meal.
                    meals[selectedIndexPath.row] = meal
                    tableView.reloadRows(at: [selectedIndexPath], with: .none)
                }
                else {
                    // Add a new meal.
                    let newIndexPath = IndexPath(row: meals.count, section: 0)
                    
                    meals.append(meal)
                    tableView.insertRows(at: [newIndexPath], with: .automatic)
                }
                
                // Save the meals.
                saveMeals()
            }
        }

你的 tableView(_:commit:forRowAt:)方法看上去是這樣的:

        // Override to support editing the table view.
        override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
            if editingStyle == .delete {
                // Delete the row from the data source
                meals.remove(at: indexPath.row)
                saveMeals()
                tableView.deleteRows(at: [indexPath], with: .fade)
            } else if editingStyle == .insert {
                // Create a new instance of the appropriate class, insert it into the array, and add a new row to the table view
            }
        }

現在,菜品列表會在合適的時間保存,接下來確保菜品會在合適的時候被加載。加載存儲的數據的合適位置是在這個table view的viewDidLoad方法中。

在合適的時機加載菜品列表

  1. 在MealTableViewController.swift中,找到viewDidLoad()方法:
        override func viewDidLoad() {
            super.viewDidLoad()
            
            // Use the edit button item provided by the table view controller.
            navigationItem.leftBarButtonItem = editButtonItem
            
            // Load the sample data.
            loadSampleMeals()
        }
  1. 在設置完編輯按鈕(navigationItem.leftBarButtonItem = editButtonItem)之后,添加如下if語句:
        // Load any saved meals, otherwise load sample data.
        if let savedMeals = loadMeals() {
            meals += savedMeals
        }

如果loadMeals()成功,就會返回一個Meal對象的數組,這個條件就是true,if語句就會執行。如果loadMeals()返回nil,就不會有菜品被加載,if語句也不會被執行。這段代碼會添加任何成功加載的菜品到meals數組。

  1. 在if語句之后,添加else分句,并把loadSampleMeals()放到分句中:
        else {
            // Load the sample data.
            loadSampleMeals()
        }

現在viewDidLoad()方法看上去是這樣的:

        override func viewDidLoad() {
            super.viewDidLoad()
            
            // Use the edit button item provided by the table view controller.
            navigationItem.leftBarButtonItem = editButtonItem
            
            // Load any saved meals, otherwise load sample data.
            if let savedMeals = loadMeals() {
                meals += savedMeals
            }
            else {
                // Load the sample data.
                loadSampleMeals()
            }
        }

檢查點:運行應用。如果你添加一些新菜品并退出應用,這些你添加的菜品會在下一次你打開應用的時候顯示。

小結

在本課中,你添加了保存和加載應用數據的功能。這讓數據持續存在于多次運行中。無論何時應用啟動,它就會加載已存在的數據。當數據被修改時,應用就會保存它。這個應用能夠安全的終止而不會丟失任何數據。

到此本應用完成。恭喜!你現在已經有了一個功能齊全的應用。

注意
想看本課的完整代碼,下載這個文件并在Xcode中打開。
下載文件

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,646評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,595評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,560評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,035評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,814評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,224評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,301評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,444評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,988評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,804評論 3 355
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,998評論 1 370
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,544評論 5 360
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,237評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,665評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,927評論 1 287
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,706評論 3 393
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,993評論 2 374

推薦閱讀更多精彩內容