Core Data資料遷移及單元測試

weixin_33890499發表於2017-09-14

1 前言

檔案結構:對於使用CoreData作為資料本地化的APP,在工程中CoreData會管理一個.xcdatamodeld包,其中包含各個版本的.xcdatamodeld資料模型,在APP執行時它們分別會被編譯成.momd的資料夾和.mom的檔案,並放於主bundle中。每個.mom檔案代表了其對於版本的NSManagedObjectModle,當APP每次啟動初始本地資料庫時,CoreData會檢查當前的NSManagedObjectModle的版本是否和資料庫PersistenceStore中的NSManagedObjectModle的版本是否一致,如果不一致會根據以下兩個資料決定程式下一步的執行結果。如果一致則會正常初始化資料庫。

//是否開啟自動資料遷移
description.shouldMigrateStoreAutomatically = true
//是否自動推斷對映模型,官方文件中對此屬性的解釋是確定CoreData是否為資料遷移自動推斷對映模型,
//但實際測試中發現,當此屬性為True時,CoreData仍會先在所有Bundle中尋找一個對映模型MappingModel,
//將識別出來的本地儲存Store用的NSManagedObjectModel對映到目標物件模型NSManagedObjectModel
//如找不到對應的MappingModel時才會自動推斷一個對映模型
description.shouldInferMappingModelAutomatically = true

版本判斷:判斷兩個NSManagedObjectModle是否為同一版本時,只需判斷其中實體陣列是否相同。因為CoreData在資料遷移時對版本的控制是通過其中所有的實體各個屬性和關係生成一個hashVersion。

資料遷移:如果開啟資料庫自動遷移,CoreData會根據使用者定義好的策略進行資料庫遷移,此過程必須放在Appdelegate中didFinishLaunchingWithOptions方法中,並且不能在子執行緒執行。CoreData僅支援單個NSManagedObjectModle版本間隔之間的自動遷移,多版本之間遷移需要自定義遷移過程。如果未開啟自動資料遷移,CoreData會丟擲異常提示建立PersistentStore的NSManagedObjectModle和開啟PersistentStore的NSManagedObjectModle不一致。

執行步驟:Since the migration is performed as a three-step process (first create the data, then relate the data, then validate the data)。在蘋果官方文件中,CoreData執行資料遷移分三個階段,CoreData根據原始資料模型和目標資料模型去載入或者建立一個資料遷移需要的對映模型,具體表現為以下三步。只有執行完下述步驟,當資料遷移成功後,舊的資料庫才會被清除。

  • 1)CoreData將源資料庫中所有物件拷貝到新的資料庫中。
  • 2)CoreData根據對映模型連線並再次關連所有物件。
  • 3)在資料拷貝過程中,CoreData不會對資料進行校驗,等前兩步結束後,CoreData才會在目標資料庫中對所有資料進行資料校驗。

注意:如果模型結構改動較大,並且自動遷移和自動推斷對映模型屬性都為YES時候(預設設定),CoreData將自動執行資料遷移,這種遷移將不會丟擲任何錯誤,遷移成功後會刪除舊資料,導致資料永久丟失。測試時需注意這點,避免來回切換版本執行測試。每次測試應刪除原有程式,再覆蓋安裝。

1 資料遷移型別

根據對資料庫改變的幅度大小,相應的資料遷移可以為以下四個量級。

  • 輕量資料遷移:當資料模型xcdatamodeld改動不大時,為NSPersistentContainer中設定一些屬性,CoreData會自動完成資料遷移的工作。
  • 簡單手動資料遷移:需要指定如何將舊的資料集對映到新的資料集,可以使用GUI工具建立相應的對映模型NSMappingModel,系統會完成部分自動化操作。
  • 複雜手動資料遷移:同樣使用對映模型,但是需要用自定義程式碼指定資料的轉換邏輯,需要建立NSEntityMigrationPolicy的子類來實現資料遷移。
  • 漸進式資料遷移:應用於非連續版本資料遷移,如從version1遷移至version4。

2 資料遷移涉及到的類

2.1 NSManagedObjectModle

在工程中會有一個以.xcdatamodeld結尾的包,其中管理若干個.xcdatamodeld檔案,每個檔案對應一個版本的NSManagedObjectModle。對於每個NSManagedObjectModle,CoreData都會根據其實體集生成一個HashVersion,當CoreData初始化資料庫時,這個資訊會以配置資訊的方式儲存在NSPersistentStore中,留待將來進行資料遷移時使用。它對應了工程中的各個NSManagedObject類,通常通過選單中的Editor生成這些類。當手動建立這些類時,必須在NSManagedObjectModle對應的實體的屬性皮膚中的Class欄位和Module欄位中填入相應的類名和名稱空間。

2.2 NSMigrationManager

資料遷移管理者,CoreData進行自動資料遷移時改物件由CoreData複雜進行建立和管理,它是資料遷移的執行者,可以通過觀察期Progress獲取資料遷移進度。

2.3 NSMappingModel

對映模型,它負責將某個版本的NSManagedObjectModle中的所有實體對映到對應的版本中。由CoreData自動推斷或在新建檔案中手動建立,CoreData會自動填充大部分欄位。

2.4 NSEntityMapping

實體對映模型,位於NSMappingModel,它負責具體的某個實體從源NSManagedObjectModle對映到目標模型中。可以在NSMappingModel中新增或者刪除。

2.5 NSEntityMigrationPolicy

實體遷移策略,位於NSEntityMapping中,它負責具體的某個實體從源NSManagedObjectModle對映到目標模型中,它比NSEntityMapping更高階,可以進行深層次自定義。可以在NSEntityMapping中指定唯一一個NSEntityMigrationPolicy,需要注意的是如果實在Swift中,必須加上工程名的字首。

3 輕量資料遷移

在執行資料遷移之前,先新建一個xcdatamodeld新版本檔案,並將其設定為當前系統採用的資料模型版本檔案。啟用NSPersistentStoreDescription中的shouldInferMappingModelAutomatically屬性。預設建立NSPersistentContainer時開啟。當滿足以下條件時,CoreData會推斷出一個對映模型進行資料遷移。隨後編譯執行APP,資料遷移完成。更多滿足輕量資料遷移的條件見官網

  • 刪除實體,屬性或者關係
  • 重新命名實體,屬性或者關係
  • 新增新的可選的屬性
  • 新增帶預設值的必選屬性
  • 將可選屬性改為必選屬性,併為其指定預設值
  • 將必選屬性改為可選屬性
  • 改變實體包含關係
  • 新增一個高一層實體,並將屬性沿著層級上下移動
  • 將to-One關係改變為to-Many
  • 將to-many型別的關係中的non-ordered改變為ordered或者反向改變

4 簡單手動資料遷移

當對NSManagedObjectModle模型改變超出輕量資料遷移的限制時,CoreData已經不能自動推斷出一個對映模型,此時我們需要手動建立MappingModel檔案,如果新模型中的實體屬性和關係都從原模型的某一個實體中繼承,此時只需進行簡單的手動資料遷移操作,不需要自定義遷移策略MigrationPolicy。

首先建立新版本的NSManagedObjectModel檔案,切記必須執行完所有對其的改變操作,並編譯程式成功,再建立對應源和目標版本的對映檔案NSMappingModel。CoreData會根據當前選擇的兩個版本的NSManagedObjectModel對NSMappingModel進行部分初始化操作,此時在ENTITY MAPPINGS中大多數的實體對映名稱都為EntityToEntity,其中to連線的前面是source NSManagedObjectModel中的實體,後面是destination NSManagedObjectModel中的實體。如果資料庫有新增並且與源資料庫無關的實體,其不需要對映,因此也不會在這裡展示 ,如果新增實體和源資料庫相關,需要在改實體的Entity Mapping選項中指定其Source,此時該實體的Mapping會自動更新。

確保目標資料庫中與源資料庫相關的所有實體都有對應的Entity Mapping,確保其中每個實體所有屬性的Value Expression都被指定。對於某個Entity Mapping,可以使用Filter Predicate限制對映發生的條件,如在名為NoteToAttachment的Entity Mapping中,Filter Predicate指定為image != nil,這表示對於源資料庫中的每個Note實體,如果其image不為空的時候,為其建立一個Attachment物件,並進行Mapping描述的相關賦值操作。

對於關係的對映,對於每個Relationship Mapping,在Key Path填入“$source”,Mapping Name中選擇其指向的實體對映。這時CoreData會產生一個函式

FUNCTION($manager, "destinationInstancesForEntityMappingNamed:sourceInstances:" , "NoteToAttachment", $source)

FUNCTION($manager, "destinationInstancesForSourceRelationshipNamed:sourceInstances:" , "attachments", $source.attachments)

第一個函式中manager指定是Migration Manager,它由CoreData在資料遷移時建立,$source指的是這個EntityMapping的source。這個函式的作用是當前對映執行時,Migration Manager根據函式第二個$source引數找到當前源資料庫中的實體,根據第一個引數指定的對映NoteToAttachment執行對映操作,得到一個目標資料庫中的實體Attachment,並將這個實體Attachment賦值給當前實體執行遷移後的實體物件。該解釋不代表CoreData內部實現方式,僅為簡化說明。另外時間測試發現成對出現的關係只需實現一個即可,這應該和CoreData內部實現有關。

第二個函式中不同的是它將源實體中名字為“attachment”的關係對應的$source.attachments集合對映到資料遷移後的實體中。函式中並未指定需要使用的對映關係,但是在屬性皮膚中仍可以選擇,此處具體邏輯還有待探究。
隨後編譯執行APP,資料遷移完成。

5 複雜手動資料遷移

當對NSManagedObjectModle模型改變超出簡單手動資料遷移限制時,首先CoreData不能自動推斷出一個對映模型,同時當我們建立對映模型時,CoreData也無法通過NSMappingModel指定新的屬性建立等操作。此時不僅需要手動建立NSMappingModel檔案。還需要為其中的某些NSEntityMapping指定遷移策略NSEntityMigrationPolicy

複雜手動資料遷移大多發生在,新模型中的實體屬性和關係不能從原模型的某一個實體中繼承,需要根據原模型中實體的某些屬性新建。此時必須進行復雜的手動資料遷移操作,需要自定義遷移策略MigrationPolicy。如果一個類需要管理多個版本的某個實體遷移策略,可以在NSMappingModel檔案中的User Info中新增欄位區分,他們可以通過mapping.userInfo獲得。

  • 第一步:建立新的NSManagedObjectModle版本,構建新的資料結構。
  • 第二步:建立NSMappingModel,選擇正確的Source Model和Destination Model。注意當建立NSMappingModel後不能再更改NSManagedObjectModle,否則CoreData在資料遷移時無法找到Mapping Model檔案。這是因為CoreData在資料遷移時識別的是hash version,儘管Model版本未改變,但是由於其內容發生改變,因此hash version也發生變化,導致找不到對應hash version版本之間的Mapping Model。如果一定要更改NSManagedObjectModle,則需要刪除NSMapping Model並重新建立。
  • 第三步:在NSMapping Model中通過簡單手動資料遷移中的步驟實現能識別的屬性和關係遷移。刪除無法從源NSManagedObjectModle中推斷的關係和屬性。
  • 第四部:新建NSEntityMigrationPolicy子類,並將其以“工程名.類名”的方式填入NSMapping Model中對應的實體內。
  • 第五步:根據需要實現下面兩個方法。正如方法名描述的,CoreData將會呼叫所有實體的物件的第一個方法,完成資料遷移三大步驟(物件的對映,關係的對映,資料校驗)中的第一步,再呼叫第二個方法完成第二步。
override func createDestinationInstances(forSource sInstance: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager) throws {
}

override func createRelationships(forDestination dInstance: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager) throws {    
}

第一個方法的使用如下:

override func createDestinationInstances(forSource sInstance: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager) throws {
  //1.建立目標物件,Manager有兩個CoreData堆疊,分別用於讀取老的源資料和寫入新的目標資料,因此此處一定要使用destinationContext,由於資料庫遷移並未完成,
  //NSManagedObjectModle還未成功載入,因此無法使用ImageAttachment(contex: NSManagedObjectContext)方法
  let description = NSEntityDescription.entity(forEntityName: "ImageAttachment", in: manager.destinationContext)
  let newAttachment = ImageAttachment(entity: description!, insertInto: manager.destinationContext)
  
  //4. 即使是手動的資料遷移策略,但是大多數屬性的遷移應該使用在Mapping Model中定義的expression實現
  do {
    try traversePropertyMappings(mapping:mapping, block: { (propertyMapping, destinationName) in
      if let valueExpression = propertyMapping.valueExpression {
        let context: NSMutableDictionary = ["source": sInstance]
        guard let destinationValue = valueExpression.expressionValue(with: sInstance, context: context) else {
          return
        }
        newAttachment.setValue(destinationValue, forKey: destinationName)
      }
    })
  } catch let error as NSError {
    print("traversePropertyMappings faild \(error), \(error.userInfo)")
  }
  
  //5. 對在Mapping Model中為無法描述的屬性,此處為新的遷移物件賦值
  if let image = sInstance.value(forKey: "image") as? UIImage {
    newAttachment.setValue(image.size.width, forKey: "width")
    newAttachment.setValue(image.size.height, forKey: "height")
  }
  let body = sInstance.value(forKeyPath: "note.body") as? NSString ?? ""
  newAttachment.setValue(body.substring(to: 80), forKey: "caption")
  
  //6. 將NSMigrationManager與sourceInstance、newAttachment和mapping關聯,以便將來在資料遷移第二階段建立關係階段時,Manager可以正確的拿到需要的物件去建立物件間的關係
  manager.associate(sourceInstance: sInstance, withDestinationInstance: newAttachment, for: mapping)
}

//2. 定義函式,其作用是檢查MappingModel檔案中當前實體對映的所以Attribute對映(不含Relationship對映)的有效性
private func traversePropertyMappings(mapping:NSEntityMapping, block: (NSPropertyMapping, String) -> ()) throws {
  if let attributeMappings = mapping.attributeMappings {
    for propertyMapping in attributeMappings {
      if let destinationName = propertyMapping.name {
        block(propertyMapping, destinationName)
      } else {
        //3. 當某個Property Mapping的名字為空時,表示在Mapping Model配置錯誤,丟擲異常資訊給予提示
        let message = "Attribute destination not configured properly"
        let userInfo = [NSLocalizedFailureReasonErrorKey: message]
        throw NSError(domain: errorDomain, code: 0, userInfo: userInfo)
      }
    }
  } else {
    let message = "No Attribute mappings found!"
    let userInfo = [NSLocalizedFailureReasonErrorKey: message]
    throw NSError(domain: errorDomain, code: 0, userInfo: userInfo)
  }
}

第二個方法中,可以通過Manager拿到dInstance的sourceInstance,並通過Mappingname拿到sourceInstance中某個關係指向的物件以該Mapping對映後的物件,從而完成關係的建立。但是通常,由於我們在關係中勾選了Inverse,因此對於成對出現的關係常常其中一個CoreData會自動對映,因此該方法一般不用。
隨後編譯執行APP,資料遷移完成。

6 漸進式資料遷移

CoreData只能自動執行單個版本之間的資料遷移,多版本之間的資料遷移有兩種策略。第一種,為所有的版本組合建立對映模型,這種方式效率太低,直接廢棄。第二種方式是建立一個策略,讓資料庫一個版本接一個版本遷移到最新版本。
此時需要建立單獨的MigrationManager,使其在資料庫初始化時進行資料遷移工作。

執行資料遷移
performMigration()
單個版本的資料遷移
migrateStoreAt(URL storeURL: URL, fromModel from: NSManagedObjectModel, toModel to: NSManagedObjectModel, mappingModel: NSMappingModel? = nil) -> Bool

以下是完整程式碼:

import UIKit
import CoreData

class DataMigrationManager: NSObject {
  let enableMigrations: Bool
  let modelName: String
  let storeName: String = "UnCloudNotesDataModel"
  var stack: CoreDataStack {
    guard enableMigrations, !store(at: storeURL, isCompatibleWithModel: currentModel) else {
      return CoreDataStack(modelName: modelName)
    }
    do {
      try performMigration()
    } catch {
      print(error)
    }
    return CoreDataStack(modelName: modelName)
  }
  private var modelList = [NSManagedObjectModel]()
  
  init(modelNamed: String, enableMigrations: Bool = false) {
    self.modelName = modelNamed
    self.enableMigrations = enableMigrations
    super.init()
  }
  
  private func metadataForStoreAtURL(sroreURL: URL) -> [String: Any] {
    let metadata: [String: Any]
    do {
      metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(ofType: NSSQLiteStoreType, at: sroreURL, options: nil)
    } catch {
      metadata = [:]
      print("Error retrieving metadata for store at URL: \(sroreURL): \(error)")
    }
    return metadata
  }
  
  private func store(at storeURL: URL, isCompatibleWithModel model: NSManagedObjectModel) -> Bool {
    let storeMetadata = metadataForStoreAtURL(sroreURL: storeURL)
    return model.isConfiguration(withName: nil, compatibleWithStoreMetadata: storeMetadata)
  }

  private var applicationSupportURL: URL {
    let path = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true).first
    return URL(fileURLWithPath: path!)
  }
  
  private lazy var storeURL: URL = {
    let storeFileName = "\(self.storeName).sqlite"
    return URL(fileURLWithPath: storeFileName, relativeTo: self.applicationSupportURL)
  }()
  
  private var storeModel: NSManagedObjectModel? {
    return NSManagedObjectModel.modelVersionsFor(modelNamed: modelName).filter{self.store(at: storeURL, isCompatibleWithModel: $0)}.first
  }
  
  private lazy var currentModel: NSManagedObjectModel = NSManagedObjectModel.model(named: self.modelName)
  
  func performMigration() throws {
    // 判斷當前程式的模型版本是否為最新版本,此處採用粗暴方法殺死程式,正常開發中,當前的Model一定為最新版本,此判斷內邏輯不會觸發。但是此處最好採用更溫和的方式。
    if !currentModel.isVersion4 {
      //      fatalError("Can only handle migrations to version 4!")
      print("Can only handle migrations to version 4!")
      return
    }

    // 準備當前工程的所有NSManagedObjectModle檔案
    modelList = NSManagedObjectModel.modelVersionsFor(modelNamed: "UnCloudNotesDataModel")

    //查詢資料庫對應的NSManagedObjectModle
    guard let currentStoreModel = self.storeModel else {
      let message = "Can not find current store model"
      let userInfo = [NSLocalizedFailureReasonErrorKey: message]
      throw NSError(domain: errorDomain, code: 0, userInfo: userInfo)
    }
    
    //查詢資料庫對應的NSManagedObjectModle,和最近的下一個版本NSManagedObjectModle在陣列中的索引
    guard var sourceModelIndex = modelList.index(of: currentStoreModel) else {
      let message = "Store model is not within momd folder named with current project's name"
      let userInfo = [NSLocalizedFailureReasonErrorKey: message]
      throw NSError(domain: errorDomain, code: 0, userInfo: userInfo)
    }
    var destModelIndex = sourceModelIndex + 1
    
    // 取出目標NSManagedObjectModle
    while destModelIndex < modelList.count {
      let sourceModel = modelList[sourceModelIndex]
      let destModel = modelList[destModelIndex]
      let mappingModel = NSMappingModel(from: nil, forSourceModel: sourceModel, destinationModel: destModel)
      let success = migrateStoreAt(URL: storeURL, fromModel: sourceModel, toModel: destModel, mappingModel: mappingModel)
      if !success {
        let message = "One sub-migration stage is failed"
        let userInfo = [NSLocalizedFailureReasonErrorKey: message]
        throw NSError(domain: errorDomain, code: 0, userInfo: userInfo)
      } else {
        sourceModelIndex = destModelIndex
        destModelIndex += 1
      }
    }
  }

  private func migrateStoreAt(URL storeURL: URL, fromModel from: NSManagedObjectModel, toModel to: NSManagedObjectModel, mappingModel: NSMappingModel? = nil) -> Bool {
    //1 建立遷移管理器
    let migrationManager = NSMigrationManager(sourceModel: from, destinationModel: to)
    migrationManager.addObserver(self, forKeyPath: "migrationProgress", options: .new, context: nil)
    
    //2 確定對映模型
    var migrationMappingModel: NSMappingModel
    var mappingSource: String
    if let mappingModel = mappingModel {
      migrationMappingModel = mappingModel
      mappingSource = "Coustom define"
    } else {
      migrationMappingModel = try! NSMappingModel.inferredMappingModel(forSourceModel: from, destinationModel: to)
      mappingSource = "CoreData infer"
    }
    
    //3 建立臨時的檔案路徑URL,儲存遷移後的資料庫
    let targetURL = storeURL.deletingLastPathComponent()
    let destinationName = storeURL.lastPathComponent + "~1"
    let destinationURL = targetURL.appendingPathComponent(destinationName)
    print("Migration start ===========================================")
    print("From Model: \(from.entityVersionHashesByName)")
    print("To Model: \(to.entityVersionHashesByName)")
    print("Mapping model: %@", mappingSource)
    print("Migrating store \(storeURL) to \(destinationURL)")

    //4 進行資料遷移
    let success: Bool
    do {
      try migrationManager.migrateStore(from: storeURL, sourceType: NSSQLiteStoreType, options: nil, with: migrationMappingModel, toDestinationURL: destinationURL, destinationType: NSSQLiteStoreType, destinationOptions: nil)
      success = true
    } catch {
      success = false
      print("Store Migration failed: \(error)")
    }
    
    //5 資料遷移成功後刪除源資料庫,並將新資料庫移動回原路徑
    if success {
      print("Store Migration Completed Successfully")
      
      let fileManager = FileManager.default
      do {
        try fileManager.removeItem(at: storeURL)
        try fileManager.moveItem(at: destinationURL, to: storeURL)
        print("Replace store file completed successfully")
      } catch {
        print("Replace store file faild, Error: \(error)")
      }
    }
    migrationManager.removeObserver(self, forKeyPath: "migrationProgress", context: nil)
    return success
  }

  override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    if keyPath == "migrationProgress" {
      super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context);
    }
  }
}

extension NSManagedObjectModel {
  class var version4: NSManagedObjectModel {
    return unCloudNotesModel(named: "UnCloudNotesDataModelv4")
  }
  var isVersion4: Bool {
    return self == type(of: self).version4
  }
  
  private class func modelURLs(in modelFolder: String) -> [URL] {
    return Bundle.main.urls(forResourcesWithExtension: "mom", subdirectory: "\(modelFolder).momd") ?? []
  }
  
  class func modelVersionsFor(modelNamed modelName: String) -> [NSManagedObjectModel] {
    return modelURLs(in: modelName).flatMap(NSManagedObjectModel.init)
  }
  
  class func unCloudNotesModel(named modelName: String) -> NSManagedObjectModel {
    let model = modelURLs(in: "UnCloudNotesDataModel").filter {$0.lastPathComponent == "\(modelName).mom" }.first.flatMap(NSManagedObjectModel.init)
    return model ?? NSManagedObjectModel()
  }
  
  // 找到momd資料夾,當用此路徑建立NSManagedObjectModle時CoreData會查詢當前版本的Model路徑URL並例項化一個Model物件。Warning:該方法只有當工程具有多個版本的NSManagedObjectModle時有效
  class func model(named modelName: String, in bundle:Bundle = .main) -> NSManagedObjectModel {
    return bundle.url(forResource: modelName, withExtension: "momd").flatMap(NSManagedObjectModel.init) ?? NSManagedObjectModel();
  }
}

//  判斷NSManagedObjectModle是同一個版本需要判斷其中實體陣列是否相同
func == (firstModel: NSManagedObjectModel, otherModel: NSManagedObjectModel) -> Bool {
  return firstModel.entitiesByName == otherModel.entitiesByName;
}

7 使用三方框架的資料遷移

通常在使用CoreData時候並不會自己動手建立CoreData棧,最常用的第三方框架是MagicRecord,在使用MagicRecord時,其內部預設開啟自動資料遷移和自動推斷對映模型。通常其初始化方法需要在APPDelegate的didFinishLaunchApplication中進行,而資料庫遷移需要在其初始化方法前進行。MagicRecord通常只需要一個資料庫名稱完成初始化,可以通過NSPersistentStore.MR_urlForStoreName(storeName)獲取資料庫路徑,執行資料遷移工作。

+ (NSDictionary *) MR_autoMigrationOptions {
    // Adding the journalling mode recommended by apple
    NSMutableDictionary *sqliteOptions = [NSMutableDictionary dictionary];
    [sqliteOptions setObject:@"WAL" forKey:@"journal_mode"];
    
    NSDictionary *options = [NSDictionary dictionaryWithObjectsAndKeys:
                             [NSNumber numberWithBool:YES], NSMigratePersistentStoresAutomaticallyOption,
                             [NSNumber numberWithBool:YES], NSInferMappingModelAutomaticallyOption,
                             sqliteOptions, NSSQLitePragmasOption,
                             nil];
    return options;
}

8 單元測試

在使用CoreData開發App時,有時我們需要測試單個方法是否有效。如果按照慣例直接執行程式檢視否個方法或者摸個邏輯是否正確,並且直接對資料庫進行修改,可能會面臨以下問題。官網教程

  • 為了測試一個小的邏輯,需要執行整個程式,還需要進入到目標頁面觸發特定邏輯,這樣很耗時間。
  • 團隊協作開發中,並不希望自己的模組受到其他開發者模組變動的影響,即當別人改變資料後導致自己的模組無法測試。
  • 在對某些裝置測試時並不想破壞其中已有的資料。

此時,單元測試能很好的提升工作效率。它並不需要執行整個程式,直接對需要測試部分邏輯檢查。為了更好解決上述問題,單元測試必須符合以下幾個標準。

  • 快:單元測試的執行時間要儘量低,即其邏輯滿足測試需要即可。
  • 獨立:將需要測試的邏輯儘量拆分,每個單元僅賦值獨立的一小塊邏輯,並且單元之間相互不會干擾。
  • 可重複:基於同樣的程式碼多次測試應該得到相同的結果,因此單元測試時資料庫需要用in-memory store的方式,確保每次測試後資料庫不會被改變。
  • 自校驗:測試結果需要指出失敗和成功。
  • 時效性:單元測試需要在正常邏輯程式碼完成後建立。

對於已有資料的APP,將這CoreData持久化型別改為in-memory store後,在初始化資料庫完成後,不會載入同一URL的資料庫,相反會建立一個新的資料庫,並且每次測試對資料的改動在測試完成後都將被清空,並且將其持久化型別改回SQLite後,原資料依舊存在。

開啟單元測試的方式可以是在建立工程時候直接勾選UnitTest或者在工程中新增型別為UnitTest的Target。接下來為需要測試的模組新建一個型別為UnitTest的檔案。併為每一個需要測試的邏輯新建一個方法。在XCTestCase的子類中setUp()方法會在每次測試開始時呼叫,而tearDown()會在每次測試結束後呼叫。

對於同步執行的方法的測試,可以被測試方法執行完後,直接檢測其執行結果。對於非同步執行的方法的測試,可以通過建立expectation的方式完成,併為這個期望通過waitForException的方式設定一個超時時間。expectation的建立方式有一下三種。

func expectationTest1() {
  let expection = expectation(description: "Done!")
  someService.callMethodWithCompletionHandler() {
    expection.fulfill()
  }
  waitForExpectations(timeout: 2.0, handler: nil)
}

func expectationTest2() {
  let predicate = NSPredicate(format: "name = %@", "1")
  expectation(for: predicate, evaluatedWith: self) { () -> Bool in
    return true
  }
  waitForExpectations(timeout: 2.0, handler: nil)
}

func expectationTest3() {
  expectation(forNotification: NSNotification.Name.NSManagedObjectContextDidSave.rawValue, object: derivedContext) { (notification) -> Bool in
    return true
  }
  let camper = camperService.addCamper("Bacon Lover", phoneNumber: "910-543-9000")
  XCTAssertNotNil(camper)
  
  waitForExpectations(timeout: 2.0) { (error) in
    XCTAssertNil(error, "Save did not occur")
  }
}

其中方法一通過建立一個expectation,並在某個非同步方法的回撥中手動觸發expectation,從而觸發waitForExpectations中的回撥。方法二通過KVO的方式觀察某個物件的屬性,自動觸發waitForExpectations中的回撥。方法三通過監測某個通知從而自動觸發waitForExpectations中的回撥。

以下是完整的某個邏輯的單元測試程式碼,在完成程式碼後。可以通過點選空心菱形進行單元測試,如果通過菱形將會變綠,否則將會變為紅色的叉,此時就需監測工程中正式的邏輯程式碼和單元測試中的程式碼,判斷是哪部分程式碼出錯並進行更改。

import XCTest
import CampgroundManager
import CoreData

class CamperServiceTests: XCTestCase {
  // Forced unwrapping make sure this line of code can be compiled successfully without
  // the specification of default values of them required at initialization method. Developer
  // should make sure these variables have a value befor using them.
  var camperService: CamperService!
  var coreDataStack: CoreDataStack!
    
  override func setUp() {
    super.setUp()
    coreDataStack = TestCoreDataStack()
    camperService = CamperService(managedObjectContext: coreDataStack.mainContext, coreDataStack: coreDataStack)
  }
  
  override func tearDown() {
    super.tearDown()
    camperService = nil
    coreDataStack = nil
  }
  
  // This test creates a camper and checks the attribute, but does not store anything to persistent
  // store, because the saving action are excuted at background thread
  func testAddCamper() {
    let camper = camperService.addCamper("Bacon Lover", phoneNumber: "910-543-9000")
    XCTAssert((camper != nil), "Camper should not be nil")
    XCTAssert(camper?.fullName == "Bacon Lover")
    XCTAssert(camper?.phoneNumber == "910-543-9000")
  }
  
  // This test creates a camper and checks if the store is successful. Maincontext are pass to
  // the inition method of CamperService, because the maincontext also save the object 
  // after it stored by derived thread. More details are wthin the addCamper method.
  func testRootContextIsSavedAfterAddingCamper() {
    let derivedContext = coreDataStack.newDerivedContext()
    camperService = CamperService(managedObjectContext: coreDataStack.mainContext, coreDataStack: coreDataStack)
    
    expectation(forNotification: NSNotification.Name.NSManagedObjectContextDidSave.rawValue, object: derivedContext) { (notification) -> Bool in
      return true
    }
    
    let camper = camperService.addCamper("Bacon Lover", phoneNumber: "910-543-9000")
    XCTAssertNotNil(camper)
    
    waitForExpectations(timeout: 2.0) { (error) in
      XCTAssertNil(error, "Save did not occur")
    }
  }
}

測試驅動開發模式(TDD-Test-Driven Development)指的是通過單元測試來驗證單個功能邏輯是否正確,根據其結果進行下一步開發。只是在實際開發中並沒有這麼多閒心。

相關文章