如何从 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 视频暗示我们应该用String
和NSManagedObjectID
的泛型类型声明数据源。那对我不起作用;我可以通过动画和行更新获得合理行为的唯一方法是使用自定义值对象作为数据源的行标识符。
使用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
假设一个获取的结果控制器,它可以被分组,委托传递一个具有String
和NSManagedObjectID
类型的快照。这可以操作成String
和TaskItem
(用作行标识符的值对象)的快照以应用于数据源:
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 = .fade
在viewDidLoad
.
@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 来监视单元格正在显示的对象的键,要么我们也可以订阅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
,以在字符串中使用的键发生更改时触发更改。
【讨论】:
我一直遇到同样的问题,感谢您确认使用 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 中的其他本机应用程序?
使用 qBittorrent 运行脚本时如何克服 Python 中的 IO 错误 13