意外行为 - 无法访问由 NSFetchedResultController (SwiftUI) 驱动的数组中的新元素

发布于 2025-01-13 00:07:32 字数 7867 浏览 3 评论 0原文

我有一个 SwiftUI 应用程序,它在驱动视图的底层逻辑过于冗长或建议进行单元测试的地方使用 MVVM 设计模式。在某些地方,我开始将 NSFetchedResultsController 与 @Published 属性结合使用,并且在开发早期,这表现得符合我的预期。

但是,我现在遇到了一种情况,即向 CoreData 存储添加内容会触发controllerDidChangeContent,并且由controller.fetchedObjects 填充的数组具有适当数量的元素,但由于我无法理解的原因,我无法访问最新元素。

有一定量的数据处理,因为我此时正在使用数组,所以我认为不会导致问题。我更怀疑关系可能以某种方式负责和/或错误负责(尽管调整底层获取请求的错误行为未能解决问题)。

有趣的是,应用程序中其他地方使用 @FetchRequest 的类似代码(因为 View 更简单,因此 ViewModel 不被认为是必要的)似乎没有遇到同样的问题。

通常分散调试会让我回到正轨,但今天不行!我已经包含了控制台输出 - 正如您所看到的,随着添加新条目(带时间戳),观察总数会增加,但应反映最新观察的 most 属性不会改变。任何指点将一如既往地受到感激。

我无法在不丢失上下文的情况下真正修剪代码 - 提前为冗长道歉;-)

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: { $0.timestamp < $1.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 { $0.cd_Parameter == parameter }
                    .prefix(2)

                if let last = twoMostRecentObservatonsForParameter
                    .first(where: { $0.timestamp > lastObservationTimeLimit }) {
                    lastObservation[parameter] = last.numericValue
                    twoMostRecentObservatonsForParameter.removeAll(where: { $0.objectID == last.objectID })
                } else {
                    lastObservation[parameter] = nil
                }

                recentObservation[parameter] = twoMostRecentObservatonsForParameter
                    .first(where: { $0.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
    }
}

控制台输出:(注意观察计数增加,但最近的观察没有改变)

I have a SwiftUI app that uses the MVVM design pattern in places where the underlying logic driving the View is either verbose or unit testing is advisable. In certain places I have taken to using a NSFetchedResultsController in conjunction with @Published properties and, early in development, this behaved as I would expect.

However, I have now encountered a situation where an addition to the CoreData store triggers controllerDidChangeContent and the array populated by controller.fetchedObjects has an appropriate number of elements but, for reasons I cannot fathom, I am unable to access the newest elements.

There is a certain amount of data processing which, as I'm working with an array by this point, I didn't think would cause a problem. I'm more suspicious that relationships may be responsible in some way and/or faulting is responsible (although adjusting faulting behaviour on the underlying fetch request failed to resolve the issue).

Interestingly, some similar code elsewhere in the app that uses @FetchRequest (because the View is simpler and so a ViewModel wasn't considered necessary) doesn't seem to suffer from the same problem.

Normally scattering debugging around has put me back on track but not today! I've included the console output - as you can see, as new entries (timestamped) are added, the total observation count increases but the most property which should reflect the most recent observation does not change. Any pointers would be gratefully received as always.

I can't really prune the code on this without losing context - apologies in advance for the verbosity ;-)

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: { $0.timestamp < $1.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 { $0.cd_Parameter == parameter }
                    .prefix(2)

                if let last = twoMostRecentObservatonsForParameter
                    .first(where: { $0.timestamp > lastObservationTimeLimit }) {
                    lastObservation[parameter] = last.numericValue
                    twoMostRecentObservatonsForParameter.removeAll(where: { $0.objectID == last.objectID })
                } else {
                    lastObservation[parameter] = nil
                }

                recentObservation[parameter] = twoMostRecentObservatonsForParameter
                    .first(where: { $0.timestamp > recentObservationTimeLimit })?.numericValue
            }
        }

        // MARK: - NSFetchedResultsControllerDelegate conformance
        internal func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
            let newObservations = controller.fetchedObjects as? [Observation] ?? []
            observations = newObservations
            updateHistoricalObservations()
        }
    }
}

NSManagedObject subclass:

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
    }
}

Console output: (note observation count increments but the most recent observation does not change)
Console debug output

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(1

与往事干杯 2025-01-20 00:07:32

您的时间戳和/或排序有问题,最旧的观察结果比最新的观察结果晚 4 天(而且是未来的情况!)

Joakim 是在赚钱 - 时间戳确实不正确;问题不在于逻辑,而在于生成用于测试目的的数据的代码错误(与数据点之间的时间间隔相关的数学错误)。垃圾输入,垃圾输出...

给我一个教训,要更加小心 - 现在将前提条件添加到生成时间序列数据的函数(以及单元测试!)。

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)
}

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 was on the money - the timestamps are indeed incorrect; the problem was not in the logic but an error in the code (maths error relating to the TimeInterval between datapoints) that generated data for testing purposes. Garbage in, garbage out...

A lesson to me to be more careful - precondition now added to the function that generated the time series data (and a unit test!).

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)
}
~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文