SwiftUI and Core Data: Data Definition

Published on

Get weekly handpicked updates on Swift and SwiftUI!

In the previous article, I listed some of the challenges and expectations encountered when using Core Data in SwiftUI. In future articles, we will attempt to create a SwiftUI + Core Data app using a new approach to see if we can avoid and improve some of the previous issues. This article will first explore how to define data.

Starting with Todo

Todo is a demo application prepared for this article series. I try to make this simple app touch more development scenarios of SwiftUI + Core Data. Users can create tasks to be completed in Todo and can use Task Groups for better management.

You can get the code for Todo at this link. The code is still being updated, so there may be some inconsistencies with what is described in the article.

https://cdn.fatbobman.com/Todo_demo_iPhone_14_Pro_2022-11-28_10.29.20.2022-11-28%2010_35_07.gif

Todo code has the following characteristics:

  • Adopt modular development approach, with data definition, view, and DB implementation in separate modules.
  • With the exception of views used for concatenation (merging multiple detail views), all detailed views are decoupled from the application’s data flow, allowing for adaptation to different frameworks (pure SwiftUI-driven, TCA, or other Redux frameworks) without code changes.
  • All views can be previewed without using any Core Data code, and can dynamically respond to mock data.

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

Which came first, the chicken or the egg?

Core Data presents data through managed objects (defined in the data model editor). This allows developers to manipulate data in a familiar way without needing to understand the specific structure and organization of persistent data. Unfortunately, managed objects are not very friendly for SwiftUI, which is mainly based on value types. Therefore, many developers convert managed object instances into struct instances in the view for easier manipulation (How to Preview SwiftUI Views with Core Data Elements in Xcode).

Therefore, in the traditional Core Data application development pattern, developers usually need to perform the following steps to create the Group Cell view shown above (using the Task Group in a Todo application as an example):

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

  • Create an entity called C_Group in Xcode’s data model editor, including any related entities such as C_Task.

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

  • It may be necessary to improve the type compatibility of the managed object by modifying the C_Group code (or adding computed properties).
  • Define a structure that is easy to use in the SwiftUI environment and create extension methods for managed objects to achieve conversion.
Swift
struct TodoGroup {
    var title: String
    var taskCount: Int // Number of tasks included in the current Group
}

extension C_Group {
    func convertToGroup() -> TodoGroup {
        .init(title: title ?? "", taskCount: tasks?.count ?? 0)
    }
}
  • Create a GroupCell view.
Swift
struct GroupCellView:View {
    @ObservedObject var group:C_Group
    var body: some View {
        let group = group.convertToGroup()
        HStack {
            Text(group.title)
            Text("\(group.taskCount)")
        }
    }
}

According to the above process, even without doing the initial modeling work, we can fully satisfy the needs of view development by relying solely on the TodoGroup structure. As a result, the process sequence will change to:

  • Define the TodoGroup structure
  • Build the view

At this point, the view can be simplified to:

Swift
struct GroupCellView:View {
    let group: TodoGroup
    var body: some View {
        HStack {
            Text(group.title)
            Text("\(group.taskCount)")
        }
    }
}

During the development process, we can adjust the TodoGroup as needed without overly considering how to organize data in Core Data or the database (although developers still need some basic knowledge of Core Data programming to avoid creating completely unrealistic data formats). Modeling and conversion of Core Data data should only be done in the final stage (after views and other logic processing are completed).

This seemingly simple conversion - from chicken (managed object) to egg (structure) to chicken (structure) to egg (managed object) - will completely disrupt our previously accustomed development process.

Other Advantages of Managed Objects

Using a struct to directly represent data in a view is certainly convenient, but we cannot ignore the other advantages of managed objects. For SwiftUI, managed objects have two very notable characteristics:

  • Lazy loading

The so-called management of managed objects refers to the fact that the object is created and held by the managed context. Only when needed, the required data is loaded from the database (or row cache). When combined with SwiftUI’s lazy loading containers (List, LazyStack, LazyGrid), a balance between performance and resource consumption can be achieved perfectly.

  • Real-time response to changes

Managed objects (NSManagedObject) conform to the ObservableObject protocol and can notify views to refresh when data changes occur.

Therefore, no matter what, we should keep the above advantages of managed objects in views. As a result, the code above will evolve into the following:

Swift
struct GroupCellViewRoot:View {
    @ObservedObject var group:C_Group
    var body:some View {
        let group = group.convertToGroup()
        GroupCellView(group:group)
    }
}

Unfortunately, it seems like everything is back to square one.

In order to retain the advantages of Core Data, we have to introduce managed objects in the view, which requires modeling and conversion.

Is it possible to create a way that can retain the advantages of managed objects while not explicitly introducing specific managed objects in the code?

Protocol-oriented programming

Protocol-oriented programming is a fundamental concept running through the Swift language and one of its main features. By having different types conform to the same protocol, developers can break free from the constraints of specific types.

BaseValueProtocol

Back to the TodoGroup type. This type is used not only to provide data for SwiftUI views, but also to provide important information for other data flows. For example, in Redux-like frameworks, it provides the required data to reducers through actions. Therefore, we can create a unified protocol for all similar data types - BaseValueProtocol.

Swift
public protocol BaseValueProtocol: Equatable, Identifiable, Sendable {}

More and more Redux-like frameworks require Actions to conform to the Equatable protocol. Therefore, types that could potentially be associated parameters of an Action must also follow this protocol. Considering the trend of moving Reducers out of the main thread in the future, making data conform to Sendable can also avoid issues related to multithreading. Since each instance of a struct requires a corresponding managed object instance, making struct types conform to Identifiable can better establish a relationship between the two.

Now we first make TodoGroup comply with this protocol:

Swift
struct TodoGroup: BaseValueProtocol {
    var id: NSManagedObjectID // A link that can connect two things, currently temporarily replaced with NSManagedObjectID
    var title: String
    var taskCount: Int
}

In the above implementation, we use NSManagedObjectID as the id type of TodoGroup, but since NSManagedObjectID also needs to be created in a managed environment, it will be replaced by other custom types in the following text.

ConvertibleValueObservableObject

Whether we define the data model first or the struct first, we ultimately need to provide a method for converting managed objects to corresponding structs. Therefore, we can consider that all managed objects that can be converted to a specified struct (complying with BaseValueProtocol) should follow the protocol below.:

Swift
public protocol ConvertibleValueObservableObject<Value>: ObservableObject, Identifiable {
    associatedtype Value: BaseValueProtocol
    func convertToValueType() -> Value
}

For example:

Swift
extension C_Group: ConvertibleValueObservableObject {
    public func convertToValueType() -> TodoGroup {
        .init(
            id: objectID, // Corresponding identifier between them
            title: title ?? "",
            taskCount: tasks?.count ?? 0
        )
    }
}

Despite the existence of NSManagedObjectID, the above two protocols still cannot be decoupled from the managed environment (not referring to the Core Data framework). Therefore, we need to create an intermediate type that can run in both managed and unmanaged environments as an identifier for both.

Swift
public enum WrappedID: Equatable, Identifiable, Sendable, Hashable {
    case string(String)
    case integer(Int)
    case uuid(UUID)
    case objectID(NSManagedObjectID)

    public var id: Self {
        self
    }
}

For the same reason that this type may be used as associated parameters for Action and as an explicit identifier for views in ForEach, we need this type to conform to the Equatable, Identifiable, Sendable, and Hashable protocols.

Since WrappedID needs to conform to Sendable, the above code will generate the following warning at compile time (NSManagedObjectID does not conform to Sendable).:

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

Fortunately, NSManagedObjectID is thread-safe and can be marked as Sendable (this has been officially confirmed by Apple in the 2022 Ask Apple Q&A). Adding the following code will eliminate the warning above:

Swift
extension NSManagedObjectID: @unchecked Sendable {}

Let’s make some adjustments to the previously defined BaseValueProtocol and ConvertibleValueObservableObject:

Swift
public protocol BaseValueProtocol: Equatable, Identifiable, Sendable {
    var id: WrappedID { get }
}

public protocol ConvertibleValueObservableObject<Value>: ObservableObject, Identifiable where ID == WrappedID {
    associatedtype Value: BaseValueProtocol
    func convertToValueType() -> Value
}

So far we have created two protocols and a new type - BaseValueProtocol, ConvertibleValueObservableObject, and WrappedID, but it doesn’t seem clear what their specific purposes are.

Protocol for Mock Data Preparation - TestableConvertibleValueObservableObject

Do you remember our original purpose? To complete most of the view and logic code without creating a Core Data model. Therefore, we must be able to make the GroupCellViewRoot view accept a universal type that can be created only from a struct (TodoGroup) and behaves like a managed object. TestableConvertibleValueObservableObject is the cornerstone of achieving this goal:

Swift
@dynamicMemberLookup
public protocol TestableConvertibleValueObservableObject<WrappedValue>: ConvertibleValueObservableObject {
    associatedtype WrappedValue where WrappedValue: BaseValueProtocol
    var _wrappedValue: WrappedValue { get set }
    init(_ wrappedValue: WrappedValue)
    subscript<Value>(dynamicMember keyPath: WritableKeyPath<WrappedValue, Value>) -> Value { get set }
}

public extension TestableConvertibleValueObservableObject where ObjectWillChangePublisher == ObservableObjectPublisher {
    subscript<Value>(dynamicMember keyPath: WritableKeyPath<WrappedValue, Value>) -> Value {
        get {
            _wrappedValue[keyPath: keyPath]
        }
        set {
            self.objectWillChange.send()
            _wrappedValue[keyPath: keyPath] = newValue
        }
    }

    func update(_ wrappedValue: WrappedValue) {
        self.objectWillChange.send()
        _wrappedValue = wrappedValue
    }

    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs._wrappedValue == rhs._wrappedValue
    }

    func convertToValueType() -> WrappedValue {
        _wrappedValue
    }

    var id: WrappedValue.ID {
        _wrappedValue.id
    }
}

Let’s define a mock data type to validate the results:

Swift
public final class MockGroup: TestableConvertibleValueObservableObject {
    public var _wrappedValue: TodoGroup
    public required init(_ wrappedValue: TodoGroup) {
        self._wrappedValue = wrappedValue
    }
}

Now, in SwiftUI views, MockGroup will have almost the same capabilities as C_Group, the only difference being that it is built using a TodoGroup instance.

Swift
let group1 = TodoGroup(id: .string("Group1"), title: "Group1", taskCount: 5)
let mockGroup = MockGroup(group1)

Thanks to the existence of WrappedID, mockGroup can be used without a managed environment.

AnyConvertibleValueObservableObject

Considering that @ObservedObject can only accept concrete types of data (unable to use any ConvertibleValueObservableObject), we need to create a type-erased container so that both C_Group and MockGroup can be used in the GroupCellViewRoot view.

Swift
public class AnyConvertibleValueObservableObject<Value>: ObservableObject, Identifiable where Value: BaseValueProtocol {
    public var _object: any ConvertibleValueObservableObject<Value>
    public var id: WrappedID {
        _object.id
    }

    public var wrappedValue: Value {
        _object.convertToValueType()
    }

    init(object: some ConvertibleValueObservableObject<Value>) {
        self._object = object
    }

    public var objectWillChange: ObjectWillChangePublisher {
        _object.objectWillChange as! ObservableObjectPublisher
    }
}

public extension ConvertibleValueObservableObject {
    func eraseToAny() -> AnyConvertibleValueObservableObject<Value> {
        AnyConvertibleValueObservableObject(object: self)
    }
}

Now make the following adjustments to the GroupCellViewRoot view:

Swift
struct GroupCellViewRoot:View {
    @ObservedObject var group:AnyConvertibleValueObservableObject<TodoGroup>
    var body:some View {
        let group = group.wrappedValue
        GroupCellView(group:group)
    }
}

We have completed the first view chain decoupled from the managed environment.

Creating a preview

Swift
let group1 = TodoGroup(id: .string("Group1"), title: "Group1", taskCount: 5)
let mockGroup = MockGroup(group1)

struct GroupCellViewRootPreview: PreviewProvider {
    static var previews: some View {
        GroupCellViewRoot(group: mockGroup.eraseToAny())
            .previewLayout(.sizeThatFits)
    }
}

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

Perhaps some people may think that using so much code just to achieve the preview of Mock data is not cost-effective. If the goal is simply to achieve this, previewing the GroupCellView view directly would be sufficient, why go through all this trouble?

Without AnyConvertibleValueObservableObject, developers can only preview some views in the application (without creating a managed environment). However, with AnyConvertibleValueObservableObject, we can achieve the desire to free all view code from the managed environment. By combining the decoupling method introduced later with Core Data data operations, it is possible to achieve the goal of completing all view and data operation logic code in the application without writing any Core Data code. Moreover, this can be previewed, interactive, and tested throughout the process.

Review

Don’t be confused by the code above. After using the method introduced in this article, the newly organized development process is as follows:

  • Define the TodoGroup struct
Swift
struct TodoGroup: BaseValueProtocol {
    var id: WrappedID
    var title: String
    var taskCount: Int // Number of tasks in the current group
}
  • Create TodoGroupView (TodoGroupViewRoot is no longer needed at this point)
Swift
struct TodoGroupView:View {
    @ObservedObject var group:AnyConvertibleValueObservableObject<TodoGroup>
    var body:some View {
        let group = group.wrappedValue
        HStack {
            Text(group.title)
            Text("\(group.taskCount)")
        }
    }
}
  • Define the MockGroup data type
Swift
public final class MockGroup: TestableConvertibleValueObservableObject {
    public var _wrappedValue: TodoGroup
    public required init(_ wrappedValue: TodoGroup) {
        self._wrappedValue = wrappedValue
    }
}

let group1 = TodoGroup(id: .string("id1"), title: "Group1", taskCount: 5)
let mockGroup = MockGroup(group1)
  • Creating a preview view
Swift
struct GroupCellViewPreview: PreviewProvider {
    static var previews: some View {
        GroupCellView(group: mockGroup.eraseToAny())
    }
}

What’s next

In the next article, we will discuss how to decouple the fetching of data from Core Data in the view layer, and create a custom FetchRequest type that can accept mock data.

This article discusses how to define data in SwiftUI and Core Data in a modern way to achieve decoupling between views and managed objects.

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