drootang
drootang

Reputation: 2503

@FetchRequest results not updating in SwiftUI when change source is share extension

There are many questions on @FetchRequest with answers that provide workarounds for forcing SwiftUI to redraw the view. I can't find any that address this question.

My app uses a share extension to insert a new item into my CoreData model (sqlite). A framework is shared between the main app and the share extension target. The main SwiftUI ContentView in my app uses a @FetchRequest property to monitor the contents of the database and update the view:

struct ContentView: View {
    @FetchRequest(fetchRequest: MyObj.allFetchRequest()) var allObjs: FetchedResults<MyObj>
    ...
}

my xcdatamodel is extended with:

    public static func allFetchRequest() -> NSFetchRequest<MyObj> {
        let request: NSFetchRequest<MyObj> = MyObj.fetchRequest()
        // update request to sort by name
        request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
        return request
    }

The share extension adds a new item using:

let newObj = MyObj(context: context)
newObj.name = "new name"
try! context.save()

After adding a new item using the share extension, when I return to my app by switching apps on the device, SceneDelegate detects sceneDidBecomeActive. In this function, I can manually run a fetch request and see that the new item has been added. I can even use answers from other SO questions to trick the UI into rebuilding in ContentView (by toggling a Bool used in the builder on receipt of a notification), but in either case, when the UI rebuilds, allObjs does not contain the updated results from the database.

In my sceneDidBecomeActive I have tried numerous combinations of code to attempt to update the context with the latest contents of the database in a way that the @FetchRequest will see it. The data is there, and the my managed context can see this new data because a manual fetch request returns the new item, but the @FetchRequest is always stale preventing a UI rebuild. In both locations I've printed the context and am sure that it is the same context. I've tried:

I'm at a loss as to why the @FetchRequest is not updating its values when ContentView redraws.

How can I force @FetchRequest to update its objects and cause a SwiftUI rebuild after my share extension adds a new object to the database?

(Xcode 11.4.1, Swift 5, target: iOS 13.1)

edit:

In my ContentView, I print the allObjs variables, their state, the memory address of the context, etc. I compare this to a new fetch from the context and all values for existing objects are the same, except the new fetch shows the newly added object, whereas allObjs does not contain the new object. It is very clear that the @FetchRequest object is not getting updated.

edit 2:

I tried creating a custom ObservableObject class with a @Published property and an explicit update() method, then using it as an @ObservedObject in ContentView. This works.

class MyObjList: ObservableObject {
    static let shared = MyObjList()
    @Published var allObjs: [MyObj]!

    init() {
        update()
    }

    func update() {
        allObjs = try! DataStore.persistentContainer.viewContext.fetch(MyObj.allFetchRequest())
    }
}

Then in ContentView:

@EnvironmentObject var allObjs: MyObjList

Which helps confirm that the data is there, it just isn't getting updated properly by @FetchRequest. This is a functional workaround.

Upvotes: 13

Views: 3781

Answers (3)

halleygen
halleygen

Reputation: 691

I solved a similar problem using persistent history tracking and remote change notifications. These make Core Data record every change that occurs to an SQLite store and sends you notifications whenever one of these changes occurred remotely (eg from an extension or iCloud).

These features aren't enabled by default. You have to configure them before loading the persistent stores:

persistentStoreDescriptions.forEach {
    $0.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
    $0.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
}

Then subscribe to remote change notifications:

NotificationCenter.default.addObserver(
    /* The persistent container/view model/MyObjList/etc that acts as observer */, 
    selector: #selector(coordinatorReceivedRemoteChanges(_:)), 
    name: .NSPersistentStoreRemoteChange, 
    object: persistentStoreCoordinator // don't forget to pass the PSC as the object!
)

Now we will be notified when remote changes occur, but we need to share these changes with the managed object contexts throughout the app. This looks different for every app: sometimes your MOCs don't need to know about the changes at all, sometimes they only need to know about some of them, sometimes all of them. Apple recently published a guide that goes into depth about how to ensure only relevant changes are processed.

In this scenario you might do:

@objc
func coordinatorReceivedRemoteChanges(_ notification: Notification) {
    let currentToken = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken
    let existingToken = ... // retrieve the previously processed history token so we don't repeat work; you might have saved it to disk using NSKeyedArchiver
    
    // Fetch the history
    let historyRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: existingToken)
    let result = try! context.execute(historyRequest) as! NSPersistentHistoryResult
    let transactions = result.result as! [NSPersistentHistoryTransaction]

    // Filter out the non-relevant changes
    for transaction in transaction {
        // Maybe we only care about changes to MyObjs 
        guard transaction.entityDescription.name == MyObj.entity().name else { continue }
        // Maybe we only care about specific kinds of changes
        guard let changes = transaction.changes, changes.contains(where: { ... }) else { continue }
        // Merge transaction into the view context
        viewContext.mergeChanges(fromContextDidSave: transaction.objectIDNotification())
    }

    // Finally, save `currentToken` so it can be used next time and optionally delete old history.
}

Any @FetchRequests using viewContext will now receive all merged changes and refresh their data accordingly.

Upvotes: 2

user25917
user25917

Reputation: 977

I use the method from the comment referred answer. I can resolve the ManagedID from URI. But can not retrieve the object by existingObject(with:). Only nil returns. And the object(with:) method crash the app.

020-05-13 18:34:54.324914+0800 SwiftUI-FetchRequest[7802:388610] *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Object's persistent store is not reachable from this NSManagedObjectContext's coordinator'
*** First throw call stack:
(
    0   CoreFoundation                      0x00007fff23e39f0e __exceptionPreprocess + 350
    1   libobjc.A.dylib                     0x00007fff50ad79b2 objc_exception_throw + 48
    2   CoreData                            0x00007fff23a0b0a9 -[_NSCoreManagedObjectID _isPersistentStoreAlive] + 0
    3   CoreData                            0x00007fff239f704a -[NSManagedObjectContext objectWithID:] + 555
    4   SwiftUI-FetchRequest                0x000000010e922933 $s20SwiftUI_FetchRequest16ContentViewModelCACycfcyypSgcfU_ + 1827
    5   SwiftUI-FetchRequest                0x000000010e922d21 $sypSgIegn_yXlSgIeyBy_TR + 161
    6   MMWormhole                          0x000000010ebbfe34 __61-[MMWormhole notifyListenerForMessageWithIdentifier:message:]_block_invoke + 36
    7   libdispatch.dylib                   0x000000010ebe1f11 _dispatch_call_block_and_release + 12
    8   libdispatch.dylib                   0x000000010ebe2e8e _dispatch_client_callout + 8
    9   libdispatch.dylib                   0x000000010ebf0d97 _dispatch_main_queue_callback_4CF + 1149
    10  CoreFoundation                      0x00007fff23d9da89 __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ + 9
    11  CoreFoundation                      0x00007fff23d985d9 __CFRunLoopRun + 2041
    12  CoreFoundation                      0x00007fff23d97ac4 CFRunLoopRunSpecific + 404
    13  GraphicsServices                    0x00007fff38b2fc1a GSEventRunModal + 139
    14  UIKitCore                           0x00007fff48bc7f80 UIApplicationMain + 1605
    15  SwiftUI-FetchRequest                0x000000010e920c3b main + 75
    16  libdyld.dylib                       0x00007fff519521fd start + 1
)
libc++abi.dylib: terminating with uncaught exception of type NSException

The context is already refresh by reset() method. And manually query the latest entry could be resolved correctly. I think its SwiftUI.framework bug. And file a issue by Feedback.app.

Upvotes: 0

Asperi
Asperi

Reputation: 257703

Try to move, if it is applicable for your app, creation of ContentView on app go to foreground (thus fetch request will be recreated from scratch)

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

    // leave here only window creation
    if let windowScene = scene as? UIWindowScene {
        let window = UIWindow(windowScene: windowScene)
        self.window = window
    }
}

// ... other code here

func sceneWillEnterForeground(_ scene: UIScene) {

    let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
    let contentView = ContentView().environment(\.managedObjectContext, context)
    let controller = UIHostingController(rootView: contentView)

    window?.rootViewController = controller
    window?.makeKeyAndVisible()
}

Upvotes: -1

Related Questions