意外行为 - 无法访问由 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)
}