jsonswiftuiobservable

Mixing ReferenceFileDocument and @Observable


I have an app in which the data model is @Observable, and views see it through

@Environment(dataModel.self) private var dataModel

Since there are a large number of views, only some of which may need to be redrawn at a given time, Apple's documentation leads me to believe that @Observable may be smarter about only redrawing views that actually need redrawing than @Published and @ObservedObject.

I originally wrote the app without document persistence, and injected the data model into the environment like this:

@main
struct MyApp: App {
    @State private var myModel = MyModel()

    var body: some Scene {
        WindowGroup {
            myDocumentView()
               .environment(myModel)
        }
    }
}

I’ve been trying to make the app document based. Although I started using SwiftData, it has trouble with Codable (you need to explicitly code each element), and a long thread in the Developer forum suggests that SwiftData does not support the Undo manager - and in any event, simple JSON serialization is all that this app requires - not a whole embedded SQLLite database.

At first, it's easy to switch to a DocumentGroup:

@main
struct MyApp: App {
    @State private var myModel = MyModel()

    var body: some Scene {
        DocumentGroup(newDocument: {MyModel() } ) { file in
            myDocumentView()
        }
    }
}

Since I've written everything using @Observable, I thought that I'd make my data model conform to ReferenceFileDocument like this:

import SwiftUI
import SwiftData
import UniformTypeIdentifiers

@Observable class MyModel: Identifiable, Codable, @unchecked Sendable, ReferenceFileDocument {

// Mark: ReferenceFileDocument protocol
    
    static var readableContentTypes: [UTType] {
        [.myuttype]
    }
    
    required init(configuration: ReadConfiguration) throws {
        if let data = configuration.file.regularFileContents {
            let decodedModel = try MyModel(json: data)
            if decodedModel != nil {
                self = decodedModel!
            } else {
                print("Unable to decode the document.")
            }
        } else {
            throw CocoaError(.fileReadCorruptFile)
        }
    }
    
    func snapshot(contentType: UTType) throws -> Data {
        try self.json()
    }
    
    func fileWrapper(snapshot: Data,
                     configuration: WriteConfiguration) throws -> FileWrapper {
        FileWrapper(regularFileWithContents: snapshot)
    }


    
    var nodes  = [Node]()  // this is the actual data model
    
    init() {
        newDocument()
    }
  ... etc.  

I've also tried a similar approach in which the ReferenceFileDocument is a separate module that serializes an instance of the data model.

The problem I'm currently experiencing is that I can't figure out how to: a) inject the newly created, or newly deserialized data model into the environment so that views can take advantage of it's @Observable properties, or b) how to cause changes in the @Observable data model to trigger serialization (actually I can observe them triggering serialization, but what's being serialized is an empty instance of the data model).

I make data model changes through a call to the Undo manager:

    // MARK: - Undo

    func undoablyPerform(_ actionName: String, with undoManager: UndoManager? = nil, doit: () -> Void) {
        let oldNodes = self.nodes
        doit()
        undoManager?.registerUndo(withTarget: self) { myself in
            self.undoablyPerform(actionName, with: undoManager) {
                self.nodes = oldNodes
            }
        }
        undoManager?.setActionName(actionName)
    }

The top level document view looks like this:

import SwiftUI
import CoreGraphics

struct myDocumentView: View {
    @Environment(MyModel.self) private var myModel    
    @Environment(\.undoManager) var undoManager

    init(document: MyModel) {
        self.myModel = document  // but of course this is wrong!
    }

... etc.

Thanks to https://stackoverflow.com/users/259521/malhal for showing an approach that lets ReferenceFileDocument conform to @Observable.


Solution

  • Not sure what you mean by don't seem to play nice, but to make ReferenceFileDocument work with @Observable just mark the class as such and remove @Published from the UI tracked properties and add @ObservationIgnored to the untracked. E.g. in the sample Building a document-based app with SwiftUI (requires updating the min deployment to >= 17 for when Observable was added), change:

    final class ChecklistDocument: ReferenceFileDocument {
    
        typealias Snapshot = Checklist
        
        @Published var checklist: Checklist
    
        // lets pretend there was a non-UI, i.e. not published property
        var temp: Bool
    

    to

    @Observable
    final class ChecklistDocument: ReferenceFileDocument {
    
        typealias Snapshot = Checklist
        
        var checklist: Checklist
    
        // our pretend var would be:
        @ObservationIgnored var temp: Bool
    

    Changes to the document that you want saved need to go through funcs that notify the undoManager in the normal way for this kind of document, e.g. also from the sample:

    // Provide operations on the checklist document.
    extension ChecklistDocument {
        
        /// Toggles an item's checked status, and registers an undo action.
        /// - Tag: PerformToggle
        func toggleItem(_ item: ChecklistItem, undoManager: UndoManager? = nil) {
            let index = checklist.items.firstIndex(of: item)!
            
            checklist.items[index].isChecked.toggle()
            
            undoManager?.registerUndo(withTarget: self) { doc in
                // Because it calls itself, this is redoable, as well.
                doc.toggleItem(item, undoManager: undoManager)
            }
        }
    

    Regarding your concern with @ObservableObject. The idea is yes the body is called when any property changes but then when you send its properties down to child Views, their bodys are only called when that value changes. When you have many Views with small body that only take what they need that is the most efficient so it actually is good practice to do this. Remember Views are value types on the stack, negligible performance-wise and smaller Views actually speeds up SwiftUI's diffing algorithm. Passing the properties (or bindings to those properties) from @ObservedObject into many small View is more efficient than using @Observable with large View. Also since @Observable is new and a bit weird it has been found to have a memory leak, e.g. Views not being destroyed when popped off nav stack. IMO this is because of a design flaw where the objects observes itself which creates a retain cycle.