在本課中,你將在FoodTracker應用會話中保存菜品列表。理解并實現數據持久化是iOS應用開發的重要組成部分。iOS有多種持久化數據的存儲解決方案;在本課中,你將使用NSCoding作為FoodTracker應用數據持久化的機制。NSCoding是一個協議,它會激活一個針對歸檔對象和其他結構體的輕量級解決方案。歸檔對象(Archived objects)能夠被存儲在磁盤上也能在以后從磁盤取回。
學習目標
在本課結束的時候,你將能夠:
- 創建一個用來存儲字符串常量的結構
- 理解靜態屬性和實例屬性之間的區別
- 使用NSCoding協議來讀寫數據
保存和加載Meal對象
在這一步中,你將在Meal類中實現保存和加載菜品的行為。使用NSCoding方法,讓Meal類負責存儲和加載它的每個屬性。它需要通過分配每個屬性值到特定的鍵來存儲它的數據。然后它通過查找和這鍵有關聯的信息來加載數據。
一個鍵就是一個簡單的字符串。你基于在應用中最有意義的方針選擇你自己的鍵。例如,你或許使用鍵 name 來存儲name屬性的值。
要清楚編碼鍵對應的每個數據,創建一個結構來存儲鍵字符串。這樣,當你需要在代碼的多個地方使用這個鍵時,你可以使用常量來替代反復輸入字符串(這增加了錯誤的可能性)。
實現編碼鍵結構
- 打開 Meal.swift。
- 在 Meal.swift中,在//MARK: Properties部分下面,添加如下結構:
//MARK: Types
struct PropertyKey {
}
- 在這個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
- 在Meal.swift,找到class行:
class Meal {
- 在Meal后面,添加冒號和NSObject來從NSObject子類化一個類
class Meal: NSObject {
- 在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方法
- 在Meal.swift中,在結束花括號之前,添加如下注釋:
//MARK: NSCoding
- 在這個注釋的下面,添加如下方法:
func encode(with aCoder: NSCoder) {
}
- 在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)
}
使用編碼方法寫入,實現初始化方法來解碼這些編碼的數據。
實現初始化方法類加載菜品
- 在文件的頂部,導入統一日志系統,在導入UIKit的下方位置。
import os.log
- 在encodeWithCoder(_:)方法下方,添加下面的初始化方法:
required convenience init?(coder aDecoder: NSCoder) {
}
required修飾詞意味著如果子類定義了一個自己的初始化器,這個初始化器在每一個子類中必須被實現。
convenience修飾詞意味著這是一個次要的初始化器,并且它必須從同一個類里調用指定初始化器。
問號意味著這時一個可失敗初始化器,它可以返回nil。
- 在初始化器里面添加如下代碼:
// 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類型。如果這些操作失敗,整個初始化失敗。
- 緊跟著前面的代碼,添加下面的代碼:
// 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屬性本身就是可選類型。
- 接著添加代碼:
let rating = aDecoder.decodeIntegerForKey(PropertyKey.rating)
decodeIntegerForKey(_:)方法解歸檔一個整型。因為decodeIntegerForKey返回值是一個Int,這里不需要降級這個解碼值,這里沒有可選類型需要解包。
- 在這個實現方法的最后添加如下代碼:
// 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構建應用。它應該沒有任何錯誤。
保存和加載菜品列表
想要能保存和加載個人的菜品,你要在用戶添加、編輯或者刪除菜品的時候保存和加載菜品列表。
實現保存菜品列表的方法
- 打開MealTableViewController.swift。
- 在//MARK: Private Methods部分,在結束花括號之前,添加如下方法:
private func saveMeals() {
}
- 在saveMeals()方法總,添加下面的代碼:
let isSuccessfulSave = NSKeyedArchiver.archiveRootObject(meals, toFile: Meal.ArchiveURL.path)
這個方法試圖把meals數組歸檔到一個指定位置,并在操作成功的時候返回true。方法使用你在Meal類中定義的常量Meal.ArchiveURL來指明保存信息的位置。
但是,如何快速的測試數據是否保存成功?在控制臺輸出消息來表示結果。
- 添加如下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)
}
}
現在實現加載菜品的方法。
實現加載菜品列表的方法
- 在MealTableViewController.swift中,在結束花括號之前,添加下面的方法:
private func loadMeals() -> [Meal]? {
}
這個方法返回一個可選類型數組,數組的元素是Meal對象。這意味著它可以返回一個Meal對象的數組,或者返回nil。
- 在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]
}
要使用這些方法,你需要添加代碼用來在用戶添加、刪除、或者編輯菜品的時候保存和加載菜品列表。
當用戶添加、刪除、或者編輯菜品的時候保存菜品列表
- 在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)
}
}
}
- 在else分句下面,添加如下代碼:
// Save the meals.
saveMeals()
一旦一個新菜品添加或已存在的菜品被更新,這段代碼就會保存meals數組。確保這段代碼在外層if語句里面。
- 在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
}
}
- 在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方法中。
在合適的時機加載菜品列表
- 在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()
}
- 在設置完編輯按鈕(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數組。
- 在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中打開。
下載文件