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.
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.
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):
- Create an entity called C_Group in Xcode’s data model editor, including any related entities such as C_Task.
- 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.
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.
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:
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:
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.
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:
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.:
public protocol ConvertibleValueObservableObject<Value>: ObservableObject, Identifiable {
associatedtype Value: BaseValueProtocol
func convertToValueType() -> Value
}
For example:
extension C_Group: ConvertibleValueObservableObject {
public func convertToValueType() -> TodoGroup {
.init(
id: objectID, // Corresponding identifier between them
title: title ?? "",
taskCount: tasks?.count ?? 0
)
}
}
The link between the two —— WrappedID
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.
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).:
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:
extension NSManagedObjectID: @unchecked Sendable {}
Let’s make some adjustments to the previously defined BaseValueProtocol and ConvertibleValueObservableObject:
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:
@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:
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.
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.
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:
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
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)
}
}
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
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)
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
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
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.