UICollectionView iOS 13以下删除动画crash

2,624 阅读4分钟

问题反馈

线上突发一个Top1的crash告警,场景是UICollectionView在删除的时候触发。

错误方法deleteItemsAtIndexPaths,错误信息如下 NSException Invalid update: invalid number of items in section 3. The number of items contained in an existing section after the update (40) must be equal to the number of items contained in that section before the update (40), plus or minus the number of items inserted or deleted from that section (0 inserted, 1 deleted) and plus or minus the number of items moved into or out of that section (0 moved in, 0 moved out).

问题分析

1、从crash原因可以知道是UICollectionView在deleteItem的时候前后的item数量一致;(正常应该是删除前40,删除39)
2、这是某个cell的删除逻辑;
3、用户点击cell跳转界面后,又触发了原来UICollectionView的cell删除动画;

从slardar(APM)的聚合信息,可以看到:
4、最后页面是并不是原来UICollectionView的界面;
5、同时所有crash版本为<=iOS 13版本。

通过上述信息和用户行为日志,可以猜测UICollectionView是在界面跳转之后触发删除动画导致crash。用iOS 12设备找到复现路径: 先正常触发UICollectionView的初始化和cell加载 => 从UICollectionView触发界面跳转,进入下一级界面 => 触发删除Cell的业务逻辑 => UICollectionView开始删除动画 => 出现crash。

下面是crash的具体代码:

- (void)bookShelfCollectionViewDeleteItemsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths {
    [self.collectionView performBatchUpdates:^{
        [self.collectionView deleteItemsAtIndexPaths:indexPaths];
    } completion:^(BOOL finished) {
    }];
}

问题归因

UICollectionView有一个逻辑是会把最近使用的cell排在最前面,于是从UICollectionView点击cell发生界面跳转后会触发UICollectionView刷新并调reloadData。但是这个reloadData并不会直接触发UICollectionView马上从dataSource和delegate去获取数据和UI,而是会等到UICollectionView展示的时候再进行触发。

然后在新界面触发某些业务逻辑,导致UICollectionView调用了deleteItemsAtIndexPaths进行cell的移除动画,此时就会产生crash。

问题修复

区分UICollectionView删除cell场景,如果是用户手动移除则会进行动画;如果是非手动触发删除则直接调用reloadData,不调用deleteItemsAtIndexPaths。

问题延伸

为什么iOS 13以上没有crash?

这是UICollectionView内部对动画前后的数量校验,iOS 12及以下的系统会有NSAssert的断言触发;iOS 13开始没有NSAssert,但是同样会有异常Log。如下:

[UICollectionView] Performing reloadData as a fallback — Invalid update: invalid number of items in section 0\. The number of items contained in an existing section after the update (17) must be equal to the number of items contained in that section before the update (17), plus or minus the number of items inserted or deleted from that section (0 inserted, 1 deleted) and plus or minus the number of items moved into or out of that section (0 moved in, 0 moved out). Collection view: <UICollectionView: 0x7fd253012800; frame = (0 150; 375 567); clipsToBounds = YES; gestureRecognizers = <NSArray: 0x60000351f480>; layer = <CALayer: 0x600003b186e0>; contentOffset: {0, 0}; contentSize: {375, 350}; adjustedContentInset: {0, 0, 49, 0}; layout: <UICollectionViewFlowLayout: 0x7fd251f0d5b0>; dataSource: <ViewController: 0x7fd251f07760>>

非当前界面调用reloadData,何时会回调cellForItemAtIndexPath?

界面出现的时候会触发layoutSubviews,此时会通过_updateVisibleCellsNow回调delegate。

image.png

UICollectionView为什么会有这个crash?

crash代码如下:

- (void)bookShelfCollectionViewDeleteItemsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths {
    [self.collectionView performBatchUpdates:^{
        [self.collectionView deleteItemsAtIndexPaths:indexPaths];
    } completion:^(BOOL finished) {
    }];
}

crash的原因是collectionView在执行deleteItemsAtIndexPaths:的时候,会对比删除前后section的item数量。假如原来item数量是20,移除了1个,那么之后的数量应该是19。

UICollectionView内部有一个关于item数量的缓存,在首次调用numberOfItemsInSection:之后会缓存这个结果值,后续继续调用numberOfItemsInSection:就不会回调dataSource去询问。

如下,只有count1会回调dataSouce,count2就直接用缓存的值。

[self.collectionView reloadData];
NSLog(@"count1:%d", [self.collectionView numberOfItemsInSection:0]); // 会回调dataSource询问
NSLog(@"count2:%d", [self.collectionView numberOfItemsInSection:0]); // 直接返回

当UICollectionView执行reloadData的时候,如果UICollectionView在当前界面会触发layoutSubviews,然后会调用_updateItemCounts更新这个缓存数据。

image.png

如果UICollectionView不在当前界面,此时这个缓存会失效,但此刻并不会马上调用dataSource的numberOfItemsInSection:。

[self.collectionView reloadData];
...
...
[self.datas removeObjectAtIndex:0];
[self.collectionView performBatchUpdates:^{
    [self.collectionView deleteItemsAtIndexPaths:@[[NSIndexPath indexPathForRow:0 inSection:0]]];
} completion:^(BOOL finished) {
}];

在后续的performBatchUpdates时候才会回调dataSource询问当前有多少个item,于是我们会按照remove数据之后的数量返回20-1=19个(因为performBatchUpdate是在第3行removeObjectAtIndex之后执行);

image.png

然后在删除动画结束时候,UICollectionView继续询问dataSource当前有多少个item,我们会返回当前的数量19个;

于是UICollectionView就认为出现异常:因为动画前返回是19个,现在删除1个之后返回还是19个。

image.png

这个也可以解释一个奇怪现象,如果在移除数据之前调用一遍numberOfItemsInSection:,即使按照原来的复现路径也不会crash。

因为第一行更新了缓存为正确数量。

NSLog(@"count:%d", [self.collectionView numberOfItemsInSection:0]);
[self.datas removeObjectAtIndex:0];
[self.collectionView performBatchUpdates:^{
    [self.collectionView deleteItemsAtIndexPaths:@[[NSIndexPath indexPathForRow:0 inSection:0]]];
} completion:^(BOOL finished) {
}];

UICollectionView.h 找到一个UICollectionViewData.h类,这里面的属性long long* _sectionItemCounts这个很可能就是负责缓存item数量的变量。

[北京/广州/深圳] 抖音番茄小说客户端团队,欢迎联系(有加必回)

juejin.cn/post/714156…