2
votes

I'm new to swift and I have trouble with understanding how environment variables works.

In Core Data, I created new Entity called "API" with one attribute id: Int32.

Then in SwiftUI, I wanted to find maximum value of id. I wrote a request, but whenever I used passed to view as environment variable managedObjectContext, it always crashed my app/preview. Here's crash info after using NSManagedObjectContext.fetch(NSFetchRequest) (using FetchRequest gives only stacktrace with exception EXC_BAD_INSTRUCTION)

...
Exception Type:        EXC_CRASH (SIGABRT)
Exception Codes:       0x0000000000000000, 0x0000000000000000
Exception Note:        EXC_CORPSE_NOTIFY

External Modification Warnings:
Thread creation by external task.

Application Specific Information:
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'The fetch request's entity 0x600003c54160 'API' appears to be from a different NSManagedObjectModel than this context's'
terminating with uncaught exception of type NSException
abort() called
CoreSimulator 704.12 - Device: iPhone 11 (8356FF2A-5F0A-42F7-AA32-396FADCF2BF6) - Runtime: iOS 13.4 (17E255) - DeviceType: iPhone 11

Application Specific Backtrace 1:
0   CoreFoundation                      0x00007fff23e3dcce __exceptionPreprocess + 350
1   libobjc.A.dylib                     0x00007fff50b3b9b2 objc_exception_throw + 48
2   CoreData                            0x00007fff239c6b99 -[NSManagedObjectContext executeFetchRequest:error:] + 5004
3   libswiftCoreData.dylib              0x00007fff513b63d4 $sSo22NSManagedObjectContextC8CoreDataE5fetchySayxGSo14NSFetchRequestCyxGKSo0gH6ResultRzlF + 68
...

Keep in mind, that this error is changing depending on which project, I'm using. In my main project I had error like that:

*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '+entityForName: nil is not a legal NSPersistentStoreCoordinator for searching for entity name 'WebsiteAPI''

Here is the code I'm using

import SwiftUI
import CoreData

struct test: View {
    private var id: Int32
    @Environment(\.managedObjectContext) var managedObjectContext

    var body: some View {
        Text("id=\(id)")
    }

    public init(context: NSManagedObjectContext) {
        self.id = -1

        //this crashes and gives no usefull information
//        let request2 = FetchRequest<API>(
//            entity: API.entity(),
//            sortDescriptors: [NSSortDescriptor(keyPath: \API.id, ascending: false)]
//        )
//        self.id = request2.wrappedValue.first?.id ?? 1

        guard let context2 = (UIApplication.shared.delegate as? AppDelegate)?.persistentContainer.viewContext else {
            fatalError("Unable to read managed object context.")
        }

        let request = NSFetchRequest<API>(entityName: "API")
        request.sortDescriptors = [NSSortDescriptor(keyPath: \API.id, ascending: false)]
        do {
            var commits = try context.fetch(request)   // OK
            commits = try context2.fetch(request)  // OK
            //commits = try self.managedObjectContext.fetch(request)  // causing crash
            self.id = Int32(commits.count)
        } catch let error {
            print(error.localizedDescription)
        }
    }
}

struct test_Previews: PreviewProvider {
    static var previews: some View {
        guard let context = (UIApplication.shared.delegate as? AppDelegate)?.persistentContainer.viewContext else {
            fatalError("Unable to read managed object context.")
        }
        return test(context: context).environment(\.managedObjectContext, context)
    }
}

All commented lines crash app. Why getting context from AppDelegate.persistentContainer.viewContext works just fine, but using environment variable managedObjectContext, which in my opinion should be same, doesn't work? I spent 5 hours on this, checked pretty much everything, tried a lot of things but with no success. In the end I can just keep getting context from AppDelegate, but what's wrong with environment variable? Am I missing some common knowledge or is just a bug? I'm getting headache from bugs that I'm encountering in Xcode, starting from missing autocompletion after clearing build folder to hundreds of errors after changing struct/file name on all references, despite successfully building afterwards. Restarting Xcode few times every day to make it working properly is normal for me.

Also some things I noticed, when I created FetchRequest as a variable and used it in some list inside body, it worked. The problem is only, when I'm trying to fetch things manually in code/function/init, like button action or methods onAppear, init etc. I tried to run app on both physical device and showing preview. Same effect.

I'm using Xcode 11.4 with Swift 5.

1

1 Answers

1
votes

Structs like the View in SwiftUI are value types and must not init any objects in the normal way because the View structs are only created during a state change and then are gone. Thus any objects they create are lost immediately. E.g. in your init method you create NSFetchRequest, NSSortDescriptor and all the fetched objects too. A View struct is typically init every time there is a state change and a parent body runs, thus you will be creating thousands of heap objects that will fill up memory and slow SwiftUI to a crawl. These problems can be diagnosed in Instruments->SwiftUI "analysis for tracing .body invocations for View types".

Obviously we do need to objects so that's where property wrappers come in. By prefixing your object allocation with a property wrapper, then the object is created in a special way where it is only init once and the same instance is given to the new struct every time it is recreated. Which as I said happens all the time in SwiftUI, more frequently or less frequently depending on how much effort you put into organising your View struct hierarchy. Health warning: Most of the sample code currently available online puts zero effort into this and needlessly update massive view hierarchies because the designed their View structs like View Controllers instead of making them as small as possible and only having properties that are actually used in body.

To solve your problem you need to use the property wrapper @StateObject to safely init your object, and it must conform to ObservableObject so that SwiftUI can be notified that the object will be changing so that after all objects have notified it can call body which will certainly be needed, unless the developer did not use the object in their body in which case the code is badly written. The object is created once just before the View's body is called, and then every time the Viewis recreated it is given the existing object rather than creating a new one. When the view is no longer shown it is automatically deinit. UseonAppearto configure the object the first time the View appears, andonChangeto update it. Fust have a funcfetchthat supplies themanagedObjectContextand youridfetch param and in it create aNSFetchedResultsControllerperform the fetch and set thefetchedObjectson an@Publishedproperty that theViewcan use. When the object sets its items it will automatically cause theView bodyto be called again updating. SwiftUI compares the body to the previously returned body and uses the differences to render the screen (using actualUIView`s). Here is a full working example I made:

import SwiftUI
import CoreData

struct ContentView: View {
    
    var body: some View {
        NavigationView {
            MasterView(name:"Master")
                .navigationTitle("Master")
        }
    }
}


class ItemsFetcher : NSObject, ObservableObject, NSFetchedResultsControllerDelegate {
    var managedObjectContext : NSManagedObjectContext?
    
    @Published
    private(set) var items : Array<Item> = []

    lazy var fetchedResultsController : NSFetchedResultsController<Item> = {
        let frc = NSFetchedResultsController<Item>(fetchRequest: Item.myFetchRequest(), managedObjectContext: managedObjectContext!, sectionNameKeyPath: nil, cacheName: nil)
        frc.delegate = self
        return frc
    }()
    
    func fetch(name:String, ascending: Bool){
        fetchedResultsController.fetchRequest.predicate = NSPredicate(format: "name = %@", name)
        fetchedResultsController.fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Item.timestamp, ascending: ascending)]
        try! fetchedResultsController.performFetch()
        items = fetchedResultsController.fetchedObjects ?? []
    }
    
    func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        objectWillChange.send()
        items = fetchedResultsController.fetchedObjects ?? []
    }
}

struct MasterView: View {
    @Environment(\.managedObjectContext) private var viewContext
    let name: String
    @State
    var ascending = false
    
    @StateObject private var itemsFetcher = ItemsFetcher()
    
    var body: some View {
        List {
            ForEach(itemsFetcher.items) { item in
                Text("Item at \(item.timestamp!, formatter: itemFormatter)")
            }
            .onDelete(perform: deleteItems)
        }
        .toolbar {
            #if os(iOS)
            ToolbarItem(placement: .navigation){
                EditButton()
            }
            #endif
            ToolbarItem(placement: .automatic){
                Button(action: addItem) {
                    Label("Add Item", systemImage: "plus")
                }
            }
            ToolbarItem(placement: .bottomBar){
                Button(action: {
                    ascending.toggle()
                }) {
                    Text(ascending ? "Descending" : "Ascending")
                }
            }
        }
        .onAppear() {
            itemsFetcher.managedObjectContext = viewContext
            fetch()
        }
        .onChange(of: ascending) { newValue in
            fetch()
        }
        
    }
    
    func fetch(){
        itemsFetcher.fetch(name: name, ascending: ascending)
    }
    
    private func addItem() {
        
        withAnimation {
            let newItem = Item(context: viewContext)
            newItem.timestamp = Date()
            newItem.name = "Master"

            do {
                try viewContext.save()
            } catch {
                // Replace this implementation with code to handle the error appropriately.
                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
                let nsError = error as NSError
                fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
            }
        }
    }

    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            offsets.map {itemsFetcher.items[$0] }.forEach(viewContext.delete)

            do {
                try viewContext.save()
            } catch {
                // Replace this implementation with code to handle the error appropriately.
                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
                let nsError = error as NSError
                fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
            }
        }
    }
}

private let itemFormatter: DateFormatter = {
    let formatter = DateFormatter()
    formatter.dateStyle = .short
    formatter.timeStyle = .medium
    return formatter
}()

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
    }
}