NSDiffableDataSourceSnapshot的`reloadItems`方法是用来做什么的?

41
我在寻找NSDiffableDataSourceSnapshot reloadItems(_:)的用法时遇到了困难:
  • 如果我要求重新加载的项目与已存在于数据源中的项目不相等,则会出现以下错误:

    Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Attempted to reload item identifier that does not exist in the snapshot: ProjectName.ClassName

  • 但是,如果该项目与已存在于数据源中的项目相等,那么“重新加载”它有什么意义呢?

你可能会认为第二个问题的答案是:好吧,可能存在项目标识符对象的某些其他方面,它们不属于其可比性,但确实反映在单元格接口中。但我发现这并不是真的;调用reloadItems后,表视图不会反映更改。

因此,当我想要更改项目时,我最终使用快照进行替换项之后进行insert,然后删除原始项。没有快照replace方法,这就是我希望reloadItems能够实现的内容。

(我在Stack Overflow上搜索了这些术语,发现很少 —— 主要只是一些关于reloadItems的特定用途的问题,例如How to update a table cell using diffable UITableView。所以我更一般地问,有人发现这种方法有什么实际用途吗?)


好的,没有什么比有一个最小化可重现示例来玩耍更好的了,所以这里有一个。

创建一个普通的iOS项目,使用其模板ViewController,并将此代码添加到ViewController中。

我会逐个解释。首先,我们有一个结构体,它将用作我们的项目标识符。 UUID是唯一的部分,因此相等性和哈希性仅取决于它:

struct UniBool : Hashable {
    let uuid : UUID
    var bool : Bool
    // equatability and hashability agree, only the UUID matters
    func hash(into hasher: inout Hasher) {
        hasher.combine(uuid)
    }
    static func ==(lhs:Self, rhs:Self) -> Bool {
        lhs.uuid == rhs.uuid
    }
}

接下来,是伪造的表视图和可差异数据源:

let tableView = UITableView(frame: .zero, style: .plain)
var datasource : UITableViewDiffableDataSource<String,UniBool>!
override func viewDidLoad() {
    super.viewDidLoad()
    self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
    self.datasource = UITableViewDiffableDataSource<String,UniBool>(tableView: self.tableView) { tv, ip, isOn in
        let cell = tv.dequeueReusableCell(withIdentifier: "cell", for: ip)
        return cell
    }
    var snap = NSDiffableDataSourceSnapshot<String,UniBool>()
    snap.appendSections(["Dummy"])
    snap.appendItems([UniBool(uuid: UUID(), bool: true)])
    self.datasource.apply(snap, animatingDifferences: false)
}

所以在我们的可区分数据源中只有一个UniBool,它的booltrue。现在设置一个按钮来调用此操作方法,该方法尝试通过使用reloadItems来切换bool值:

@IBAction func testReload() {
    if let unibool = self.datasource.itemIdentifier(for: IndexPath(row: 0, section: 0)) {
        var snap = self.datasource.snapshot()
        var unibool = unibool
        unibool.bool = !unibool.bool
        snap.reloadItems([unibool]) // this is the key line I'm trying to test!
        print("this object's isOn is", unibool.bool)
        print("but looking right at the snapshot, isOn is", snap.itemIdentifiers[0].bool)
        delay(0.3) {
            self.datasource.apply(snap, animatingDifferences: false)
        }
    }
}

所以,问题来了。我使用UUID匹配的项目调用reloadItems,但其bool被切换为“此对象的isON为false”。但是当我询问快照时,它告诉我,它唯一的项目标识符的bool仍然为true

这就是我要问的。如果快照不会获取bool的新值,那么reloadItems一开始是用来做什么的呢?

显然,我可以只替换一个不同的UniBool,即具有不同UUID的UniBool。但是,我不能调用reloadItems;因为该UniBool尚未在数据中,所以我们会崩溃。我可以通过调用insert,然后是remove来解决这个问题,这正是我如何解决它的方法。

但我的问题是:如果不是用于这个目的,那么reloadItems有什么作用?


@Paulw11 不,你说得对,但这些都没有任何影响。完全合法将数据完全存储在数据源中。你好像在暗示唯一创建可重载快照的方法是要有一个数据源,在单元格提供程序函数根本不查看其所接收到的值 - 它必须完全查看外部存储库。但是,如果我想要这样做,那么为什么还需要diffable data source呢?我可以使用旧的cellForRowAt实现。 - matt
是的,我在思考一些更多的事情时已经删除了评论。然而,我的理解是可比较数据源的主要好处是它让你简单地操作你的后备存储而不必担心插入/移动/删除的顺序,这通常会导致旧方法崩溃。你只需提供被要求的单元格,你可以添加操作到快照中,以添加/移动/删除的标识符为基础,而不必担心数组索引甚至部分索引。 - Paulw11
1
回到WWDC 2019视频,我认为你应该能够在没有任何后备存储的情况下使用数据源。我在测试了你的示例代码之后,发现这绝对是一个bug。如果将UniBool改为一个类而不是结构体,则会得到预期的行为。似乎reloadItems并没有实际上从快照中取出新值,因此它适用于引用类型。 - Paulw11
1
是的,它们给人的印象是通常不需要后备存储;它是用于某些外部影响可以异步地改变数据的情况。我注意到在他们自己的示例中,例如现代集合视图示例,他们并不经常使用后备存储。他们有时会演示如何使用后备存储,以防您恰好拥有一个,但他们并不总是使用它。 - matt
我刚刚偶然发现了这个问题,因为苹果在iOS 15中添加了一个新的reconfigureItems方法,它似乎有与reloadItems相同的问题,我不得不将我的模型从结构体更改为类才能使其正常工作。文档说reconfigureItems应该用于更新现有单元格的内容,而不是用新单元格替换它们,但到目前为止我发现的唯一区别是reloadItems会触发单元格的prepareForReuse,而reconfigureItems则不会。 - PatrickDotStar
显示剩余8条评论
5个回答

13

我已经就问题中所展示的行为提交了一个错误报告,因为我认为这不是良好的行为。但是,按照目前的情况,我认为我可以猜测一下这个想法的意图。


当您告诉快照reload某个项目时,它并不会读取您提供的项目的数据!它只是查看该项,作为一种标识您要重新加载的已经在数据源中的项目的方式。

(因此,如果您提供的项目可等同于但不完全相同于已经在数据源中的项目,则您提供的项目和已经在数据源中的项目之间的“差异”将根本不重要;数据源将从未被告知任何内容发生了变化。)

然后,当您将该快照apply到数据源时,数据源会告诉表视图重新加载相应的单元格。这导致数据源的单元格提供程序函数再次调用。

好的,那么数据源的单元格提供程序函数被调用了,具有通常的三个参数 - 表视图、索引路径和来自数据源的数据。但是我们刚才说过,来自数据源的数据没有更改。那么重新加载的意义何在呢?

答案显然是,单元格提供程序函数预期从其他地方获取(至少一部分)要在新出列的单元格中显示的新数据。您需要查看某种“后备存储”,以便单元格提供程序查看。例如,您可能正在维护一个字典,其中键是单元格标识符类型,而值是可能重新加载的额外信息。

这肯定是合法的,因为根据定义,单元格标识符类型是可哈希的,因此可以用作字典键,并且还因为单元格标识符必须在数据中是唯一的,否则数据源将拒绝数据(崩溃)。而且,查找将是即时的,因为这是一个字典。


以下是一个完整的工作示例,您只需将其复制并粘贴到项目中即可。该表显示三个名称以及用户可以点击的星形,以表示收藏或非收藏。名称存储在可区分数据源中,但喜爱状态存储在外部后备存储中。

extension UIResponder {
    func next<T:UIResponder>(ofType: T.Type) -> T? {
        let r = self.next
        if let r = r as? T ?? r?.next(ofType: T.self) {
            return r
        } else {
            return nil
        }
    }
}
class TableViewController: UITableViewController {
    var backingStore = [String:Bool]()
    var datasource : UITableViewDiffableDataSource<String,String>!
    override func viewDidLoad() {
        super.viewDidLoad()
        let cellID = "cell"
        self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellID)
        self.datasource = UITableViewDiffableDataSource<String,String>(tableView:self.tableView) {
            tableView, indexPath, name in
            let cell = tableView.dequeueReusableCell(withIdentifier: cellID, for: indexPath)
            var config = cell.defaultContentConfiguration()
            config.text = name
            cell.contentConfiguration = config
            var accImageView = cell.accessoryView as? UIImageView
            if accImageView == nil {
                let iv = UIImageView()
                iv.isUserInteractionEnabled = true
                let tap = UITapGestureRecognizer(target: self, action: #selector(self.starTapped))
                iv.addGestureRecognizer(tap)
                cell.accessoryView = iv
                accImageView = iv
            }
            let starred = self.backingStore[name, default:false]
            accImageView?.image = UIImage(systemName: starred ? "star.fill" : "star")
            accImageView?.sizeToFit()
            return cell
        }
        var snap = NSDiffableDataSourceSnapshot<String,String>()
        snap.appendSections(["Dummy"])
        let names = ["Manny", "Moe", "Jack"]
        snap.appendItems(names)
        self.datasource.apply(snap, animatingDifferences: false)
        names.forEach {
            self.backingStore[$0] = false
        }
    }
    @objc func starTapped(_ gr:UIGestureRecognizer) {
        guard let cell = gr.view?.next(ofType: UITableViewCell.self) else {return}
        guard let ip = self.tableView.indexPath(for: cell) else {return}
        guard let name = self.datasource.itemIdentifier(for: ip) else {return}
        guard let isFavorite = self.backingStore[name] else {return}
        self.backingStore[name] = !isFavorite
        var snap = self.datasource.snapshot()
        snap.reloadItems([name])
        self.datasource.apply(snap, animatingDifferences: false)
    }
}

这可能不是全部的故事。尽管在快照中只指定了单个项目标识符以重新加载,但表视图正在重新加载整个表。在单元格配置块的主体内放置一个打印语句,以打印正在重新绘制的索引路径。每次都会打印表的所有索引路径。这相当于调用reloadData()。 - MH175
请注意,所有这些可能在iOS 15中完全更改(就我所知)。 - matt
我进行了一些调查,我相当确定这是一个错误。在 iPhone 13 模拟器上运行的 iOS 15 上按预期工作。我的 Xcode 项目设置为构建 iOS14.7。我在运行 iOS14.8.1 的物理 iPhone 12 上尝试了它,并再次遇到了错误。因此,我想我们可以认为这在 IOS15 中已经“修复”了。 - MH175
我应该补充说明一下,我尝试创建了一个包含10,000个项目的支持存储,并且配置块仅在所有可见单元格上调用。UIKit似乎正在优化重绘(我认为UITableView始终如此)。我担心会产生较大的重绘负荷。但这也是需要注意的问题。 - MH175
@MH175,您是在暗示reloadItems在iOS 15上可以对值类型进行操作而无需使用后备存储(就像@matt上面建议的那样)吗? - Kunal Shah

7
根据您提供的新示例代码,我认为这似乎是一个错误。当您将reloadItems添加到快照中时,它会正确地触发数据源闭包以请求更新的单元格,但是传递给闭包的IdentifierType项目是原始值,而不是使用reloadItems调用提供的新值。
如果我将您的UniBool结构更改为类,使其成为引用类型而不是值类型,则事情会按预期运行(因为现在有一个UniBool的单个实例,而不是具有相同标识符的新实例)。
目前似乎有几个可能的解决方法:
  1. 对于IdentifierType,使用引用类型而不是值类型
  2. 使用额外的后备存储,例如数组,并通过indexPath在数据源闭包中访问它。
我认为这两种方法都不是理想的解决方法。
有趣的是,当我将UniBool更改为类之后,我尝试创建一个新实例,该实例具有与现有实例相同的uuid并重新加载它;代码崩溃并引发异常,指出“重新加载时指定了无效的项目标识符”;这听起来对我来说不太对;只有hashValue应该有影响,而不是实际对象引用。原始对象和新对象都具有相同的hashValue,并且==返回true

原回答

reloadItems 可以使用,但有两个重要的注意点:

  1. 必须从数据源的当前 snapshot 开始,并在其上调用 reloadItems。不能创建新的快照。

  2. 不能依赖于传递给 CellProvider 闭包的 item 做除了 identifier 之外的任何事情 - 它不代表来自后端模型(数组)的最新数据。

第二个点意味着您需要使用提供的 indexPathitem.id 来从模型中获取更新的对象。

我创建了一个简单的 example,它在表格行中显示当前时间;这是数据源结构体:

struct RowData: Hashable {
    var id: UUID = UUID()
    var name: String
    private let possibleColors: [UIColor] = [.yellow,.orange,.cyan]
    var timeStamp = Date()
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(self.id)
    }
    
    static func ==(lhs: RowData, rhs: RowData) -> Bool {
        return lhs.id == rhs.id
    }
}

请注意,尽管hash函数仅使用id属性,但还必须重写==,否则在尝试重新加载行时将出现无效标识符崩溃。
每秒钟会重新加载一组随机选择的行。运行代码后,您会看到这些随机选择的行上的时间已更新。
以下是使用reloadItems的代码:
self.timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { (timer) in
    guard let datasource = self.tableview.dataSource as? UITableViewDiffableDataSource<Section,RowData> else {
        return
    }
    var snapshot = datasource.snapshot()
    var rowIdentifers = Set<RowData>()
    for _ in 0...Int.random(in: 1...self.arrItems.count) {
        let randomIndex = Int.random(in: 0...self.arrItems.count-1)
        self.arrItems[randomIndex].timeStamp = Date()
        rowIdentifers.insert(self.arrItems[randomIndex])
    }

    snapshot.reloadItems(Array(rowIdentifers))
    datasource.apply(snapshot)
}

谢谢!看起来你正在做的正是没有为我更新界面,所以现在我需要弄清楚差异在哪里。 - matt
看,当我做你所做的事情时——或者至少我认为是同样的事情——我会崩溃并显示“指定的重新加载项标识符无效”。我推测这是因为新行标识符与任何现有行标识符都不相等。这就是问题的关键所在;如果它们必须相同,那么重新加载的意义何在? - matt
好的,我明白了。你的date只是一个计算属性。但是假设你改变了name属性并重新加载,我认为你会崩溃,就像我一样。如果你尝试通过实现==来避免涉及name,那么当你重新加载时,名称显示不会更新。这就是我正在努力解决的问题。 - matt
1
嗯,是的,这很奇怪。我将 timeStamp 更改为简单属性后,程序崩溃了。如果我重写 == 使其只比较 id == id,那么程序就不会崩溃,但时间戳也不会更新。有趣的是,我添加了一个背景颜色计算随机属性,它确实发生了变化,所以它正确地调用了闭包来更新单元格,只是没有获取到更新的 timeStamp 属性。 - Paulw11
是的,你之前的评论就是我的问题要点。我们现在完全理解一致,你已经完美地理解了这个问题。 - matt
显示剩余5条评论

7

我通过Swift Senpai发现,更新这些diffabledatasource的方式取决于你的模型是类(按引用传递)还是结构体(按值传递)。按引用传递的方式可以获取该项,更新它,然后重新加载该项:

// Model is a class compliant with Hasable and Equatable, name String property
guard let selectedItem = dataSource.itemIdentifier(for: indexPath) else { return}
// modify item
selectedItem.name = "new name"
// update the snapshot
var newSnapShot = dataSource.snapshot()
newSnapshot.reloadItems([selectedItem])
dataSource.apply(newSnapshot)

因此,上述代码将适用于一个类模型(该类需要显式实现hast(into:)和==(lhs:rhs:))。

另一方面,结构体要求您复制该项,更新它,然后从快照中插入已更新的项并删除旧项。

// Model is a struct with name String property
guard let selectedItem = dataSource.itemIdentifier(for: indexPath) else { return}
// update the item
var updatedSelectedItem = selectedItem
updatedSelectedItem.name = "new name"
// update snapshot
var newSnapShot = dataSource.snapshot()
newSnapshot.insertItems([updatedSelectedItem], beforeItem: selectedItem)
newSnapshot.deleteItems([selectedItem])
dataSource.apply(newSnapshot)

这些对我很有用。


2
谢谢,但您会注意到在第二个示例中,您没有说出 reloadItems。这正是问题所在,因此除了我已经说过的内容之外,这并没有回答实际问题。 - matt
1
我想关键在于,如果你有一个结构体,reloadItems无法正常工作。你必须插入和删除项目。reloadItems只会重新加载原始结构体的值,因为它们是按值传递的。 - Jim Kardach
我认为这一点已经被证明了,例如 https://dev59.com/A1IG5IYBdhLWcg3wkR1z#64093453。而且我在我的答案中展示了如何使 reloadItems 即使对于结构体也能正常工作。 - matt

5

我发布了同样的问题,没有意识到。我通过首先将我的模型转换为类来使其工作。然后在调用'reloadItems'之后调用'applySnapshot'即可。

func toggleSelectedStateForItem(at indexPath: IndexPath, animate: Bool = true) {
    let item = dataSource.itemIdentifier(for: indexPath)!
    var snapshot = dataSource.snapshot()
    item.isSelected = !item.isSelected
    snapshot.reloadItems([item])
    dataSource.apply(snapshot)
}

如果您真的认为问题是重复的,请尝试将其标记为重复,以帮助保持网站的清洁。 - Daemon Painter
嗨@DaemonPainter,我不同意这里的被接受答案... - bobby123uk
您可以在此处评论接受的答案,接受的答案可能随时间而变化。 - Daemon Painter
在iOS15中遇到了同样的问题(在15之前可以工作)。将结构体转换为类解决了这个问题。 - rmp

0
在我的情况下,添加重新加载项目的检查:
if #available(iOS 15.0, *) {
    if !snapshot.reloadedItemIdentifiers.contains(YourItem) {
        snapshot.reloadItems([YourItem])
        self.dataSource.apply(snapshot, animatingDifferences: false)
    }
} else {
    snapshot.reloadItems([YourItem])
    self.dataSource.apply(snapshot, animatingDifferences: false)
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接