0

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 SQLite 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.

0

1 Answer 1

0

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.

Sign up to request clarification or add additional context in comments.

13 Comments

My understanding is that objectWillChange is sent to the entire ObservedObject, which would cause all affected views to be redrawn, which isn't what I want. Again, my understanding of Observable is that it diffs the model changes against the elements required by each view, and only redraws affected views. Also, it seems to be Apple's direction going forward, so I'd pref to stick with Observed, particularly since the memory leak doesn't seem to particularly affect my application. Hopefully it will be fixed eventually ...
Yes @ObservedObject will call body but then you pass the properties to child Views and their body is only called if the property changed.
Hmmm... it seems that the bottom line here is that ReferenceFileDocument is fundamentally incompatible with Observable. True?
Should be possible to make it compatible, I added note to answer on that.
So I just tried to use @Observable with ReferenceFileDocument and it seems to work ok, so I changed my answer to reflect that.
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.