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

Posted

技术标签:

【中文标题】如何从 iOS 13 中的 NSFetchResultsController 获取可区分的快照?【英文标题】:How to get a diffable snapshot from an NSFetchResultsController in iOS 13? 【发布时间】:2019-10-20 17:27:14 【问题描述】:

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

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

【问题讨论】:

【参考方案1】:

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)

viewDidLoad 中的初始 performFetch 更新了没有动画的表格视图。此后的所有更新,包括仅刷新单元格的更新,都适用于动画。

【讨论】:

周到,精美的演示文稿。感谢您为此付出了如此多的努力。我想知道我们是否可以通过消除感叹号来使其更安全,但这只是风格问题。 @matt 谢谢马特。我用一些 cmets 和改进的布局更新了委托方法示例。该文档建议将 NSDiffableDataSourceSnapshotReference 转换为 NSDiffableDataSourceSnapshot 并这样做消除了一些强制转换。一种强制转换仍然存在,我个人对此感到满意,因为它与获取请求的类型相关。其他人可能更愿意消除它。 感谢您的演示。试过了,它确实有效。属性更改时,该行会自行刷新。但是动画很震撼。我认为最好操纵行以获得更好的用户体验。 @francisfeng:我通常将默认行动画设置为淡入淡出,例如dataSource.defaultRowAnimation = .fadeviewDidLoad. @san-ho-zay 如何访问 DataSource 单元提供程序关闭中的 Item 部分?因为我想根据它所属的部分的类型配置不同的单元格。在单元格提供程序闭包中访问 FRC 属性有时会在为更改设置动画时导致崩溃【参考方案2】:

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

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

这留下了您将如何填充单元格的问题。在 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
    
()

【讨论】:

好像在插入、删除、移动或更改对象时调用此方法;但是当对象刚刚更改并且没有插入、删除或移动时应用快照,不会导致重新加载单元格 @SAHM 根据我的经验,如果 animatingDifferences 设置为 false对象刚刚更改时会重新加载单元格。 @SAHM 我正在​​使用折衷方案。我在控制器中添加了一个属性var animatingDifferences = false,因为我不想为数据的初始加载设置动画。在didChangeContentWith snapshot 委托方法中,我设置animatingDifferences:animatingDifferences,然后将animatingDifferences 设置为true。每当编辑一行时,我都会在保存上下文之前将属性显式设置为false。所以表格视图基本上是动画的,除了第一次重新加载和编辑一行后重新加载。 @SAHM 我用DiffableDataSource 更新了两个项目,其中一个包含核心数据和多个部分以及自定义标题视图。它很好用,但是要获得更好的控制,您必须将UITableViewDiffableDataSource 子类化,并且为表格视图的初始加载设置动画会导致问题。我什至写了一个协议扩展来包含didChangeContentWith snapshot 并推断获取结果控制器的通用类型。 @SAHM 在保存前使用 gainPermanentIDsForObjects。在应用之前验证快照是否只有永久 ID。【参考方案3】:

更新 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 非常奇怪,因为它无法从 fetch 控制器获取初始快照以用于填充表。对performFetch 的调用调用与第一个快照内联的委托。我们不习惯我们的方法调用导致委托调用,这是一个真正的痛苦,因为在我们的委托中,我们希望为更新而不是初始加载设置动画,如果我们为初始加载设置动画,那么我们会看到一个警告,表明表格正在更新而不在窗口中。解决方法是设置标志performingFetch,在performFetch 之前为初始快照委托调用设置为真,然后在之后设置为假。

最后,这是迄今为止最烦人的变化,因为我们不再可以更新表格视图控制器中的单元格,我们需要稍微打破 MVC 并将我们的对象设置为单元格子类的属性。获取控制器快照只是使用对象 ID 数组的部分和行的状态。快照没有对象版本的概念,因此它不能用于更新当前单元格。因此在cellProvider 块中,我们不更新单元格的视图,只设置对象。在那个子类中,我们要么使用 KVO 来监视单元格正在显示的对象的键,要么我们也可以订阅NSManagedObjectContextobjectsDidChange 通知并检查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,以在字符串中使用的键发生更改时触发更改。

【讨论】:

我一直遇到同样的问题,感谢您确认使用 KVO 之类的东西来解决。我并不是说 KVO 是最好的解决方案,但它有助于解释很多“奇怪”的 api 工作。 由于将临时 ID 传递给快照,我遇到了崩溃。感谢您澄清快照不应有临时 ID。我不知道managedObjectContext.obtainPermanentIDs(for:)(Swift 等价物),在保存之前我现在正在使用它,它解决了我的问题。【参考方案4】:

正如其他人指出的那样,如果在第一次加载表时使用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.


                
            

【讨论】:

【参考方案5】:

我有一个解决方案,如果你想为插入、删除、移动提供漂亮的动画并且不想闪烁更新!

这里是:

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

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 委托实现:

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)

我在这里期待您对此解决方案的反馈。

【讨论】:

以上是关于如何从 iOS 13 中的 NSFetchResultsController 获取可区分的快照?的主要内容,如果未能解决你的问题,请参考以下文章

如何使用 Javascript 或 JQuery 从 Salesforce1 应用程序导航到 iOS 13 中的其他本机应用程序?

如何从 iOS 中的 JSON 数组中删除空对象?

使用 qBittorrent 运行脚本时如何克服 Python 中的 IO 错误 13

iOS 13 中的本地通知 didReceive

如何从 IOS 中的 PHPhotoLibrary 获取 ref url?

如何从ios中的以下输出中获取值?