iOS 11中UITableView performBatchUpdates的completion闭包未调用
我有一个UITableView用于展示多个RSS源的聚合帖子流。每当应用从源拉取新帖子时,我会创建一个包含UITableView当前行所需的所有插入和删除操作的对象,并将这些对象放入FIFO队列中。每隔几秒,我会从队列取出首个元素,尝试通过UITableView:performBatchUpdates方法执行所有插入和删除操作。
一切正常,除非用户上下滚动UITableView。此时更新会停止,因为我设置了一个标志确保在上一批插入/删除完成后才启动下一批,但有时UITableView:performBatchUpdates的completion闭包从未被调用,导致标志无法清除。
以下是处理UITableView变更队列的代码:
@objc func updateFeedPostsTableView() { guard feedUpdateQueue.count > 0, !feedTableUpdateInProgress else { return } feedTableUpdateInProgress = true let feedUpdate = feedUpdateQueue.first! feedUpdateQueue.remove(at: 0) self.aggregatedRSSFeed = feedUpdate.feed self.feedPostsTableView.performBatchUpdates ({ self.feedPostsTableView.deleteRows(at: feedUpdate.indexPathsOfDeletedPosts, with: .fade) self.feedPostsTableView.insertRows(at: feedUpdate.indexPathsOfNewPosts, with: .top) }, completion: { (success) in self.feedTableUpdateInProgress = false }) }
我的问题是:为何UITableView.performBatchUpdates会出现completion块不调用的情况?我哪里操作有误?
环境:iOS 11.2,Xcode 9.2。
结合你的场景和iOS 11的已知问题,我来分析几个可能的原因和对应的解决办法:
1. 数据源更新与TableView操作的顺序不匹配
你当前的代码是先更新数据源self.aggregatedRSSFeed,再在performBatchUpdates的闭包里执行删除/插入操作。虽然苹果允许在调用方法前修改数据源,但如果你的indexPathsOfDeletedPosts/indexPathsOfNewPosts和更新后的数据源不匹配(比如删除的行已经不存在、插入的位置超出数据源行数),TableView会进入内部异常状态,直接跳过completion闭包的执行。
修复方案:把数据源的更新移到performBatchUpdates的闭包内部,确保数据源变更和TableView操作的原子性:
@objc func updateFeedPostsTableView() { guard feedUpdateQueue.count > 0, !feedTableUpdateInProgress else { return } feedTableUpdateInProgress = true let feedUpdate = feedUpdateQueue.first! feedUpdateQueue.remove(at: 0) self.feedPostsTableView.performBatchUpdates ({ // 先更新数据源,确保和后续的TableView操作完全匹配 self.aggregatedRSSFeed = feedUpdate.feed self.feedPostsTableView.deleteRows(at: feedUpdate.indexPathsOfDeletedPosts, with: .fade) self.feedPostsTableView.insertRows(at: feedUpdate.indexPathsOfNewPosts, with: .top) }, completion: { (success) in self.feedTableUpdateInProgress = false }) }
2. iOS 11滚动状态下的BatchUpdates Bug
iOS 11早期版本(包括你使用的11.2)存在一个已知问题:当TableView处于拖拽中或减速滚动状态时,调用performBatchUpdates可能会导致completion闭包完全不触发。这是因为系统在滚动时会暂停部分UI更新逻辑,但没有正确处理completion的回调。
修复方案:在执行更新前检查TableView的滚动状态,如果正在滚动/减速,延迟到滚动停止后再执行更新:
@objc func updateFeedPostsTableView() { guard feedUpdateQueue.count > 0, !feedTableUpdateInProgress else { return } // 检查滚动状态,避免在滚动时执行batchUpdates if self.feedPostsTableView.isDragging || self.feedPostsTableView.isDecelerating { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { self.updateFeedPostsTableView() } return } feedTableUpdateInProgress = true let feedUpdate = feedUpdateQueue.first! feedUpdateQueue.remove(at: 0) self.feedPostsTableView.performBatchUpdates ({ self.aggregatedRSSFeed = feedUpdate.feed self.feedPostsTableView.deleteRows(at: feedUpdate.indexPathsOfDeletedPosts, with: .fade) self.feedPostsTableView.insertRows(at: feedUpdate.indexPathsOfNewPosts, with: .top) }, completion: { (success) in self.feedTableUpdateInProgress = false // 完成后立刻处理下一个更新,替代定时器触发 self.updateFeedPostsTableView() }) }
这里我还把定时器触发改成了completion里递归调用,这样能更精准地控制更新的顺序,避免定时器和completion的时序冲突。
3. 空操作导致的Completion不触发
如果你的feedUpdate对象中,indexPathsOfDeletedPosts和indexPathsOfNewPosts都是空数组,那么performBatchUpdates实际上没有任何UI操作需要执行。在iOS 11的某些版本中,这种空操作会导致系统跳过completion闭包的调用,直接结束方法。
修复方案:在执行前先检查是否有实际的操作需要执行,如果没有,直接清除标志并处理下一个队列元素:
@objc func updateFeedPostsTableView() { guard feedUpdateQueue.count > 0, !feedTableUpdateInProgress else { return } let feedUpdate = feedUpdateQueue.first! // 检查是否有实际操作 if feedUpdate.indexPathsOfDeletedPosts.isEmpty && feedUpdate.indexPathsOfNewPosts.isEmpty { feedUpdateQueue.remove(at: 0) // 直接处理下一个更新 self.updateFeedPostsTableView() return } // 后续的滚动检查、batchUpdates逻辑... }
4. 无效的IndexPath导致静默失败
如果你的indexPathsOfDeletedPosts包含了超出当前数据源范围的行,或者indexPathsOfNewPosts的位置不符合更新后的数据源行数,TableView会静默失败(不会崩溃,但也不会执行任何操作),同时跳过completion闭包。
检查建议:在执行deleteRows/insertRows前,添加日志或断言来验证IndexPath的有效性:
// 验证删除的IndexPath是否有效 for indexPath in feedUpdate.indexPathsOfDeletedPosts { assert(indexPath.row < self.aggregatedRSSFeed.count, "Invalid delete indexPath: \(indexPath)") } // 验证插入的IndexPath是否有效(更新数据源后) self.aggregatedRSSFeed = feedUpdate.feed for indexPath in feedUpdate.indexPathsOfNewPosts { assert(indexPath.row <= self.aggregatedRSSFeed.count, "Invalid insert indexPath: \(indexPath)") }
内容的提问来源于stack exchange,提问作者Greg Anderson

