Applying new snapshot to diffable data source in iOS 15 trigger unnecessary cell registration call

I try to figure out the improvement of apply(_:animatingDifferences:) function call without animation in iOS 15 Simulator.

As log shown below, after removing an item and apply edited snapshot to diffable data source, it trigger several unnecessary calling to new cell that did not appear on screen.

ENV:

  1. MacBook Air (M1, 2020), macOS Big Sur 11.4 (20F71)
  2. Xcode Version 13.0 beta (13A5154h),
  3. iOS 15.0 Simulator
configure AlbumItemCell:(0x0000000128042400) at section:(0), item:(0)

configure AlbumItemCell:(0x0000000125624aa0) at section:(0), item:(1)

configure AlbumItemCell:(0x00000001280442a0) at section:(0), item:(2)

configure AlbumItemCell:(0x0000000128045670) at section:(0), item:(3)

configure AlbumItemCell:(0x0000000128050af0) at section:(0), item:(4)

configure AlbumItemCell:(0x0000000125626430) at section:(0), item:(5)

remove last items

configure AlbumItemCell:(0x000000012571f970) at section:(0), item:(0)

configure AlbumItemCell:(0x000000012571f970) at section:(0), item:(1)

configure AlbumItemCell:(0x000000012571f970) at section:(0), item:(2)

configure AlbumItemCell:(0x000000012571f970) at section:(0), item:(3)

configure AlbumItemCell:(0x000000012571f970) at section:(0), item:(4)
Answered by StanOZ in 680373022

The definition of ITEM list below, could you see  any problem here? @J0hn

class AlbumItem: Hashable {
  let albumURL: URL
  let albumTitle: String
  let imageItems: [AlbumDetailItem]
  init(albumURL: URL, imageItems: [AlbumDetailItem] = []) {
    self.albumURL = albumURL
    self.albumTitle = albumURL.lastPathComponent.displayNicely
    self.imageItems = imageItems
  }

  func hash(into hasher: inout Hasher) {
    hasher.combine(identifier)
  }

  static func == (lhs: AlbumItem, rhs: AlbumItem) -> Bool {
    return lhs.identifier == rhs.identifier
  }

  private let identifier = UUID()
}

BTW, those unnecessary calling  occurs in  demo on session 10252,to reproduce this issue:

  1. add delegate for the collection view, disable prefetching to reduce logs:
  private func configureHierarchy() {
        collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createLayout())
        collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        collectionView.backgroundColor = .systemBackground
//        collectionView.prefetchDataSource = self
        collectionView.isPrefetchingEnabled = false
        collectionView.delegate = self
        view.addSubview(collectionView)
    }

2.  implement the selection callback for removing item from snapshot:

extension PostGridViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        print("remove items at section:(\(indexPath.section)), item:(\(indexPath.item))")
        var snapshot = dataSource.snapshot()
        let identfiers = snapshot.itemIdentifiers(inSection: Section.ID.allCases[indexPath.section])
        snapshot.deleteItems([identfiers[indexPath.item]])
        dataSource.apply(snapshot, animatingDifferences: false)
    }
}

  1. finally, remove reload stuff in cell registeration:
 let cellRegistration = UICollectionView.CellRegistration<DestinationPostCell, DestinationPost.ID> { [weak self] cell, indexPath, postID in
            guard let self = self else { return }
            print("configure DestinationPostCell:(\(Unmanaged.passUnretained(cell).toOpaque())) at section:(\(indexPath.section)), item:(\(indexPath.item))")
            let post = self.postsStore.fetchByID(postID)
            let asset = self.assetsStore.fetchByID(post.assetID)
            cell.configureFor(post, using: asset)
        }

THEN I ran this demo and clicked the first item to remove it, similar log came out:

configure DestinationPostCell:(0x000000015ef11940) at section:(0), item:(0)
configure DestinationPostCell:(0x000000015ed20410) at section:(0), item:(1)
configure DestinationPostCell:(0x000000015ed21900) at section:(1), item:(0)
remove items at section:(0), item:(0)
configure DestinationPostCell:(0x000000015ed433c0) at section:(1), item:(2)
configure DestinationPostCell:(0x000000015ed433c0) at section:(0), item:(0)
configure DestinationPostCell:(0x000000015ed433c0) at section:(1), item:(0)
configure DestinationPostCell:(0x000000015ed433c0) at section:(1), item:(1)
configure DestinationPostCell:(0x000000015ef11940) at section:(1), item:(1)

What do the equatable and hashable methods of your ITEM look like?

Accepted Answer

The definition of ITEM list below, could you see  any problem here? @J0hn

class AlbumItem: Hashable {
  let albumURL: URL
  let albumTitle: String
  let imageItems: [AlbumDetailItem]
  init(albumURL: URL, imageItems: [AlbumDetailItem] = []) {
    self.albumURL = albumURL
    self.albumTitle = albumURL.lastPathComponent.displayNicely
    self.imageItems = imageItems
  }

  func hash(into hasher: inout Hasher) {
    hasher.combine(identifier)
  }

  static func == (lhs: AlbumItem, rhs: AlbumItem) -> Bool {
    return lhs.identifier == rhs.identifier
  }

  private let identifier = UUID()
}

BTW, those unnecessary calling  occurs in  demo on session 10252,to reproduce this issue:

  1. add delegate for the collection view, disable prefetching to reduce logs:
  private func configureHierarchy() {
        collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createLayout())
        collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        collectionView.backgroundColor = .systemBackground
//        collectionView.prefetchDataSource = self
        collectionView.isPrefetchingEnabled = false
        collectionView.delegate = self
        view.addSubview(collectionView)
    }

2.  implement the selection callback for removing item from snapshot:

extension PostGridViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        print("remove items at section:(\(indexPath.section)), item:(\(indexPath.item))")
        var snapshot = dataSource.snapshot()
        let identfiers = snapshot.itemIdentifiers(inSection: Section.ID.allCases[indexPath.section])
        snapshot.deleteItems([identfiers[indexPath.item]])
        dataSource.apply(snapshot, animatingDifferences: false)
    }
}

  1. finally, remove reload stuff in cell registeration:
 let cellRegistration = UICollectionView.CellRegistration<DestinationPostCell, DestinationPost.ID> { [weak self] cell, indexPath, postID in
            guard let self = self else { return }
            print("configure DestinationPostCell:(\(Unmanaged.passUnretained(cell).toOpaque())) at section:(\(indexPath.section)), item:(\(indexPath.item))")
            let post = self.postsStore.fetchByID(postID)
            let asset = self.assetsStore.fetchByID(post.assetID)
            cell.configureFor(post, using: asset)
        }

THEN I ran this demo and clicked the first item to remove it, similar log came out:

configure DestinationPostCell:(0x000000015ef11940) at section:(0), item:(0)
configure DestinationPostCell:(0x000000015ed20410) at section:(0), item:(1)
configure DestinationPostCell:(0x000000015ed21900) at section:(1), item:(0)
remove items at section:(0), item:(0)
configure DestinationPostCell:(0x000000015ed433c0) at section:(1), item:(2)
configure DestinationPostCell:(0x000000015ed433c0) at section:(0), item:(0)
configure DestinationPostCell:(0x000000015ed433c0) at section:(1), item:(0)
configure DestinationPostCell:(0x000000015ed433c0) at section:(1), item:(1)
configure DestinationPostCell:(0x000000015ef11940) at section:(1), item:(1)

Well, I thought I saw something, but maybe not...

I do want to call something out, though:

private let identifier = UUID()

If you are ever reconstructing these items more than once, the diffableDatasource will see them as new items EVERY time. It could be wise to use something naturally unique to your "item" like the URL, for example.

Also because your equatable looks like:

static func == (lhs: AlbumItem, rhs: AlbumItem) -> Bool {
    return lhs.identifier == rhs.identifier
  }

DiffableDatasource will not re-create a cell if cell if a property of the item changes.


One way I think about it is this:

-HashID identifies the existence of a cell in the collectionView."

-If HashID and Equals match between snapshots, the collectionView will visually keep that cell consistent. For example, you'll see move animation if the item's index changes.

-If HashID matches, but equality does not, then diffableDatasource will dequeue a new cell and call the configuration method. (In iOS 15 you can also nominate Items for a reconfigure instead of a full-on dequeuing.)


In your case, for short term debugging, I would try using the URL as the "unique identifier" of your item. Long term, you may also need to incorporate a "modification date" into the equality method because a URL's contents can change and require a redraw the cell.

Please give it a try on iOS 15 beta 2, there are new optimizations in beta 2 (which were not in beta 1) that will reduce or eliminate the number of extra cells requested when you use apply(_:animatingDifferences:). In some cases, you may still see a few extra cells requested, but this will usually only happen the first time you apply a snapshot. The reason that extra cells can be requested sometimes is because UICollectionView needs to perform self-sizing of some cells near the visible region in order to resolve estimated sizes and accurately determine which cells need to be visible, so in certain cases it will ask for cells that may not immediately become visible. However, in iOS 15 any extra cells requested for self-sizing will be kept cached by UICollectionView as prepared cells (similar to the way cells get prefetched), so that they can be immediately made visible when you scroll to them.

Cool, I had retry it on iOS 15 beta2, it just works like a charm~

Please give it a try on iOS 15 beta 2, as there are new optimizations (which were not in beta 1) that will reduce or eliminate the number of extra cells requested when you use apply(_:animatingDifferences:).

In some cases, you may still see a few extra cells requested, but this will usually only happen the first time you apply a snapshot. The reason that extra cells can be requested sometimes is because UICollectionView needs to perform self-sizing of some cells near the visible region in order to resolve estimated sizes and accurately determine which cells need to be visible, so in certain cases it will ask for cells that may not immediately become visible. However, in iOS 15 any extra cells requested for self-sizing will be kept cached by UICollectionView as prepared cells (similar to the way cells get prefetched), so that they can be immediately made visible when you scroll to them.

Applying new snapshot to diffable data source in iOS 15 trigger unnecessary cell registration call
 
 
Q