數據持久化方案解析(三) —— 基于NSCoding的持久化存儲(一)

版本記錄

版本號 時間
V1.0 2018.10.25 星期四

前言

數據的持久化存儲是移動端不可避免的一個問題,很多時候的業務邏輯都需要我們進行本地化存儲解決和完成,我們可以采用很多持久化存儲方案,比如說plist文件(屬性列表)、preference(偏好設置)、NSKeyedArchiver(歸檔)、SQLite 3、CoreData,這里基本上我們都用過。這幾種方案各有優缺點,其中,CoreData是蘋果極力推薦我們使用的一種方式,我已經將它分離出去一個專題進行說明講解。這個專題主要就是針對另外幾種數據持久化存儲方案而設立。
1. 數據持久化方案解析(一) —— 一個簡單的基于SQLite持久化方案示例(一)
2. 數據持久化方案解析(二) —— 一個簡單的基于SQLite持久化方案示例(二)

開始

首先看一下寫作環境

Swift 4.2, iOS 12, Xcode 10

在iOS中有很多方法可以將數據保存到磁盤 - 原始文件API,Property List SerializationCore Data,Realm等第三方解決方案,當然還有NSCoding

對于需要大量數據的應用程序,Core DataRealm通常是最佳選擇。對于輕量數據要求,NSCoding通常更好,因為它更容易采用和使用。

Swift 4還提供了另一種輕量級選擇:Codable。這與NSCoding非常相似,但存在一些差異。

如果您需要一種簡單的方法將數據保存到磁盤,NSCodingCodable都是不錯的選擇!但是,它們都不支持查詢或創建復雜的對象圖。如果您需要這些,則應使用Core Data,Realm或其他數據庫解決方案。

NSCoding的主要缺點是需要你依賴Foundation。 Codable不依賴于任何框架,但您必須在Swift中編寫模型才能使用它。

Codable還提供了NSCoding沒有的幾個豐富功能。例如,您可以使用它輕松地將模型序列化為JSON。

許多FoundationUIKit類都使用NSCoding,因為它自iOS 2.0開始就存在 - 是的,十幾年!大約一年前,在Swift 4中添加了Codable,由于許多Apple類都是用Objective-C編寫的,因此很可能很快就不會更新以支持Codable

無論您選擇在應用程序中使用NSCoding還是Codable,最好了解兩者是如何工作的。在本教程中,您將學習有關NSCoding的所有知識!

你將開展一個名為“Scary Creatures”的示例項目。這個應用程序可以讓你保存生物的照片并評估它們的可怕程度。 但是,此時它不會保留數據,因此如果重新啟動應用程序,則添加的所有生物都將丟失。 因此,該應用程序還不是很有用......

在本教程中,您將使用NSCodingFileManager保存并加載每個生物的數據。 之后,您將熟悉NSSecureCoding以及它可以做些什么來改善您的應用程序中的數據加載。

首先,在Xcode中打開入門項目并瀏覽項目中的文件:

您將使用的主要文件是:

  • ScaryCreatureData.swift包含有關生物,名稱和等級的簡單數據。
  • ScaryCreatureDoc.swift包含有關該生物的完整信息,包括數據,縮略圖圖像和該生物的完整圖像。
  • MasterViewController.swift顯示所有存儲生物的列表。
  • DetailViewController.swift顯示所選生物的細節,并允許您對其進行評分。

構建并運行以了解應用程序的工作方式。


Implementing NSCoding - 實現NSCoding

NSCoding是一種協議,您可以在數據類上實現該協議,以支持將數據編碼和解碼到數據緩沖區中,然后數據緩沖區可以保留在磁盤上。

實現NSCoding實際上非常容易 - 這就是為什么你會發現它有用的原因。

首先,打開ScaryCreatureData.swift并將NSCoding添加到類聲明中,如下所示:

class ScaryCreatureData: NSObject, NSCoding

然后將這兩個方法添加到類中:

func encode(with aCoder: NSCoder) {
  //add code here
}

required convenience init?(coder aDecoder: NSCoder) {
  //add code here
  self.init(title: "", rating: 0)
}

您需要實現這兩個方法以使類符合NSCoding。 第一種方法編碼對象。 第二個解碼數據以實例化新對象。

簡而言之,encode(with:)是編碼器,init(coder:)是解碼器。

注意:此處關鍵字convenience的存在不是NSCoding的要求。 它就在那里,因為你在初始化程序中調用了一個指定的初始化程序init(title:rating :)。 如果您嘗試刪除它,Xcode會給您一個錯誤。 如果您選擇自動修復它,編輯器將再次添加相同的關鍵字。

在實現這兩個方法之前,為了代碼組織,在類的開頭添加以下枚舉:

enum Keys: String {
  case title = "Title"
  case rating = "Rating"
}

雖然看似非常微不足道,但它是值得的。 Codable鍵使用String數據類型。 如果沒有編譯器捕獲你的錯誤,字符串很容易拼錯。 通過使用枚舉,編譯器將確保您始終使用一致的鍵名稱,Xcode將在您鍵入時為您提供代碼完成。

現在,你準備好了有趣的部分。 添加以下內容進行encode(with:)

aCoder.encode(title, forKey: Keys.title.rawValue)
aCoder.encode(rating, forKey: Keys.rating.rawValue)

encode(_:forKey :)將作為第一個參數提供的值寫入并將其綁定到鍵。 提供的值必須是符合NSCoding協議的類型。

將以下代碼添加到init?(coder:)的開頭:

let title = aDecoder.decodeObject(forKey: Keys.title.rawValue) as! String
let rating = aDecoder.decodeFloat(forKey: Keys.rating.rawValue)

這恰恰相反。 您從指定鍵提供的NSCoder對象中讀取值。 由于您要保存兩個值,因此您希望再次讀取相同的兩個值以正常恢復應用程序。

您現在需要使用解碼的內容實際構建生物的數據。 替換此行:

self.init(title: "", rating: 0)

使用下面這行

self.init(title: title, rating: rating)

OK! 使用這幾行代碼,ScaryCreatureData類符合NSCoding。


Loading and Saving to Disk - 加載和存儲到磁盤

接下來,您需要添加代碼來訪問磁盤,讀取和寫入存儲的生物數據。

為了提高性能 - 在構建應用程序時始終牢記這一點 - 您不會立即加載所有數據。

1. Adding the Initializer - 添加初始化程序

打開ScaryCreatureDoc.swift并將以下內容添加到類的末尾:

var docPath: URL?
  
init(docPath: URL) {
  super.init()
  self.docPath = docPath    
}

docPath將存儲ScaryCreatureData信息在磁盤上的位置。 這里的訣竅是你應該在第一次訪問它時將信息加載到內存中,而不是初始化對象。

但是,如果要創建一個全新的生物,則此路徑將為nil,因為尚未為該文檔創建文件。 您將在旁邊添加Bookkeeping代碼,以確保在創建新生物時設置此代碼。

2. Adding Bookkeeping Code - 添加簿記代碼

將此枚舉添加到ScaryCreatureDoc的開頭,緊跟在大括號后面:

enum Keys: String {
  case dataFile = "Data.plist"
  case thumbImageFile = "thumbImage.png"
  case fullImageFile = "fullImage.png"
}

接下來,將thumbtergetter替換為:

get {
  if _thumbImage != nil { return _thumbImage }
  if docPath == nil { return nil }

  let thumbImageURL = docPath!.appendingPathComponent(Keys.thumbImageFile.rawValue)
  guard let imageData = try? Data(contentsOf: thumbImageURL) else { return nil }
  _thumbImage = UIImage(data: imageData)
  return _thumbImage
}

接下來,使用以下命令替換fullImagegetter

get {
  if _fullImage != nil { return _fullImage }
  if docPath == nil { return nil }
  
  let fullImageURL = docPath!.appendingPathComponent(Keys.fullImageFile.rawValue)
  guard let imageData = try? Data(contentsOf: fullImageURL) else { return nil }
  _fullImage = UIImage(data: imageData)
  return _fullImage
}

由于您要將每個生物保存在自己的文件夾中,因此您將創建一個幫助程序類,以提供存儲該生物文檔的下一個可用文件夾。

創建一個名為ScaryCreatureDatabase.swift的新Swift文件,并在文件末尾添加以下內容:

class ScaryCreatureDatabase: NSObject {
  class func nextScaryCreatureDocPath() -> URL? {
    return nil
  }
}

你會在一段時間內為這個新類添加更多內容。 現在,返回ScaryCreatureDoc.swift并將以下內容添加到類的末尾:

func createDataPath() throws {
  guard docPath == nil else { return }

  docPath = ScaryCreatureDatabase.nextScaryCreatureDocPath()
  try FileManager.default.createDirectory(at: docPath!,
                                          withIntermediateDirectories: true,
                                          attributes: nil)
}

createDataPath()正如其名稱所說的那樣。 它使用數據庫中的下一個可用路徑填充docPath屬性,并且僅當docPathnil時才創建該文件夾。 如果不是,這意味著它已經正確發生。

3. Saving Data - 保存數據

接下來,您將添加邏輯以將ScaryCreateData保存到磁盤。 在createDataPath()的定義之后添加此代碼:

func saveData() {
  // 1
  guard let data = data else { return }
    
  // 2
  do {
    try createDataPath()
  } catch {
    print("Couldn't create save folder. " + error.localizedDescription)
    return
  }
    
  // 3
  let dataURL = docPath!.appendingPathComponent(Keys.dataFile.rawValue)
    
  // 4
  let codedData = try! NSKeyedArchiver.archivedData(withRootObject: data, 
                                                    requiringSecureCoding: false)
    
  // 5
  do {
    try codedData.write(to: dataURL)
  } catch {
    print("Couldn't write to save file: " + error.localizedDescription)
  }
}

這是這樣做的:

  • 1) 確保data中存在某些內容,否則只需return,因為沒有任何內容可以保存。
  • 2) 調用createDataPath()以準備保存創建的文件夾中的數據。
  • 3) 構建要寫入信息的文件的路徑。
  • 4) 編碼data,ScaryCreatureData的一個實例,您之前使其符合NSCoding。 您現在將requiresSecureCoding設置為false,但稍后您將進行此操作。
  • 5) 將編碼數據寫入在步驟3中創建的文件路徑。

接下來,將此行添加到init(title:rating:thumbImage:fullImage:)的末尾:

saveData()

這可確保在創建新實例后保存數據。

很好! 這樣可以保存數據。 好吧,該應用程序仍然不會實際保存圖像,但您將在本教程后面添加它。

4. Loading Data - 加載數據中

如上所述,我們的想法是在您第一次訪問它時將信息加載到內存中,而不是在初始化對象時加載。 如果你有很長的生物列表,這可以改善應用程序的加載時間。

注意:ScaryCreatureDoc中的屬性都是通過帶有gettersetter的私有屬性訪問的。 初學者項目本身并沒有從中受益,但它已經添加,以便您繼續執行后續步驟。

打開ScaryCreatureDoc.swift并使用以下內容替換data的getter:

get {
  // 1
  if _data != nil { return _data }
  
  // 2
  let dataURL = docPath!.appendingPathComponent(Keys.dataFile.rawValue)
  guard let codedData = try? Data(contentsOf: dataURL) else { return nil }
  
  // 3
  _data = try! NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(codedData) as?
      ScaryCreatureData
  
  return _data
}

這就是加載先前通過調用saveData()創建的已保存ScaryCreatureData所需的全部內容。 這是它的作用:

  • 1) 如果數據已經加載到內存中,只需返回它。
  • 2) 否則,請將已保存文件的內容作為一種Data讀取。
  • 3) Unarchive先前編碼的ScaryCreatureData對象的內容并開始使用它們。

您現在可以從磁盤保存和加載數據! 但是,在應用程序準備發布之前還有更多內容。

5. Deleting Data - 刪除數據

應用程序還應允許用戶刪除生物,也許留下來太可怕了。

saveData()的定義之后立即添加以下代碼:

func deleteDoc() {
  if let docPath = docPath {
    do {
      try FileManager.default.removeItem(at: docPath)
    }catch {
      print("Error Deleting Folder. " + error.localizedDescription)
    }
  }
}

此方法只刪除包含文件的整個文件夾,其中包含生物數據。


Completing ScaryCreatureDatabase - 完成ScaryCreatureDatabase

您之前創建的ScaryCreatureDatabase類有兩個作業。 第一個,你已經寫了一個空方法,是提供下一個可用的路徑來創建一個新的生物文件夾。 它的第二項工作是加載你之前保存的所有存儲的生物。

在實現這兩個功能之一之前,您需要一個幫助方法來返回應用程序存儲生物的位置 - 數據庫實際位于何處。

打開ScaryCreatureDatabase.swift,并在開始類花括號后面添加此代碼:

static let privateDocsDir: URL = {
  // 1
  let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
  
  // 2
  let documentsDirectoryURL = paths.first!.appendingPathComponent("PrivateDocuments")
  
  // 3
  do {
    try FileManager.default.createDirectory(at: documentsDirectoryURL,
                                            withIntermediateDirectories: true,
                                            attributes: nil)
  } catch {
    print("Couldn't create directory")
  }
  return documentsDirectoryURL
}()

這是一個非常方便的變量,它存儲數據庫文件夾路徑的計算值,您在此處將其命名為“PrivateDocuments”。以下是它的工作原理:

  • 1) 獲取應用程序的Documents文件夾,這是所有應用程序都具有的標準文件夾。
  • 2) 構建指向包含所有內容的數據庫文件夾的路徑。
  • 3) 如果文件夾不存在則創建該文件夾并返回路徑。

您現在已準備好實現上述兩個函數。 您將從保存的文檔加載數據庫開始。 將以下代碼添加到類的底部:

class func loadScaryCreatureDocs() -> [ScaryCreatureDoc] {
  // 1
  guard let files = try? FileManager.default.contentsOfDirectory(
    at: privateDocsDir,
    includingPropertiesForKeys: nil,
    options: .skipsHiddenFiles) else { return [] }
  
  return files
    .filter { $0.pathExtension == "scarycreature" } // 2
    .map { ScaryCreatureDoc(docPath: $0) } // 3
}

這會加載存儲在磁盤上的所有.scarycreature文件,并返回一個ScaryCreatureDoc項目數組。 在這里,你這樣做:

  • 1) 獲取數據庫文件夾的所有內容。
  • 2) 過濾列表僅包含以.scarycreature結尾的項目。
  • 3) 從篩選的列表中加載數據庫并將其返回。

接下來,您要正確返回用于存儲新文檔的下一個可用路徑。 用這個替換nextScaryCreatureDocPath()的實現:

// 1
guard let files = try? FileManager.default.contentsOfDirectory(
  at: privateDocsDir,
  includingPropertiesForKeys: nil,
  options: .skipsHiddenFiles) else { return nil }

var maxNumber = 0

// 2
files.forEach {
  if $0.pathExtension == "scarycreature" {
    let fileName = $0.deletingPathExtension().lastPathComponent
    maxNumber = max(maxNumber, Int(fileName) ?? 0)
  }
}

// 3
return privateDocsDir.appendingPathComponent(
  "\(maxNumber + 1).scarycreature",
  isDirectory: true)

與之前的方法類似,您獲取數據庫的所有內容,過濾它們,附加到privateDocsDir并返回它。

跟蹤磁盤上所有項目的簡單方法是按編號命名文件夾;通過查找名為最高編號的文件夾,您可以輕松地提供下一個可用路徑。

注意:使用數字只是為基于文檔的數據庫命名和跟蹤文件夾的一種方法。 只要每個文件夾都有一個唯一的名稱,您就可以選擇其他方式,這樣就不會意外地用新的文件替換現有的項目。

好的 - 你差不多完成了! 是時候嘗試了。


Trying It Out! - 試一試!

在運行應用程序之前,在privateDocsDir的類屬性定義的末尾return之前添加此行:

print(documentsDirectoryURL.absoluteString)

當應用程序在模擬器中運行時,這將幫助您準確了解計算機上包含文檔的文件夾的位置。

現在,運行應用程序。 從控制臺復制值,但跳過“file://”部分。 路徑應以“/ Users”開頭,以“/ PrivateDocuments”結尾。

打開Finder應用程序。 從菜單導航,Go ? Go to Folder并在對話框中粘貼路徑:

Paste the path you copied from the console here

打開文件夾時,其內容應如下所示:

您在此處看到的項目是由MasterViewController.loadCreatures()創建的,這是在初學者項目中為您實現的。 每次運行應用程序時,它都會在磁盤上添加更多文檔......這實際上并不正確! 發生這種情況是因為在加載應用程序時您沒有從磁盤讀取數據庫的內容。 你馬上解決這個問題,但首先,你需要實現更多的東西。

如果用戶在表視圖上觸發刪除,則還需要從數據庫中刪除該生物。 在同一個文件中,用以下代碼替換tableView(_:commit:forRowAt :)的實現:

if editingStyle == .delete {
  let creatureToDelete = creatures.remove(at: indexPath.row)
  creatureToDelete.deleteDoc()
  tableView.deleteRows(at: [indexPath], with: .fade)
}

最后一件事你需要考慮:你完成了添加和刪除功能,但是編輯呢? 別擔心......它就像實現刪除一樣簡單。

打開DetailViewController.swift并在rateViewRatingDidChange(rateView:newRating :)titleFieldTextChanged(_ :)的末尾添加以下行:

detailItem?.saveData()

這只是告訴ScaryCreatureDoc對象在用戶界面中更改其信息時保存自己。


Saving and Loading Images - 保存和加載圖像

該生物應用程序剩下的最后一件事是保存和加載圖像。 您不會將它們保存在列表文件本身中;將它們作為普通圖像文件保存在其他存儲數據旁邊會更方便,所以現在你要編寫代碼。

ScaryCreatureDoc.swift中,在類的末尾添加以下代碼:

func saveImages() {
  // 1
  if _fullImage == nil || _thumbImage == nil { return }
  
  // 2
  do {
    try createDataPath()
  } catch {
    print("Couldn't create save Folder. " + error.localizedDescription)
    return
  }
  
  // 3
  let thumbImageURL = docPath!.appendingPathComponent(Keys.thumbImageFile.rawValue)
  let fullImageURL = docPath!.appendingPathComponent(Keys.fullImageFile.rawValue)
  
  // 4
  let thumbImageData = _thumbImage!.pngData()
  let fullImageData = _fullImage!.pngData()
  
  // 5
  try! thumbImageData!.write(to: thumbImageURL)
  try! fullImageData!.write(to: fullImageURL)
}

這有點類似于之前在saveData()中編寫的內容:

  • 1) 確保存儲圖像;否則,沒有必要繼續執行。
  • 2) 如果需要,創建數據路徑。
  • 3) 構建指向磁盤上每個文件的路徑。
  • 4) 將每個圖像轉換為PNG數據表示,以便您在磁盤上寫入。
  • 5) 將生成的數據寫入磁盤的各自路徑中。

項目中有兩點要調用saveImages()。

第一個是初始化程序init(title:rating:thumbImage:fullImage:)。 打開ScaryCreatureDoc.swift,并在此初始化程序結束時,在saveData()之后,添加以下行:

saveImages()

第二點是在DetailViewController.swift里面的imagePickerController(_:didFinishPickingMediaWithInfo:)。 您將找到一個調度閉包,您可以在其中更新detailItem中的圖像。 將此行添加到閉包的末尾:

self.detailItem?.saveImages()

現在,您可以保存,更新和刪除生物。 該應用程序已準備好保存您將來可能遇到的所有恐怖和非恐怖的生物。

如果你現在要構建并運行并從磁盤恢復可怕的生物,你會發現有些人擁有圖像而其他人沒有,就像這樣:

使用Xcode調試控制臺中打印的路徑,查找并刪除PrivateDocuments文件夾。 現在構建并運行一次。 你會看到他們的圖像的初始生物:

當你保存你的生物時,你無法看到你已經保存了什么。 打開MasterViewController.swift并將loadCreatures()的實現替換為:

creatures = ScaryCreatureDatabase.loadScaryCreatureDocs()

這會從磁盤加載生物而不是使用預先填充的列表。

構建并再次運行。 嘗試更改標題和評級。 當您返回主屏幕時,應用程序會將更改保存到磁盤。


Implementing NSSecureCoding - 實現NSSecureCoding

在iOS 6中,Apple推出了一些基于NSCoding的新功能。 您可能已經注意到,您解碼了存檔中的值,以將它們存儲在如下行的變量中:

let title = aDecoder.decodeObject(forKey: Keys.title.rawValue) as! String

讀取值時,它已經加載到內存中,然后將其轉換為您應該知道的數據類型。 如果出現問題并且先前寫入的對象的類型無法轉換為所需的數據類型,則該對象將完全加載到內存中,然后轉換嘗試將失敗。

訣竅是行動的順序;雖然應用程序根本不會使用該對象,但該對象已經完全加載到內存中,然后在失敗的轉換后釋放。

NSSecureCoding提供了一種加載數據的方法,同時在解碼時驗證其類,而不是之后。 最好的部分是它非常容易實現。

首先,在ScaryCreatureData.swift中,使類實現協議NSSecureCoding,以便類聲明如下所示:

class ScaryCreatureData: NSObject, NSCoding, NSSecureCoding

然后在類的末尾添加以下代碼:

static var supportsSecureCoding: Bool {
  return true
}

這就是您遵守NSSecureCoding所需的全部內容,但您尚未從中獲益。

用以下代碼替換encode(with :)實現:

aCoder.encode(title as NSString, forKey: Keys.title.rawValue)
aCoder.encode(NSNumber(value: rating), forKey: Keys.rating.rawValue)

現在,用這個替換init?(coder :)的實現:

let title = aDecoder.decodeObject(of: NSString.self, forKey: Keys.title.rawValue) 
  as String? ?? ""
let rating = aDecoder.decodeObject(of: NSNumber.self, forKey: Keys.rating.rawValue)
self.init(title: title, rating: rating?.floatValue ?? 0)

如果查看新的初始化代碼,您會注意到這個decodeObject(of:forKey :)decodeObject(forKey :)不同,因為它所采用的第一個參數是一個類。

不幸的是,使用NSSecureCoding要求你在Objective-C中使用string和float對應對象;這就是使用NSStringNSNumber的原因,然后將值轉換回Swift StringFloat。

最后一步是告訴NSKeyedArchiver使用安全編碼。 在ScaryCreatureDoc.swift中,更改saveData()中的以下行:

let codedData = try! NSKeyedArchiver.archivedData(withRootObject: data, 
                                                  requiringSecureCoding: false)

替換成下面

let codedData = try! NSKeyedArchiver.archivedData(withRootObject: data, 
                                                  requiringSecureCoding: true)

在這里,您只需將true傳遞給requiresSecureCoding而不是false。 這告訴NSKeyedArchiver在歸檔對象及其后代時強制執行NSSecureCoding。

注意:以前使用NSSecureCoding編寫的文件現在不兼容。 您需要刪除以前保存的所有數據或從模擬器中卸載應用程序。 在實際場景中,您必須遷移舊數據。

后記

本篇主要講述了基于NSCoding的持久化存儲,感興趣的給個贊或者關注~~~

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容