如何从 iOS 13 中的 NSFetchResultsController 获取可区分的快照?

mat*_*att 11 core-data ios ios13

因此,我们在 WWDC 2019 视频 230 中,从大约 14 分钟开始,据称 NSFetchedResultsController 现在提供 NSDiffableDataSourceSnapshot,因此我们可以将其直接应用于可区分的数据源 (UITableViewDiffableDataSource)。

但这并不完全是他们所说的,或者我们得到的。我们在委托方法中得到的controller(_:didChangeContentWith:)是一个 NSDiffableDataSourceReference。我们如何从中获取实际快照,我的可区分数据源泛型类型应该是什么?

san*_*zay 12

在WWDC视频意味着我们应该宣布与通用类型的数据源StringNSManagedObjectID。那对我不起作用;我可以通过动画和行更新获得合理行为的唯一方法是使用自定义值对象作为数据源的行标识符。

使用快照NSManagedObjectID作为项目标识符的问题在于,尽管获取的结果委托会收到与该标识符关联的托管对象的更改的通知,但它提供的快照可能与我们可能已应用到的前一个快照没有什么不同数据源。将此快照映射到使用值对象作为标识符的快照上,当底层数据发生变化并解决单元更新问题时会产生不同的哈希值。

考虑一个待办事项列表应用程序的数据源,其中有一个带有任务列表的表视图。每个单元格显示一个标题和任务是否完成的一些指示。值对象可能如下所示:

struct TaskItem: Hashable {
    var title: String
    var isComplete: Bool
}
Run Code Online (Sandbox Code Playgroud)

数据源呈现这些项目的快照:

typealias DataSource = UITableViewDiffableDataSource<String, TaskItem>

lazy var dataSource = DataSource(tableView: tableView) { tableView, indexPath, item in {
    let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
    cell.textLabel?.text = item.title
    cell.accessoryType = item.isComplete ? .checkmark : .none
    return cell
}
Run Code Online (Sandbox Code Playgroud)

假设一个获取的结果控制器,它可以被分组,委托被传递一个类型为String和的快照NSManagedObjectID。这可以被处理成StringTaskItem(用作行标识符的值对象)的快照以应用于数据源:

func controller(
    _ controller: NSFetchedResultsController<NSFetchRequestResult>,
    didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference
) {
    // Cast the snapshot reference to a snapshot
    let snapshot = snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>

    // Create a new snapshot with the value object as item identifier
    var mySnapshot = NSDiffableDataSourceSnapshot<String, TaskItem>()

    // Copy the sections from the fetched results controller's snapshot
    mySnapshot.appendSections(snapshot.sectionIdentifiers)

    // For each section, map the item identifiers (NSManagedObjectID) from the
    // fetched result controller's snapshot to managed objects (Task) and
    // then to value objects (TaskItem), before adding to the new snapshot
    mySnapshot.sectionIdentifiers.forEach { section in
        let itemIdentifiers = snapshot.itemIdentifiers(inSection: section)
            .map {context.object(with: $0) as! Task}
            .map {TaskItem(title: $0.title, isComplete: $0.isComplete)}
        mySnapshot.appendItems(itemIdentifiers, toSection: section)
    }

    // Apply the snapshot, animating differences unless not in a window
    dataSource.apply(mySnapshot, animatingDifferences: view.window != nil)
}
Run Code Online (Sandbox Code Playgroud)

初始performFetchinviewDidLoad更新没有动画的表视图。此后的所有更新,包括仅刷新单元格的更新,都使用动画。


mat*_*att 11

diffable 数据源应该用泛型类型 String 和 NSManagedObjectID 声明。现在您可以将引用转换为快照:

func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
    let snapshot = snapshot as NSDiffableDataSourceSnapshot<String,NSManagedObjectID>
    self.ds.apply(snapshot, animatingDifferences: false)
}
Run Code Online (Sandbox Code Playgroud)

这留下了您将如何填充单元格的问题。在 diffable 数据源中(self.ds在我的示例中),当您填充单元格时,返回到获取的结果控制器并获取实际的数据对象。

例如,在我的表格视图中,我name在每个单元格中显示了一个组:

lazy var ds : UITableViewDiffableDataSource<String,NSManagedObjectID> = {
    UITableViewDiffableDataSource(tableView: self.tableView) {
        tv,ip,id in
        let cell = tv.dequeueReusableCell(withIdentifier: self.cellID, for: ip)
        cell.accessoryType = .disclosureIndicator
        let group = self.frc.object(at: ip)
        cell.textLabel!.text = group.name
        return cell
    }
}()
Run Code Online (Sandbox Code Playgroud)

  • 似乎当插入、删除、移动或更改对象时会调用此方法;但是,当对象刚刚更改且“未”插入、删除或移动时应用快照,不会导致单元格重新加载 (10认同)
  • @SAHM根据我的经验,如果“animatingDifferences”设置为“false”,*当对象刚刚更改时*单元格会重新加载。 (8认同)
  • @SAHM 我正在​​使用折衷方案。我在控制器中添加了一个属性“var animatingDifferences = false”,因为我不想为数据的初始加载设置动画。在“didChangeContentWith snapshot”委托方法中,我设置了“animatingDifferences:animatingDifferences”,然后将“animatingDifferences”设置为“true”。每当编辑一行时,我都会在保存上下文之前将该属性显式设置为“false”。因此,除了第一次重新加载和编辑行后重新加载之外,表视图基本上是动画的。 (3认同)
  • @SAHM 在保存之前使用obtainPermanentIDsForObjects。在应用之前验证快照是否仅具有永久 ID。 (2认同)

mal*_*hal 6

更新 2: iOS 14b2 对象删除作为删除和插入出现在快照中,并且 cellProvider 块被调用 3 次!(Xcode 12b2)。

更新 1: animatingDifferences:self.view.window != nil似乎是解决第一次与其他时间动画问题的好技巧。

切换到获取控制器快照 API 需要做很多事情,但首先要回答您的问题,委托方法简单地实现为:

- (void)controller:(NSFetchedResultsController *)controller didChangeContentWithSnapshot:(NSDiffableDataSourceSnapshot<NSString *,NSManagedObjectID *> *)snapshot{
    [self.dataSource applySnapshot:snapshot animatingDifferences:!self.performingFetch];
}
Run Code Online (Sandbox Code Playgroud)

至于其他更改,快照不得包含临时对象 ID。所以在你保存一个新对象之前,你必须让它有一个永久的 ID:

- (void)insertNewObject:(id)sender {
    NSManagedObjectContext *context = [self.fetchedResultsController managedObjectContext];
    Event *newEvent = [[Event alloc] initWithContext:context];//
        
    // If appropriate, configure the new managed object.
    newEvent.timestamp = [NSDate date];
    
    NSError *error = nil;
    if(![context obtainPermanentIDsForObjects:@[newEvent] error:&error]){
        NSLog(@"Unresolved error %@, %@", error, error.userInfo);
         abort();
    }
    
    if (![context save:&error]) {
        // Replace this implementation with code to handle the error appropriately.
        // abort() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
        NSLog(@"Unresolved error %@, %@", error, error.userInfo);
        abort();
    }
}
Run Code Online (Sandbox Code Playgroud)

您可以通过在快照委托中放置断点并检查快照对象以确保其中没有临时 ID 来验证这是否有效。

下一个问题是这个 API 非常奇怪,因为无法从 fetch 控制器获取初始快照以用于填充表。调用performFetch调用与第一个快照内联的委托。我们不习惯导致委托调用的方法调用,这是一个真正的痛苦,因为在我们的委托中,我们希望对更新进行动画处理而不是初始加载,如果我们对初始加载进行动画处理,那么我们会看到警告表正在更新而不是在窗口中。解决方法是设置一个标志performingFetchperformFetch在初始快照委托调用之前使其为真,然后在之后将其设置为假。

最后,这是迄今为止最烦人的变化,因为我们不再可以更新表视图控制器中的单元格,我们需要稍微打破 MVC 并将我们的对象设置为单元格子类的属性。获取控制器快照只是使用对象 ID 数组的部分和行的状态。快照没有对象版本的概念,因此它不能用于更新当前单元格。因此在cellProvider块中我们不更新单元格的视图只设置对象。在那个子类中,我们要么使用 KVO 来监视单元格显示的对象的键,要么我们也可以订阅NSManagedObjectContext objectsDidChange通知并检查changedValues. 但本质上,现在更新对象的子视图是单元类的责任。以下是 KVO 所涉及内容的示例:

#import "MMSObjectTableViewCell.h"

static void * const kMMSObjectTableViewCellKVOContext = (void *)&kMMSObjectTableViewCellKVOContext;

@interface MMSObjectTableViewCell()

@property (assign, nonatomic) BOOL needsToUpdateViews;

@end

@implementation MMSObjectTableViewCell

- (instancetype)initWithCoder:(NSCoder *)coder
{
    self = [super initWithCoder:coder];
    if (self) {
        [self commonInit];
    }
    return self;
}

- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(nullable NSString *)reuseIdentifier
{
    self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
    if (self) {
        [self commonInit];
    }
    return self;
}

- (void)commonInit{
    _needsToUpdateViews = YES;
}

- (void)awakeFromNib {
    [super awakeFromNib];
    // Initialization code
}

- (void)setSelected:(BOOL)selected animated:(BOOL)animated {
    [super setSelected:selected animated:animated];

    // Configure the view for the selected state
}

- (void)setCellObject:(id<MMSCellObject>)cellObject{
    if(cellObject == _cellObject){
        return;
    }
    else if(_cellObject){
        [self removeCellObjectObservers];
    }
    MMSProtocolAssert(cellObject, @protocol(MMSCellObject));
    _cellObject = cellObject;
    if(cellObject){
        [self addCellObjectObservers];
        [self updateViewsForCurrentFolderIfNecessary];
    }
}

- (void)addCellObjectObservers{
    // can't addObserver to id
    [self.cellObject addObserver:self forKeyPath:@"title" options:0 context:kMMSObjectTableViewCellKVOContext];
    // ok that its optional
    [self.cellObject addObserver:self forKeyPath:@"subtitle" options:0 context:kMMSObjectTableViewCellKVOContext];
}

- (void)removeCellObjectObservers{
    [self.cellObject removeObserver:self forKeyPath:@"title" context:kMMSObjectTableViewCellKVOContext];
    [self.cellObject removeObserver:self forKeyPath:@"subtitle" context:kMMSObjectTableViewCellKVOContext];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
    if (context == kMMSObjectTableViewCellKVOContext) {
        [self updateViewsForCurrentFolderIfNecessary];
    } else {
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

- (void)updateViewsForCurrentFolderIfNecessary{
    if(!self.window){
        self.needsToUpdateViews = YES;
        return;
    }
    [self updateViewsForCurrentObject];
}

- (void)updateViewsForCurrentObject{
    self.textLabel.text = self.cellObject.title;
    if([self.cellObject respondsToSelector:@selector(subtitle)]){
        self.detailTextLabel.text = self.cellObject.subtitle;
    }
}

- (void)willMoveToWindow:(UIWindow *)newWindow{
    if(newWindow && self.needsToUpdateViews){
        [self updateViewsForCurrentObject];
    }
}

- (void)prepareForReuse{
    [super prepareForReuse];
    self.needsToUpdateViews = YES;
}

- (void)dealloc
{
    if(_cellObject){
        [self removeCellObjectObservers];
    }
}

@end
Run Code Online (Sandbox Code Playgroud)

我在 NSManagedObjects 上使用的协议:

@protocol MMSTableViewCellObject <NSObject>

- (NSString *)titleForTableViewCell;
@optional
- (NSString *)subtitleForTableViewCell;

@end
Run Code Online (Sandbox Code Playgroud)

注意我keyPathsForValuesAffectingValueForKey在托管对象类中实现以在字符串中使用的键更改时触发更改。