TEAM LICENSES: Save money and learn new skills through a Hacking with Swift+ team license >>

SwiftUI macOS document app architecture in a concurrent world

Forums > SwiftUI

I'm trying to figure out the correct structure for a macOS document app using SwiftUI and Swift 5.5 concurrency features.

I want to demonstrate updating a document's data asynchronously, in a thread safe manner, with the ability to read / write the data to a file, also thread-safe and in the background. Yet I am struggling to:

  • write clean code - some of it looks inelegant at best, more like clunky, compared to my prior apps which used DispatchQueues etc
  • implement Codeable conformance for an actor

I'm seeking ideas, corrections and advice on how to improve on this. I've posted the full code over at GitHub, as I will only highlight some particular elements here. This is a minimum viable app, just for proof-of-concept purposes.

The app

The app displays a list of Records with a button to add more. It should be able to save and reload the list from a file.

Current approach / design

I've chosen the ReferenceFileDocument protocol for the Document type, as this is what I would use in a future app which has a more complex data structure. (i.e. I'm not planning on using a pure set of structs to hold a documents' data)

Document has a property content of type RecordsModelView representing the top-level data structure.

RecordsModelView is annotated with @MainActor to ensure any updates it receives will be processed on the main thread.

RecordsModelView has a property of type RecordsModel. This is an actor ensuring read/write of its array of Records are thread safe, but not coordinated via the MainActor for efficiency.

The app assumes that the func to add an item takes a long time, and hence runs it from with a Task. Although not demonstrated here, I am also making the assumption that addRecord maybe called from multiple background threads, so needs to be thread safe, hence the use of an actor.

The code compiles and runs allowing new items to be added to the list but...

Issues

Firstly, I can't annotate Document with @MainActor - generates compiler errors I cannot resolve. If I could I think it might solve some of my issues...

Secondly, I therefore have a clunky way for Document to initialise its content property (which also has to be optional to make it work). This looks nasty, and has the knock on effect of needing to unwrap it everywhere it is referenced:

final class Document: ReferenceFileDocument {

    @Published var content: RecordsViewModel?

    init() {
        Task { await MainActor.run { self.content = RecordsViewModel() } }
    }

    // Other code here
}

Finally, I can't get the RecordsModel to conform to Encodable. I've tried making encode(to encoder: Encoder) async, but this does not resolve the issue. At present, therefore RecordsModel is just conformed to Decodable.

    func encode(to encoder: Encoder) async throws { // <-- Actor-isolated instance method 'encode(to:)' cannot be used to satisfy a protocol requirement
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(records, forKey: .records)
    }

3      

I've worked out a solution - it's taken my way too long: I feel daft looking back on my first clumsy attempt.

Two of my key mistakes were to put the my RecordViewModel object (which needed to conform to ObservableObject) on the MainActor, and to create an actor type, RecordsModel, to hold the property that the needed to be isolated.

Here's the original attempt:

actor RecordsModel: Decodable {
    var records: [Record] = []

    enum CodingKeys: String, CodingKey { case records }

    init() {}

    init(from decoder: Decoder) async throws { ... }

    // Unable to conform to Encodable at present with this implementation
    func encode(to encoder: Encoder) throws { ... }

    func addRecord() -> [Record] {
        self.records.append(Record(value: Int.random(in: 0...10))) // Assume it takes a long time to compute `value`
        return self.records
    }
}

@MainActor
class RecordsViewModel: ObservableObject {
    @Published var records: [Record]
    private let recordsModel: RecordsModel

    init() {
        self.records = []
        self.recordsModel = RecordsModel()
    }

    init(fromRecordsModel recordsModel: RecordsModel) async {
        self.records = await recordsModel.records
        self.recordsModel = recordsModel
    }

    func addRecord() {
        // Given addRecord takes time to complete, we run it in the background
        Task {
            self.records = await recordsModel.addRecord()
        }
    }
}

My new approach doesn't create an actor, but puts a property isolatedRecords in the view model, isolated with a global actor. This is complimented by a non-isolated published version, which is updated on the MainActor after any updates to its isolated twin.

Here's the new view model class:

final class Records: ObservableObject, Codable {
    @Published var records: [Record]
    @MyActor private var isolatedRecords: [Record]

    init() {
        self.records = []
        self.isolatedRecords = []
    }

    enum CodingKeys: String, CodingKey { case records }
    init(from decoder: Decoder) throws { ... }
    func encode(to encoder: Encoder) throws { ... }

    @MyActor func append(_ value: Int) -> [Record] {
        self.isolatedRecords.append(Record(value))
        return isolatedRecords
    }

    func addRecord() {
        Task() {
            let newNumber = Int.random(in: 0...10) // Assume lots of processing here, hence we run it as a Task
            let newRecords = await self.append(newNumber)
            await MainActor.run { self.records = newRecords }
        }
    }
}

This has succeeded in ensuring the code is free of race-conditions, whilst keeping processing and update syncronisation of the record's array off the main thread, and removing all the other issues I encountered such as trying to conform the actor to Encodable, putting the Document on the MainActor and more. The code is more elegant also.

I've left the full updated project at GitHub

3      

Hacking with Swift is sponsored by Blaze.

SPONSORED Still waiting on your CI build? Speed it up ~3x with Blaze - change one line, pay less, keep your existing GitHub workflows. First 25 HWS readers to use code HACKING at checkout get 50% off the first year. Try it now for free!

Reserve your spot now

Sponsor Hacking with Swift and reach the world's largest Swift community!

Archived topic

This topic has been closed due to inactivity, so you can't reply. Please create a new topic if you need to.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.