Проблемы с сохранением NSManagedObjects в фоновом режиме

Я боролся с этим в течение нескольких дней. Я буду признателен за любую помощь.

У меня есть местоположение NSManagedObject и изображение NSManagedObject, они имеют отношения «один ко многим», т. е. в одном местоположении много изображений.

У меня есть 2 экрана, на первом пользователь добавляет местоположения в контексте просмотра, и они добавляются и извлекаются без проблем.

Теперь на втором экране я хочу получить изображения на основе местоположения, выбранного на первом экране, а затем отобразить изображения в представлении коллекции. Изображения сначала загружаются с flickr, а затем сохраняются в БД.

Я хочу сохранять и извлекать изображения в фоновом контексте, и это вызывает у меня много проблем.

  1. Когда я пытаюсь сохранить каждое изображение, полученное с flickr, я получаю предупреждение о том, что есть висячий объект, и связь может быть установлена:

Это мой код сохранения:

  func saveImagesToDb () {

        //Store the image in the DB along with its location on the background thread
        if (doesImageExist()){
            dataController.backgroundContext.perform {

                for downloadedImage in self.downloadedImages {
                    print ("saving to context")
                    let imageOnMainContext = Image (context: self.dataController.viewContext)
                    let imageManagedObjectId = imageOnMainContext.objectID
                    let imageOnBackgroundContext = self.dataController.backgroundContext.object(with: imageManagedObjectId) as! Image

                    let locationObjectId = self.imagesLocation.objectID
                    let locationOnBackgroundContext = self.dataController.backgroundContext.object(with: locationObjectId) as! Location

                    let imageData = NSData (data: downloadedImage.jpegData(compressionQuality: 0.5)!)
                    imageOnBackgroundContext.image = imageData as Data
                    imageOnBackgroundContext.location = locationOnBackgroundContext


                    try? self.dataController.backgroundContext.save ()
                }
            }
        }
    }

Как вы можете видеть в приведенном выше коде, я создаю NSManagedObject в фоновом контексте на основе идентификатора, полученного из контекста представления. Каждый раз, когда вызывается saveImagesToDb, я получаю предупреждение, так в чем проблема?

  1. Несмотря на предупреждение выше, когда я получаю данные через FetchedResultsController (который работает в фоновом контексте). Представление коллекции иногда прекрасно отображает изображения, а иногда я получаю эту ошибку:

Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid update: invalid number of items in section 0. The number of items contained in an existing section after the update (4) must be equal to the number of items contained in that section before the update (1), plus or minus the number of items inserted or deleted from that section (1 inserted, 0 deleted) and plus or minus the number of items moved into or out of that section (0 moved in, 0 moved out).'

Вот несколько фрагментов кода, связанных с настройкой FetchedResultsController и обновлением представления коллекции на основе изменений в контексте или в FetchedResultsController.

  func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {

        guard let imagesCount = fetchedResultsController.fetchedObjects?.count else {return 0}

        return imagesCount
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        print ("cell data")
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "photoCell", for: indexPath) as! ImageCell
        //cell.placeImage.image = UIImage (named: "placeholder")

        let imageObject = fetchedResultsController.object(at: indexPath)
        let imageData = imageObject.image
        let uiImage = UIImage (data: imageData!)

        cell.placeImage.image = uiImage
        return cell
    }



func setUpFetchedResultsController () {
        print ("setting up controller")
        //Build a request for the Image ManagedObject
        let fetchRequest : NSFetchRequest <Image> = Image.fetchRequest()
        //Fetch the images only related to the images location

        let locationObjectId = self.imagesLocation.objectID
        let locationOnBackgroundContext = self.dataController.backgroundContext.object(with: locationObjectId) as! Location
        let predicate = NSPredicate (format: "location == %@", locationOnBackgroundContext)

        fetchRequest.predicate = predicate
        fetchRequest.sortDescriptors = [NSSortDescriptor(key: "location", ascending: true)]

        fetchedResultsController = NSFetchedResultsController (fetchRequest: fetchRequest, managedObjectContext: dataController.backgroundContext, sectionNameKeyPath: nil, cacheName: "\(latLongString) images")

        fetchedResultsController.delegate = self

        do {
            try fetchedResultsController.performFetch ()
        } catch {
            fatalError("couldn't retrive images for the selected location")
        }
    }

    func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {

        print ("object info changed in fecthed controller")

        switch type {
        case .insert:
            print ("insert")
            DispatchQueue.main.async {
                print ("calling section items")
                self.collectionView!.numberOfItems(inSection: 0)
                self.collectionView.insertItems(at: [newIndexPath!])
            }
            break

        case .delete:
            print ("delete")

            DispatchQueue.main.async {
                self.collectionView!.numberOfItems(inSection: 0)
                self.collectionView.deleteItems(at: [indexPath!])
            }
            break
        case .update:
            print ("update")

            DispatchQueue.main.async {
                self.collectionView!.numberOfItems(inSection: 0)
                self.collectionView.reloadItems(at: [indexPath!])
            }
            break
        case .move:
            print ("move")

            DispatchQueue.main.async {
                self.collectionView!.numberOfItems(inSection: 0)
                self.collectionView.moveItem(at: indexPath!, to: newIndexPath!)

            }

        }
    }

    func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) {
        print ("section info changed in fecthed controller")
        let indexSet = IndexSet(integer: sectionIndex)
        switch type {
        case .insert:
            self.collectionView!.numberOfItems(inSection: 0)
            collectionView.insertSections(indexSet)
            break
        case .delete:
            self.collectionView!.numberOfItems(inSection: 0)
            collectionView.deleteSections(indexSet)
        case .update, .move:
            fatalError("Invalid change type in controller(_:didChange:atSectionIndex:for:). Only .insert or .delete should be possible.")
        }

    }

    func addSaveNotificationObserver() {
        removeSaveNotificationObserver()
        print ("context onbserver notified")
        saveObserverToken = NotificationCenter.default.addObserver(forName: .NSManagedObjectContextObjectsDidChange, object: dataController?.backgroundContext, queue: nil, using: handleSaveNotification(notification:))
    }

    func removeSaveNotificationObserver() {
        if let token = saveObserverToken {
            NotificationCenter.default.removeObserver(token)
        }
    }

    func handleSaveNotification(notification:Notification) {
        DispatchQueue.main.async {
            self.collectionView!.numberOfItems(inSection: 0)
            self.collectionView.reloadData()
        }
    }

Что я делаю неправильно? Я буду признателен за любую помощь.


person Dania    schedule 02.01.2019    source источник


Ответы (4)


Я не могу сказать вам, в чем проблема с 1), но я думаю, что 2) не (просто) проблема с базой данных.

Ошибка, которую вы получаете, обычно возникает, когда вы добавляете или удаляете элементы/разделы в представлении коллекции, но когда после этого вызывается numberOfItemsInSection, числа не складываются. Пример: у вас есть 5 элементов и вы добавляете 2, но затем вызывается numberOfItemsInSection и возвращает 6, что создает несоответствие.

В вашем случае я предполагаю, что вы добавляете элементы с помощью collectionView.insertItems(), но эта строка впоследствии возвращает 0:

guard let imagesCount = fetchedResultsController.fetchedObjects?.count else {return 0}

Что меня также смутило в вашем коде, так это следующие части:

 DispatchQueue.main.async {
            print ("calling section items")
            self.collectionView!.numberOfItems(inSection: 0)
            self.collectionView.insertItems(at: [newIndexPath!])
        }

Вы запрашиваете там количество элементов, но фактически ничего не делаете с результатом функции. Есть ли причина для этого?

Несмотря на то, что я не знаю, в чем проблема CoreData, я бы посоветовал вам не обращаться к БД в методах делегата tableview, а иметь массив элементов, который извлекается один раз и обновляется только при изменении содержимого БД. Это, вероятно, более производительно и намного проще в обслуживании.

person Robin Bork    schedule 02.01.2019
comment
Спасибо @Робин Борк. Что касается строки, которая выглядит запутанной, я использовал ее на основе ответа Майкла Су здесь: stackoverflow.com/questions/19199985/. В обсуждении говорится, что проблема связана с ошибкой в ​​представлении коллекции, поскольку он не знает количество элементов, которые у него есть, поэтому добавление этой строки позволит узнать количество, но кажется, что это не решает проблему полностью. Напечатал imagesCount, для первых 3-х операций вставки всегда 1, вдруг подскочило до 4, после этого вставка выдает краш. Любая идея, почему это происходит? - person Dania; 02.01.2019
comment
О, это хорошо, я не знал об этой ошибке. Что касается вашей проблемы: это может быть вызвано состоянием гонки при добавлении данных в БД в фоновом режиме, но трудно сказать, не попробовав, так что это просто предположение. Я рекомендую: 1) переместить весь код БД из VC для более чистой архитектуры и 2) кэшировать элементы по одному при каждом изменении (вставка/удаление) в свойство, чтобы была центральная истина для содержимого вашего представления коллекции в любой данный момент. Прямо сейчас каждый метод обратного вызова делает свой собственный вызов БД, и мы не знаем, всегда ли результат один и тот же. - person Robin Bork; 02.01.2019
comment
Еще одно наблюдение: я вижу, что у вас есть код для добавления и удаления разделов в представлении коллекции. Это называется? Насколько я вижу, в вашем numberOfItemsInSection вы не делаете различий между разными разделами, что также может привести к неправильному подсчету. - person Robin Bork; 02.01.2019
comment
Спасибо за помощь. Да, вы правы насчет ненужных обновлений раздела. Наконец-то я смог решить обе проблемы, я опубликовал ответ об этом, вы можете проверить это. Спасибо еще раз - person Dania; 03.01.2019

У вас есть общая проблема с несогласованностью UICollectionView во время пакетного обновления. Если вы выполняете удаление/добавление новых элементов в неправильном порядке, UICollectionView может произойти сбой. Эта проблема имеет 2 типичных решения:

  1. используйте -reloadData() вместо пакетных обновлений.
  2. использовать сторонние библиотеки с безопасной реализацией пакетного обновления. Что-то вроде этого https://github.com/badoo/ios-collection-batch-updates
person Eugene El    schedule 02.01.2019

Проблема в том, что NSFetchedResultsController должен использовать только основной поток NSManagedObjectContext.

Решение: создайте два объекта NSManagedObjectContext, один в основном потоке для NSFetchedResultsController и один в фоновом потоке для выполнения записи данных.

let writeContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) let readContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType) let fetchedController = NSFetchedResultsController(fetchRequest: request, managedObjectContext: readContext, sectionNameKeyPath: nil, cacheName: nil) writeContext.parent = readContext

UICollectionView будет правильно обновлен после сохранения данных в writeContext со следующей цепочкой:

writeContext (фоновый поток) -> readContext (основной поток) -> NSFetchedResultsController (основной поток) -> UICollectionView (основной поток)

person meim    schedule 03.01.2019

Я хотел бы поблагодарить Robin Bork, Eugene El и meim за их ответы.

Наконец-то я смог решить обе проблемы.

Что касается проблемы с CollectionView, я чувствовал, что обновлял его слишком много раз, как вы можете видеть в коде, я использовал для обновления два метода FetchedResultsController делегата, а также через наблюдатель, который наблюдает за любыми изменениями в контексте. Поэтому я удалил все это и просто использовал этот метод:

func controllerWillChangeContent(_ controller: 

    NSFetchedResultsController<NSFetchRequestResult>) {
            DispatchQueue.main.async {
                self.collectionView.reloadData()
            }
        }

В дополнение к этому, в CollectionView есть ошибка, связанная с сохранением количества элементов в разделе, иногда как упоминал Юджин Эль. Итак, я просто использовал reloadData для обновления своих элементов, и это сработало хорошо, я удалил использование любого метода, который настраивает свои элементы поэлементно, например, вставляя элемент в определенный IndexPath.

Для проблемы с висячим предметом. Как видно из кода, у меня были объект Location и объект Image. Мой объект местоположения уже был заполнен местоположением и исходил из view context, поэтому мне просто нужно было создать из него соответствующий объект, используя его идентификатор (как вы видите в коде в вопросе).

Проблема была в объекте изображения, я создавал объект на view context (который не содержит вставленных данных), получал его идентификатор, а затем создавал соответствующий объект на background context. Прочитав об этой ошибке и подумав о своем коде, я подумал, что причина может быть в том, что объект изображения на view context не содержит никаких данных. Итак, я удалил код, который создает этот объект на view context, и создал его непосредственно на background context, и использовал его, как в коде ниже, и это сработало!

func saveImagesToDb () {

        //Store the image in the DB along with its location on the background thread
        dataController.backgroundContext.perform {
            for downloadedImage in self.downloadedImages {
                let imageOnBackgroundContext = Image (context: self.dataController.backgroundContext)

                //imagesLocation is on the view context
                let locationObjectId = self.imagesLocation.objectID
                let locationOnBackgroundContext = self.dataController.backgroundContext.object(with: locationObjectId) as! Location

                let imageData = NSData (data: downloadedImage.jpegData(compressionQuality: 0.5)!)
                imageOnBackgroundContext.image = imageData as Data
                imageOnBackgroundContext.location = locationOnBackgroundContext


                guard (try? self.dataController.backgroundContext.save ()) != nil else {
                    self.showAlert("Saving Error", "Couldn't store images in Database")
                    return
                }
            }
        }

    }

Если у кого-то есть другое мнение, отличное от того, что я сказал о том, почему первый метод, который сначала создает пустой объект изображения на view context, а затем создает соответствующий объект на background context, не работает, сообщите нам об этом.

person Dania    schedule 03.01.2019