스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
Build apps that share data through CloudKit and Core Data
Learn how to easily build apps that share data between multiple iCloud users with NSPersistentCloudKitContainer. Discover how to create informative experiences around shared data and learn about the CloudKit technologies that support these features in Core Data. To get the most out of this session, check out our previous videos on NSPersistentCloudKitContainer: "Using Core Data With CloudKit" from WWDC19 and "Sync a Core Data store with the CloudKit public database" from WWDC20.
리소스
관련 비디오
WWDC22
WWDC21
- Bring Core Data concurrency to Swift and SwiftUI
- There and back again: Data transfer on Apple Watch
- What's new in CloudKit
Tech Talks
-
다운로드
Hi. I'm Nick Gillett, an engineer here at Apple on the Core Data team. In this session, I'll show you how to easily build applications that use NSPersistentCloudKitContainer to share data with multiple iCloud users.
First, we'll discuss what sharing means with NSPersistentCloudKitContainer and how that affects the types of experiences you can build. Next, we'll take a deep dive into the mechanics of sharing. And finally, we'll briefly discuss how NSPersistentCloudKitContainer can help you enable some additional protection for sensitive data stored in iCloud. Let's get started with sharing.
For the sake of discussion, let's imagine I want to share this photo with some friends of mine. How might I do that? On Apple platforms, there are a number of ways we can share the data our applications create. Photos implements a system control here in the lower left that allows me to call up an Action sheet. It has a wide variety of actions I can take, including a number of ways to share. For example, I can send it to my friends as an iMessage or email. But ideally, we would have a single place directly in the Photos app where my friends and I can share our photos with each other. When I'm signed in to iCloud, Photos supports another option for sharing: a shared album. Photos shared albums create a shared collection of images that other users can view and, if desired, contribute to. I simply give the new album a title and then tap Next to choose the set of participants. Here I've chosen four friends to share the photo with. Heather, Jermaine, Percy, and Mary are all test accounts I regularly work with when I'm building sharing features. When I tap Next, I can see my new album with the photo I shared. Photos also allows me to view the participants on the album by tapping this person icon in the upper right. Tapping that brings up the set of participants. Here I can see their invitation status and some of the permissions on the shared album. How might we build an experience like this? And more importantly, how would such an experience change the applications we build? Well, let me show you. To demonstrate how sharing works with NSPersistentCloudKitContainer, I'm going to be using our sample application, Syncing a Core Data Store with the Cloud. I've already modified it to support sharing posts with different iCloud users. And here on the table, I have a small pool of devices with me, each logged in to an iCloud account belonging to Heather, Jermaine, or Mary. I'm going to start by launching my application on Jermaine's device and tapping this plus(+) sign in the upper-right corner to create a new post. I'll give it a simple title-- "Sharing demos are great"-- and tap Done.
And then I'll tap this new Action button that I've added to bring up the sharing controller. I want to send each person an invite to work on this post with me via email. So I'll tap Mail and then enter the information for my friends. I've already saved Heather and Mary in my address book, so they're easy to find. Finally, I'll tap Send to send the email.
Now, on Heather's device, I'll open Mail and tap the link inside the email I sent, which opens up my application. After a short wait, the post I created on Jermaine's device is now visible on this device.
On Mary's device, I'll open Mail and tap the link inside the email I sent, which opens up my application.
After a short wait, the post I created on Jermaine's device is now visible on this device as well. But how does all of this work? And exactly how much did I have to change in the sample application? The answer is, "Not much." Sharing is by far the most complicated feature we have built in to NSPersistentCloudKitContainer. It brings together a huge amount of domain knowledge about how CloudKit works and how to operate on records and objects. Naturally, this domain knowledge is reflected in the APIs we have built for NSPersistentCloudKitContainer. So let's take a look at exactly how NSPersistentCloudKitContainer shares objects.
In my brief demo, I showed an application that makes use of two CloudKit databases, the .private and the .shared database. Each of these is mirrored to a persistent store in my application, one using the .private database scope and the other using the .shared database scope. Using a single managed object context, my application can access data in both stores. Let's look at that change in a bit more detail. The first change I had to make was to tell NSPersistentCloudKitContainer to mirror the .shared CloudKit database to a new persistent store. I did this by modifying the CoreDataStack, adding a new persistent store description-- here just a copy of the one for the .private store with a different URL. Then, I set its CloudKit container options’ databaseScope property to .shared. This is new iOS 15 and allows NSPersistentCloudKitContainer to be configured to mirror persistent stores to the .shared CloudKit database. To complete the demo, I had to make two other changes.
I adopted a new method on NSPersistentCloudKitContainer to create the share. share(_ managedObjects: to share: completion:) is a new method designed to pair directly with UICloudSharingController. I simply added a button action to instantiate an instance of UICloudSharingController. share(_ managedObjects: to share: completion:) is meant to be invoked in the create-share phase of UICloudSharingController's workflow. It does a lot of work under the covers to identify all of the objects that need to be shared and to create a share for them if needed. Finally, I invoke UICloudSharingController's completion block with the results provided by NSPersistentCloudKitContainer, which tells it I'm ready to continue the sharing flow. This means that you can share objects using NSPersistentCloudKitContainer in just a few lines of code.
The last change I had to make was to be able to accept share invitations, which I do using this new method on NSPersistentCloudKitContainer: acceptShareInvitations(from metadata: into persistentStore: I used this method in the AppDelegeate's application userDidAcceptCloudKitShare( with metadata:) method to simply pass the incoming share metadata directly to NSPersistentCloudKitContainer. This method accepts the share with the CloudKit server in the container associated with the persistent store I provide-- here, the shared store for my application. After the share is accepted, NSPersistentCloudKitContainer automatically syncs all of the shared objects into the local store. So that's how we use NSPersistentCloudKitContainer to combine the .private and .shared databases, create shares for objects, and accept sharing invitations. But our applications are usually designed to manage large collections of data. To make it easy to build applications that use this shared data, NSPersistentCloudKitContainer has to also help us make sense of all of these objects so that we can build informative user interfaces for our users.
To understand these challenges a bit more clearly, we need to identify two crucial concepts for sharing.
The first is the notion of a set of actors. We call them the owner and the participants. The owner is the iCloud account that actually owns an object. Owners create and share objects with a set of participants. A participant is any other iCloud account that is allowed to operate on those objects in some way. Participants can have different roles and permissions that constrain how they can act on a particular set of objects, which brings us to the second key concept: how NSPersistentCloudKitContainer and CloudKit structure these shared objects.
In Core Data, we think of our objects in terms of NSManagedObject. NSPersistentCloudKitContainer turns those managed objects into instances of CKRecord that are stored in CloudKit. If you've worked with sharing before, you're probably familiar with hierarchical sharing, where these records are associated with a root record called the share. But this isn't how NSPersistentCloudKitContainer works. NSPersistentCloudKitContainer uses a new feature in CloudKit called Record Zone Sharing, covered in more detail in the session "What's New in CloudKit." But let's take a look at how NSPersistentCloudKitContainer uses Record Zone Sharing to share managed objects. In a CloudKit database-- for example, the .private database-- NSPersistentCloudKitContainer typically manages a private zone to store the objects an application creates. In Record Zone Sharing, shared CKRecords are contained inside a shared CKRecordZone. Shared record zones are identified by the presence of a single CKShare record. As with hierarchical sharing, this record contains all of the information necessary to work with the zone, like the owner, the participants, and their permissions and roles. NSPersistentCloudKitContainer manages these zones and automatically assigns records to them. Because there's no root record, NSPersistentCloudKitContainer also has to understand how the concepts of owners and participants apply to the entire record zone.
Let's imagine I have a collection of people I want to share with. Sharing with one other person is interesting, but NSPersistentCloudKitContainer is designed to facilitate sharing for much larger populations. Each of these participants will be able to access and operate on the objects I share with them. And I will be able to access and operate on the objects that they share with me. Each participant will also have their own collection of devices. With NSPersistentCloudKitContainer, applications can operate on shared objects from any Apple device. For each participant, NSPersistentCloudKitContainer manages objects in two CloudKit databases, the .private and the .shared database. In my .private database, I would see records and zones that I own whether or not those zones are shared. For example, the zone that NSPersistentCloudKitContainer manages. With sharing, NSPersistentCloudKitContainer will also create shared zones for me with a CKShare record that controls who can access these zones that I own. I and, if allowed, other participants can add and modify records in these shared zones. In my .shared database, I would see record zones that other users have shared with me. If I'm allowed to, I can add records that I own to any of those zones just as they can in the zones that I own.
Another user will see a different set of zones in their .private and .shared databases, depending on whether or not they are the owner of those zones. For example, this user may have a private zone and a shared zone that they own in their .private database. In addition, they would see my two zones that I share with them and any other zones they are a participant on in their .shared database. So how does NSPersistentCloudKitContainer know where to keep your records? In many cases, it can infer where records belong based on the relationship they have to other objects. But you can also tell share(_ managedObjects: to share: completion:) to store objects in a specific shared zone by passing it a non-nil CKShare. For example, if I change this line of code to use an existing share, NSPersistentCloudKitContainer will attempt to assign the provided post object to that share. These are all the changes I had to make for my first demo, but my application also needs to effectively communicate what objects are shared, who they're shared with, and what those participants can do. Our users need all of that information so that they can make good decisions about the objects they choose to share. So let's look at how I've changed my application to communicate these states and privileges. Returning to the sample application, I can see the data from the first demo is now displayed with some new user interface decorations to indicate that the post is shared. If I tap on it, I can also see the participants displayed at the bottom of the detail view controller with their role, permissions, and acceptance state. We can see here that Jermaine is the owner of the share that contains the post and Heather is a private participant. Now I'm going to add a new post, give it a title, and tap Done.
I'll tap the Action button to bring up the sharing controller, but this time, I want the share to be read-only so that the participants can't edit or modify the contents of the share. So I'll change the share options to mark the share as View Only. Next, I'll tap Mail and invite Jermaine and Mary.
Finally, I'll send the email.
On Mary's device, I'll accept the new share, and now I can see the new post. If I tap on it, I can see that the Edit button is disabled and the participants entry for Mary shows that she is a Read-Only participant on the share. Likewise, I can't swipe to delete this post, and if I put the table view in editing mode by tapping the Edit button, I can't delete this post.
On Jermaine's device now, I'll accept the new share.
And now I can see the new post. If I tap on it, I can see that the Edit button is disabled and the participants entry for Jermaine shows that he is a Read-Only participant on the share.
Even in this humble sample application, I've had to make a number of changes to the user interface to present information about shared objects. I had to decorate the post table cells to indicate which ones are shared. I also had to add logic to enable or disable editing controls, depending on the permissions assigned to the current user participant. And finally, I had to build new user interface elements to display information about the participants on an individual share. All of this work required accessing some metadata about the CKShare a specific post resides in. NSPersistentCloudKitContainer includes a number of API methods to align with each of these concerns. fetchShares(matching objectIDs:) is new in iOS 15 and allows me to get the CKShare for a specific post. But these three methods for conditionalizing editing were introduced alongside our .public database support at WWDC in 2020. You can use these methods in your applications wherever you need to customize your user interfaces. But in my application, I took a slightly different approach. Instead of invoking methods on NSPersistentCloudKitContainer directly, I built a protocol that exposes a specific method for each customization I needed. It's called the SharingProvider. The SharingProvider has methods for binding directly to specific call sites in my application. For example, I might need to know whether or not an object is shared. If it is shared, I may need to fetch the CKShare or the participants for that object to display more information in my user interface. Finally, an object may not always be mutable and individual participants can have different permissions on the same object. This protocol makes it easy to add specific logic to my application code. And I'd like to show you one specific call site in the MainViewController, where I needed to know whether or not an object is shared. Here I used isShared to decide whether or not to convert the title of a post to an attributed string and prefix it with the person.circle symbol to show that the post is part of a share. Customizations like this necessarily require more complicated code than if I chose not to support sharing. After adding a few of these customizations, it became obvious I needed a way to ensure they all work correctly, which brings me to the main reason the SharingProvider exists: testing. The SharingProvider protocol makes it easy to test these decision points by injection. This snippet of code is part of a test case I wrote for the MainViewController to ensure that its table cells correctly indicate if a post is shared. I left off the scaffolding for creating the sample data, but the test crafts a mixed set of managed objects that it identifies as shared or not shared by the presence of their objectID in this set. Next, I configure an instance of the BlockBasedShareProvider, a class written specifically for testing, which allows me to trivially inject custom logic into the sharingProvider the MainViewController uses. Here I'm setting the isSharedBlock to call the contains method of the set I created. This is a cute trick in Swift that allows me to simply check if the provided objectID is in sharedObjectIDs. Then, I set the BlockBasedProvider as the provider for the view controller, completing the injection. Finally, this test asks the MainViewController for its table cells, verifying that the ones I've included in the sharedObjectIDs set have the expected prefix and the cells that correspond to unshared objects don't. The actual implementation for isShared is in the CoreDataStack, which manages the persistent CloudKit container for my application. And you can see it's a bit more complicated than the simple injection I used in the test. Now, I could go through this implementation line by line, but that's not what's important here. What's important is that it's way more complicated than I can easily exercise every time I want to verify a change to the table view, and trying to do so would add a lot of friction to the development process. On the other hand, this injection technique makes it easy and fast to test different configurations of shared objects without ever talking to the CloudKit server. It's a little bit more code up front to write all of these tests and structure the application in a way that facilitates this type of injection, but the resulting confidence and reliability are well worth it. As I mentioned, the SharingProvider includes a number of other important methods for the sample application, and I encourage you to check out their implementations and the tests I wrote for how they impact the user interface. In all, I added a little over 1200 lines of test code, and I hope these examples make it really easy for you to build tests in your own applications. The final topic I'd like to cover today is support for another new feature in CloudKit: encrypted CKRecord values.
These values are stored in a new payload on CKRecord called encryptedValues, introduced in the "What's New in CloudKit" session. This new payload on CKRecord allows values to be encrypted using key material from the user's keychain. These values are decrypted locally on device after they're downloaded from the CloudKit server, and they're encrypted locally on device before they are uploaded to the CloudKit server. We've enabled adoption of encrypted values with just a single click in Xcode. Let's see that in action.
Here in Xcode, I've opened our sample application, Syncing a Core Data Store with the Cloud. I'll open the CoreDataCloudKitDemo managed object model, and there's a specific property on the post entity I want to show you called location. If I select the location attribute, I can see its configuration in the data model inspector on the right. I've configured it to be an optional Transformable attribute and checked this new Allows Cloud Encryption checkbox. This new checkbox tells NSPersistentCloudKitContainer that the value for this attribute should be stored in the encryptedValues payload of the resulting CKRecord. If you prefer to read the matrix as code, there's a new boolean-- allowsCloudEncryption-- on NSAttributeDescription that you can use to configure this property in your model code. Now, encryption with CloudKit is an at-introduction time decision. That means we can't change our mind later and choose to encrypt fields that aren't already encrypted today in production. Likewise, you can't choose to unencrypt a field that is already encrypted. Once the CloudKit schema is pushed to production, we can't change any of the field types. So be sure to use NSPersistentCloudKitContainer's initializeSchema method to ensure that all your fields are present and correctly typed before deploying your schema to production.
It's been my pleasure to introduce just some of the changes we've made to NSPersistentCloudKitContainer to support sharing. There's a lot of new API to learn about. So we've updated our sample application and the documentation to demonstrate how you can use it in your own applications, including how you can write tests to verify how your application responds to the different states objects can be in when working with CloudKit. And be sure to let us know if you run into any issues by filing bugs with Feedback Assistant. As always, I can't wait to see what you build with NSPersistentCloudKitContainer. Stay active, close your rings, and have a great WWDC 2021. [music]
-
-
5:20 - Add shared store description
let privateStoreDescription = container.persistentStoreDescriptions.first! let storesURL = privateStoreDescription.url!.deletingLastPathComponent() privateStoreDescription.url = storesURL.appendingPathComponent("private.sqlite") privateStoreDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) privateStoreDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) let sharedStoreURL = storesURL.appendingPathComponent("shared.sqlite") let sharedStoreDescription = privateStoreDescription.copy() sharedStoreDescription.url = sharedStoreURL let containerIdentifier = privateStoreDescription.cloudKitContainerOptions!.containerIdentifier let sharedStoreOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: containerIdentifier) sharedStoreOptions.databaseScope = .shared sharedStoreDescription.cloudKitContainerOptions = sharedStoreOptions container.persistentStoreDescriptions.append(sharedStoreDescription)
-
6:00 - shareNoteAction, DetailViewController.swift
@IBAction func shareNoteAction(_ sender: Any) { guard let barButtonItem = sender as? UIBarButtonItem else { fatalError("Not a UI Bar Button item??") } guard let post = self.post else { fatalError("Can't share without a post") } let container = AppDelegate.sharedAppDelegate.coreDataStack.persistentContainer let cloudSharingController = UICloudSharingController { (controller, completion: @escaping (CKShare?, CKContainer?, Error?) -> Void) in container.share([post], to: nil) { objectIDs, share, container, error in if let actualShare = share { post.managedObjectContext?.performAndWait { actualShare[CKShare.SystemFieldKey.title] = post.title } } completion(share, container, error) } } cloudSharingController.delegate = self if let popover = cloudSharingController.popoverPresentationController { popover.barButtonItem = barButtonItem } present(cloudSharingController, animated: true) {} }
-
17:06 - SharingProvider
protocol SharingProvider { func isShared(object: NSManagedObject) -> Bool func isShared(objectID: NSManagedObjectID) -> Bool func participants(for object: NSManagedObject) -> [RenderableShareParticipant] func shares(matching objectIDs: [NSManagedObjectID]) throws -> [NSManagedObjectID: RenderableShare] func canEdit(object: NSManagedObject) -> Bool func canDelete(object: NSManagedObject) -> Bool }
-
17:58 - Decorate table cells for shared posts, MainViewController.swift
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard let cell = tableView.dequeueReusableCell(withIdentifier: "PostCell", for: indexPath) as? PostCell else { fatalError("###\(#function): Failed to dequeue a PostCell. Check the cell reusable identifier in Main.storyboard.") } let post = dataProvider.fetchedResultsController.object(at: indexPath) cell.title.text = post.title cell.post = post cell.collectionView.reloadData() cell.collectionView.invalidateIntrinsicContentSize() if let attachments = post.attachments, attachments.allObjects.isEmpty { cell.hasAttachmentLabel.isHidden = true } else { cell.hasAttachmentLabel.isHidden = false } if sharingProvider.isShared(object: post) { let attachment = NSTextAttachment(image: UIImage(systemName: "person.circle")!) let attributedString = NSMutableAttributedString(attachment: attachment) attributedString.append(NSAttributedString(string: " " + (post.title ?? ""))) cell.title.text = nil cell.title.attributedText = attributedString } return cell }
-
18:44 - Testing the MainViewController's table view, TestMainViewController.swift
func testSharedPostsGetDisclosure() { var sharedObjectIDs: Set<NSManagedObjectID> = Set() let context = coreDataStack.persistentContainer.viewContext self.generatePosts(in: context, postSaveBlock: { posts in for (index, post) in posts.enumerated() where (index % 4) == 0 { sharedObjectIDs.insert(post.objectID) } }) let provider = BlockBasedShareProvider(stack: coreDataStack) provider.isSharedBlock = sharedObjectIDs.contains mainViewController.sharingProvider = provider do { try mainViewController.dataProvider.fetchedResultsController.performFetch() } catch let error { XCTFail("Error while fetching \(error)") } reloadTableView() let rowCount = mainViewController.tableView(mainViewController.tableView, numberOfRowsInSection: 0) XCTAssertEqual(100, rowCount) guard let expectedSharedImage = UIImage(systemName: "person.circle") else { XCTFail("Failed to get the person system image.") return } for index in 0..<rowCount { let indexPath = IndexPath(row: index, section: 0) let post = mainViewController.dataProvider.fetchedResultsController.object(at: indexPath) guard let title = post.title else { XCTFail("All posts should have been given a title.") return } guard let cell = mainViewController.tableView(mainViewController.tableView, cellForRowAt: indexPath) as? PostCell else { XCTFail("Encountered an unexpected cell type in the main view controller's table view.") return } if sharedObjectIDs.contains(post.objectID) { guard let attributedText = cell.title.attributedText else { XCTFail("Failed to get the attributed text of \(cell). Was it not set?") return } guard let attachment = attributedText.attributes(at: 0, effectiveRange: nil)[.attachment] as? NSTextAttachment else { XCTFail("Expected an image attachment at the first character.") return } XCTAssertEqual(expectedSharedImage, attachment.image) } else { XCTAssertEqual(cell.title.text, title) } } } class BlockBasedShareProvider: SharingProvider { var coreDataStack: CoreDataStack init(stack: CoreDataStack) { coreDataStack = stack } func isShared(object: NSManagedObject) -> Bool { return isShared(objectID: object.objectID) } public var isSharedBlock: ((_ object: NSManagedObjectID) -> Bool)? = nil func isShared(objectID: NSManagedObjectID) -> Bool { guard let block = isSharedBlock else { return coreDataStack.isShared(objectID: objectID) } return block(objectID) } public var participantsBlock: ((_ object: NSManagedObject) -> [RenderableShareParticipant])? = nil func participants(for object: NSManagedObject) -> [RenderableShareParticipant] { guard let block = participantsBlock else { return coreDataStack.participants(for: object) } return block(object) } public var sharesBlock: ((_ objectIDs: [NSManagedObjectID]) -> [NSManagedObjectID: RenderableShare])? = nil func shares(matching objectIDs: [NSManagedObjectID]) throws -> [NSManagedObjectID: RenderableShare] { guard let block = sharesBlock else { return try coreDataStack.shares(matching: objectIDs) } return block(objectIDs) } public var canEditBlock: ((_ object: NSManagedObject) -> Bool)? = nil func canEdit(object: NSManagedObject) -> Bool { guard let block = canEditBlock else { return coreDataStack.canEdit(object: object) } return block(object) } public var canDeleteBlock: ((_ object: NSManagedObject) -> Bool)? = nil func canDelete(object: NSManagedObject) -> Bool { guard let block = canDeleteBlock else { return coreDataStack.canDelete(object: object) } return block(object) } }
-
20:01 - CoreDataStack + Sharing, CoreDataStack.swift
extension CoreDataStack: SharingProvider { func isShared(object: NSManagedObject) -> Bool { return isShared(objectID: object.objectID) } func isShared(objectID: NSManagedObjectID) -> Bool { var isShared = false if let persistentStore = objectID.persistentStore { if persistentStore == sharedPersistentStore { isShared = true } else { let container = persistentContainer do { let shares = try container.fetchShares(matching: [objectID]) if nil != shares.first { isShared = true } } catch let error { print("Failed to fetch share for \(objectID): \(error)") } } } return isShared } func participants(for object: NSManagedObject) -> [RenderableShareParticipant] { var participants = [CKShare.Participant]() do { let container = persistentContainer let shares = try container.fetchShares(matching: [object.objectID]) if let share = shares[object.objectID] { participants = share.participants } } catch let error { print("Failed to fetch share for \(object): \(error)") } return participants } func shares(matching objectIDs: [NSManagedObjectID]) throws -> [NSManagedObjectID: RenderableShare] { return try persistentContainer.fetchShares(matching: objectIDs) } func canEdit(object: NSManagedObject) -> Bool { return persistentContainer.canUpdateRecord(forManagedObjectWith: object.objectID) } func canDelete(object: NSManagedObject) -> Bool { return persistentContainer.canDeleteRecord(forManagedObjectWith: object.objectID) } }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.