肘子的 Swift 记事本

SwiftUI and Core Data: Safely Responding to Data

Published on

Get weekly handpicked updates on Swift and SwiftUI!

Ensuring that the application does not crash unexpectedly due to Core Data issues is a minimal requirement for developers. This article will introduce the reasons that may cause serious errors in the view, how to avoid them, and how to provide better and more accurate information to users while ensuring real-time response to data changes in the view. Since this article will involve many techniques and methods introduced in previous sections, it is best to read them together.

  • Code for the demo project Todo can be obtained at here

Managed Objects and Optional Values

The concept of optional entity attributes in Core Data existed before the existence of Swift, allowing properties to be temporarily invalid. For example, when you create a new NSManagedObject with a string attribute, the initial value (with no default value) is nil, which is no problem before the object is validated (usually at save time).

When a developer sets a default value for an attribute in the model editor (disabling optional), Xcode generated managed object class definition code will still declare many types as optional value types. By manually modifying the type (changing String? to String), the declaration code can partially improve the friendliness of using managed objects in the view.

Compared to declaring properties with default values as optional value types (such as String), declaring numerical properties is more confusing. For example, the count property (Integer 16) below is set to optional in the model editor, but is still a non-optional value type (Int16) in the generated code.

https://cdn.fatbobman.com/image-20221212090247999.png

https://cdn.fatbobman.com/image-20221212090306573.png

Moreover, developers cannot change the property type to Int16? by modifying the declaration code.

https://cdn.fatbobman.com/image-20221212090739291.png

This means that developers will lose the powerful ability of optionals in Swift on certain attribute types of entities. The reason for this is that the “optional” in the model editor in Xcode does not correspond to the optional values in the Swift language. Core Data is limited by the type restrictions expressible in Objective-C, and even with scalar conversion, it does not have the ability to correspond to native types in Swift.

If we remove scalar types, we can let the model editor generate specific types that support optional values (such as NSNumber?).

https://cdn.fatbobman.com/image-20221212092612578.png

https://cdn.fatbobman.com/image-20221212092628708.png

Developers can declare computed properties for managed objects to achieve conversion between NSNumber? and Int16?.

Developers may wonder if they can use the exclamation mark (!) to force unwrap an optional property of an entity defined as optional in the model and declared as an optional value type in the managed object’s type declaration (e.g. the timestamp property mentioned above), as long as they can ensure that there is a value to save.

In fact, this is the way it is used in the Core Data template provided by Xcode.

https://cdn.fatbobman.com/image-20221212101526366.png

But is this really the correct usage? Could there be serious security risks? If the database field corresponding to the timestamp has a value, will the timestamp always have a value? Is it possible for it to be nil?

Removing and Reactive Programming

Instances of managed objects are created in the managed context and can only safely run within the thread of the managed context to which they are bound. Each managed object corresponds to a record in persistent storage (excluding relationships).

To save memory, the hosting object will actively release the space occupied by the unreferenced managed object instances (retainsRegisteredObjects defaults to false) in the up-down distribution. That is to say, if a view used to display data of a managed object instance is destroyed, and if there are no other views or code referring to the managed object instance displayed in the view, the hosting context will release the memory occupied by these data from memory.

When retainsRegisteredObjects is true, managed objects will be internally kept with a strong reference, even if there is no external code referencing the managed object instance, the object instance will not be destroyed.

From another perspective, even if the delete method is used in a managed context to delete the data corresponding to the instance in the database, if the managed object instance is still referenced by code or views, Swift will not destroy the instance. At this time, the managed object context sets the managedObjectContext property of the instance to nil, canceling its binding with the managed context. If the optional value type property of the instance (such as the timestamp that must have had a value before) is accessed again, the return value will be nil. Forcibly unwrapping it will cause the application to crash.

With the popularization of cloud synchronization and persistent storage history tracking, a certain data in the database of Core Data may be deleted by other devices or other processes using the same database in the same device at any time. Developers can no longer assume complete control over the data as they did before. If safety measures are not taken for data that may have been deleted at any time, serious problems may arise in the code or view.

Returning to the Core Data template code created by Xcode, we make the following attempt: deleting the data one second after entering the NavigationLink:

Swift
ForEach(items) { item in
    NavigationLink {
        Text("Item at \(item.timestamp!, formatter: itemFormatter)")
            .onAppear{
                // Delete the data one second after entering the NavigationLink
                DispatchQueue.main.asyncAfter(deadline: .now() + 1){
                    viewContext.delete(item)
                    try! viewContext.save()
                }
            }
    } label: {
        Text(item.timestamp!, formatter: itemFormatter)
    }
}

https://cdn.fatbobman.com/coreData-optional-demo1_2022-12-12_11.16.51.2022-12-12%2011_18_34.gif

There were no issues at all! It didn’t crash. Could it be that our previous discussion was all wrong?

In the Core Data template code, only one line of code is used to declare the subviews:

Swift
Text("Item at \(item.timestamp!, formatter: itemFormatter)")

Therefore, in the ForEach of ContentView, item is not considered as a Source of truth that can trigger view updates (the items obtained through Fetch Request are the Source of truth). After deleting data, even if the content of item has changed, the declaration statement of that row (Text) will not be refreshed, so there will be no forced unwrapping failure. As the content of FetchRequest changes, the List will be refreshed again. Since the data corresponding to NavigationLink no longer exists, NavigationView automatically returns to the root view.

However, usually in the subview, we use ObservedObject to mark the managed object instance to respond to data changes in real-time. Therefore, if we adjust the code to the normal writing mode, we can see where the problem is:

Swift
struct Cell:View {
    @ObservedObject var item:Item // Respond to data changes
    @Environment(\.managedObjectContext) var viewContext
    var body: some View {
        Text("Item at \(item.timestamp!, formatter: itemFormatter)")
            .onAppear{
                DispatchQueue.main.asyncAfter(deadline: .now() + 1){
                    viewContext.delete(item)
                    try! viewContext.save()
                }
            }
    }
}

List {
    ForEach(items) { item in
        NavigationLink {
            Cell(item: item) // Pass managed object
        } label: {
            Text(item.timestamp!, formatter: itemFormatter)
        }
    }
    .onDelete(perform: deleteItems)
}

https://cdn.fatbobman.com/coreData-optional-demo2_2022-12-12_11.29.10.2022-12-12%2011_31_10.gif

After deleting the data, the managed object context sets the item’s manageObjectContext to nil. At this point, the Cell view will refresh driven by the item’s ObjectWillChangePublisher, and force unwrapping will cause the application to crash. This problem can be avoided by providing alternative values.

Swift
Text("Item at \(item.timestamp ?? .now, formatter: itemFormatter)")

What if we use the ConvertibleValueObservableObject protocol discussed in our article ”SwiftUI and Core Data: Data Definition”? Can providing alternate values for properties in convertToValueType prevent crashes? The answer is that the original version may still have issues.

After the data is deleted, the manageObjectContext of the hosted object instance is set to nil. Since AnyConvertibleValueObservableObject conforms to the ObservableObject protocol, it will also trigger the update of the Cell view. In the new round of rendering, if we restrict the convertToGroup conversion process to run on the thread where the managed object context is located, the conversion will fail due to the inability to obtain context information. Assuming we do not limit the thread on which the conversion process runs, the fallback method will still be valid for managed object instances created by the view context (but other thread errors may occur).

In order for the ConvertibleValueObservableObject protocol to meet various scenarios, we need to make the following adjustments:

Swift
public protocol ConvertibleValueObservableObject<Value>: ObservableObject, Equatable, Identifiable where ID == WrappedID {
    associatedtype Value: BaseValueProtocol
    func convertToValueType() -> Value? // Change the return type to Value?
}

public extension TestableConvertibleValueObservableObject where ObjectWillChangePublisher == ObservableObjectPublisher {
    ...

    func convertToValueType() -> WrappedValue? { // Change to return Value?
        _wrappedValue
    }
}

public class AnyConvertibleValueObservableObject<Value>: ObservableObject, Identifiable where Value: BaseValueProtocol {

    public var wrappedValue: Value? { // Change to return Value?
        _object.convertToValueType()
    }
}

By doing so, we can use ‘if let’ in the view code to ensure that the crash issue mentioned above does not occur:

Swift
public struct Cell: View {
    @ObservedObject var item: AnyConvertibleValueObservableObject<Item>

    public var body: some View {
        if let item = item.wrappedValue {
           Text("Item at \(item.timestamp, formatter: itemFormatter)")
        }
    }
}

In order to support conversion in any hosting context thread, the implementation of convertToValueType will be as follows, taking TodoGroup in Todo as an example:

Swift
extension C_Group: ConvertibleValueObservableObject {
    public var id: WrappedID {
        .objectID(objectID)
    }

    public func convertToValueType() -> TodoGroup? {
        guard let context = managedObjectContext else { // Check if the context can be obtained
            return nil
        }
        return context.performAndWait { // Execute in the thread of the context to ensure thread safety
            TodoGroup(
                id: id,
                title: title ?? "",
                taskCount: tasks?.count ?? 0
            )
        }
    }
}

Since the synchronous version of performAndWait does not support return values, we need to enhance it to some extent:

Swift
extension NSManagedObjectContext {
    @discardableResult
    func performAndWait<T>(_ block: () throws -> T) throws -> T {
        var result: Result<T, Error>?
        performAndWait {
            result = Result { try block() }
        }
        return try result!.get()
    }

    @discardableResult
    func performAndWait<T>(_ block: () -> T) -> T {
        var result: T?
        performAndWait {
            result = block()
        }
        return result!
    }
}

In reactive programming, developers should not assume that every component can be in an ideal environment, and should strive to ensure that they can be safe and stable in any situation in order to ensure the stable operation of the entire system.

Provide correct alternative content for deleted managed object instances.

Some may find the title of this section odd. If the managed object has already been deleted, what information needs to be provided?

In the previous demo, when the data is deleted (by using the delay operation in the onAppear closure), NavigationView will automatically return to the root view. In this case, the view that holds the data will disappear along with the data deletion.

However, in many cases, developers may not use the NavigationLink version used in the demo. In order to have stronger control over the view, developers usually choose a programmable NavigationLink version. In this case, when the data is deleted, the application will not automatically return to the root view. Additionally, in some other operations, in order to ensure the stability of the modal view, we usually mount the modal view outside of the List. For example:

Swift
@State var item: Item?

List {
    ForEach(items) { item in
        VStack {
            Text("\(item.timestamp ?? .now)")
            Button("Show Detail") {
                self.item = item // Show modal view
                // Simulate delayed deletion
                DispatchQueue.main.asyncAfter(deadline: .now() + 2){
                    viewContext.delete(item)
                    try! viewContext.save()
                }
            }
            .buttonStyle(.bordered)
        }
    }
    .onDelete(perform: deleteItems)
}
// Modal view
.sheet(item: $item) { item in
    Cell(item: item)
}

struct Cell: View {
    @ObservedObject var item: Item
    var body: some View {
        // To see the change clearly. When timestamp is nil, the current time will be displayed.
        Text("\((item.timestamp ?? .now).timeIntervalSince1970)")
    }
}

When running the above code, after the data is deleted, the item in the Sheet view will use alternative data because the managedObjectContext is nil, which can make the user confused.

https://cdn.fatbobman.com/coreData-optional-demo3_2022-12-12_14.20.17.2022-12-12%2014_21_06.gif

We can avoid the above problem by retaining valid values.

Swift
struct Cell: View {
    let item: Item // No need to use ObservedObject
    /*
     If using MockableFetchRequest, then
     let item: AnyConvertibleValueObservableObject<ItemValue>
    */
    @State var itemValue:ItemValue?
    init(item: Item) {
        self.item = item
        // When initialized, get the valid value
        self._itemValue = State(wrappedValue: item.convertToValueType())
    }
    var body: some View {
        VStack {
            if let itemValue {
                Text("\((itemValue.timestamp).timeIntervalSince1970)")
            }
        }
        .onReceive(item.objectWillChange){ _ in
            // After the item changes, if it can be converted to a valid value, update the view
            if let itemValue = item.convertToValueType() {
                self.itemValue = itemValue
            }
        }
    }
}

public struct ItemValue:BaseValueProtocol {
    public var id: WrappedID
    public var timestamp:Date
}

extension Item:ConvertibleValueObservableObject {
    public var id: WrappedID {
        .objectID(objectID)
    }

    public func convertToValueType() -> ItemValue? {
        guard let context = managedObjectContext else { return nil}
        return context.performAndWait{
            ItemValue(id: id, timestamp: timestamp ?? .now)
        }
    }
}

https://cdn.fatbobman.com/coreData-optional-demo4_2022-12-12_14.20.17.2022-12-12%2014_21_06.gif

Passing value types outside of views

In the previous code, we passed only managed object instances for the subviews (AnyConvertibleValueObservableObject is also a secondary wrapper for managed object instances). But in the Redux class framework, for thread safety (Reducers may not run on the main thread, please refer to the previous articles), we do not directly send the managed object instances to the Reducer, but pass the converted value types.

The following code comes from TaskListContainer.swift in the Todo project TCA Target.

https://cdn.fatbobman.com/image-20221212162439240.png

Although value types help us avoid potential thread risks, a new problem arises where views cannot respond in real-time to changes in managed object instances. By obtaining the managed object instance corresponding to the value type data in the view, we can ensure both safety and real-time responsiveness.

For convenience of demonstration, we still use the ordinary SwiftUI data flow as an example:

Swift
@State var item: ItemValue? // value type

List {
    ForEach(items) { item in
        VStack {
            Text("\(item.timestamp ?? .now)")
            Button("Show Detail") {
                self.itemValue = item.convertToValueType() // pass value type
                // simulate delayed content modification
                DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
                    item.timestamp = .now
                    try! viewContext.save()
                }
            }
            .buttonStyle(.bordered)
        }
    }
    .onDelete(perform: deleteItems)
}
.sheet(item: $itemValue) { item in
    Cell(itemValue: item) // parameter is value type
}

struct Cell: View {
    @State var itemValue: ItemValue // value type
    @Environment(\.managedObjectContext) var context

    var body: some View {
        VStack {
            if let itemValue {
                Text("\((itemValue.timestamp).timeIntervalSince1970)")
            }
        }
        // get the corresponding managed object instance in the view and respond to changes in real time
        .task { @MainActor in
            guard case .objectID(let id) = itemValue.id else {return}
            if let item = try? context.existingObject(with: id) as? Item {
                for await _ in item.objectWillChange.values {
                    if let itemValue = item.convertToValueType() {
                        self.itemValue = itemValue
                    }
                }
            }
        }
    }
}

In my personal experience, to ensure thread safety, managed objects should only be passed between views, and it is best to only obtain data used for view display within the view. Any possible transfer process that may be separated from the view should use the value type version of the managed object instance.

Perform a secondary confirmation when modifying data

In order to avoid excessive impact on the main thread, we usually perform operations that will cause data changes in a private context. Setting the parameter of the operation method to a value type will force developers to first confirm whether the corresponding data (in the database) exists when operating on data (such as adding, deleting, and changing).

For example (code from CoreDataStack.swift in the Todo project):

Swift
@Sendable
func _updateTask(_ sourceTask: TodoTask) async {
    await container.performBackgroundTask { [weak self] context in
        // First, confirm if the task exists
        guard case .objectID(let taskID) = sourceTask.id,
              let task = try? context.existingObject(with: taskID) as? C_Task else {
            self?.logger.error("can't get task by \(sourceTask.id)")
            return
        }
        task.priority = Int16(sourceTask.priority.rawValue)
        task.title = sourceTask.title
        task.completed = sourceTask.completed
        task.myDay = sourceTask.myDay
        self?.save(context)
    }
}

Through existingObject, we will ensure that the next step of the operation is performed only when the data is valid, thus avoiding unexpected crashes caused by operating on deleted data.

What’s Next

In the next article, we will explore the issue of modular development. How to decouple specific managed object types and Core Data operations from views and features.

I'm really looking forward to hearing your thoughts! Please Leave Your Comments Below to share your views and insights.

Fatbobman(东坡肘子)

I'm passionate about life and sharing knowledge. My blog focuses on Swift, SwiftUI, Core Data, and Swift Data. Follow my social media for the latest updates.

You can support me in the following ways