1. 程式人生 > >一個完整的 Core Data 應用

一個完整的 Core Data 應用

在這篇文章中,我們將建立一個小型但卻全面支援 Core Data 的應用。此應用允許你建立巢狀的列表;每個列表的 item 都可以有子列表,這將允許你建立非常深層次的 items。為了讓大家完整的瞭解發生了什麼,我們將通過使用手動建立堆疊的方式來代替 Xcode 中 Core Data 的模板。這個應用的程式碼放到了 GitHub 上。

我們將怎麼建立?

首先,我們建立一個 PersistentStack 物件,為其提供一個 Core Data 模型和一個檔名,PersistentStack 會返回一個 managed object context。然後,我們將要建立我們的 Core Data 模型。接著,我們將建立一個簡單的 table view controller 來顯示使用 fetched results controller 取回的 item 根目錄,並且通過增加 items,sub-items 的導航,刪除 items,增加 undo 支援,來一步一步進行互動。

設定堆疊

我們將為主佇列建立一個 managed object context。在比較老的程式碼中,你可能見到 [[NSManagedObjectContext alloc] init]。而目前,你應該用 initWithConcurrencyType: 初始化,以明確你是使用基於佇列的併發模型。

- (void)setupManagedObjectContext
{
    self.managedObjectContext = 
         [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType];
    self.managedObjectContext.persistentStoreCoordinator = 
        [[NSPersistentStoreCoordinator alloc] initWithManagedObjectModel:self.managedObjectModel];
    NSError* error;
    [self.managedObjectContext.persistentStoreCoordinator 
         addPersistentStoreWithType:NSSQLiteStoreType
                      configuration:nil
                                URL:self.storeURL 
                            options:nil 
                              error:&error];
    if (error) {
        NSLog(@"error: %@", error);
    }
    self.managedObjectContext.undoManager = [[NSUndoManager alloc] init];
}

檢查錯誤是非常重要的,因為在開發過程中,這很有可能經常出錯。當 Core Data 發現你改變了資料模型時,就會暫停操作。你也可以通過設定選項來告訴 Core Data 在遇到這種情況後怎麼做,這在 Martin 關於 遷移 的文章中徹底的解釋了。注意,最後一行增加了一個 undo manager;我們將在稍後用到。在 iOS 中,你需要明確的去增加一個 undo manager,但是在 Mac 中,undo manager 是預設有的。

這段程式碼建立了一個真正簡單的 Core Data 堆疊:一個擁有持久化儲存協調器的 managed object context,其擁有一個持久化儲存。更復雜的設定

都是可能的;最常見的是擁有多個 managed object context(每一個都在單獨的佇列中)。

建立一個模型

建立模型比較簡單,我們只需要增加一個新檔案到我們的專案,在 Core Data 選項中選擇 Data Model template。這個模型檔案將會被編譯成字尾名為 .momd 型別的檔案,我們將會在執行時載入這個檔案來為持久化儲存建立需要用的 NSManagedObjectModel,模型的原始碼是簡單的 XML,根據我們的經驗,一般來說當你 check 到程式碼版本管理中時,應該不會有任何 merge 的困難。如果你願意,你還可以在程式碼中建立一個 managed object model。

一旦你建立了模型,你就可以增加 Item 實體,這個實體有兩個屬性:字串型別的 title 和 integer 型別的 order。然後,增加兩個關係:一個叫做 parent,表示這個 item 的父 item;另一個叫 children,是一個一對多的關係。設定它們為彼此相反的關係,也就是說,你設定 a 的 parent 為 b,那麼 b 就會自動有一個 children 為 a。

通常,你甚至可以完全拋開 order 屬性,而去使用排序好的關係。然而,它們並不能很好的和 fetched results controllers(後面會用到)整合在一起工作。我們要麼需要重新實現 fetched results controller 的一部分,要麼重新實現排序,通常我們都會選擇後者。

現在,從選單中選擇 Editor > NSManagedObject subclass...,建立一個繫結到實體的 NSManagedObject 的子類,這將會建立兩個檔案:Item.hItem.m。在標頭檔案中,會有一個額外的類別,我們需要將其刪除(這是遺留原因導致的)。

建立一個 Store 類

對於我們的模型,我們將建立一個根節點作為我們 item 樹的開始。我們需要一個地方來建立這個根節點,並且方便以後找到。因此,我們可以通過建立一個簡單的儲存類來達到這個目的。儲存類有一個 managed object context,還有一個 rootItem 方法。在 app delegate 中,我們將會在程式啟動時查詢這個 root item,並且傳給了 root view controller。作為一種優化,為了查詢這個 item 變得更快,你可以將 item 物件的 id 儲存到 user defaults 中:

- (Item*)rootItem
{
    NSFetchRequest* request = [NSFetchRequest fetchRequestWithEntityName:@"Item"];
    request.predicate = [NSPredicate predicateWithFormat:@"parent = %@", nil];
    NSArray* objects = [self.managedObjectContext executeFetchRequest:request error:NULL];
    Item* rootItem = [objects lastObject];
    if (rootItem == nil) {
        rootItem = [Item insertItemWithTitle:nil 
                                      parent:nil 
                      inManagedObjectContext:self.managedObjectContext];
    }
    return rootItem;
}

大多數情況下,增加一個 item 都是簡單的。然而,我們需要設定 order 屬性值比任何其父節點的子節點的值更大。我們將會設定第一個子節點的 order 值 為0,隨後每一個子節點都會增加1。我們在 Item 類中建立一個自定義的方法來實現:

+ (instancetype)insertItemWithTitle:(NSString*)title
                             parent:(Item*)parent
             inManagedObjectContext:(NSManagedObjectContext *)managedObjectContext
{
    NSUInteger order = parent.numberOfChildren;
    Item* item = [NSEntityDescription insertNewObjectForEntityForName:self.entityName
                                               inManagedObjectContext:managedObjectContext];
    item.title = title;
    item.parent = parent;
    item.order = @(order);
    return item;
}

獲得子節點數量的方法很簡單:

- (NSUInteger)numberOfChildren
{
    return self.children.count;
}

為了支援自動更新我們的 table view,我們需要使用 fetched results controller。Fetched results controller 是一個可以管理取出大量 item 請求的物件,同時對使用 Core Data 的 table view 來說,它也是一個完美的小夥伴,在下一節中我們將會用到:

- (NSFetchedResultsController*)childrenFetchedResultsController
{
    NSFetchRequest* request = [NSFetchRequest fetchRequestWithEntityName:[self.class entityName]];
    request.predicate = [NSPredicate predicateWithFormat:@"parent = %@", self];
    request.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"order" ascending:YES]];
    return [[NSFetchedResultsController alloc] initWithFetchRequest:request 
                                               managedObjectContext:self.managedObjectContext 
                                                 sectionNameKeyPath:nil 
                                                          cacheName:nil];
}

增加一個支援 Table-View 的 Fetched Results Controller

我們下一步是建立一個 root view controller:一個從 NSFetchedResultsController 讀取資料的 table view。Fetched results controller 管理你的讀取請求,如果你為它分配一個 delegate,那麼在 managed object context 中發生的任何改變都會通知你。實際上,這意味著如果你實現了 delegate 方法,當資料模型中發生相關變化時,你可以自動更新你的 table view。比如,你在後臺執行緒同步,並且把變化儲存到資料庫中,那麼你的 table view 將會自動更新。

建立 Table View 的 Data Source

更輕量的 View Controllers 這篇文章中,我們演示了怎麼從 table view 中分離出 data source。這裡,我們將會用同樣的方法建立一個 fetched results controller。我們建立一個分離出的 FetchedResultsControllerDataSource 類,它扮演了 table view 的 data source,通過監聽 fetched results controller,自動更新 table view。

我們初始化一個 table view 物件,初始化方法如下:

- (id)initWithTableView:(UITableView*)tableView
{
    self = [super init];
    if (self) {
        self.tableView = tableView;
        self.tableView.dataSource = self;
    }
    return self;
}

當我們設定 fetch results controller 時,我們需要設定自己為 delegate,並且執行初始化的 fetch 操作。performFetch: 方法經常容易被忘了呼叫,那麼你將得不到結果(並且不會出錯):

- (void)setFetchedResultsController:(NSFetchedResultsController*)fetchedResultsController
{
    _fetchedResultsController = fetchedResultsController;
    fetchedResultsController.delegate = self;
    [fetchedResultsController performFetch:NULL];
}

因為我們的類實現了 UITableViewDataSource 協議,我們需要實現相關的方法。在這兩個方法中,我們只需要向 fetched results controller 請求需要的資訊:

- (NSInteger)numberOfSectionsInTableView:(UITableView*)tableView
{
    return self.fetchedResultsController.sections.count;
}

- (NSInteger)tableView:(UITableView*)tableView numberOfRowsInSection:(NSInteger)sectionIndex
{
    id<NSFetchedResultsSectionInfo> section = self.fetchedResultsController.sections[sectionIndex];
    return section.numberOfObjects;
}

然而,當我們需要建立 cell 的時候,只需要一些簡單的步驟:向 fetched results controller 請求正確的物件,從 table view 出列一個cell,然後告訴 delegate (即一個 view controller) 用相應的物件配置這個 cell。作為 view controller,只會關心用模型物件更新cell:

- (UITableViewCell*)tableView:(UITableView*)tableView 
        cellForRowAtIndexPath:(NSIndexPath*)indexPath
{
    id object = [self.fetchedResultsController objectAtIndexPath:indexPath];
    id cell = [tableView dequeueReusableCellWithIdentifier:self.reuseIdentifier
                                             forIndexPath:indexPath];
    [self.delegate configureCell:cell withObject:object];
    return cell;
}

建立 Table View Controller

現在,我們可以建立一個 view controller,使用剛剛建立的類顯示 item 列表。在示例程式中,我們建立一個 Storyboard,並且增加一個擁有 table view controller 的 navigation controller。這會自動設定 view controller 作為資料來源,而這不是我們想要的效果。因此,在我們的viewDidLoad中,我們做下面的操作:

fetchedResultsControllerDataSource =
    [[FetchedResultsControllerDataSource alloc] initWithTableView:self.tableView];
self.fetchedResultsControllerDataSource.fetchedResultsController = 
    self.parent.childrenFetchedResultsController;
fetchedResultsControllerDataSource.delegate = self;
fetchedResultsControllerDataSource.reuseIdentifier = @"Cell";

在初始化 fetched results controller data source 時,table view 的資料來源可以被設定。reuse 識別符號匹配在 Storyboard 中相對應的物件。現在,我們需要實現 delegate 方法:

- (void)configureCell:(id)theCell withObject:(id)object
{
    UITableViewCell* cell = theCell;
    Item* item = object;
    cell.textLabel.text = item.title;
}

當然,除了設定 text 的 label 外,你還可以做更多的事情,但是你應該已經明白了要領。現在我們已經為顯示資料準備好了相當多的事情,但是卻仍然沒有增加資料的方法,這看起來非常空。

增加互動

我們將會增加兩種和資料互動的方法。首先,我們需要實現增加 items。然後我們需要實現 fetched results controller 的 delegate 方法去更新 table view,並且增加刪除和 undo 支援。

增加 Items

為了增加 items,我們借鑑 Clear 的互動設計,這是我認為最漂亮的應用之一。我們增加一個 text field 作為 table view 的頭,並修改 table view 的 content inset,確保它預設保持隱藏,正如 Joe 在 scroll view 這篇文章中解釋一樣。像往常一樣,所有的程式碼都在 github 上,這裡是插入 item 相關的程式碼,在 textFieldShouldReturn:

[Item insertItemWithTitle:title 
                   parent:self.parent
   inManagedObjectContext:self.parent.managedObjectContext];
textField.text = @"";
[textField resignFirstResponder];

監聽改變

下一步是確保 table view 會為新建立的 item 插入一行。有好幾種方法可以做到,但是我們將會使用 fetched results controller 的代理方法:

- (void)controller:(NSFetchedResultsController*)controller
   didChangeObject:(id)anObject
       atIndexPath:(NSIndexPath*)indexPath
     forChangeType:(NSFetchedResultsChangeType)type
      newIndexPath:(NSIndexPath*)newIndexPath
{
    if (type == NSFetchedResultsChangeInsert) {
        [self.tableView insertRowsAtIndexPaths:@[newIndexPath]
                              withRowAnimation:UITableViewRowAnimationAutomatic];
    }
}

fetched results controller 也會在刪除、改變和移動時呼叫一些方法(我們將在稍後實現)。如果你一次有很多改變,你可以多實現兩個方法,那麼 table view 將會動畫地展現所有的改變。對於單個 item 的插入和刪除,這並不會有任何不同,但是如果你選擇實現同時同步,那麼將會變得更漂亮:

- (void)controllerWillChangeContent:(NSFetchedResultsController*)controller
{
    [self.tableView beginUpdates];
}

- (void)controllerDidChangeContent:(NSFetchedResultsController*)controller
{
    [self.tableView endUpdates];
}

使用 Collection View

值得注意的是,fetched results controllers 並非只能用於 table views;你可以將它只用在任何 view 中。因為它們是基於 indexPath 的,所以它們能與 collection views 很好的一起工作。由於 collection view 沒有 beginUpdatesendUpdates 方法,卻有一個 performBatchUpdates 方法,所以我們需要稍加改變。你可以收集你得到的所有更新,然後在 controllerDidChangeContent 中,用 block 執行所有的更新。Ash Furrow 寫了一個關於如何做的例子

實現你自己的 Fetched Results Controller

你不必使用 NSFetchedResultsController。實際上,在很多情況下,為你的程式建立一個類似的類將顯得更有意義。你可以做的是註冊 NSManagedObjectContextObjectsDidChangeNotification。然後你就可以得到一個 notificationuserInfo 字典將會包含改變物件,插入物件,刪除物件的列表,然後你可以按你喜歡的方式執行這些操作。

傳遞 Model 物件

現在我們可以增加並且列出 items 了,現在我們需要確定能夠建立 sub-lists。在 Storyboard 中,你可以通過拖拽一個 cell 到 view controller 中來建立一個 segue。最好給 segue 指定一個名字,這樣,如果一個 view controller 中有多個 segues 的話,我們就可以將其區分開了。

我處理 segues 的模式看起來像這樣:首先,你嘗試識別出這個 segue,對於每一個 segue,你為它的目標 view controller 單獨寫一個方法:

- (void)prepareForSegue:(UIStoryboardSegue*)segue sender:(id)sender
{
    [super prepareForSegue:segue sender:sender];
    if ([segue.identifier isEqualToString:selectItemSegue]) {
        [self presentSubItemViewController:segue.destinationViewController];
    }
}

- (void)presentSubItemViewController:(ItemViewController*)subItemViewController
{
    Item* item = [self.fetchedResultsControllerDataSource selectedItem];
    subItemViewController.parent = item;
}

子 view controller 需要唯一的東西就是item。通過 item,也可以得到 managed object context。我們從 data source 中得到選中的 item(通過 table view 選中的 item 的 index 值,從 fetched results controller 中取出正確的 item),就這麼簡單。

很不幸的是,在 app delegate 中,將 managed object context 作為一個屬性,然後總是在任何地方訪問它,這是模式非常常見。這其實是一個壞主意。如果你想要為你 view controller 中的一部分使用一個不同的 managed object context時,將很難重構,此外,你的程式碼將變得很難測試。

現在,嘗試在 sub-list 中增加一個 item,你很有可能得到一個 crash。這是因為我們現在有兩個 fetched results controllers,一個是 topmost view controller,還有一個是root view controller。後者嘗試去更新它的 table view,而它的table view是離屏的(offscreen),就這樣所有的操作都crash了。解決方案是告訴我們的data source停止監聽fetched results controller的代理方法:

- (void)viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];
    self.fetchedResultsControllerDataSource.paused = NO;
}

- (void)viewWillDisappear:(BOOL)animated
{
    [super viewWillDisappear:animated];
    self.fetchedResultsControllerDataSource.paused = YES;
}

一種方法就是在 data source 中設定 fetched results controller 的代理為 nil,這樣就再也不會收到更新通知了。當我們離開 paused 狀態時,還需要加上去:

- (void)setPaused:(BOOL)paused
{
    _paused = paused;
    if (paused) {
        self.fetchedResultsController.delegate = nil;
    } else {
        self.fetchedResultsController.delegate = self;
        [self.fetchedResultsController performFetch:NULL];
        [self.tableView reloadData];
    }
}

這樣 performFetch 就會確保你的 data source 保持最新的。當然,更好的實現方法並不是設定代理為 nil,而是記錄每一個在 paused 狀態下的改變,相應的,在離開 paused 狀態後,更新 table view。

刪除

為了支援刪除,我們需要花費幾步操作。首先,我們需要確信我們的 table view 支援刪除。第二,我們需要從 core data 中刪除物件,並且保證我們的排序是正確的。

為了支援滑動刪除,我們需要在 data source 中實現兩個方法:

- (BOOL)tableView:(UITableView*)tableView
 canEditRowAtIndexPath:(NSIndexPath*)indexPath
{
    return YES;
}

- (void)tableView:(UITableView *)tableView 
 commitEditingStyle:(UITableViewCellEditingStyle)editingStyle
 forRowAtIndexPath:(NSIndexPath *)indexPath {
     if (editingStyle == UITableViewCellEditingStyleDelete) {
        id object = [self.fetchedResultsController objectAtIndexPath:indexPath];
        [self.delegate deleteObject:object];
     }
}

我們需要通知代理(the view controller)刪除物件,而不是直接刪除。這樣,我們不需要將 store object 分配給data source(data source 在整個專案中都必須可重用),並且保持自定義操作的靈活性。view controller 只需在 managed object context 中簡單的呼叫 deleteObject:

然而,還有兩個重要的問題需要被解決:我們怎麼處理被刪除 item 的子 item,怎麼強制我們的 order 變化?幸運的是,傳播刪除是很簡單的:在我們的資料模型中,我們可以選擇 Cascade 作為子關係的刪除規則。

為了強制我們的 order 變化,我們可以重寫 prepareForDeletion 方法,用更高一級的 order 更新所有兄弟節點。

- (void)prepareForDeletion
{
    NSSet* siblings = self.parent.children;
    NSPredicate* predicate = [NSPredicate predicateWithFormat:@"order > %@", self.order];
    NSSet* siblingsAfterSelf = [siblings filteredSetUsingPredicate:predicate];
    [siblingsAfterSelf enumerateObjectsUsingBlock:^(Item* sibling, BOOL* stop)
    {
        sibling.order = @(sibling.order.integerValue - 1);
    }];
}

現在我們幾乎快完成了。我們可以與 table view 的 cell 互動,並且可以刪除模型物件。最後一步是實現一旦模型物件被刪除後,刪除 table view cell 的必要的程式碼。在 data sources 的方法 controller:didChangeObject:... 中,我們增加另一個 if 語句:

...
else if (type == NSFetchedResultsChangeDelete) {
    [self.tableView deleteRowsAtIndexPaths:@[indexPath]
                          withRowAnimation:UITableViewRowAnimationAutomatic];
}

增加 Undo 支援

Core Data 優點之一就是集成了 undo 支援。我們將為增加晃動撤銷功能,第一步就是告訴程式我們可以這麼做:

application.applicationSupportsShakeToEdit = YES;

現在,這個功能可以被任何抖動觸發,程式將會向 first responder 請求 undo manager,並且執行一次 undo 操作。在上個月的文章中,我們瞭解了,一個 view controller 也在響應鏈中(responder chain),這也正是我們將要使用的。在我們的 view controller 中,我們重寫來自 UIResponder 類中的兩個方法:

- (BOOL)canBecomeFirstResponder {
    return YES;
}

- (NSUndoManager*)undoManager
{
    return self.managedObjectContext.undoManager;
}

現在,當一個抖動發生時,managed object context 的 undo manager 將會得到一個undo訊息,並且撤銷最後一次改變。記住,在 iOS 中,managed object context 預設並沒有一個 undo manager,(而在 Mac 中,新建的 managed object context 預設是有的),所以我們需要在持久化堆疊中設定:

self.managedObjectContext.undoManager = [[NSUndoManager alloc] init];

基本上就是這樣了。現在,當你抖動時,你將得到 iOS 預設有兩個按鈕的提醒框:一個是 undo 按鈕,一個 cancel 按鈕。Core Data 的一個非常好的特性是將改變自動分組。比如,addItem:parent 將會記錄作為一個 undo 處理。關於刪除,也是一樣的。

為了讓使用者管理 undo 操作更容易一些,我們可以給操作命名,並且將 textFieldShouldReturn: 的第一行修改成這樣:

NSString* title = textField.text;
NSString* actionName = [NSString stringWithFormat:
    NSLocalizedString(@"add item \"%@\"", @"Undo action name of add item"), title];
[self.undoManager setActionName:actionName];
[self.store addItem:title parent:nil];

現在,當用戶抖動時,除了普通的 "Undo" 標籤外,他將得到更多的上下文環境。

編輯

編輯目前在示例程式中並不支援,但是這只是一個改變物件屬性的問題。比如,改變一個 item 的 title,只需要設定 title 屬性就好了。改變 foo item 的 parent,只需要設定 parent 屬性為一個新值 bar,所有的東西都將得到更新,bar 現在有一個 childrenfoo,因為我們使用 fetched results controllers,使用者介面同樣也會自動更新。

重新排序

重新排序 cell,在現有程式中也是不可行的,但是這實現起來很簡單。但是,還有一個需要注意的地方:如果你允許使用者重新排序,你將需要在 model 中更新 order 屬性,並且從 fetched results controller 得到一個 delegate call(你需要忽略這個呼叫,因為cell已經被移動了)。這在 NSFetchedResultsControllerDelegate 的文件中有解釋。

儲存

儲存非常簡單,就是在 managed object context 中呼叫 save 而已。因為我們並不直接訪問 managed object context,所以是在 store 中進行儲存。唯一的困難的是什麼時候去儲存。Apple 的示例程式碼在 applicationWillTerminate: 中執行這個操作,但是這取決於你的使用情況,這也有可能在 applicationDidEnterBackground: 中,甚至當你程式執行時呼叫。

討論

在寫這篇文章和示例程式時,我初始時就犯了一個錯誤:我沒有選擇使用一個空的根 item 來作為所有使用者建立的 item 的 parent,而是讓它們都指向了一個 nil。這將造成很多問題:因為 view controller 中的父 item 可能是 nil,我們需要將 store(或 managed object context) 傳給每一個子 view controller。同樣的,強制 order 重新排序也非常困難,因為我們需要查找出一個 item 的所有兄弟節點,這樣會迫使 Core Data 到磁碟上讀取資料。不幸的是,當寫這些程式碼時,這些問題並沒有立刻弄明白,一些問題只是在寫測試時才變得清晰。當我重新寫程式碼的時候,我知道了將 Store 類中大部分程式碼移到 Item 類中,就這樣,事情變得清楚多了。