崩溃:尝试将索引路径移动到不存在的索引路径

Crash: Attempt to move index path to index path that does not exist

有人在 here 之前问过这个错误,但他没有更新他的模型,据我所知,这就是我所做的一切,然后刷新 tableView,所以我想看看是否有人对此有任何想法.我真的很感激任何建议,因为我似乎无法深入了解这个问题,而且用户一直在报告这个问题。

问题是: 如果用户将任务从 Overdue 拖到任何其他部分,应用程序就会崩溃。错误是这样的:

*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'attempt to move index path (<NSIndexPath: 0x9ed3b3d9edf53a85> {length = 2, path = 0 - 0}) to index path (<NSIndexPath: 0x9ed3b3d9edf52a85> {length = 2, path = 1 - 0}) that does not exist - there are only 0 rows in section 1 after the update'

来自分析的其他报告也通过类似(同样模糊的)堆栈跟踪指向此错误:

Invalid update: invalid number of rows in section 0. The number of rows contained in an existing section after the update (1) must be equal to the number of rows contained in that section before the update (1), plus or minus the number of rows inserted or deleted from that section (0 inserted, 0 deleted) and plus or minus the number of rows moved into or out of that section (0 moved in, 1 moved out)

这是我的设置: 我有一个显示多个任务并按截止日期对它们进行分组的表格视图。每个任务的过滤数组都有一个变量,像这样:

class ZoneController: UIViewController {
var incompleteTasks: [Task] {
    let tasks = zone.tasks.filter({ ![=10=].completed })
    if zone.groupTasks { return tasks.sortedByDueDate() }
    else { return tasks }
}

var overdueTasks: [Task] {
    let tasks = zone.tasks.filter({ ([=10=].dueDate?.isInThePast ?? false) && ![=10=].completed })
    if zone.groupTasks { return tasks.sortedByDueDate() }
    else { return tasks }
}

var todayTasks: [Task] {
    let tasks = zone.tasks.filter({ ([=10=].dueDate?.isToday ?? false) && ![=10=].completed && !([=10=].dueDate?.isInThePast ?? false) /* This is here because a task could be today but a few hours earlier, in which case it needs to be overdue */ })
    if zone.groupTasks { return tasks.sortedByDueDate() }
    else { return tasks }
}
var tomorrowTasks: [Task] {
    let tasks = zone.tasks.filter({ ([=10=].dueDate?.isTomorrow ?? false) && ![=10=].completed })
    if zone.groupTasks { return tasks.sortedByDueDate() }
    else { return tasks }
}

var laterTasks: [Task] {
    let laterTasks = zone.tasks.filter({
        var isLater = true
        if let dueDate = [=10=].dueDate { isLater = (dueDate > Calendar.current.dayAfterTomorrow()) }
        return ![=10=].completed && isLater
    })
    return laterTasks.sortedByDueDate()
}
}

tableview 可以选择在组之间移动任务并更改其截止日期,因此在 tableView moveRowAt 中,我正在切换 destinationIndexPath 并相应地更改 dueDate,然后重新加载 tableView。这是负责 tableView 的代码:

import UIKit
import WidgetKit
import AppCenterAnalytics

extension ZoneController: UITableViewDelegate, UITableViewDataSource {

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    if tasksTabSelected {
        if zone.groupTasks {
            switch section {
            case 0: return overdueTasks.count
            case 1: return todayTasks.count
            case 2: return tomorrowTasks.count
            default: return laterTasks.count
            }
        } else {
            return incompleteTasks.count
        }
    } else {
        return zone.ideas.count
    }
}

func numberOfSections(in tableView: UITableView) -> Int {
    tasksTabSelected && zone.groupTasks ? 4 : 1
}

func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
    if tasksTabSelected && zone.groupTasks && !incompleteTasks.isEmpty {
        // This makes the headers scroll with the rest of the content
        let dummyViewHeight = CGFloat(44)
        tableView.tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: tableView.bounds.size.width, height: dummyViewHeight))
        tableView.contentInset = UIEdgeInsets(top: -dummyViewHeight, left: 0, bottom: 0, right: 0)
        
        let headerTitles = ["Overdue", "Today", "Tomorrow", "Later"]
        
        let header = tableView.dequeueReusableCell(withIdentifier: "TaskHeaderCell") as! TaskHeaderCell
        header.icon.image = UIImage(named: "header-\(headerTitles[section])")
        header.name.text = headerTitles[section]
        return header
    } else {
        return nil
    }
}

func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
    let footer = UIView()
    footer.backgroundColor = .clear
    return footer
}

func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
    var shouldBeTall = false
    
    if tasksTabSelected && zone.groupTasks {
        switch section {
        case 0: shouldBeTall = overdueTasks.count != 0
        case 1: shouldBeTall = todayTasks.count != 0
        case 2: shouldBeTall = tomorrowTasks.count != 0
        case 3: shouldBeTall = laterTasks.count != 0
        default: return 0
        }
    }
    
    return shouldBeTall ? 24 : 0
}

func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
    let tasksAreGrouped = tasksTabSelected && zone.groupTasks && !incompleteTasks.isEmpty
    return tasksAreGrouped && section != 0 || tasksAreGrouped && section == 0 && !overdueTasks.isEmpty ? 48 : 0
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    if tasksTabSelected {
        let cell = tableView.dequeueReusableCell(withIdentifier: "TaskCell") as! TaskCell
        let accentColor = UIColor(hex: zone.firstColor)
        
        var task: Task?
        
        if zone.groupTasks {
            print("Section: \(indexPath.section) has \(tableView.numberOfRows(inSection: indexPath.section))")
            switch indexPath.section {
            case 0: task = overdueTasks[indexPath.row]
            case 1: task = todayTasks[indexPath.row]
            case 2: task = tomorrowTasks[indexPath.row]
            case 3: task = laterTasks[indexPath.row]
            default: break
            }
        } else {
            task = incompleteTasks[indexPath.row]
        }
        
        cell.task = task
        cell.accentColor = accentColor
        cell.zoneGroupsTasks = zone.groupTasks
        
        cell.configure()
        return cell
    } else {
        let cell = tableView.dequeueReusableCell(withIdentifier: "IdeaCell") as! IdeaCell
        let idea = zone.ideas[indexPath.row]
        cell.content.text = idea.content.replacingOccurrences(of: "\n\n\n\n", with: " ").replacingOccurrences(of: "\n\n\n", with: " ").replacingOccurrences(of: "\n\n", with: " ").replacingOccurrences(of: "\n", with: " ")
        cell.idea = idea
        return cell
    }
}


func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    if tasksTabSelected {
        let vc = Storyboards.main.instantiateViewController(identifier: "TaskDetails") as! TaskDetails
        vc.colors = [UIColor(hex: zone.firstColor), UIColor(hex: zone.secondColor)]
        
        if zone.groupTasks {
            switch indexPath.section {
            case 0: vc.task = overdueTasks[indexPath.row]
            case 1: vc.task = todayTasks[indexPath.row]
            case 2: vc.task = tomorrowTasks[indexPath.row]
            case 3: vc.task = laterTasks[indexPath.row]
            default: break
            }
        } else {
            vc.task = incompleteTasks[indexPath.row]
        }
        
        presentSheet(vc)
    } else {
        let vc = Storyboards.main.instantiateViewController(identifier: "IdeaDetails") as! IdeaDetails
        vc.color = UIColor(hex: zone.firstColor)
        vc.idea = zone.ideas[indexPath.row]
        presentSheet(vc)
    }
}

func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
    if tasksTabSelected {
        if zone.groupTasks && dragSourceIndexPath?.section != destinationIndexPath.section {
            guard let dragSourceTimestamp = dragSourceTimestamp else { return }
            var task = zone.tasks.first(where: { [=11=].id == dragSourceTimestamp })
            task?.reminderNeeded = true
            
            switch destinationIndexPath.section {
            case 1:
                task?.dueDate = Calendar.current.date(byAdding: .hour, value: 1, to: Date())
                task?.reminderNeeded = false
            case 2: task?.dueDate = Calendar.current.tomorrow(at: 9)
            case 3: task?.dueDate = Calendar.current.inTwoWeeks(at: 9)
            default: break
            }
            task?.save()
        } else {
            guard
                let sourceIndex = zone.tasks.firstIndex(where: { [=11=].id == incompleteTasks[sourceIndexPath.row].id }),
                let destinationIndex = zone.tasks.firstIndex(where: { [=11=].id == incompleteTasks[destinationIndexPath.row].id })
            else { return }
            Storage.zones[zoneIndex].tasks.move(from: sourceIndex, to: destinationIndex)
        }
    } else {
        Storage.zones[zoneIndex].ideas.move(from: sourceIndexPath.row, to: destinationIndexPath.row)
    }
    reloadTableView() // This version is being called to make sure the zone is being refreshed, even though technically tableView.reloadData() would have been enough.
    Push.updateAllReminders()
    WidgetCenter.shared.reloadAllTimelines()
    Analytics.trackEvent("Reordered tasks or ideas")
}

func tableView(_ tableView: UITableView, dragSessionWillBegin session: UIDragSession) {
    tableView.vibrate()
    // This is here to prevent users from dragging this task to other zones and a weird scrolling bug that happens
    if let pageController = self.parent as? PVC { pageController.decouple() }
}

func tableView(_ tableView: UITableView, dragSessionDidEnd session: UIDragSession) {
    // This is here to prevent users from dragging this task to other zones and a weird scrolling bug that happens
    if let pageController = self.parent as? PVC { pageController.recouple() }
}
    
}

extension ZoneController: UITableViewDragDelegate {

func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
    dragSourceIndexPath = indexPath
    dragSourceTimestamp = (tableView.cellForRow(at: indexPath) as? TaskCell)?.task.id
    return [UIDragItem(itemProvider: NSItemProvider())]
}

func tableView(_ tableView: UITableView, dragPreviewParametersForRowAt indexPath: IndexPath) -> UIDragPreviewParameters? {
    let param = UIDragPreviewParameters()
    if #available(iOS 14.0, *) { param.shadowPath = UIBezierPath(rect: .zero) }
    param.backgroundColor = .clear
    return param
}

}

extension ZoneController: UITableViewDropDelegate {

func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {

    if session.localDragSession != nil { // Drag originated from the same app.
        let isSameSection = destinationIndexPath?.section == dragSourceIndexPath?.section
        let permitted = !isSameSection && destinationIndexPath?.section != 0 || !tasksTabSelected || !zone.groupTasks
        return UITableViewDropProposal(operation: permitted ? .move : .forbidden, intent: .insertAtDestinationIndexPath)
    }

    return UITableViewDropProposal(operation: .cancel, intent: .unspecified)
}

func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {
}

func tableView(_ tableView: UITableView, dropPreviewParametersForRowAt indexPath: IndexPath) -> UIDragPreviewParameters? {
    let param = UIDragPreviewParameters()
    if #available(iOS 14.0, *) { param.shadowPath = UIBezierPath(rect: .zero) }
    param.backgroundColor = .clear
    return param
}

}

注意几点:

  1. task.save() 方法将任务保存到磁盘。

  2. 如果我完全注释掉 moveRowAt 中的代码,崩溃仍然会发生,所以不是那里的东西引起的。

  3. reloadTableView 方法在需要时设置一个空图像,并再次从磁盘读取区域(其中包含所有任务)。出于性能原因,我将区域存储在内存中作为 ViewController 的变量(如果我总是从磁盘读取它,滚动会非常卡顿)。

     @objc func reloadTableView() {
     DispatchQueue.main.async { [self] in
         zone = Storage.zones[zoneIndex]
         tableView.reloadData()
         let hidden = tasksTabSelected && incompleteTasks.isEmpty || !tasksTabSelected && zone.ideas.isEmpty
         emptyImage.isHidden = !hidden
         emptyImage.image = UIImage(named: tasksTabSelected ? "no-tasks" : "no-ideas")
     }
     }
    

您需要更改 table 视图从中获取数据以响应 UI 更改的模型对象,然后重新加载 table。 目前,您似乎正在更改 Storage.zones,但随后您将更新异步排队到用于呈现 table 视图的数据模型的视图 zone 副本。它们在一段时间内不同步(至少运行循环的一个周期),这很可能是您崩溃的时候。

您说您正在更新磁盘然后将其读回,但我没有看到读回它的代码,也没有看到明显调用该代码的代码。因此,尚不清楚这对设备负载等有多敏感,因此您遇到导致问题的延迟的可能性有多大。

旁白:如果您有多个项目,使用 UserDefaults 来存储它可能并不理想。

除非项目列表真的很小,否则在 numberOfRowsInSection 期间调用过滤器和排序不太理想(它经常被调用)。只要您正在缓存区域,您也可以将组织的数据缓存到您的过滤器返回的子列表中? 无论如何,这可能不是您崩溃的根本原因。