1、问题背景

今天收到了如下的 线上Crash:

image-20230719162721476.png

2、复现问题

冗长的堆栈信息,核心内容如下:

OS Version: iPhone OS 16.5.1 (20F75)
Hardware Model: iPhone12,1
Launch Time: 2023-07-19 09:38:13
Date/Time: 2023-07-19 09:53:35
*** 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 (5) 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 (2 inserted, 2 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: 0x106b56000; frame = (0 32; 414 279); clipsToBounds = YES; gestureRecognizers = <NSArray: 0x2830be5e0>; backgroundColor = UIExtendedGrayColorSpace 0 0; layer = <CALayer: 0x28387c540>; contentOffset: {0, 0}; contentSize: {1122, 279}; adjustedContentInset: {0, 0, 0, 0}; layout: <UIPrintPreviewFlowLayout: 0x1280625d0>; dataSource: <UIPrintPreviewViewController: 0x106970a00>>
Last Exception Backtrace:
0 CoreFoundation 0x00000001a699ccc0 0x1a6993000 + 40128
1 libobjc.A.dylib bool method_lists_contains_any<method_list_t*>(method_list_t**, method_list_t**, objc_selector**, unsigned long) (in libobjc.A.dylib) 1503
2 Foundation 0x00000001a113a56c 0x1a0c59000 + 5117292
3 UIKitCore -[UICollectionView _Bug_Detected_In_Client_Of_UICollectionView_Invalid_Number_Of_Items_In_Section:] (in UIKitCore) 95
4 UIKitCore -[UICollectionView _endItemAnimationsWithInvalidationContext:tentativelyForReordering:animator:collectionViewAnimator:] (in UIKitCore) 9915
5 UIKitCore -[UICollectionView _updateRowsAtIndexPaths:updateAction:updates:] (in UIKitCore) 395
6 UIKitCore -[UICollectionView reloadItemsAtIndexPaths:] (in UIKitCore) 51

当看到 -[UICollectionView reloadItemsAtIndexPaths:] ,就大概知道怎么复现了,然后简单调试后,写了如下代码,能够安稳复现:

#import "AMKCrashByInvalidUpdateCollectionViewController.h"@interface AMKCrashByInvalidUpdateCollectionViewController () <UICollectionViewDelegateFlowLayout, UICollectionViewDataSource>
@property (nonatomic, strong, readwrite, nullable) UICollectionView *collectionView;
@property (nonatomic, strong, readwrite, nullable) NSMutableArray<NSMutableArray *> *dataSource;
@end@implementation AMKCrashByInvalidUpdateCollectionViewController
​
+ (void)load {
    id __block token = [NSNotificationCenter.defaultCenter addObserverForName:UIApplicationDidFinishLaunchingNotification object:nil queue:NSOperationQueue.mainQueue usingBlock:^(NSNotification * _Nonnull note) {
        [NSNotificationCenter.defaultCenter removeObserver:token];
        [(UINavigationController *)UIApplication.sharedApplication.delegate.window.rootViewController pushViewController:self.new animated:YES];
    }];
}
​
#pragma mark - Dealloc
​
- (void)dealloc {
    
}
​
#pragma mark - Init Methods#pragma mark - Life Circle
​
- (void)viewDidLoad {
    [super viewDidLoad];
    self.view.backgroundColor = self.view.backgroundColor?:[UIColor whiteColor];
    self.navigationItem.rightBarButtonItems = @[
        [UIBarButtonItem.alloc initWithTitle:@"Crash0" style:UIBarButtonItemStylePlain target:self action:@selector(crashByInvalidUpdate_0:)],
        [UIBarButtonItem.alloc initWithTitle:@"Crash1" style:UIBarButtonItemStylePlain target:self action:@selector(crashByInvalidUpdate_1:)],
        [UIBarButtonItem.alloc initWithTitle:@"Crash2" style:UIBarButtonItemStylePlain target:self action:@selector(crashByInvalidUpdate_2:)],
        [UIBarButtonItem.alloc initWithTitle:@"Crash3" style:UIBarButtonItemStylePlain target:self action:@selector(crashByInvalidUpdate_3:)],
        [UIBarButtonItem.alloc initWithTitle:@"Reload" style:UIBarButtonItemStylePlain target:self.collectionView action:@selector(reloadData)]
    ];
    [self.collectionView reloadData];
}
​
#pragma mark - Getters & Setters
​
- (UICollectionView *)collectionView {
    if(!_collectionView) {
        UICollectionViewFlowLayout *collectionViewFlowLayout = [UICollectionViewFlowLayout.alloc init];
        _collectionView = [UICollectionView.alloc initWithFrame:self.view.bounds collectionViewLayout:collectionViewFlowLayout];
        _collectionView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
        _collectionView.backgroundColor = [UIColor whiteColor];
        _collectionView.dataSource = self;
        _collectionView.delegate = self;
        [_collectionView registerClass:UICollectionViewCell.class forCellWithReuseIdentifier:NSStringFromClass(UICollectionViewCell.class)];
        [_collectionView registerClass:UICollectionReusableView.class forSupplementaryViewOfKind:UICollectionElementKindSectionHeader withReuseIdentifier:NSStringFromClass(UICollectionReusableView.class)];
        [_collectionView registerClass:UICollectionReusableView.class forSupplementaryViewOfKind:UICollectionElementKindSectionFooter withReuseIdentifier:NSStringFromClass(UICollectionReusableView.class)];
        [self.view addSubview:_collectionView];
    }
    return _collectionView;
}
​
#pragma mark - Data & Networking
​
- (NSMutableArray<NSMutableArray *> *)dataSource {
    if (!_dataSource) {
        _dataSource = @[].mutableCopy;
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
        [_dataSource addObject:@[@0, @1, @2].mutableCopy];
    }
    return _dataSource;
}
​
#pragma mark - Layout Subviews#pragma mark - Action Methods// 2023-07-19 11:43:35.418265+0800 AMKCategories_Example[29884:11021065] *** 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 (5) 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 (0 inserted, 0 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: 0x106020800; frame = (0 0; 414 808); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = <NSArray: 0x282268c60>; backgroundColor = <UIDynamicSystemColor: 0x2839a1ec0; name = systemBackgroundColor>; layer = <CALayer: 0x282c946a0>; contentOffset: {0, 0}; contentSize: {414, 50}; adjustedContentInset: {0, 0, 34, 0}; layout: <UICollectionViewFlowLayout: 0x105d55900>; dataSource: <AMKCrashByInvalidUpdateCollectionViewController: 0x105f21440>>'
- (void)crashByInvalidUpdate_0:(id)sender {
    [self.dataSource[0] addObjectsFromArray:@[@3, @4]];
    [self.collectionView reloadItemsAtIndexPaths:@[[NSIndexPath indexPathForRow:5 inSection:0]]];
}
​
- (void)crashByInvalidUpdate_1:(id)sender {
    [self.dataSource[0] addObjectsFromArray:@[@3, @4]];
    [self.collectionView reloadItemsAtIndexPaths:@[[NSIndexPath indexPathForRow:5 inSection:1]]];
}
​
- (void)crashByInvalidUpdate_2:(id)sender {
    [self.dataSource[0] addObjectsFromArray:@[@3, @4]];
    [self.collectionView reloadItemsAtIndexPaths:@[]];
}
​
// 2023-07-19 11:18:20.410828+0800 AMKCategories_Example[29850:11010021] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid batch updates detected: the number of sections and/or items returned by the data source before and/or after performing the batch updates are inconsistent with the updates.
- (void)crashByInvalidUpdate_3:(id)sender {
    [self.dataSource[0] addObjectsFromArray:@[@3, @4]];
    [self.collectionView reloadItemsAtIndexPaths:@[[NSIndexPath indexPathForRow:5 inSection:0]]];
}
​
#pragma mark - Notifications#pragma mark - KVO#pragma mark - Protocol#pragma mark UICollectionViewDataSource
​
- (NSInteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView {
    return self.dataSource.count;
}
​
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
    return self.dataSource[section].count;
}
​
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
    UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:NSStringFromClass(UICollectionViewCell.class) forIndexPath:indexPath];
    cell.contentView.backgroundColor = [UIColor colorWithRed:arc4random()%255/255.0 green:arc4random()%255/255.0 blue:arc4random()%255/255.0 alpha:0.2];
    return cell;
}
​
#pragma mark UICollectionViewDelegate
​
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
    [collectionView deselectItemAtIndexPath:indexPath animated:YES];
    NSLog(@"%@", indexPath);
}
​
#pragma mark - Helper Methods@end

3、定位问题

经过上述 4个 case 的验证,承认:当经过 -reloadItemsAtIndexPaths: 方法部分改写时,只需「当时 collectionView 与数据源 的“section 个数”、“各 section 中 item 的个数” 不共同」,就会引起 crash

—— 即使参数为「空数组」,即使「只有 section A 的 items 个数对不上,可是改写的 section B」,都会 Crash

4、解决方案

有了上述定论,就很容易给出解决方案了:

—— 在部分改写时,校验下「当时 collectionView 与数据源 的“section 个数”、“各 section 中 item 的个数”」是否共同

  • 若共同,则不做干预,持续部分改写
  • 若不共同,则改写整个视图

收拾代码如下:

#import "UICollectionView+AMKCrashProtectorForInvalidUpdate.h"
#import <AMKCategories/NSObject+AMKMethodSwizzling.h>
​
@implementation UICollectionView (AMKCrashProtectorForInvalidUpdate)
​
+ (void)load {
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        [self amk_swizzleInstanceMethod:@selector(reloadItemsAtIndexPaths:) withMethod:@selector(AMKCrashProtector_reloadItemsAtIndexPaths:)];
    });
}
​
- (void)AMKCrashProtector_reloadItemsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths {
    NSException *exception = nil;
    
    // 若「section 数量不共同」,则直接改写
    NSInteger oldNumberOfSections = self.numberOfSections;
    NSInteger newNumberOfSections = [self.dataSource respondsToSelector:@selector(numberOfSectionsInCollectionView:)] ? [self.dataSource numberOfSectionsInCollectionView:self] : oldNumberOfSections;
    if (oldNumberOfSections != newNumberOfSections) {
        NSString *reason = [NSString stringWithFormat:@"Invalid update: 当时有 %ld 个 section,但 dataSource 有 %ld 个。Collection view: %@", oldNumberOfSections, newNumberOfSections, self.description];
        exception = [NSException exceptionWithName:NSInternalInconsistencyException reason:reason userInfo:@{}];
    }
    // 否则逐个验证「section 的 items 数量是否共同」,只需有不共同的,则直接改写
    else if (self.dataSource) {
        for (NSInteger section = 0; section < self.numberOfSections; section++) {
            NSInteger oldNumberOfItemsInSection = [self numberOfItemsInSection:section];
            NSInteger newNumberOfItemsInSection = [self.dataSource collectionView:self numberOfItemsInSection:section];
            if (oldNumberOfItemsInSection != newNumberOfItemsInSection) {
                NSString *reason = [NSString stringWithFormat:@"Invalid update: section %ld 当时有 %ld 个 items,但 dataSource 有 %ld 个。Collection view: %@", section, oldNumberOfItemsInSection, newNumberOfItemsInSection, self.description];
                exception = [NSException exceptionWithName:NSInternalInconsistencyException reason:reason userInfo:@{}];
                break;
            }
        }
    }
        
    if (exception) {
        @try {
            [exception raise];
        } @catch (NSException *exception) {
            // 此处直接弹窗警告了,正式项目中,能够直接上报
            NSString *title = @"CrashProtector";
            NSString *message = [NSString stringWithFormat:@"检测到无效的 indexPath,已整体改写:%@", exception];
            UIAlertController *alertController = [UIAlertController alertControllerWithTitle:title message:message preferredStyle:UIAlertControllerStyleAlert];
            [alertController addAction:[UIAlertAction actionWithTitle:@"好的" style:UIAlertActionStyleCancel handler:nil]];
            [UIApplication.sharedApplication.delegate.window.rootViewController presentViewController:alertController animated:YES completion:nil];
            NSLog(@"%@ => %@", exception, exception.userInfo);
        }
        
        [self reloadData];
    } else {
        [self AMKCrashProtector_reloadItemsAtIndexPaths:indexPaths];
    }
}
​
@end

运转效果如下:

4731dd46896500a80fc647d36a8cdd04.jpg

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。