记一次 UITableView 中的崩溃调试过程

我们的应用是类似微博这样的 Feed 流(使用 UITableView 实现)产品。

在用户未登录的情况下刷新 Feed 流时,会有一定的策略在 Feed 数据源中插入登录卡片,引导用户点击该卡片进行登录。用户登录成功后会将该登录卡片从 Feed 数据源中删除并刷新 table view 界面。但昨天出了一个偶现的 bug:用户通过登录卡片登录成功后,在执行卡片删除操作时应用竟然崩溃了。

调试信息

收集到调试信息如下:

  • 控制台打印消息

* Assertion failure in -[UITableView _endCellAnimationsWithContext:], /BuildRoot/Library/Caches/com.apple.xbs/Sources/UIKit/UIKit-3694.4.18/UITableView.m:1950

* Terminating app due to uncaught exception ‘NSInternalInconsistencyException’, reason: ‘Invalid update: invalid number of rows in section 0. The number of rows contained in an existing section after the update (19) must be equal to the number of rows contained in that section before the update (21), plus or minus the number of rows inserted or deleted from that section (0 inserted, 1 deleted) and plus or minus the number of rows moved into or out of that section (0 moved in, 0 moved out).’

  • 崩溃主线程函数帧栈

复现问题

顺着上述函数帧栈信息,在用户未登录的前提下去刷新 Feed 流,让登录卡片出现并登录,登录完成后执行删除登录卡片的操作。这样就比较顺利地复现了崩溃问题,但试了几次之后也发现这个问题并不是必现的。

分析问题

从上面的信息可以初步看出崩溃是由于 table view 中显示的数据和其数据源不一致导致的。

上图的 -[XXXFeedListView dismissGuideCardCellView] 方法中删除登录卡片时只使用了两句代码:

1
2
[self.listModel.dataList removeObjectAtIndex:indexPath.row];
[self.listView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];

先删除数据源中的某一条数据,再使用 -[UITableView deleteRowsAtIndexPaths:withRowAnimation:] 将数据源同步到 table view 上。按理说这样的操作并不会导致任何问题,但为什么这里执行到 -[UITableView deleteRowsAtIndexPaths:withRowAnimation:] 时会崩溃呢?

当问题不是必现的时候,我第一反应觉得应该多线程异步操作导致的问题,认为是在执行删除操作时其它地方在非主线程也对该列表进行了写操作。于是在删除数据源中的数据这行代码前后加了打印信息:

1
2
3
4
5
6
7
8
9
NSLog(@"dismissGuideCardCellView count0: %ld", self.listModel.dataList.count);
NSLog(@"dismissGuideCardCellView count1: %ld", [self.listView.dataSource tableView:self.listView numberOfRowsInSection:0]);
[self.listModel.dataList removeObjectAtIndex:indexPath.row];
NSLog(@"dismissGuideCardCellView count2: %ld", self.listModel.dataList.count);
NSLog(@"dismissGuideCardCellView count3: %ld", [self.listView.dataSource tableView:self.listView numberOfRowsInSection:0]);
[self.listView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];

打印信息如下:

1
2
3
4
5
dismissGuideCardCellView count0: 23
dismissGuideCardCellView count1: 23
dismissGuideCardCellView count2: 22
dismissGuideCardCellView count3: 22

这样看起来也没问题,也就是说很可能不是多线程引起的问题。

意外收获

其实在加这几句打印信息的时候有了个意外的收获。在加下面这句代码时:

1
NSLog(@"dismissGuideCardCellView count1: %ld", [self.tableView.dataSource tableView:self.tableView numberOfRowsInSection:0]);

Xcode 自动补全出现了一个以前没留意过的方法 -[UITableView numberOfRowsInSection:],我猜很多读者也没见过它。它确实和我们经常实现的数据源方法 -[UITableViewDataSource tableView:numberOfRowsInSection:] 非常像,一不留神就错过它了。乍看觉得这两个函数返回的值应该是相等的。于是我多加了个打印信息,变成这样:

1
2
3
4
5
6
7
8
9
10
11
NSLog(@"dismissGuideCardCellView count0: %ld", self.listModel.dataList.count);
NSLog(@"dismissGuideCardCellView count1: %ld", [self.listView.dataSource tableView:self.listView numberOfRowsInSection:0]);
NSLog(@"dismissGuideCardCellView count2: %ld", [self.listView numberOfRowsInSection:0]);
[self.listModel.dataList removeObjectAtIndex:indexPath.row];
NSLog(@"dismissGuideCardCellView count3: %ld", self.listModel.dataList.count);
NSLog(@"dismissGuideCardCellView count4: %ld", [self.listView.dataSource tableView:self.listView numberOfRowsInSection:0]);
NSLog(@"dismissGuideCardCellView count5: %ld", [self.listView numberOfRowsInSection:0]);
[self.listView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];

打印信息如下:

1
2
3
4
5
6
7
dismissGuideCardCellView count0: 23
dismissGuideCardCellView count1: 23
dismissGuideCardCellView count2: 24
dismissGuideCardCellView count3: 22
dismissGuideCardCellView count4: 22
dismissGuideCardCellView count5: 24

上面提到的两个方法返回的值竟然不一致。看下 Apple 对 -[UITableView numberOfRowsInSection:] 的描述:

Returns the number of rows (table cells) in a specified section.
UITableView gets the value returned by this method from its data source and caches it.

也就是说,UITableView 会从数据源读取列表元素个数并对其进行缓存

经过试验,发现调用 -[UITableView numberOfRowsInSection:] 时并不会引发对 -[UITableViewDataSource tableView:numberOfRowsInSection:] 的调用,也就是说它并不会获取到最新的数据条数。但调用 -[UITableView reloadData]-[UITableView deleteRowsAtIndexPaths:withRowAnimation:]-[UITableView insertRowsAtIndexPaths:withRowAnimation:]等方法后,却能够刷新该缓存。当再次调用该方法时就能得到正确的值了。

解决问题

由此可得,应该是在其他地方对数据源进行了操作,但操作后没有同时使用上述的 reload/delete/insert 等方法对 table view 进行更新,也就没有更新缓存,从而导致界面与数据源不一致。当执行到删除登录卡片的代码时,由于数据源和界面不一致,当执行到 -[UITableView deleteRowsAtIndexPaths:withRowAnimation:] 时就崩溃了,出现了上述的 NSInternalInconsistencyException 异常。

经过阅读代码,确实发现有一行代码:

1
[self.listModel.dataList removeObject:object];

但并没有通过 reload/delete/insert 等方法对 table view 进行更新,导致 -[UITableView numberOfRowsInSection:] 方法返回的数目还是对数据源进行删除操作之前的数目。

因此只要在该行代码之后利用 reload/delete/insert 等方法对 table view 进行更新后,下一次在其他地方执行 reload/delete/insert 操作后也就不再崩溃了。

总结一下就是,在第一次对 table view 进行 reload 操作之后,所有引起数据源变化的操作都应该及时地通过 reload/delete/insert 等方法对 table view 进行更新,避免数据源和界面的数据不一致。

其他方案

在解决这个问题前,我在网上看了几种解决方案,比较相关的有以下两种:

  1. 对数据源进行删除操作之后,执行 -[UITableView reloadData] 方法。
    这种方法是能够解决问题的,但我觉得是不优雅的解决方式,没必要因为删除了数据源中的一条数据而去 reload 整个 table view。而且也并没有找到问题的根源。

  2. 另一种不少人给出的解决方案是使用 -[UITableView beginUpdates/endUpdates],即:

1
2
3
[self.tableView beginUpdatess];
[self.tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
[self.tableView endUpdatess];

但在这里使用这对方法也无济于事。

官方文档关于 -[UITableView beginUpdates/endUpdates] 这对方法的说明如下:

Begins a series of method calls that insert, delete, or select rows and sections of the table view.

Use the performBatchUpdates:completion: method instead of this one whenever possible.

Call this method if you want subsequent insertions, deletion, and selection operations (for example, cellForRowAtIndexPath: and indexPathsForVisibleRows) to be animated simultaneously. You can also use this method followed by the endUpdatess method to animate the change in the row heights without reloading the cell. This group of methods must conclude with an invocation of endUpdatess. These method pairs can be nested. If you do not make the insertion, deletion, and selection calls inside this block, table attributes such as row count might become invalid. You should not call reloadData within the group; if you call this method within the group, you must perform any animations yourself.

从上面的引用可以看出,这对方法一般有以下两种用途:

  • 如果有一些连续的 reload/delete/insert 等操作,并希望这些操作的动画能够同步进行,则可以将这些操作放到 -[UITableView beginUpdates/endUpdates] 两个方法之间:

    1
    2
    3
    4
    [self.tableView beginUpdates];
    [self.tableView insertRowsAtIndexPaths:insertIndexPaths withRowAnimation:UITableViewRowAnimationRight];
    [self.tableView deleteRowsAtIndexPaths:deleteIndexPaths withRowAnimation:UITableViewRowAnimationLeft];
    [self.tableView endUpdates];
  • 如果希望不通过 reload 整个 table view 来改变行高,可以直接使用下面的两行代码实现:

    1
    2
    [self.tableView beginUpdates];
    [self.tableView endUpdates];

私以为,如果不理解一个解决方案的原理而盲目套用的话,有时候看似解决了当前的问题,但实际上只是埋下了另一个地雷。

希望这篇文章对大家有所助益。如有不足,望多指教。

参考链接

0%