- 原文地址:iOS File Provider Extension Tutorial
- 原文做者:Ryan Ackermann
- 译文出自:掘金翻译计划
- 本文永久连接:github.com/xitu/gold-m…
- 译者:iWeslie
- 校对者:swants
在本教程中,你将学习 File Provider 拓展以及如何使用它把你 App 的内容公开出来。html
File Provider 在 iOS 11 中引入,它经过 iOS 的 文件 App 来访问你 App 管理的内容。同时其余的 App 也可使用 UIDocumentBrowserViewController
或 UIDocumentPickerViewController
来访问你 App 的数据。前端
File Provider 拓展的主要任务是:android
你将使用 Heroku 按钮 来配置托管文件的服务器。在服务器设置完成后,你须要配置扩展来对服务器的内容进行枚举。ios
首先,请先 下载资源,完成后找到 Favart-Starter 文件夹并打开 Favart.xcodeproj。确保你已选择 Favart 的 scheme,而后编译并运行该 App,你会看到如下内容:git
该 App 提供了一个基础的 View 来告诉用户如何启用 File Provider 扩展,由于你实际上不会在 App 内执行任何操做。每次在本教程中编译运行 App 时,你都将返回主屏幕并打开 文件 这个 App 来访问你的扩展。github
注意:若是要在真机上运行该项目,除了为两个 target 设置开发者信息外,还须要在 Configuration 文件夹中编辑 Favart.xcconfig。将 Bundle ID 更新为惟一值。swift
示例项目将这个值用于两个 target 中 build setting 里的
PRODUCT_BUNDLE_IDENTIFIER
,Provider.entitlements 里的 App Groups 标识符,还有 Info.plist 中的NSExtensionFileProviderDocumentGroup
。在项目中若是没有同步更新它们,你将会获得模糊而且让人无法调试的编译报错信息,而使用自定义的 build settings 将会是一个聪明的方法。后端
示例项目中已经包含了你将用于 File Provider 扩展的基本组件:api
首先,你须要一个本身的后端服务器实例。幸运的是,使用 Heroku Button 将很容易完成这个操做。单击下面的按钮访问 Heroku 的 dashboard。xcode
在你注册完 Heroku 的免费帐号后,你将看到如下页面:
在此页面上,你能够给你的 App 取一个名字,也能够将该字段留空,Heroku 将为你自动生成一个名称。没必要配置其余东西,如今你能够点击 Deploy app 按钮,一下子以后你的后端就会启动并运行。
在 Heroku 完成部署 App 以后,单击底部的 View。这会跳转到你托管实例的后端 URL。在根目录下,你应该看到一条 JSON 数据,是你熟悉的 Hello world!。
最后,你须要复制 Heroku 实例的 URL,可是只须要其中的域名部分:{app-name}.herokuapp.com。
在 starter 项目中,打开 Provider/NetworkClient.swift。在文件的顶部,你应该会看到一条警告,告诉你 Add your Heroku URL here。删除这个警告并用你的 URL 替换 components.host
占位符字符串。
如今你就完成了服务器配置。接下来,你将定义 File Provider 所依赖的模型。
首先,File Provider 须要一个遵循了 NSFileProviderItem
协议的模型。此模型将提供有关 File Provider 所管理的文件的信息。starter 项目在 FileProviderItem.swift 中已经定义了 FileProviderItem
,在使用它以前须要遵循一些协议。
虽然该协议含有 27 个属性,但咱们只须要其中 4 个。其余一些可选属性为 File Provider 提供有关每一个文件的详细信息以及一些其余功能。在本教程中,你将用到以四个属性:itemIdentifier
、parentItemIdentifier
、filename
和 typeIdentifier
。
itemIdentifier
给模型提供了惟一标示符。File Provider 使用 parentIdentifier
来跟踪它在扩展的层次结构中的位置。
filename
是 文件 里显示的 App 名字。typeIdentifier
是一个 统一类型标识符(UTI)。
在 FileProviderItem
能够遵循 NSFileProviderItem
协议以前,它还须要一个处理来自后端数据的方法。MediaItem
定义了一个后端数据的简单模型。咱们并非直接在 FileProviderItem
中使用这个模型,而是使用 MediaItemReference
来处理 File Provider 扩展的一些额外逻辑从而把其中的坑填上。
你将在本教程中使用 MediaItemReference
有两个缘由:
NSFileProviderItem
所需的全部信息,所以你须要在其余地方获取它。为了将教程的重心放到 File Provider 扩展自己上,你将使用 MediaItemReference
来快速入门,你须要将四个必填字段嵌入到 URL
对象中。而后将该 URL 编码成 NSFilProviderItemIdentifier
。你不须要手动存储其余东西,由于 NSFileProviderExtension
会为你处理它。
打开 Provider/MediaItemReference.swift 并把如下代码添加到 MediaItemReference
里:
// 1
private let urlRepresentation: URL
// 2
private var isRoot: Bool {
return urlRepresentation.path == "/"
}
// 3
private init(urlRepresentation: URL) {
self.urlRepresentation = urlRepresentation
}
// 4
init(path: String, filename: String) {
let isDirectory = filename.components(separatedBy: ".").count == 1
let pathComponents = path.components(separatedBy: "/").filter { !$0.isEmpty } + [filename]
var absolutePath = "/" + pathComponents.joined(separator: "/")
if isDirectory {
absolutePath.append("/")
}
absolutePath = absolutePath.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? absolutePath
self.init(urlRepresentation: URL(string: "itemReference://\(absolutePath)")!)
}
复制代码
如下是代码的详解:
NSFileProviderItem
所需的大部分信息。在添加最终初的始化器以前,请把文件顶部的 import 语句替换成:
import FileProvider
复制代码
接下来在刚刚那段代码下面添加如下初始化器:
init?(itemIdentifier: NSFileProviderItemIdentifier) {
guard itemIdentifier != .rootContainer else {
self.init(urlRepresentation: URL(string: "itemReference:///")!)
return
}
guard let data = Data(base64Encoded: itemIdentifier.rawValue),
let url = URL(dataRepresentation: data, relativeTo: nil)
else {
return nil
}
self.init(urlRepresentation: url)
}
复制代码
大部分扩展都将使用此初始化器。注意开头的 itemReference://
。你能够单独处理根目录的标识符以确保能正确设置其 URL 的路径。
对于其余项,你能够将标识符的原始值转换为 base64 编码后的数据来检索 URL。URL 中的信息来自第一次对实例进行枚举的网络请求。
既然如今初始化器已经设置好了,是时候为这个模型添加一些属性了。首先,在文件顶部添加以下 import
:
import MobileCoreServices
复制代码
这将让你能够访问文件类型,在这个结构体里继续添加:
// 1
var itemIdentifier: NSFileProviderItemIdentifier {
if isRoot {
return .rootContainer
} else {
return NSFileProviderItemIdentifier(rawValue: urlRepresentation.dataRepresentation.base64EncodedString())
}
}
var isDirectory: Bool {
return urlRepresentation.hasDirectoryPath
}
var path: String {
return urlRepresentation.path
}
var containingDirectory: String {
return urlRepresentation.deletingLastPathComponent().path
}
var filename: String {
return urlRepresentation.lastPathComponent
}
// 2
var typeIdentifier: String {
guard !isDirectory else {
return kUTTypeFolder as String
}
let pathExtension = urlRepresentation.pathExtension
let unmanaged = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, pathExtension as CFString, nil)
let retained = unmanaged?.takeRetainedValue()
return (retained as String?) ?? ""
}
// 3
var parentReference: MediaItemReference? {
guard !isRoot else {
return nil
}
return MediaItemReference(urlRepresentation: urlRepresentation.deletingLastPathComponent())
}
复制代码
你须要知道记住如下几点:
itemIdentifier
必须是惟一的。若是是根目录,那么它使用 NSFileProviderItemIdentifier.rootContainer
,不然从 URL 建立一个标识符。UTTypeCreatePreferredIdentifierForTag
其实是一个返回给定输入的 UTI 类型的 C 函数。你在此处添加了一些其余属性,这些属性不须要太多解释,但在建立 NSFileProviderItem
时很是有用。如今参考模型已经建立完成了,是时候把全部东西与 FileProviderItem
进行挂钩了。
打开 FileProviderItem.swift 并在顶部添加:
import FileProvider
复制代码
而后在文件的最底部添加:
// MARK: - NSFileProviderItem
extension FileProviderItem: NSFileProviderItem {
// 1
var itemIdentifier: NSFileProviderItemIdentifier {
return reference.itemIdentifier
}
var parentItemIdentifier: NSFileProviderItemIdentifier {
return reference.parentReference?.itemIdentifier ?? itemIdentifier
}
var filename: String {
return reference.filename
}
var typeIdentifier: String {
return reference.typeIdentifier
}
// 2
var capabilities: NSFileProviderItemCapabilities {
if reference.isDirectory {
return [.allowsReading, .allowsContentEnumerating]
} else {
return [.allowsReading]
}
}
// 3
var documentSize: NSNumber? {
return nil
}
}
复制代码
FileProviderItem
如今已经遵循 NSFileProviderItem
并实现了全部必须的属性。以上代码的详解以下:
MediaItemReference
的逻辑。NSFileProviderItemCapabilities
表示能够对文档浏览器中的项目执行哪些操做,例如读取和删除。对于该 App,你只须要容许读取和枚举目录。在实际项目中,你可能会使用 .allowsAll
,由于用户但愿全部操做均可以进行。NSFileProviderManager.writePlaceholder(at:withMetadata:)
会崩溃。这多是框架的一个错误,可是通常状况下 App 的文件扩展不管如何都会提供 documentSize
。以上就是模型,NSFileProviderItem
还有更多其余属性,可是你目前实现的已经足够了。
如今模型已经完善好了,能够拿来使用了。你须要告诉系统你 App 里有什么内容才能向用户展现模型定义的 item。
NSFileProviderEnumerator
定义系统和 App 内容间的关系。你稍后将看到系统是如何经过提供表示当前上下文的 NSFileProviderItemIdentifier
从而请求枚举器的。若是用户当前在根目录下,系统将会提供 .rootContainer
标识符。在其余目录下时,系统则会传入你模型定义的项目的标识符。
首先,在 starter 里构建枚举器。打开 Provider/FileProviderEnumerator.swift 并在 path
下添加:
private var currentTask: URLSessionTask?
复制代码
此属性将存储对当前网络请求任务的引用。这提可让你随时取消请求。
接下来把 enumerateItems(for:startingAt:)
里的内容替换成:
let task = NetworkClient.shared.getMediaItems(atPath: path) { results, error in
guard let results = results else {
let error = error ?? FileProviderError.noContentFromServer
observer.finishEnumeratingWithError(error)
return
}
let items = results.map { mediaItem -> FileProviderItem in
let ref = MediaItemReference(path: self.path, filename: mediaItem.name)
return FileProviderItem(reference: ref)
}
observer.didEnumerate(items)
observer.finishEnumerating(upTo: nil)
}
currentTask = task
复制代码
这里实现了 NetworkClient 单例获取指定路径的内容。请求成功后,枚举器的观察者经过调用 didEnumerate
和 finishEnumerating(upTo:)
来返回新的数据。经过 finishEnumeratingWithError
来通知枚举器的观察者请求到的结果是否有错误。
注意:实际的 App 可能使用分页来获取数据,这就会用到
NSFileProviderPage
来执行此操做。首先 App 将使用整数做为页面索引,而后将其序列化并存储在NSFileProviderPage
结构体中。
最后你讲把下面的内容添加到 invalidate()
来完成这个枚举器:
currentTask?.cancel()
currentTask = nil
复制代码
若是有须要,那就会取消当前的网络请求,由于有些状况下可能须要访问用户的网络状态或者当前的位置,也多是一些一些资源的使用状况。
完成该方法后,你就可使用此枚举器访问后端服务器的数据,接下来就会进入 FileProviderExtension
类。
打开 Provider/FileProviderExtension.swift 并把 item(for:)
的代码替换成:
guard let reference = MediaItemReference(itemIdentifier: identifier) else {
throw NSError.fileProviderErrorForNonExistentItem(withIdentifier: identifier)
}
return FileProviderItem(reference: reference)
复制代码
系统会提供 identifier 参数,而且你须要给那个 identifier 返回一个 FileProviderItem
。这个 guard 语句确保了建立的 MediaItemReference
是有效的。
接下来,把 urlForItem(withPersistentIdentifier:)
和 persistentIdentifierForItem(at:)
替换成如下内容:
// 1
override func urlForItem(withPersistentIdentifier identifier: NSFileProviderItemIdentifier) -> URL? {
guard let item = try? item(for: identifier) else {
return nil
}
return NSFileProviderManager.default.documentStorageURL
.appendingPathComponent(identifier.rawValue, isDirectory: true)
.appendingPathComponent(item.filename)
}
// 2
override func persistentIdentifierForItem(at url: URL) -> NSFileProviderItemIdentifier? {
let identifier = url.deletingLastPathComponent().lastPathComponent
return NSFileProviderItemIdentifier(identifier)
}
复制代码
如下是代码详解:
urlForItem(withPersistentIdentifier:)
返回的每一个 URL 都须要映射回最初设置的 NSFileProviderItemIdentifier
。在该方法中,你要以 <documentStorageURL>/<itemIdentifier>/<filename>
的格式构建 URL 并采用 <itemIdentifier>
做为标识符。如今有两个方法都须要你传入一个指向远端文件的占位符 URL 。首先你将建立一个帮助辅助方法来完成这个功能,将如下内容添加到 providePlaceholder(at:)
:
// 1
guard let identifier = persistentIdentifierForItem(at: url),
let reference = MediaItemReference(itemIdentifier: identifier)
else {
throw FileProviderError.unableToFindMetadataForPlaceholder
}
// 2
try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
// 3
let placeholderURL = NSFileProviderManager.placeholderURL(for: url)
let item = FileProviderItem(reference: reference)
// 4
try NSFileProviderManager.writePlaceholder(at: placeholderURL, withMetadata: item)
复制代码
以上代码完成的功能以下:
NSFileManager
来执行此操做。url
参数是用来显示图像的,而不是占位符。所以你要使用 placeholderURL(for:)
来建立占位符 URL,并获取此占位符将表示的 NSFileProviderItem
。接下来把 providePlaceholder(at:completionHandler:)
的内容替换成:
do {
try providePlaceholder(at: url)
completionHandler(nil)
} catch {
completionHandler(error)
}
复制代码
当 File Provider 须要一个占位符 URL 时,它将调用 providePlaceholder(at:completionHandler:)
。你将尝试使用上面的辅助方法建立占位符,若是抛出错误,则将其传递给 completionHandler
。就像在 providePlaceholder(at:)
中同样,这个步骤成功以后就不须要传递任何内容,File Provider 只须要你的占位符 URL。
当用户在目录下切换时,File Provider 将调用 enumerator(for:)
来请求给定 identifier 的 FileProviderEnumerator
。用如下内容替换该法的内容:
if containerItemIdentifier == .rootContainer {
return FileProviderEnumerator(path: "/")
}
guard let ref = MediaItemReference(itemIdentifier: containerItemIdentifier), ref.isDirectory
else {
throw FileProviderError.notAContainer
}
return FileProviderEnumerator(path: ref.path)
复制代码
此方法确保了给定 identifier 对应的是一个目录。若是是根目录,则仍然建立枚举器,由于根目录也是有效目录。
编译并运行,App 启动后,打开 文件 App,点击两次右下角的 浏览,你就会进入 文件 的根目录。选择 更多位置,会出现 提供者 或展开一个列表,点击开启你 App 的拓展。
注意:若是找不到 更多位置 展开的项目不能点击,你能够再点击一下右上角的 编辑 按钮。
你如今有一个有效的 File Provider 扩展了,可是还缺乏一些重要的东西,接下来你将添加它们。
由于 App 会显示后端请求来的图片,所以显示出图像的缩略图很是重要,你能够重写一个方法来生成缩略图。
在 enumerator(for:)
下面添加:
// MARK: - Thumbnails
override func fetchThumbnails(for itemIdentifiers: [NSFileProviderItemIdentifier], requestedSize size: CGSize, perThumbnailCompletionHandler: @escaping (NSFileProviderItemIdentifier, Data?, Error?) -> Void,
completionHandler: @escaping (Error?) -> Void) -> Progress {
// 1
let progress = Progress(totalUnitCount: Int64(itemIdentifiers.count))
for itemIdentifier in itemIdentifiers {
// 2
let itemCompletion: (Data?, Error?) -> Void = { data, error in
perThumbnailCompletionHandler(itemIdentifier, data, error)
if progress.isFinished {
DispatchQueue.main.async {
completionHandler(nil)
}
}
}
guard let reference = MediaItemReference(itemIdentifier: itemIdentifier), !reference.isDirectory
else {
progress.completedUnitCount += 1
let error = NSError.fileProviderErrorForNonExistentItem(withIdentifier: itemIdentifier)
itemCompletion(nil, error)
continue
}
let name = reference.filename
let path = reference.containingDirectory
// 3
let task = NetworkClient.shared.downloadMediaItem(named: name, at: path) { url, error in
guard let url = url, let data = try? Data(contentsOf: url, options: .alwaysMapped) else {
itemCompletion(nil, error)
return
}
itemCompletion(data, nil)
}
// 4
progress.addChild(task.progress, withPendingUnitCount: 1)
}
return progress
}
复制代码
虽然这种方法很是冗长,但其逻辑很简单:
Progress
对象,该对象会记录每一个缩略图请求的状态。itemIdentifier
定义了一个 completion 闭包,该闭包将负责调用此方法所需的每一个项的闭包以及最后调用最后一个闭包。NetworkClient
将缩略图文件从服务器下载到临时文件。在下载完成后,completion handler 将下载的 data
传递给 itemCompletion
闭包。注意:在处理较大的数据时,为每一个占位符都发出单独的网络请求可能须要耗费一些时间。所以若是可能的话,你的后端应提供单个请求中的批量下载图像方法。
编译并运行。打开 文件 里的拓展就能看到你的缩略图了:
如今当你选择一个项目时,该 App 将会显示一个没有完整图像的空白视图:
到目前为止,你只实现了预览缩略图的显示,还须要添加完整图片的显示。
与缩略图生成同样,让完整的图片显示只须要一个方法,即 startProvidingItem(at:completionHandler:)
。将如下内容添加到 FileProviderExtension
类的底部:
// MARK: - Providing Items
override func startProvidingItem(at url: URL, completionHandler: @escaping ((_ error: Error?) -> Void)) {
// 1
guard !fileManager.fileExists(atPath: url.path) else {
completionHandler(nil)
return
}
// 2
guard let identifier = persistentIdentifierForItem(at: url), let reference = MediaItemReference(itemIdentifier: identifier) else {
completionHandler(FileProviderError.unableToFindMetadataForItem)
return
}
// 3
let name = reference.filename
let path = reference.containingDirectory
NetworkClient.shared.downloadMediaItem(named: name, at: path, isPreview: false) { fileURL, error in
// 4
guard let fileURL = fileURL else {
completionHandler(error)
return
}
// 5
do {
try self.fileManager.moveItem(at: fileURL, to: url)
completionHandler(nil)
} catch {
completionHandler(error)
}
}
}
复制代码
以上的代码功能是:
URL
的 MediaItemReference
来确认须要从后端请求哪一个文件。编译并运行,打开扩展后选择任何一张图,你能够看到完整的图片。
当你打开更多文件时,该扩展程序须要删除已经下载了的文件,File Provider 扩展内置了这个功能。
你必须重写 stopProvidingItem(at:)
,这样才能清理下载了的文件并提供新的占位符。在 FileProviderExtension
类的底部添加如下内容:
override func stopProvidingItem(at url: URL) {
try? fileManager.removeItem(at: url)
try? providePlaceholder(at: url)
}
复制代码
这样就能删除图片,并调用 providePlaceholder(at:)
来生成一个新的占位符。
以上就完成了 File Provider 的最基本功能。文件枚举,缩略图预览以及查看文件内容是此扩展的基本组件。
到如今为止,你的 File Provider 的功能就齐全了。
你如今已经拥有了一个包含了有效的 File Provider 的 App,这个扩展程序能够枚举以及显示后端服务器的东西。
你能够点击 下载资源 来下载完整版的项目。
你能够在 Apple 关于 File Provider 的文档 中了解更多有关 File Provider 的操做。你还可使用其余扩展程序将自定义 UI 添加到 File Provider,你能够从 这里 能够阅读到更多相关信息。
若是你对其余在 iOS 上使用文件的操做感兴趣,你能够查看 的更多方式感兴趣,请查看 基于文档的 App。
但愿你喜欢这个教程!若是你有任何问题或意见,能够加入 原文 最下面的讨论组。
若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。