iCloud Drive, Document-Based App
在 iOS 应用中,使用 CloudKit 存储数据十分方便。当开发者需要存储一些用户文档时,例如照片、文本文件,或自定义的文档类型,可以使用 iCloud Drive 作为文件存储系统。相对于存储在应用的 Documents
目录,iCloud Drive 具有自动同步功能。当使用 iCloud Drive 构建 Document-Based 的应用,需要应用支持 CloudKit 并设置 Info.plist
指定存储位置等;使用 bookmark
获取文件真实路径,通过 NSMetadataItem
查询文件;构建自己的 UIDcoument
之类实现读写操作等。
bookmark
一般情况下,可以通过文件路径访问文件,但 Document-Based App 会开放文件管理权限给用户,即用户可以随意移动、重命名文件,应该使用 url.bookmarkData()
获取文件书签,当文件被重命名、移动时也可以获取文件路径。
let bookmark = try url.bookmarkData(options: .suitableForBookmarkFile, includingResourceValuesForKeys: nil, relativeTo: nil)
如果文件还没有被下载到本地,
url.bookmarkData()
返回为nil
,报错文件不存在。
通过 bookmark
转 URL
var isStale = false
let bookmarkUrl = try URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &isStale)
print(isStale, bookmarkUrl)
NSMetadataItem
使用 NSMetadataQuery
在指定位置(如 iCloud Drive)查询文件
class Fetcher {
// ...
// 1.
fileprivate lazy var metadataQuery = NSMetadataQuery()
fileprivate lazy var workerQueue: OperationQueue = {
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 1
return queue
}()
fileprivate func queryImage() {
guard let originalPath = originalPath else { return }
guard let url = URL(string: originalPath) else { return }
print("project \(project?.name ?? "--")", "query original image like \(url.lastPathComponent) at \(originalPath)")
// 2.
metadataQuery.predicate = NSPredicate(format: "%K like %@", NSMetadataItemFSNameKey, url.lastPathComponent)
// 3.
metadataQuery.searchScopes = [
NSMetadataQueryUbiquitousDocumentsScope, // iCloud container (iCloud Drive)
NSMetadataQueryAccessibleUbiquitousExternalDocumentsScope
]
// 4.
NotificationCenter.default.addObserver(self, selector: #selector(finishGathering(_:)), name: .NSMetadataQueryDidFinishGathering, object: metadataQuery)
// 5.
metadataQuery.operationQueue = workerQueue
// 6.
metadataQuery.start()
}
@objc
fileprivate func finishGathering(_ notification: Notification) {
let results = (metadataQuery.results as! [NSMetadataItem]).map { (item: NSMetadataItem) -> URL in
print("downloading status", item.value(forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) ?? "nil")
return item.value(forAttribute: NSMetadataItemURLKey) as! URL
}
guard let url = results.first else {
print("query: no result")
return
}
print("\(project?.name ?? "--") quered url", url, FileManager.default.fileExists(atPath: url.path) ? "exists" : "need downloading")
print(FileManager.default.isReadableFile(atPath: url.path) ? "readable" : "can not read")
do {
let bookmark = try url.bookmarkData()
print(bookmark)
} catch {
print(error, "for queried url")
}
}
// ...
}
如果有结果,可以通过
item.value(forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey)
判断状态;也可以使用FileManger
的fileExists(atPath:)
或isReadableFile(atPath:)
判断是否已经下载到本地。
常用文件位置
public extension URL {
static var ubiquityDocuments: URL? {
return FileManager.default.url(forUbiquityContainerIdentifier: nil)?.appendingPathComponent("Documents")
}
static var documents: URL {
// return try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
let documentsDirectory = paths[0]
return documentsDirectory
}
static var temporary: URL {
return URL(fileURLWithPath:NSTemporaryDirectory(), isDirectory: true)
}
}
使用系统「文件」App
设置 info.plist
<key>NSUbiquitousContainers</key>
<dict>
<key>iCloud.YOUR_BUNDLE_IDENTIFIER</key>
<dict>
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
<true/>
<key>NSUbiquitousContainerSupportedFolderLevels</key>
<string>Any</string>
<key>NSUbiquitousContainerName</key>
<string>App Name</string>
</dict>
</dict>
之后可以通过 URL.ubiquityDocuments
访问「文件」应用中的文档目录,使用 FileManager
实例管理目录。文档操作请参考下文。
开发实践
iCloud Drive 可用性
iCloud Drive 仅在 iOS 和 macOS 平台可用,当需要检测可用性时可以使用:
FileManager.default.ubiquityIdentityToken != nil
就像之前定义的,也可以通过以下方法获取 iCloud Documents 路径:
guard let iCloudDocuments = URL.uniquityDocuments else { return }
// works
print(iCloudDocuments)
iCloud Drive 在 tvOS 及 watchOS,包括 WatchKit extension 上不可用,此外 Key-Value Store 在 watchOS 上也不可用,此时可以使用 CloudKit 检测 iCloud 可用性,即调用 CKContainer 的
accountStatus(completionHandler:)
实现 UIDocument
子类
通常应用存储的文件类型的解析和存储由我们自己实现 UIDocument
的子类来很好的完成。
func load(fromContents contents: Any, ofType typeName: String?) throws
在读取 iCloud 存储上的文件时被调用,读取到的数据会通过 contents
传入,进而自主实现解析逻辑。
override func contents(forType typeName: String) throws -> Any
是在写入 iCloud 存储时使用,用于把我们要存储的文档编译成 Data
。
class PhotoDocument: UIDocument {
private(set) var image: UIImage?
private(set) var data: Data?
override func load(fromContents contents: Any, ofType typeName: String?) throws {
guard let contents = contents as? Data else {
throw PhotoDocumentError.invalidInputToRead
}
data = contents
image = UIImage(data: contents)
}
override func contents(forType typeName: String) throws -> Any {
if let image = image {
data = image.jpegData(compressionQuality: 1)
}
if let data = data { return data }
throw PhotoDocumentError.noContentToSave
}
}
enum PhotoDocumentError: Error {
case invalidInputToRead
case noContentToSave
}
新建 iCloud 文档
let photoURL = URL.ubiquityDocuments.appendingPathComponent('temp.jpg')!
let photoDocument = PhotoDocument(fileURL: photoURL)
photoDocument.saveToURL(photoDocument.fileURL, forSaveOperation: .creating) {
success in
if (success) {
print("created")
}
}
以上方法创建了一个新文档,也可以用 Data
的 saveToURL
来创建一个 iCloud 文档
保存 iCloud 文档
只需调用 UIDocument
子类的 saveToURL(url, saveOperation, completionHandler)
方法即可。
url
需要在ubiquity documents
目录下,如果已经UIDocument
子类的实例,可以通过document.fileURL
获取;saveOperation
可选creating
或overwriting
打开 iCloud 文档
photoDocument.open {
success in
if (success) {
DispatchQueue.main.async {
imageView.image = photoDocument.image
}
}
}
例如为 KingFisher 实现一个 Provider
struct CloudPhotoProvider: ImageDataProvider {
var cacheKey: String { return url.path }
let url: String
init(url: URL) {
self.url = url
}
func data(handler: @escaping (Result<Data, Error>) -> Void) {
do {
// 先尝试打开本地文件
let data = try Data(contentsOf: url)
handler(.success(data))
} catch {
// 否则尝试打开 iCloud 文档
let document = PhotoDocument(fileURL: url)
document.open {
(success: Bool) in
if success {
handler(.success(document.data))
return
}
// 此处应该有更好的错误处理逻辑
handler(.failure(error))
}
}
}
}
查找 iCloud 文档
上文已经提到过,使用 NSMetadataQuery
查找 iCloud 文档
let metadataQuery = NSMetadataQuery()
let filePattern = "*.jpg"
metadataQuery.predicate = NSPredicate(format: "%K LIKE %@", NSMetadataItemFSNameKey, filePattern)
// metadataQuery.operationQueue = workerQueue
/*
Ask for both in-container documents and external documents so that
the user gets to interact with all the documents she or he has ever
opened in the application, without having to pull the document picker
again and again.
*/
metadataQuery.searchScopes = [
NSMetadataQueryUbiquitousDocumentsScope,
NSMetadataQueryAccessibleUbiquitousExternalDocumentsScope
]
NotificationCenter.default.addObserver(self, selector: #selector(finishGathering(_:)), name: .NSMetadataQueryDidFinishGathering, object: metadataQuery)
NotificationCenter.default.addObserver(self, selector: #selector(queryUpdated(_:)), name: .NSMetadataQueryDidUpdate, object: metadataQuery)
metadataQuery.start()
通过 Notification 信息中的 NSMetadataItem
处理查找结果
@objc
func queryUpdated(_ notification: Notification) {
// let changedMetadataItems = notification.userInfo?[NSMetadataQueryUpdateChangedItemsKey] as? [NSMetadataItem]
// let removedMetadataItems = notification.userInfo?[NSMetadataQueryUpdateRemovedItemsKey] as? [NSMetadataItem]
// let addedMetadataItems = notification.userInfo?[NSMetadataQueryUpdateAddedItemsKey] as? [NSMetadataItem]
// Handle the metadata items, update UI if needed
}
@objc
func finishGathering(_ notification: Notification) {
metadataQuery.disableUpdates()
let metadataQueryResults = metadataQuery.results as! [NSMetadataItem]
metadataQuery.enableUpdates()
// Handle the metadata items, update UI if needed
}
得到查询结果 [NSMetadataItem]
后,可以通过 value(forAttribute: String)
获取信息,例如
items.forEach { (item) in
let displayName = item.value(forAttribute: NSMetadataItemDisplayNameKey)
let url = item.value(forAttribute: NSMetadataItemURLKey)
}
参考
- https://stacktips.com/tutorials/ios/getting-started-with-icloud-storage-ios-tutorial
- https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/AccessingFilesandDirectories/AccessingFilesandDirectories.html#//apple_ref/doc/uid/TP40010672-CH3-SW10
- https://developer.apple.com/library/archive/qa/qa1935/_index.html
- WWDC 2015: Building Document Based Apps https://developer.apple.com/videos/play/wwdc2015/234/
- WWDC 2017: Building Greate Document-based Apps in iOS 11 https://developer.apple.com/videos/play/wwdc2017/229/
- WWDC 2018: Managing Documents in Your iOS Apps https://developer.apple.com/videos/play/wwdc2018/216
- WWDC 2020: Building Document-based Apps in SwiftUI https://developer.apple.com/videos/play/wwdc2020/10039/
- ShapeEdit: Building a Simple iCloud Document App https://developer.apple.com/library/archive/samplecode/ShapeEdit/Introduction/Intro.html#//apple_ref/doc/uid/TP40016100-Intro-DontLinkElementID_2
- 使用 NSFileCoodinator 对文件进行操作https://developer.apple.com/documentation/foundation/nsfilecoordinator/1412420-prepare