SwiftUI:如何更新由静态数据驱动并从另一个动态数据集中提取信息的列表?

3
我甚至不确定标题问题是否有意义。请继续阅读:)
编辑:交叉链接到Apple的开发者论坛
编辑:这是该项目的源代码
我有一个SwiftUI视图,它使用“固定”数据结构和来自Core Data的数据的特殊组合,我正在努力理解如何使两者合作。我相信这不是你平均的“高级核心数据和swiftui教程”所涵盖的内容,因为我刚刚花了三天时间查看文档、指南和教程,但我找不到可行的方法。让我解释一下。
我的视图主要是一个List,显示了一组“固定”的行。我们称之为“预算类别”——是的,我正在制作第n个预算应用程序,请耐心等待。它看起来更或多或少像这样。

BudgetListView

预算的结构保持不变--除非用户更改它,但这是另一天的问题--Core Data保存每个类别的“月度实例”,我们称之为。因此,驱动列表的数据在一个budget属性中,该属性具有sections,每个部分都有categories。列表的代码如下:
var body: some View {
    VStack {
        // some other views
        
        List {
            ForEach(budget.sections) { section in
                if !section.hidden {
                    Section(header: BudgetSectionCell(section: section,
                                                      amountText: ""
                    ) {
                        ForEach(section.categories) { category in
                            if !category.hidden {
                                BudgetCategoryCell(category: category, amountText: formatAmount(findBudgetCategoryEntry(withId: category.id)?.amount ?? 0) ?? "--")
                            }
                        }
                    }
                }
            }
        }
        
        // more views
    }
}

你们中聪明的人会注意到findBudgetCategoryEntry(withId: UUID) -> BudgetCategoryEntry函数。我的想法是使用来自固定预算结构的ID,要求Core Data给我提供与我的预算、月份和年份对应的预算类别条目,然后在单元格中仅显示金额。
现在,因为我需要能够指定monthyear,所以我将它们作为我的视图中的@State属性。
@State private var month: Int = 0
@State private var year: Int = 0

然后我需要一个 FetchRequest,但由于我需要设置引用实例属性 (monthyear) 的 NSPredicates,所以我不能使用 @FetchRequest 包装器。因此,这就是我的代码:
private var budgetEntriesRequest: FetchRequest<BudgetCategoryEntry>
private var budgetEntries: FetchedResults<BudgetCategoryEntry> { budgetEntriesRequest.wrappedValue }

init(budget: BudgetInfo, currentBudgetId: Binding<UUID?>) {
    _budget = .init(initialValue: budget)
    _currentBudgetId = currentBudgetId
    var cal = Calendar(identifier: .gregorian)
    cal.locale = Locale(identifier: self._budget.wrappedValue.localeIdentifier)
    let now = Date()
    _month = .init(initialValue: cal.component(.month, from: now))
    _monthName = .init(initialValue: cal.monthSymbols[self._month.wrappedValue])
    _year = .init(initialValue: cal.component(.year, from: now))

    budgetEntriesRequest = FetchRequest<BudgetCategoryEntry>(entity: BudgetCategoryEntry.entity(),
                                                             sortDescriptors: [],
                                                             predicate: NSCompoundPredicate(andPredicateWithSubpredicates: [
                                                                NSPredicate(format: "budgetId == %@", budget.id as CVarArg),
                                                                NSPredicate(format: "month == %i", _month.wrappedValue),
                                                                NSPredicate(format: "year == %i", _year.wrappedValue)
                                                             ])
    )
}

private func findBudgetCategoryEntry(withId: UUID) -> BudgetCategoryEntry? {
    return budgetEntries.first(where: { $0.budgetCategoryId == withId })
}

这似乎有效。一次。在视图出现时。然后祝你好运改变monthyear并相应地更新budgetEntries,这让我感到困惑,因为我认为对@State变量的任何更新都会重新渲染整个视图,但是也许SwiftUI比那更聪明,并且在某些变量更改时知道要更新哪些部分。问题就在于:这些变量只间接影响List,因为它们应该强制执行新的获取请求,然后进而更新budgetEntries。但这也行不通,因为budgetEntries并没有直接影响List,因为它由findBudgetCategoryEntry(withId: UUID)函数中介。

然后我尝试了许多设置组合,包括使budgetEntriesRequest成为@State变量,并进行适当的初始化更改,但这会使访问budgetEntries失败,我不完全确定原因,但我可以看出这不是正确的方法。

我能想到的唯一方法就是将findBudgetCategoryEntry作为一个Dictionary属性,其中使用类别ID作为键来访问该条目。虽然我现在能够理解它的意义,但我还没有尝试过,因为这仍然取决于是否更改NSPredicates中的三个变量实际上会再次运行获取请求。
总之:我愿意听取建议。
编辑:我尝试了这里概述的解决方案,看起来是一个明智的做法,事实上它有点起作用,因为它提供了几乎相同的功能,并且条目似乎发生了变化。但是存在两个问题:
  1. 似乎有一些延迟。例如,当我更改月份时,我会得到先前选择的月份的预算条目。我通过onChange(of: month)验证了这一点,它打印出FetchedResults,但也许只是因为FetchedResults在调用onChange之后更新。我不确定。
  2. 列表仍然没有更新。我尝试像示例中那样将其包装在DynamicFetchView中,以某种方式说服SwiftUI将FetchedResults视为内容视图数据集的一部分,但没有成功。

针对第二个问题,我尝试了一些愚蠢的方法,但有时候愚蠢的方法也能奏效,即使用一个布尔值的@State,在onChange(of: month)(和年份)时切换它,然后在列表上添加类似于.background(needsRefresh ? Color.clear : Color.clear) 的内容,但当然这也没有起作用。

编辑2:我验证了之前编辑中提到的第1点,如果稍等一会儿,FetchedResults确实会正确更新。所以此时,我只是无法让列表使用正确的值重新绘制其单元格。

编辑3:稍后在BudgetCategoryCell行上设置断点后,我可以确认budgetEntries (FetchedResults)已更新,并且当我仅更改@State属性(month)时,List将被重新绘制--是的,我确保删除了所有的hack和onChange内容。

编辑4:根据nezzy's suggestion建立ViewModel的建议,我执行了以下操作:

class ViewModel: ObservableObject {
    var budgetId: UUID {
        didSet {
            buildRequest()
            objectWillChange.send()
        }
    }

    var month: Int {
        didSet {
            buildRequest()
            objectWillChange.send()
        }
    }

    var year: Int {
        didSet {
            buildRequest()
            objectWillChange.send()
        }
    }

    var budgetEntriesRequest: FetchRequest<BudgetCategoryEntry>
    public var budgetEntries: FetchedResults<BudgetCategoryEntry> { budgetEntriesRequest.wrappedValue }

    init(budgetId: UUID, month: Int, year: Int) {
        self.budgetId = budgetId
        self.month = month
        self.year = year
        budgetEntriesRequest = FetchRequest<BudgetCategoryEntry>(entity: BudgetCategoryEntry.entity(),
                                                                 sortDescriptors: [],
                                                                 predicate: NSCompoundPredicate(andPredicateWithSubpredicates: [
                                                                    NSPredicate(format: "budgetId == %@", self.budgetId.uuidString),
                                                                    NSPredicate(format: "month == %ld", self.month),
                                                                    NSPredicate(format: "year == %ld", self.year)
                                                                 ])
                               )
    }

    func buildRequest() {
        budgetEntriesRequest = FetchRequest<BudgetCategoryEntry>(entity: BudgetCategoryEntry.entity(),
                                                                 sortDescriptors: [],
                                                                 predicate: NSCompoundPredicate(andPredicateWithSubpredicates: [
                                                                    NSPredicate(format: "budgetId == %@", budgetId.uuidString),
                                                                    NSPredicate(format: "month == %ld", month),
                                                                    NSPredicate(format: "year == %ld", year)
                                                                 ])
                               )
    }

    private func findBudgetCategoryEntry(withId: UUID) -> BudgetCategoryEntry? {
        return budgetEntries.first(where: { $0.budgetCategoryId == withId })
    }
}

但是当通过budgetEntries计算属性访问budgetEntriesRequest.wrappedValue时,仍然会出现EXC_BAD_INSTRUCTION

编辑5:我使用了DynamicFetchView技术创建了一个最小可重现示例,并成功使List更新。因此,我知道这种技术是可行的并且有效的。现在我需要弄清楚在我的主应用程序中做错了什么。

编辑6:我尝试用HStack下面的内容替换BudgetCategoryCell,现在我可以更新列表的单元格了。看起来这可能是绑定的问题。现在我正在尝试如何使BudgetCategoryCell成为具有绑定的视图,考虑到我正在将局部变量传递给它。


我认为你应该将这些内容分成子视图,并在那里执行子请求(就像 https://dev59.com/rrjna4cB1Zd3GeqP4huE#59345830 中所示),但由于没有访问项目的复杂性,这只是猜测。我可以看一下这个项目吗? - Asperi
@Asperi 这是项目链接:http://git.morpheu5.net/Morpheu5/yDnab-iOS,但你的方法与nezzy的回答类似吗?无论如何,我尝试了非常类似的方法来重新创建谓词,但没有提取视图并且没有任何进展。我将尝试提取视图。然而,问题确实在于列表视图。底层 Core Data 数据得到了正确更新,但是列表中显示的数字没有变化。 - Morpheu5
好的,我构建并运行了该项目...但它甚至不包含来自问题的变量(例如findBudgetCategoryEntry)...那么我应该怎么做才能让它崩溃?(我在这里和那里玩了一会儿,但没有崩溃)。 - Asperi
没有崩溃的问题。当你打开应用程序时,你应该有一个“默认预算”,你进去,点击闪电图标,选择“所有类别清零”,这将为你选择的月份创建预算条目。如果你打开创建的sqlite文件,你可以更改值,重新启动应用程序,并尝试更改月份以查看列表是否更新。一些东西被注释掉了,因为我正在测试。 - Morpheu5
3个回答

1

这个 init(budget: BudgetInfo, currentBudgetId: Binding<UUID?> 是为了视图而存在的吗?如果是,我相信问题不在于 @State,而在于 budgetEntriesRequest 只在 init 中被创建。将 budgetEntriesRequest 设为计算属性应该可以解决这个问题。

var budgetEntriesRequest: FetchRequest<BudgetCategoryEntry> { 
    FetchRequest<BudgetCategoryEntry>(entity: BudgetCategoryEntry.entity(),
                                                             sortDescriptors: [],
                                                             predicate: NSCompoundPredicate(andPredicateWithSubpredicates: [
                                                                NSPredicate(format: "budgetId == %@", budget.id as CVarArg),
                                                                NSPredicate(format: "month == %i", month),
                                                                NSPredicate(format: "year == %i", year)
                                                             ])
    )
}

编辑:

由于请求只需要在值改变时构建,因此构建ViewModel可能非常有用,例如:

class ViewModel: ObservableObject {
   var month: Int = 0 {
     didSet { 
       buildRequest()
       objectWillChange.send()
     }
   }

   func buildRequest() {
     //TODO: Build the request here 
   }

   func findBudgetCategoryEntry(withId: UUID) -> BudgetCategoryEntry? {}
}

1
更新我的评论,附上我所思考的大纲。 - nezzy
我明白了!谢谢,我也会尝试那个。 - Morpheu5
看起来没问题,只是再确认一下,在 View 中你是否将 ViewModel 设为 @ObservedObject 了? - nezzy
是的,我做了。看起来在 budgetEntriesRequest.wrappedValue 的 getter 中释放了某个对象。异常在 FetchRequest.wrappedValue.getter 中触发,在一半左右有一个 testq 调用了 objc_release,然后执行跳到结尾的 ud2 操作码,这就触发了异常。 - Morpheu5
我的最后猜测是在获取包装值之前尝试调用update()。如果不行,你可能可以使用NSFetchRequest来解决这个问题。 - nezzy
显示剩余6条评论

1

我是一个遇到类似问题在这里的人,虽然我没有直接解决这个问题,但有一个相当简单的解决方法。

经过分析,看起来 FetchRequests 在其谓词改变时不会重新获取。这是显而易见的,因为如果您在 init 中打印 fetchrequest,您会发现谓词确实改变了。但正如您可能已经经历过的那样,获取的结果从未改变。虽然我没有确凿的证据支持这一点,但我认为这可能是最近引入的错误,因为许多使用 SwiftUI 的旧版本的教程声称可以解决这个问题,但大多数在尝试将 Binding 与谓词参数连接时会抛出类型错误。

我的解决方法是在没有谓词的情况下进行获取,然后在正文中使用绑定应用 Array.filter。这样,当绑定值更改时,您可以获得更新的筛选结果。

对于您的代码,可以像这样:

@FetchRequest(entity: BudgetCategoryEntry.entity(), sortDescriptors: []) var budgetEntries: FetchedResults<BudgetCategoryEntry>

var body: some View {
    let entryArray = budgetEntries.filter { (entry) -> Bool in
         entry.month == month && entry.budgetId == budgetId && entry.year == year
    }
    if entryArray.count != 0 {
        Text("error / no data")
    } else {
        EntryView(entry: entryArray.first!)
    }
}


然而,过滤器通常比查询慢,而且我不确定实际的性能影响。


1
这实际上是我考虑过但不想采取的方法,因为从长远来看,在过滤之前可能需要加载和卸载大量数据,尽管我们不再处于256 MB RAM的时代,但如果可能的话,应该避免对内存施加压力。话虽如此,我还是设法让自己的代码工作了。首先,通过使用DynamicFetchView方法,我可以实际上获得更新的结果。其次,事实证明BudgetCategoryCell视图没有正确更新。现在它已经内联了,所以现在它可以工作了。所以现在我必须弄清楚为什么。 - Morpheu5

0
原来是List正确刷新了,但其内部的BudgetCategoryCell没有。我直接将BudgetCategoryCell的主体提取到ForEach中,突然间就开始按预期工作了。现在我需要想办法更新该视图,但这是另一个问题的事情了。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接