意外行为 - 无法访问由 NSFetchedResultController (SwiftUI) 驱动的数组中的新元素
Unexpected behaviour - unable to access new elements in an array driven by an NSFetchedResultController (SwiftUI)
我有一个 SwiftUI 应用程序,它在驱动视图的底层逻辑冗长或建议进行单元测试的地方使用 MVVM 设计模式。在某些地方,我已经开始将 NSFetchedResultsController 与 @Published 属性结合使用,并且在开发早期,它的表现符合我的预期。
但是,我现在遇到了这样一种情况,即添加到 CoreData 存储会触发 controllerDidChangeContent 并且由 controller.fetchedObjects 填充的数组具有适当数量的元素,但是由于我无法理解的原因,我无法访问最新元素。
有一定数量的数据处理,因为此时我正在处理数组,所以我认为不会造成问题。我更怀疑关系可能以某种方式负责 and/or 错误是负责任的(尽管调整底层获取请求的错误行为未能解决问题)。
有趣的是,应用程序中其他地方使用@FetchRequest 的一些类似代码(因为 View 更简单,因此认为不需要 ViewModel)似乎没有遇到同样的问题。
通常四处分散调试让我回到正轨但今天不是!我已经包含了控制台输出 - 如您所见,随着新条目(带时间戳)的添加,总观察计数增加,但应该反映最近观察的最多 属性 不会改变。如有任何指点,我们将一如既往地收到。
我无法在不丢失上下文的情况下真正修剪代码 - 提前为冗长道歉;-)
ViewModel:
extension ParameterGridView {
final class ViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDelegate {
@Published var parameters: [Parameter] = []
@Published var lastObservation: [Parameter : Double] = [:]
@Published var recentObservation: [Parameter : Double] = [:]
let patient: Patient
private let dataController: DataController
private let viewContext: NSManagedObjectContext
private let frc: NSFetchedResultsController<Observation>
var observations: [Observation] = []
init(patient: Patient, dataController: DataController) {
self.patient = patient
self.dataController = dataController
self.viewContext = dataController.container.viewContext
let parameterFetch = Parameter.fetchAll
self.parameters = try! dataController.container.viewContext.fetch(parameterFetch)
let observationFetch = Observation.fetchAllDateSorted(for: patient)
self.frc = NSFetchedResultsController(
fetchRequest: observationFetch,
managedObjectContext: dataController.container.viewContext,
sectionNameKeyPath: nil,
cacheName: nil)
try! self.frc.performFetch()
observations = self.frc.fetchedObjects ?? []
super.init()
frc.delegate = self
updateHistoricalObservations()
}
// MARK: - METHODS
/// UI controls for entering new Observations default to the last value entered
/// This function calculates the median value for the Parameter's reference range to be used in the event no historical observations are available
/// - Parameter parameter: Parameter used to derive start value
/// - Returns: median value for the Parameter's reference range
func medianReferenceRangeFor(_ parameter: Parameter) -> Double {
let rangeMagnitude = parameter.referenceRange.upperBound - parameter.referenceRange.lowerBound
return parameter.referenceRange.lowerBound + (rangeMagnitude / 2)
}
/// Adds a new Observation to the Core Data store
/// - Parameters:
/// - parameter: Parameter for the observation
/// - value: Observation value
func addObservationFor(_ parameter: Parameter, with value: Double) {
_ = Observation.create(in: viewContext,
patient: patient,
parameter: parameter,
numericValue: value)
try! viewContext.save()
}
/// Obtains clinically relevant historical observations from the dataset for each Parameter
/// lastObservation = an observation within the last 15 minutes
/// recentObservation= an observation obtained within the last 4 hours
/// There may be better names for these!
private func updateHistoricalObservations() {
let lastObservationTimeLimit = Date.now.offset(.minute, value: -15)!.offset(.second, value: -1)!
let recentObservationTimeLimit = Date.now.offset(.hour, value: -4)!.offset(.second, value: -1)!
Logger.coreData.debug("New Observations.count = \(self.observations.count)")
let sortedObs = observations.sorted(by: { [=10=].timestamp < .timestamp })
let newestObs = sortedObs.first!
let oldestObs = sortedObs.last!
Logger.coreData.debug("Newest obs: \(newestObs.timestamp) || \(newestObs.numericValue)")
Logger.coreData.debug("Oldest obs: \(oldestObs.timestamp) || \(oldestObs.numericValue)")
for parameter in parameters {
var twoMostRecentObservatonsForParameter = observations
.filter { [=10=].cd_Parameter == parameter }
.prefix(2)
if let last = twoMostRecentObservatonsForParameter
.first(where: { [=10=].timestamp > lastObservationTimeLimit }) {
lastObservation[parameter] = last.numericValue
twoMostRecentObservatonsForParameter.removeAll(where: { [=10=].objectID == last.objectID })
} else {
lastObservation[parameter] = nil
}
recentObservation[parameter] = twoMostRecentObservatonsForParameter
.first(where: { [=10=].timestamp > recentObservationTimeLimit })?.numericValue
}
}
// MARK: - NSFetchedResultsControllerDelegate conformance
internal func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
let newObservations = controller.fetchedObjects as? [Observation] ?? []
observations = newObservations
updateHistoricalObservations()
}
}
}
NSManagedObject 子类:
extension Observation {
// Computed properties excluded to aid clarity
class func create(in context: NSManagedObjectContext,
patient: Patient,
parameter: Parameter,
numericValue: Double? = nil,
stringValue: String? = nil) -> Observation {
precondition(!((numericValue != nil) && (stringValue != nil)), "No values sent to initialiser")
let observation = Observation(context: context)
observation.cd_Patient = patient
observation.timestamp = Date.now
observation.parameter = parameter
if let value = numericValue {
observation.numericValue = value
} else {
observation.stringValue = stringValue!
}
try! context.save()
return observation
}
static var fetchAll: NSFetchRequest<Observation> {
let request = Observation.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \Observation.cd_timestamp, ascending: true)]
return request
}
static func fetchAllDateSorted(for patient: Patient) -> NSFetchRequest<Observation> {
let request = fetchAll
request.sortDescriptors = [NSSortDescriptor(keyPath: \Observation.cd_timestamp, ascending: true)]
request.predicate = NSPredicate(format: "%K == %@", #keyPath(Observation.cd_Patient), patient)
return request
}
static func fetchDateSorted(for patient: Patient, and parameter: Parameter) -> NSFetchRequest<Observation> {
let patientPredicate = NSPredicate(format: "%K == %@", #keyPath(Observation.cd_Patient), patient)
let parameterPredicate = NSPredicate(format: "%K == %@", #keyPath(Observation.cd_Parameter), parameter)
let compoundPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [patientPredicate, parameterPredicate])
let request = fetchAll
request.predicate = compoundPredicate
return request
}
}
控制台输出:(注意观察计数递增,但最近的观察没有改变)
There is something wrong with your timestamps and/or sorting, the oldest observation is 4 days newer than the newest one (and it is in the future!)
Joakim 很赚钱——时间戳确实不正确;问题不在于逻辑,而在于为测试目的生成数据的代码错误(与数据点之间的 TimeInterval 相关的数学错误)。垃圾进,垃圾出...
给我的教训是要更加小心 - 现在将前提条件添加到生成时间序列数据的函数(以及单元测试!)。
static func placeholderTimeSeries(for parameter: Parameter, startDate: Date, numberOfValues: Int) -> [(Date, Double)] {
let observationTimeInterval: TimeInterval = (60*5) // 5 minute intervals, not 5 hours! Test next time!!
let observationPeriodDuration: TimeInterval = observationTimeInterval * Double(numberOfValues)
let observationEndDate = startDate.advanced(by: observationPeriodDuration)
precondition(observationEndDate < Date.now, "Observation period end date is in the future")
return placeholderTimeSeries(valueRange: parameter.referenceRange,
valueDelta: parameter.controlStep...(3 * parameter.controlStep),
numberOfValues: numberOfValues,
startDate: startDate,
dataTimeInterval: observationTimeInterval)
}
我有一个 SwiftUI 应用程序,它在驱动视图的底层逻辑冗长或建议进行单元测试的地方使用 MVVM 设计模式。在某些地方,我已经开始将 NSFetchedResultsController 与 @Published 属性结合使用,并且在开发早期,它的表现符合我的预期。
但是,我现在遇到了这样一种情况,即添加到 CoreData 存储会触发 controllerDidChangeContent 并且由 controller.fetchedObjects 填充的数组具有适当数量的元素,但是由于我无法理解的原因,我无法访问最新元素。
有一定数量的数据处理,因为此时我正在处理数组,所以我认为不会造成问题。我更怀疑关系可能以某种方式负责 and/or 错误是负责任的(尽管调整底层获取请求的错误行为未能解决问题)。
有趣的是,应用程序中其他地方使用@FetchRequest 的一些类似代码(因为 View 更简单,因此认为不需要 ViewModel)似乎没有遇到同样的问题。
通常四处分散调试让我回到正轨但今天不是!我已经包含了控制台输出 - 如您所见,随着新条目(带时间戳)的添加,总观察计数增加,但应该反映最近观察的最多 属性 不会改变。如有任何指点,我们将一如既往地收到。
我无法在不丢失上下文的情况下真正修剪代码 - 提前为冗长道歉;-)
ViewModel:
extension ParameterGridView {
final class ViewModel: NSObject, ObservableObject, NSFetchedResultsControllerDelegate {
@Published var parameters: [Parameter] = []
@Published var lastObservation: [Parameter : Double] = [:]
@Published var recentObservation: [Parameter : Double] = [:]
let patient: Patient
private let dataController: DataController
private let viewContext: NSManagedObjectContext
private let frc: NSFetchedResultsController<Observation>
var observations: [Observation] = []
init(patient: Patient, dataController: DataController) {
self.patient = patient
self.dataController = dataController
self.viewContext = dataController.container.viewContext
let parameterFetch = Parameter.fetchAll
self.parameters = try! dataController.container.viewContext.fetch(parameterFetch)
let observationFetch = Observation.fetchAllDateSorted(for: patient)
self.frc = NSFetchedResultsController(
fetchRequest: observationFetch,
managedObjectContext: dataController.container.viewContext,
sectionNameKeyPath: nil,
cacheName: nil)
try! self.frc.performFetch()
observations = self.frc.fetchedObjects ?? []
super.init()
frc.delegate = self
updateHistoricalObservations()
}
// MARK: - METHODS
/// UI controls for entering new Observations default to the last value entered
/// This function calculates the median value for the Parameter's reference range to be used in the event no historical observations are available
/// - Parameter parameter: Parameter used to derive start value
/// - Returns: median value for the Parameter's reference range
func medianReferenceRangeFor(_ parameter: Parameter) -> Double {
let rangeMagnitude = parameter.referenceRange.upperBound - parameter.referenceRange.lowerBound
return parameter.referenceRange.lowerBound + (rangeMagnitude / 2)
}
/// Adds a new Observation to the Core Data store
/// - Parameters:
/// - parameter: Parameter for the observation
/// - value: Observation value
func addObservationFor(_ parameter: Parameter, with value: Double) {
_ = Observation.create(in: viewContext,
patient: patient,
parameter: parameter,
numericValue: value)
try! viewContext.save()
}
/// Obtains clinically relevant historical observations from the dataset for each Parameter
/// lastObservation = an observation within the last 15 minutes
/// recentObservation= an observation obtained within the last 4 hours
/// There may be better names for these!
private func updateHistoricalObservations() {
let lastObservationTimeLimit = Date.now.offset(.minute, value: -15)!.offset(.second, value: -1)!
let recentObservationTimeLimit = Date.now.offset(.hour, value: -4)!.offset(.second, value: -1)!
Logger.coreData.debug("New Observations.count = \(self.observations.count)")
let sortedObs = observations.sorted(by: { [=10=].timestamp < .timestamp })
let newestObs = sortedObs.first!
let oldestObs = sortedObs.last!
Logger.coreData.debug("Newest obs: \(newestObs.timestamp) || \(newestObs.numericValue)")
Logger.coreData.debug("Oldest obs: \(oldestObs.timestamp) || \(oldestObs.numericValue)")
for parameter in parameters {
var twoMostRecentObservatonsForParameter = observations
.filter { [=10=].cd_Parameter == parameter }
.prefix(2)
if let last = twoMostRecentObservatonsForParameter
.first(where: { [=10=].timestamp > lastObservationTimeLimit }) {
lastObservation[parameter] = last.numericValue
twoMostRecentObservatonsForParameter.removeAll(where: { [=10=].objectID == last.objectID })
} else {
lastObservation[parameter] = nil
}
recentObservation[parameter] = twoMostRecentObservatonsForParameter
.first(where: { [=10=].timestamp > recentObservationTimeLimit })?.numericValue
}
}
// MARK: - NSFetchedResultsControllerDelegate conformance
internal func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
let newObservations = controller.fetchedObjects as? [Observation] ?? []
observations = newObservations
updateHistoricalObservations()
}
}
}
NSManagedObject 子类:
extension Observation {
// Computed properties excluded to aid clarity
class func create(in context: NSManagedObjectContext,
patient: Patient,
parameter: Parameter,
numericValue: Double? = nil,
stringValue: String? = nil) -> Observation {
precondition(!((numericValue != nil) && (stringValue != nil)), "No values sent to initialiser")
let observation = Observation(context: context)
observation.cd_Patient = patient
observation.timestamp = Date.now
observation.parameter = parameter
if let value = numericValue {
observation.numericValue = value
} else {
observation.stringValue = stringValue!
}
try! context.save()
return observation
}
static var fetchAll: NSFetchRequest<Observation> {
let request = Observation.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \Observation.cd_timestamp, ascending: true)]
return request
}
static func fetchAllDateSorted(for patient: Patient) -> NSFetchRequest<Observation> {
let request = fetchAll
request.sortDescriptors = [NSSortDescriptor(keyPath: \Observation.cd_timestamp, ascending: true)]
request.predicate = NSPredicate(format: "%K == %@", #keyPath(Observation.cd_Patient), patient)
return request
}
static func fetchDateSorted(for patient: Patient, and parameter: Parameter) -> NSFetchRequest<Observation> {
let patientPredicate = NSPredicate(format: "%K == %@", #keyPath(Observation.cd_Patient), patient)
let parameterPredicate = NSPredicate(format: "%K == %@", #keyPath(Observation.cd_Parameter), parameter)
let compoundPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [patientPredicate, parameterPredicate])
let request = fetchAll
request.predicate = compoundPredicate
return request
}
}
控制台输出:(注意观察计数递增,但最近的观察没有改变)
There is something wrong with your timestamps and/or sorting, the oldest observation is 4 days newer than the newest one (and it is in the future!)
Joakim 很赚钱——时间戳确实不正确;问题不在于逻辑,而在于为测试目的生成数据的代码错误(与数据点之间的 TimeInterval 相关的数学错误)。垃圾进,垃圾出...
给我的教训是要更加小心 - 现在将前提条件添加到生成时间序列数据的函数(以及单元测试!)。
static func placeholderTimeSeries(for parameter: Parameter, startDate: Date, numberOfValues: Int) -> [(Date, Double)] {
let observationTimeInterval: TimeInterval = (60*5) // 5 minute intervals, not 5 hours! Test next time!!
let observationPeriodDuration: TimeInterval = observationTimeInterval * Double(numberOfValues)
let observationEndDate = startDate.advanced(by: observationPeriodDuration)
precondition(observationEndDate < Date.now, "Observation period end date is in the future")
return placeholderTimeSeries(valueRange: parameter.referenceRange,
valueDelta: parameter.controlStep...(3 * parameter.controlStep),
numberOfValues: numberOfValues,
startDate: startDate,
dataTimeInterval: observationTimeInterval)
}