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

时间:2019-10-20 17:27:14

标签: ios core-data ios13

所以这里是WWDC 2019视频230,大约从第14分钟开始,据称NSFetchedResultsController现在提供了NSDiffableDataSourceSnapshot,因此我们可以将其直接应用于可扩散数据源(UITableViewDiffableDataSource)。

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

5 个答案:

答案 0 :(得分:5)

更新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];
}

对于其他更改,快照不得包含临时对象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();
    }
}

您可以通过在快照委托中放置一个断点并检查快照对象以确保其中没有临时ID来验证此工作是否正常。

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

最后,这是迄今为止最令人讨厌的更改,因为我们不再能够更新表视图控制器中的单元格,我们需要稍微破坏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

以及我在NSManagedObjects上使用的协议:

@protocol MMSTableViewCellObject <NSObject>

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

@end

请注意,当字符串中使用的键发生更改时,我会在托管对象类中实现keyPathsForValuesAffectingValueForKey来触发更改。

答案 1 :(得分:5)

WWDC视频暗示我们应该使用StringNSManagedObjectID的通用类型声明数据源。那对我没有用;我可以通过动画和行更新获得明智行为的唯一方法是使用自定义值对象作为数据源的行标识符。

使用NSManagedObjectID作为项目标识符的快照的问题在于,尽管已将与该标识符关联的托管对象的更改通知给获取的结果委托,但出售的快照可能与我们可能已将其应用于数据源的前一个。当基础数据发生更改时,使用值对象作为标识符将此快照映射到一个快照会产生不同的哈希值,并解决了单元更新问题。

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

struct TaskItem: Hashable {
    var title: String
    var isComplete: Bool
}

数据源呈现以下项目的快照:

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
}

假设可以对获取的结果控制器进行分组,则向委托传递一个快照,快照的类型为StringNSManagedObjectID。可以将其操作为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)
}

performFetch中的初始viewDidLoad将不使用动画更新表视图。此后的所有更新(包括仅刷新单元格的更新)都可以使用动画。

答案 2 :(得分:1)

应使用通用类型String和NSManagedObjectID声明可扩散数据源。现在,您可以将引用强制转换为快照:

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

这留下了您将如何填充单元格的问题。在可扩散数据源(在我的示例中为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
    }
}()

答案 3 :(得分:1)

正如其他人指出的那样,如果在第一次加载表时使用animatingDifferences: true,则UITableView将加载为空白。

如果基础模型数据发生变化,animatingDifferences: true不会强制重新加载单元格。

此行为似乎是一个错误。

更糟糕的是,当uitableview处于editMode且用户尝试使用trailingSwipeActionsConfigurationForRowAt删除记录时,整个应用程序崩溃

我的解决方法是在所有情况下都将animatingDifferences设置为“ false”。当然,令人遗憾的是所有动画都丢失了。我为此问题向Apple提交了错误报告。

 func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
                let newSnapshot = snapshot as NSDiffableDataSourceSnapshot<String,NSManagedObjectID>
                
   self.apply(newSnapshot, animatingDifferences: false)} //setting animatingDifferences to 'false' is the only work around I've found for table cells not appearing on load, and other bugs, including crash if user tries to delete a record.


                
            }

答案 4 :(得分:1)

我有一个解决方案,如果您想制作漂亮的动画来进行插入,删除,移动并且不想闪烁以进行更新!

这里是:

首先创建一个像这样的结构:

struct SomeManageObjectContainer: Hashable {
    var objectID: NSManagedObjectID
    var objectHash: Int
    
    init(objectID: NSManagedObjectID, objectHash: Int) {
        self.objectID = objectID
        self.objectHash = objectHash
    }
    
    init(objectID: NSManagedObjectID, someManagedObject: SomeManagedObject) {
        var hasher = Hasher()
        //Add here all the Values of the ManagedObject that can change and are displayed in the cell
        hasher.combine(someManagedObject.someValue)
        hasher.combine(someManagedObject.someOtherValue)
        let hashValue = hasher.finalize()
        
        self.init(objectID: objectID, objectHash: hashValue)
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(objectID)
    }
    
    static func == (lhs: SomeManageObjectContainer, rhs: SomeManageObjectContainer) -> Bool {
        return (lhs.objectID == rhs.objectID)
    }
}

然后我使用以下两种帮助方法:

func someManagedObjectContainers(itemIdentifiers: [NSManagedObjectID]) -> [SomeManageObjectContainer] {
    var container = [SomeManageObjectContainer]()
    for objectID in itemIdentifiers {
        container.append(someManagedObjectContainer(objectID: objectID))
    }
    return container
}

func someManagedObjectContainer(objectID: NSManagedObjectID) -> SomeManageObjectContainer {
    guard let someManagedObject = try? managedObjectContext.existingObject(with: objectID) as? SomeManagedObject else {
        fatalError("Managed object should be available")
    }
    
    let container = SomeManageObjectContainer(objectID: objectID, someManagedObject: someManagedObject)
    return container
}

最后是NSFetchedResultsController Delegate实现:

func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
    guard let dataSource = collectionView.dataSource as? UICollectionViewDiffableDataSource<String, SomeManageObjectContainer> else {
        assertionFailure("The data source has not implemented snapshot support while it should")
        return
    }
    let snapshot = snapshot as NSDiffableDataSourceSnapshot<String, NSManagedObjectID>

    var mySnapshot = NSDiffableDataSourceSnapshot<String, SomeManageObjectContainer>()
    
    mySnapshot.appendSections(snapshot.sectionIdentifiers)
    mySnapshot.sectionIdentifiers.forEach { (section) in
        let itemIdentifiers = snapshot.itemIdentifiers(inSection: section)
        mySnapshot.appendItems(someManagedObjectContainers(itemIdentifiers: itemIdentifiers), toSection: section)
    }
    
    //Here we find the updated Objects an put them in reloadItems
    let currentSnapshot = dataSource.snapshot() as NSDiffableDataSourceSnapshot<String, SomeManageObjectContainer>
    let reloadIdentifiers: [SomeManageObjectContainer] = mySnapshot.itemIdentifiers.compactMap { container in
        
        let currentContainer = currentSnapshot.itemIdentifiers.first { (currentContainer) -> Bool in
            if currentContainer == container {
                return true
            }
            return false
        }
        
        if let currentContainer = currentContainer {
            if currentContainer.objectHash != container.objectHash {
                return container
            }
        }
        
        return nil
    }
    mySnapshot.reloadItems(reloadIdentifiers)

    var shouldAnimate = collectionView?.numberOfSections != 0
    if collectionView?.window == nil {
        shouldAnimate = false
    }
    
    dataSource.apply(mySnapshot, animatingDifferences: shouldAnimate)
}

我希望在这里收到您对这种解决方案的反馈。