iCloud Drive, Document-Based App

发布
更新
字数 1518
阅读 8 分钟
阅读量 2598

在 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)
}

参考