投稿文章,作者:劉小壯
導讀:
認識CoreData—初識CoreData
認識CoreData—基礎使用
認識CoreData—使用進階
正文:
在之前的文章中,已經講了很多關於CoreData使用相關的知識點。這篇文章中主要講兩個方面,NSFetchedResultsController和版本遷移。
文章題目中雖然有“高級”兩個字,其實講的東西並不高級,只是因為上一篇文章中東西太多了,把兩個較復雜的知識點挪到這篇文章中。
文章中如有疏漏或錯誤,還請各位及時提出,謝謝!
NSFetchedResultsController
在開發過程中會經常用到UITableView這樣的視圖類,這些視圖類需要自己管理其數據源,包括網絡獲取、本地存儲都需要寫代碼進行管理。
而在CoreData中提供了NSFetchedResultsController類(fetched results controller,也叫FRC),FRC可以管理UITableView或UICollectionView的數據源。這個數據源主要指本地持久化的數據,也可以用這個數據源配合著網絡請求數據一起使用,主要看業務需求了。
本篇文章會使用UITableView作為視圖類,配合NSFetchedResultsController進行後面的演示,UICollectionView配合NSFetchedResultsController的使用也是類似,這裡就不都講了。
簡單介紹
就像上面說到的,NSFetchedResultsController就像是上面兩種視圖的數據管理者一樣。FRC可以監聽一個MOC的改變,如果MOC執行了托管對象的增刪改操作,就會對本地持久化數據發生改變,FRC就會回調對應的代理方法,回調方法的參數會包括執行操作的類型、操作的值、indexPath等參數。
實際使用時,通過FRC“綁定”一個MOC,將UITableView嵌入在FRC的執行流程中。在任何地方對這個“綁定”的MOC存儲區做修改,都會觸發FRC的回調方法,在FRC的回調方法中嵌入UITableView代碼並做對應修改即可。
由此可以看出FRC最大優勢就是,始終和本地持久化的數據保持統一。只要本地持久化的數據發生改變,就會觸發FRC的回調方法,從而在回調方法中更新上層數據源和UI。這種方式講的簡單一點,就可以叫做數據帶動UI。
FRC
但是需要注意一點,在FRC的初始化中傳入了一個MOC參數,FRC只能監測傳入的MOC發生的改變。假設其他MOC對同一個存儲區發生了改變,FRC則不能監測到這個變化,不會做出任何反應。
所以使用FRC時,需要注意FRC只能對一個MOC的變化做出反應,所以在CoreData持久化層設計時,盡量一個存儲區只對應一個MOC,或設置一個負責UI的MOC,這在後面多線程部分會詳細講解。
修改模型文件結構
在寫代碼之前,先對之前的模型文件結構做一些修改。
Employee結構
講FRC的時候,只需要用到Employee這一張表,其他表和設置直接忽略。需要在Employee原有字段的基礎上,增加一個String類型的sectionName字段,這個字段就是用來存儲section title的,在下面的文章中將會詳細講到。
初始化FRC
下面例子是比較常用的FRC初始化方式,初始化時指定的MOC,還用之前講過的MOC初始化代碼,UITableView初始化代碼這裡也省略了,主要突出FRC的初始化。
// 創建請求對象,並指明操作Employee表 NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:@"Employee"]; // 設置排序規則,指明根據height字段升序排序 NSSortDescriptor *heightSort = [NSSortDescriptor sortDescriptorWithKey:@"height" ascending:YES]; request.sortDescriptors = @[heightSort]; // 創建NSFetchedResultsController控制器實例,並綁定MOC NSError *error = nil; fetchedResultController = [[NSFetchedResultsController alloc] initWithFetchRequest:request managedObjectContext:context sectionNameKeyPath:@"sectionName" cacheName:nil]; // 設置代理,並遵守協議 fetchedResultController.delegate = self; // 執行獲取請求,執行後FRC會從持久化存儲區加載數據,其他地方可以通過FRC獲取數據 [fetchedResultController performFetch:&error]; // 錯誤處理 if (error) { NSLog(@"NSFetchedResultsController init error : %@", error); } // 刷新UI [tableView reloadData];
在上面初始化FRC時,傳入的sectionNameKeyPath:參數,是指明當前托管對象的哪個屬性當做section的title,在本文中就是Employee表的sectionName字段為section的title。從NSFetchedResultsSectionInfo協議的indexTitle屬性獲取這個值。
在sectionNameKeyPath:設置屬性名後,就以這個屬性名作為分組title,相同的title會被分到一個section中。
初始化FRC時參數managedObjectContext:傳入了一個MOC參數,FRC只能監測這個傳入的MOC發生的本地持久化改變。就像上面介紹時說的,其他MOC對同一個持久化存儲區發生的改變,FRC則不能監測到這個變化。
再往後面看到cacheName:參數,這個參數我設置的是nil。參數的作用是開啟FRC的緩存,對獲取的數據進行緩存並指定一個名字。可以通過調用deleteCacheWithName:方法手動刪除緩存。
但是這個緩存並沒有必要,緩存是根據NSFetchRequest對象來匹配的,如果當前獲取的數據和之前緩存的相匹配則直接拿來用,但是在獲取數據時每次獲取的數據都可能不同,緩存不能被命中則很難派上用場,而且緩存還占用著內存資源。
在FRC初始化完成後,調用performFetch:方法來同步獲取持久化存儲區數據,調用此方法後FRC保存數據的屬性才會有值。獲取到數據後,調用tableView的reloadData方法,會回調tableView的代理方法,可以在tableView的代理方法中獲取到FRC的數據。調用performFetch:方法第一次獲取到數據並不會回調FRC代理方法。
代理方法
FRC中包含UITableView執行過程中需要的相關數據,可以通過FRC的sections屬性,獲取一個遵守
在這個協議中有如下定義,可以看出這些屬性和UITableView的執行流程是緊密相關的。
@protocol NSFetchedResultsSectionInfo /* Name of the section */ @property (nonatomic, readonly) NSString *name; /* Title of the section (used when displaying the index) */ @property (nullable, nonatomic, readonly) NSString *indexTitle; /* Number of objects in section */ @property (nonatomic, readonly) NSUInteger numberOfObjects; /* Returns the array of objects in the section. */ @property (nullable, nonatomic, readonly) NSArray *objects; @end // NSFetchedResultsSectionInfo
在使用過程中應該將FRC和UITableView相互嵌套,在FRC的回調方法中嵌套UITableView的視圖改變邏輯,在UITableView的回調中嵌套數據更新的邏輯。這樣可以始終保證數據和UI的同步,在下面的示例代碼中將會演示FRC和UITableView的相互嵌套。
Table View Delegate
// 通過FRC的sections數組屬性,獲取所有section的count值 - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { return fetchedResultController.sections.count; } // 通過當前section的下標從sections數組中取出對應的section對象,並從section對象中獲取所有對象count - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return fetchedResultController.sections[section].numberOfObjects; } // FRC根據indexPath獲取托管對象,並給cell賦值 - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { Employee *emp = [fetchedResultController objectAtIndexPath:indexPath]; UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"identifier" forIndexPath:indexPath]; cell.textLabel.text = emp.name; return cell; } // 創建FRC對象時,通過sectionNameKeyPath:傳遞進去的section title的屬性名,在這裡獲取對應的屬性值 - (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section { return fetchedResultController.sections[section].indexTitle; } // 是否可以編輯 - (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath { return YES; } // 這裡是簡單模擬UI刪除cell後,本地持久化區數據和UI同步的操作。在調用下面MOC保存上下文方法後,FRC會回調代理方法並更新UI - (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath { if (editingStyle == UITableViewCellEditingStyleDelete) { // 刪除托管對象 Employee *emp = [fetchedResultController objectAtIndexPath:indexPath]; [context deleteObject:emp]; // 保存上下文環境,並做錯誤處理 NSError *error = nil; if (![context save:&error]) { NSLog(@"tableView delete cell error : %@", error); } } }
上面是UITableView的代理方法,代理方法中嵌套了FRC的數據獲取代碼,這樣在刷新視圖時就可以保證使用最新的數據。並且在代碼中簡單實現了刪除cell後,通過MOC調用刪除操作,使本地持久化數據和UI保持一致。
就像上面cellForRowAtIndexPath:方法中使用的一樣,FRC提供了兩個方法輕松轉換indexPath和NSManagedObject的對象,在實際開發中這兩個方法非常實用,這也是FRC和UITableView、UICollectionView深度融合的表現。
- (id)objectAtIndexPath:(NSIndexPath *)indexPath; - (nullable NSIndexPath *)indexPathForObject:(id)object;
Fetched Results Controller Delegate
// Cell數據源發生改變會回調此方法,例如添加新的托管對象等 - (void)controller:(NSFetchedResultsController *)controller didChangeObject:(id)anObject atIndexPath:(nullable NSIndexPath *)indexPath forChangeType:(NSFetchedResultsChangeType)type newIndexPath:(nullable NSIndexPath *)newIndexPath { switch (type) { case NSFetchedResultsChangeInsert: [tableView insertRowsAtIndexPaths:@[newIndexPath] withRowAnimation:UITableViewRowAnimationAutomatic]; break; case NSFetchedResultsChangeDelete: [tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationAutomatic]; break; case NSFetchedResultsChangeMove: [tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationAutomatic]; [tableView insertRowsAtIndexPaths:@[newIndexPath] withRowAnimation:UITableViewRowAnimationAutomatic]; break; case NSFetchedResultsChangeUpdate: { UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath]; Employee *emp = [fetchedResultController objectAtIndexPath:indexPath]; cell.textLabel.text = emp.name; } break; } } // Section數據源發生改變回調此方法,例如修改section title等。 - (void)controller:(NSFetchedResultsController *)controller didChangeSection:(id )sectionInfo atIndex:(NSUInteger)sectionIndex forChangeType:(NSFetchedResultsChangeType)type { switch (type) { case NSFetchedResultsChangeInsert: [tableView insertSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationAutomatic]; break; case NSFetchedResultsChangeDelete: [tableView deleteSections:[NSIndexSet indexSetWithIndex:sectionIndex] withRowAnimation:UITableViewRowAnimationAutomatic]; break; default: break; } } // 本地數據源發生改變,將要開始回調FRC代理方法。 - (void)controllerWillChangeContent:(NSFetchedResultsController *)controller { [tableView beginUpdates]; } // 本地數據源發生改變,FRC代理方法回調完成。 - (void)controllerDidChangeContent:(NSFetchedResultsController *)controller { [tableView endUpdates]; } // 返回section的title,可以在這裡對title做進一步處理。這裡修改title後,對應section的indexTitle屬性會被更新。 - (nullable NSString *)controller:(NSFetchedResultsController *)controller sectionIndexTitleForSectionName:(NSString *)sectionName { return [NSString stringWithFormat:@"sectionName %@", sectionName]; }
上面就是當本地持久化數據發生改變後,被回調的FRC代理方法的實現,可以在對應的實現中完成自己的代碼邏輯。
在上面的章節中講到刪除cell後,本地持久化數據同步的問題。在刪除cell後在tableView代理方法的回調中,調用了MOC的刪除方法,使本地持久化存儲和UI保持同步,並回調到下面的FRC代理方法中,在代理方法中對UI做刪除操作,這樣一套由UI的改變引發的刪除流程就完成了。
目前為止已經實現了數據和UI的雙向同步,即UI發生改變後本地存儲發生改變,本地存儲發生改變後UI也隨之改變。可以通過下面添加數據的代碼來測試一下,NSFetchedResultsController就講到這裡了。
- (void)addMoreData { Employee *employee = [NSEntityDescription insertNewObjectForEntityForName:@"Employee" inManagedObjectContext:context]; employee.name = [NSString stringWithFormat:@"lxz 15"]; employee.height = @(15); employee.brithday = [NSDate date]; employee.sectionName = [NSString stringWithFormat:@"3"]; NSError *error = nil; if (![context save:&error]) { NSLog(@"MOC save error : %@", error); } }
版本遷移
CoreData版本遷移的方式有很多,一般都是先在Xcode中,原有模型文件的基礎上,創建一個新版本的模型文件,然後在此基礎上做不同方式的版本遷移。
本章節將會講三種不同的版本遷移方案,但都不會講太深,都是從使用的角度講起,可以滿足大多數版本遷移的需求。
為什麼要版本遷移?
在已經運行程序並通過模型文件生成數據庫後,再對模型文件進行的修改,如果只是修改已有實體屬性的默認值、最大最小值、Fetch Request等屬性自身包含的參數時,並不會發生錯誤。如果修改模型文件的結構,或修改屬性名、實體名等,造成模型文件的結構發生改變,這樣再次運行程序就會導致崩潰。
在開發測試過程中,可以直接將原有程序卸載就可以解決這個問題,但是本地之前存儲的數據也會消失。如果是線上程序,就涉及到版本遷移的問題,否則會導致崩潰,並提示如下錯誤:
CoreData: error: Illegal attempt to save to a file that was never opened. "This NSPersistentStoreCoordinator has no persistent stores (unknown). It cannot perform a save operation.". No last error recorded.
然而在需求不斷變化的過程中,後續版本肯定會對原有的模型文件進行修改,這時就需要用到版本遷移的技術,下面開始講版本遷移的方案。
創建新版本模型文件
本文中講的幾種版本遷移方案,在遷移之前都需要對原有的模型文件創建新版本。
選中需要做遷移的模型文件 -> 點擊菜單欄Editor -> Add Model Version -> 選擇基於哪個版本的模型文件(一般都是選擇目前最新的版本),新建模型文件完成。
對於新版本模型文件的命名,我在創建新版本模型文件時,一般會拿當前工程版本號當做後綴,這樣在模型文件版本比較多的時候,就可以很容易將模型文件版本和工程版本對應起來。
創建新版本模型文件
添加完成後,會發現之前的模型文件會變成一個文件夾,裡面包含著多個模型文件。
模型文件夾
在新建的模型文件中,裡面的文件結構和之前的文件結構相同。後續的修改都應該在新的模型文件上,之前的模型文件不要再動了,在修改完模型文件後,記得更新對應的模型類文件。
基於新的模型文件,對Employee實體做如下修改,下面的版本遷移也以此為例。
修改之前
添加一個String類型的屬性,設置屬性名為sectionName。
修改之後
此時還應該選中模型文件,設置當前模型文件的版本。這裡選擇將最新版本設置為剛才新建的1.1.0版本,模型文件設置工作完成。
Show The File Inspector -> Model Version -> Current 設置為最新版本。
設置版本
對模型文件的設置已經完成了,接下來系統還要知道我們想要怎樣遷移數據。在遷移過程中可能會存在多種可能,蘋果將這個靈活性留給了我們完成。剩下要做的就是編寫遷移方案以及細節的代碼。
輕量級版本遷移
輕量級版本遷移方案非常簡單,大多數遷移工作都是由系統完成的,只需要告訴系統遷移方式即可。在持久化存儲協調器(PSC)初始化對應的持久化存儲(NSPersistentStore)對象時,設置options參數即可,參數是一個字典。PSC會根據傳入的字典,自動推斷版本遷移的過程。
字典中設置的key:
NSMigratePersistentStoresAutomaticallyOption設置為YES,CoreData會試著把低版本的持久化存儲區遷移到最新版本的模型文件。
NSInferMappingModelAutomaticallyOption設置為YES,CoreData會試著以最為合理地方式自動推斷出源模型文件的實體中,某個屬性到底對應於目標模型文件實體中的哪一個屬性。
版本遷移的設置是在創建MOC時給PSC設置的,為了使代碼更直觀,下面只給出發生變化部分的代碼,其他MOC的初始化代碼都不變。
// 設置版本遷移方案 NSDictionary *options = @{NSMigratePersistentStoresAutomaticallyOption : @YES, NSInferMappingModelAutomaticallyOption : @YES}; // 創建持久化存儲協調器,並將遷移方案的字典當做參數傳入 [coordinator addPersistentStoreWithType:NSSQLiteStoreType configuration:nil URL:[NSURL fileURLWithPath:dataPath] options:options error:nil];
修改實體名
假設需要對已存在實體進行改名操作,需要將重命名後的實體Renaming ID,設置為之前的實體名。下面是Employee實體進行操作。
修改實體名
修改後再使用實體時,應該將實體名設為最新的實體名,這裡也就是Employee2,而且數據庫中的數據也會遷移到Employee2表中。
Employee2 *emp = [NSEntityDescription insertNewObjectForEntityForName:@"Employee2" inManagedObjectContext:context]; emp.name = @"lxz"; emp.brithday = [NSDate date]; emp.height = @1.9; [context save:nil];
Mapping Model 遷移方案
輕量級遷移方案只是針對增加和改變實體、屬性這樣的一些簡單操作,假設有更復雜的遷移需求,就應該使用Xcode提供的遷移模板(Mapping Model)。通過Xcode創建一個後綴為.xcmappingmodel的文件,這個文件是專門用來進行數據遷移用的,一些變化關系也會體現在模板中,看起來非常直觀。
這裡還以上面更改實體名,並遷移實體數據為例子,將Employee實體遷移到Employee2中。首先將Employee實體改名為Employee2,然後創建Mapping Model文件。
Command + N 新建文件 -> 選擇 Mapping Model -> 選擇源文件 Source Model -> 選擇目標文件 Target Model -> 命名 Mapping Model 文件名 -> Create 創建完成。
Mapping Model 文件
現在就創建好一個Mapping Model文件,文件中顯示了實體、屬性、Relationships,源文件和目標文件之間的關系。實體命名是EntityToEntity的方式命名的,實體包含的屬性和關聯關系,都會被添加到遷移方案中(Entity Mapping,Attribute Mapping,Relationship Mapping)。
在遷移文件的下方是源文件和目標文件的關系。
對應關系
在上面圖中改名後的Employee2實體並沒有遷移關系,由於是改名後的實體,系統還不知道實體應該怎樣做遷移。所以選中Mapping Model文件的Employee2 Mappings,可以看到右側邊欄的Source為invalid value。因為要從Employee實體遷移數據過來,所以將其選擇為Employee,遷移關系就設置完成了。
設置完成後,還應該將之前EmployeeToEmployee的Mappings刪除,因為這個實體已經被Employee2替代,它的Mappings也被Employee2 Mappings所替代,否則會報錯。
設置遷移關系
在實體的遷移過程中,還可以通過設置Predicate的方式,來簡單的控制遷移過程。例如只需要遷移一部分指定的數據,就可以通過Predicate來指定。可以直接在右側Filter Predicate的位置設置過濾條件,格式是$source.height < 100,$source代表數據源的實體。
Filter Predicate
更復雜的遷移需求
如果還存在更復雜的遷移需求,而且上面的遷移方式不能滿足,可以考慮更復雜的遷移方式。假設要在遷移過程中,對遷移的數據進行更改,這時候上面的遷移方案就不能滿足需求了。
對於上面提到的問題,在Mapping Model文件中選中實體,可以看到Custom Policy這個選項,選項對應的是NSEntityMigrationPolicy的子類,可以創建並設置一個子類,並重寫這個類的方法來控制遷移過程。
- (BOOL)createDestinationInstancesForSourceInstance:(NSManagedObject *)sInstance entityMapping:(NSEntityMapping *)mapping manager:(NSMigrationManager *)manager error:(NSError **)error;
版本遷移總結
版本遷移在需求的變更中肯定是要發生的,但是我們應該盡量避免這樣的情況發生。在最開始設計模型文件數據結構的時候,就應該設計一個比較完善並且容易應對變化的結構,這樣後面就算發生變化也不會對結構主體造成大的改動。