iosswiftcore-datacloudkitnspersistentcloudkitcontainer

CloudKit & NSPersistentCloudKitContainer - Fetch the CKRecord of creatorUserRecordID for every CKRecord in public database


I wasn't sure how to phrase the question. I know it's misleading.

Context:

I'm using NSPersistentCloudKitContainer for my public and private database. The CoreData model has two configurations: Local, Cloud. Local corresponds to the private CloudKit database and Cloud to the public CloudKit database. Both configurations contain all objects: List, ListItem, and Like.

List has a to-many relationship to ListItem (with inverse). ListItem has a to-many relationship to Like (with inverse).

The private database implementation is working as expected.

A user can create lists and list items. Each of these objects can be set as public or private using the attribute isPublic.

I have a singleton object that monitors changes to Lists and ListItems using the NSManagedObjectContextDidChange notification. If the user updates the isPublic attribute to true a copy of the List or ListItem is created in the public database. If the user sets isPublic to false, the List or ListItem has an isTrashed attribute that's set to true in the public database (not sure when to actually delete the records from the public database but, that's for another question).

The user can also view all ListItems in the public database using a NSFetchedResultsController to load data and for monitor changes.

Problem

I'm able to display all public ListItems using a UITableView and a NSFetchedResultsController. I have a custom UITableViewCell that displays the user who created the ListItem's username and likes like so:

  1. Get the creatorUserRecordID for the NSManagedObject using NSPersistentCloudKitContainer.record(for:)
  2. Get the User CKRecord for the creatorUserRecordID using CKContainer.default().publicCloudDatabase.record(for:)
  3. Access the username of the User CKRecord

I do this async call for every UITableViewCell. Is this correct? If so, I'll need to implement a caching mechanism.

Not only that, what if the async call fails? Now, I have a UITableView cell that has a blank username. Should I prefetch the username for all items in the NSFetchedResultsController beforehand? That could be thousands of objects that haven't even been displayed and increases loading time.

Is it correct to async fetch the User CKRecord of each ListItem or is there a better way to access the user who created the CKRecord?

Should I create a custom User NSManagedObject?

I'd also like to display the usernames of each user who liked the list item. Does this mean for every like I'll need to do the same process? That means I'll have an API call for each username of each list item and each like of each list item ((1 + x) * y API calls; x = number of likes, y = number of list items).

Say I'm displaying 100 ListItems with an average of 20 likes. That means I'll make:

(1 + 20) * 100 = 2100 API calls (without caching)

That hits the 40 requests/second limit with less than 2 list items.

If more information is needed or more code, let me know.

It seems like my best option is to create a custom User object in my CoreData model since the NSPersistentCloudKitContainer setup is fetching relationships for me.

Sample Code (UITableViewCell)

public func configure(listItem: ListItem, environment: Environment) {
    self.listItem = listItem
    self.environment = environment
    self.userRecordId = self.environment?.coreDataStack.creatorUserRecordID(for: listItem)
    
    self.fetchUserTask?.cancel()
    
    self.fetchUserTask = Task {
        guard let userRecordId = self.userRecordId,
              let userRecord = try? await CKContainer.default().publicCloudDatabase.record(for: userRecordId) else {
                self.userNameLabel.text = "I'm server poor. You can't view this information."
            return
        }
        
        if !Task.isCancelled {
            Task { @MainActor in
                self.userRecord = userRecord
                self.userNameLabel.text = self.userRecord?["username"]
            }
        }
    }
    
    self.contentTextView.text = listItem.title
    
    self.dateCreatedLabel.text = DateFormatter.localizedString(
        from: listItem.modifiedAt!,
        dateStyle: .medium,
        timeStyle: .short
    )
    
    let likes = self.listItem?.likes
    self.likeButton.setTitle("\(likes?.count ?? 0)", for: .normal)
    
    if let likes = likes?.allObjects as? [Like] {
        for like in likes {
            guard let userRecordId = self.environment?.coreDataStack.creatorUserRecordID(for: like) else {
                self.likeButton.isEnabled = false
                continue
            }
            
            if userRecordId.recordName == CKCurrentUserDefaultName {
                self.likeButton.isEnabled = false
                break
            }
        }
    }
}

Solution

  • UI implementation issues

    Performing data fetching and any datasource interaction should not be done from UITableViewCell subclass, since this breaks MVC pattern.

    Instead, implement datasource interaction and fetching in your UIViewController subclass.

    CloudKit issues

    According to this CoreData implicitly updates changes from CloudKit when it is properly configured, so you don't have to fetch manually from CKDatabase (but, probably this works only for private CloudKit database, see below).

    According to documentation of NSPersistentCloudKitContainer, it

    mirrors select persistent stores to a CloudKit private database.

    Thus, you need to implement a persistence layer for the public database yourself. It has nothing to do with UI yet, it has to be implemented purely as a service that gets notified about changes and synchronizes the data with your core data stack. Only if done this way, your data will be fetched only when necessary anywhere in your app, in the most economic and consistent way that saves your requests quota. In other words, you should implement your own service that does the job of NSPersistentCloudKitContainer for the public database.

    CKDatabase has a method to fetch records in batch:

    public func fetch(withRecordIDs recordIDs: [CKRecord.ID], desiredKeys: [CKRecord.FieldKey]? = nil, completionHandler: (Result<[CKRecord.ID : Result<CKRecord, any Error>], any Error>) -> Void)
    

    So, synchronization from CloudKit to CoreData can look like this:

    1. Create User entity in your schema.
    2. Collect record ids of all users for both list items and their likes
    3. Fetch those users from CloudKit and create or update corresponding CoreData User objects with them.
    class MyUserSyncService {
        let coreDataStack: MyCoreDataStack
        func syncAll() {
            let allListItemsAndLikesCreatorUserRecordIDs = coreDataStack.allListItemsAndLikesUserRecordIDs()
            coreDataStack.deleteAllUsers(notMatchingRecordIDs: allListItemsAndLikesCreatorUserRecordIDs)
            sync(userRecordIDs: allListItemsAndLikesCreatorUserRecordIDs)
        }
    
        func sync(userRecordIDs: [CKRecord.ID]) {
            CKContainer.default().publicCloudDatabase
                .fetch(
                    withRecordIDs: userRecordIDs,
                    desiredKeys: ["username"]
                ) { result in
                    guard let successResult = try? result.get() else { /* log error */ return }
                    successResult.forEach {
                        guard let record = try? $0.value.get() else { /* log error */ return }
                        coreDataStack.createOrUpdateUser(
                            withRecordID: $0.key,
                            userName: record["username"]
                        )
                    }
                }
        }
    }
    
    1. Decide when you want to perform synchronization (i.e. during app start, when some screen is opened, upon changes to likes or listItems in CoreData if those changes came from CloudKit, with some regular cadence, etc.)
    2. For the purpose of presentation in UI, only fetch Users from CoreData that are already synchronized. If user is missing in CoreData, it could be due to some error, or because synchronization is not yet completed, thus, you should decide how do you handle such situation (maybe you should have "someone" as a fallback user with a fallback avatar).