UICollectionView performBatchUpdates can trigger a crash if the collection view is flagged for layout

Originator:matej
Number:rdar://28167779 Date Originated:06-Sep-2016 11:01 AM
Status:Open Resolved:
Product:iOS Product Version:iOS 9, iOS 10
Classification:Serious Bug Reproducible:Always
 
Summary:

Using `-[UICollectionView performBatchUpdates:completion:]` can in some cases trigger an assertion (`NSInternalInconsistencyException`) due to the data source being queried for the updated item counts too quickly. 

As per `UICollectionView` programming guide, we should first update the model and then do any incremental collection view updates. If we follow this guideline and the collection view is in a certain state (needing layout?), the `UICollectionViewData` source methods are called immediately when the `performBatchUpdates` call is reached. This updates the internal state, which causes the incremental updates to be applied on an already up-to-date state.

```
items.append("three")

collectionView.performBatchUpdates({
    collectionView.insertItems(at: [NSIndexPath(item: 2, section: 0) as IndexPath])
}, completion: nil)
```

Steps to Reproduce:

If Swift hasn’t been rewritten in the meantime, just build and run the attached sample. The application should crash after a few seconds. 
All the relevant example code is in `ViewController.swift`. 

Expected Results:

The incremental updates would be performed without any issues. 

Actual Results:

An exception is thrown because the item counts get updated before the incremental updates are applied. 

```
2016-09-06 10:29:55.833 CollectionViewBatchingIssue[1586:213220] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid update: invalid number of items in section 0.  The number of items contained in an existing section after the update (3) must be equal to the number of items contained in that section before the update (3), plus or minus the number of items inserted or deleted from that section (1 inserted, 0 deleted) and plus or minus the number of items moved into or out of that section (0 moved in, 0 moved out).'
*** First throw call stack:
(
	0   CoreFoundation                      0x000000010f57fd85 __exceptionPreprocess + 165
	1   libobjc.A.dylib                     0x000000010cc2edeb objc_exception_throw + 48
	2   CoreFoundation                      0x000000010f57fbea +[NSException raise:format:arguments:] + 106
	3   Foundation                          0x000000010c878d5a -[NSAssertionHandler handleFailureInMethod:object:file:lineNumber:description:] + 198
	4   UIKit                               0x000000010d922077 -[UICollectionView _endItemAnimationsWithInvalidationContext:tentativelyForReordering:] + 15363
	5   UIKit                               0x000000010d929497 -[UICollectionView _performBatchUpdates:completion:invalidationContext:tentativelyForReordering:] + 415
	6   UIKit                               0x000000010d9292d5 -[UICollectionView _performBatchUpdates:completion:invalidationContext:] + 74
	7   UIKit                               0x000000010d929278 -[UICollectionView performBatchUpdates:completion:] + 53
	8   CollectionViewBatchingIssue         0x000000010c71af68 _TFC27CollectionViewBatchingIssue14ViewControllerP33_AD2F90D15FF8949970866ECEAB23D08711updateItemsfT_T_ + 1048
	9   CollectionViewBatchingIssue         0x000000010c71a865 _TFFC27CollectionViewBatchingIssue14ViewController13viewDidAppearFSbT_U_FT_T_ + 21
	10  CollectionViewBatchingIssue         0x000000010c71a957 _TTRXFo___XFdCb___ + 39
	11  libdispatch.dylib                   0x000000011018cd9d _dispatch_call_block_and_release + 12
	12  libdispatch.dylib                   0x00000001101ad3eb _dispatch_client_callout + 8
	13  libdispatch.dylib                   0x0000000110192686 _dispatch_after_timer_callback + 334
	14  libdispatch.dylib                   0x00000001101ad3eb _dispatch_client_callout + 8
	15  libdispatch.dylib                   0x00000001101a07e5 _dispatch_source_latch_and_call + 1750
	16  libdispatch.dylib                   0x000000011019b770 _dispatch_source_invoke + 1057
	17  libdispatch.dylib                   0x0000000110195051 _dispatch_main_queue_callback_4CF + 1324
	18  CoreFoundation                      0x000000010f4d90f9 __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ + 9
	19  CoreFoundation                      0x000000010f49ab99 __CFRunLoopRun + 2073
	20  CoreFoundation                      0x000000010f49a0f8 CFRunLoopRunSpecific + 488
	21  GraphicsServices                    0x0000000111287ad2 GSEventRunModal + 161
	22  UIKit                               0x000000010d093f09 UIApplicationMain + 171
	23  CollectionViewBatchingIssue         0x000000010c71c84f main + 111
	24  libdyld.dylib                       0x00000001101e192d start + 1
	25  ???                                 0x0000000000000001 0x0 + 1
)
```

Regression:

Tested on iOS 9 and iOS 10. Happens on both versions. 

Commenting out the dummy `collectionView.reloadData()` causes the collection view to use a different code path that doesn’t trigger the issue. This reload data call here might look pretty artificial in the example, but in a complex application it might very well happen. 

The issue also doesn’t happen if the model is updated inside the `performBatchUpdates` block. I could not find any documentation requiring this and it would also very cumbersome to do in our case where the data source update happens in a different object. 

There are reports out there of this happening in other cases as well. http://stackoverflow.com/q/26898835/88854

Notes:

Stack trace leading from the `performBatchUpdates` call to the data source methods being triggered. 

```
* thread #1: tid = 0x35d28, 0x000000010ebfe1ab CollectionViewBatchingIssue`ViewController.collectionView(collectionView=0x00007f9ce301f800, section=0, self=0x00007f9ce280c530) -> Int + 27 at ViewController.swift:57, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
    frame #0: 0x000000010ebfe1ab CollectionViewBatchingIssue`ViewController.collectionView(collectionView=0x00007f9ce301f800, section=0, self=0x00007f9ce280c530) -> Int + 27 at ViewController.swift:57
    frame #1: 0x000000010ebfe232 CollectionViewBatchingIssue`@objc ViewController.collectionView(UICollectionView, numberOfItemsInSection : Int) -> Int + 66 at ViewController.swift:0
    frame #2: 0x000000010fe4856c UIKit`-[UICollectionViewData _updateItemCounts] + 492
    frame #3: 0x000000010fe4b009 UIKit`-[UICollectionViewData numberOfSections] + 22
    frame #4: 0x000000010fe2cf51 UIKit`-[UICollectionViewFlowLayout _getSizingInfos] + 445
    frame #5: 0x000000010fe2ec47 UIKit`-[UICollectionViewFlowLayout _fetchItemsInfoForRect:] + 118
    frame #6: 0x000000010fe283fd UIKit`-[UICollectionViewFlowLayout prepareLayout] + 273
    frame #7: 0x000000010fe48c3d UIKit`-[UICollectionViewData _prepareToLoadData] + 67
    frame #8: 0x000000010fe49411 UIKit`-[UICollectionViewData validateLayoutInRect:] + 53
    frame #9: 0x000000010fdf653a UIKit`-[UICollectionView layoutSubviews] + 199
    frame #10: 0x000000010f631980 UIKit`-[UIView(CALayerDelegate) layoutSublayersOfLayer:] + 703
    frame #11: 0x0000000114382c00 QuartzCore`-[CALayer layoutSublayers] + 146
    frame #12: 0x000000011437708e QuartzCore`CA::Layer::layout_if_needed(CA::Transaction*) + 366
    frame #13: 0x000000010f621205 UIKit`-[UIView(Hierarchy) layoutBelowIfNeeded] + 1129
    frame #14: 0x000000010fe0c3e9 UIKit`-[UICollectionView _performBatchUpdates:completion:invalidationContext:tentativelyForReordering:] + 241
    frame #15: 0x000000010fe0c2d5 UIKit`-[UICollectionView _performBatchUpdates:completion:invalidationContext:] + 74
    frame #16: 0x000000010fe0c278 UIKit`-[UICollectionView performBatchUpdates:completion:] + 53
  * frame #17: 0x000000010ebfdf68 CollectionViewBatchingIssue`ViewController.updateItems(self=0x00007f9ce280c530) -> () + 1048 at ViewController.swift:51
    frame #18: 0x000000010ebfd865 CollectionViewBatchingIssue`ViewController.(self=0x00007f9ce280c530) -> ()).(closure #1) + 21 at ViewController.swift:20
    frame #19: 0x000000010ebfd957 CollectionViewBatchingIssue`thunk + 39 at ViewController.swift:0
    frame #20: 0x000000011266fd9d libdispatch.dylib`_dispatch_call_block_and_release + 12
    frame #21: 0x00000001126903eb libdispatch.dylib`_dispatch_client_callout + 8
    frame #22: 0x0000000112675686 libdispatch.dylib`_dispatch_after_timer_callback + 334
    frame #23: 0x00000001126903eb libdispatch.dylib`_dispatch_client_callout + 8
    frame #24: 0x00000001126837e5 libdispatch.dylib`_dispatch_source_latch_and_call + 1750
    frame #25: 0x000000011267e770 libdispatch.dylib`_dispatch_source_invoke + 1057
    frame #26: 0x0000000112678051 libdispatch.dylib`_dispatch_main_queue_callback_4CF + 1324
    frame #27: 0x00000001119bc0f9 CoreFoundation`__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ + 9
    frame #28: 0x000000011197db99 CoreFoundation`__CFRunLoopRun + 2073
    frame #29: 0x000000011197d0f8 CoreFoundation`CFRunLoopRunSpecific + 488
    frame #30: 0x000000011376aad2 GraphicsServices`GSEventRunModal + 161
    frame #31: 0x000000010f576f09 UIKit`UIApplicationMain + 171
    frame #32: 0x000000010ebff84f CollectionViewBatchingIssue`main + 111 at AppDelegate.swift:12
    frame #33: 0x00000001126c492d libdyld.dylib`start + 1
```

Comments

Example

https://github.com/PSPDFKit-labs/radar.apple.com/tree/master/28167779%20-%20CollectionViewBatchingIssue

The apple guidelines actually show updating the data source (i.e. items.append[3]) within that performBatchUpdates block. So it makes sense that this would crash.

The workaround is to not call performBatchUpdates if initial model is empty i.e. only inserts will be made to an empty model, but call reloadData instead.


Please note: Reports posted here will not necessarily be seen by Apple. All problems should be submitted at bugreport.apple.com before they are posted here. Please only post information for Radars that you have filed yourself, and please do not include Apple confidential information in your posts. Thank you!