[译] iOS 中的 File Provider 拓展

在本教程中,你将学习 File Provider 拓展以及如何使用它把你 App 的内容公开出来。html

File Provider 在 iOS 11 中引入,它经过 iOS 的 文件 App 来访问你 App 管理的内容。同时其余的 App 也可使用 UIDocumentBrowserViewControllerUIDocumentPickerViewController 来访问你 App 的数据。前端

File Provider 拓展的主要任务是:android

  • 建立表示云端内容的占位文件。
  • 当有 App 访问文件内容时先对文件进行下载或上传。
  • 在更新文件后发出通知来把更新上传到服务器。
  • 枚举存储的文件和目录。
  • 对文档执行操做,例如重命名、移动或删除。

你将使用 Heroku 按钮 来配置托管文件的服务器。在服务器设置完成后,你须要配置扩展来对服务器的内容进行枚举。ios

着手开始

首先,请先 下载资源,完成后找到 Favart-Starter 文件夹并打开 Favart.xcodeproj。确保你已选择 Favart 的 scheme,而后编译并运行该 App,你会看到如下内容:git

The container app for your File Provider.

该 App 提供了一个基础的 View 来告诉用户如何启用 File Provider 扩展,由于你实际上不会在 App 内执行任何操做。每次在本教程中编译运行 App 时,你都将返回主屏幕并打开 文件 这个 App 来访问你的扩展。github

注意:若是要在真机上运行该项目,除了为两个 target 设置开发者信息外,还须要在 Configuration 文件夹中编辑 Favart.xcconfig。将 Bundle ID 更新为惟一值。swift

示例项目将这个值用于两个 target 中 build setting 里的 PRODUCT_BUNDLE_IDENTIFIERProvider.entitlements 里的 App Groups 标识符,还有 Info.plist 中的 NSExtensionFileProviderDocumentGroup。在项目中若是没有同步更新它们,你将会获得模糊而且让人无法调试的编译报错信息,而使用自定义的 build settings 将会是一个聪明的方法。后端

示例项目中已经包含了你将用于 File Provider 扩展的基本组件:api

  • NetworkClient.swift 包含用于与 Heroku 服务器通讯的网络请求客户端。
  • FileProviderExtension.swift 就是 File Provider 拓展自己。
  • FileProviderEnumerator.swift 包含了枚举器,用于枚举目录的内容。
  • Models 是一组用来完成扩展所需的模型。

使用 Heroku 设置后端

首先,你须要一个本身的后端服务器实例。幸运的是,使用 Heroku Button 将很容易完成这个操做。单击下面的按钮访问 Heroku 的 dashboard。xcode

Deploy

在你注册完 Heroku 的免费帐号后,你将看到如下页面:

Deploy to Heroku

在此页面上,你能够给你的 App 取一个名字,也能够将该字段留空,Heroku 将为你自动生成一个名称。没必要配置其余东西,如今你能够点击 Deploy app 按钮,一下子以后你的后端就会启动并运行。

Deploy successful

在 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 所依赖的模型。

定义 NSFileProviderItem

首先,File Provider 须要一个遵循了 NSFileProviderItem 协议的模型。此模型将提供有关 File Provider 所管理的文件的信息。starter 项目在 FileProviderItem.swift 中已经定义了 FileProviderItem,在使用它以前须要遵循一些协议。

虽然该协议含有 27 个属性,但咱们只须要其中 4 个。其余一些可选属性为 File Provider 提供有关每一个文件的详细信息以及一些其余功能。在本教程中,你将用到以四个属性:itemIdentifierparentItemIdentifierfilenametypeIdentifier

itemIdentifier 给模型提供了惟一标示符。File Provider 使用 parentIdentifier 来跟踪它在扩展的层次结构中的位置。

filename文件 里显示的 App 名字。typeIdentifier 是一个 统一类型标识符(UTI)

FileProviderItem 能够遵循 NSFileProviderItem 协议以前,它还须要一个处理来自后端数据的方法。MediaItem 定义了一个后端数据的简单模型。咱们并非直接在 FileProviderItem 中使用这个模型,而是使用 MediaItemReference 来处理 File Provider 扩展的一些额外逻辑从而把其中的坑填上。

你将在本教程中使用 MediaItemReference 有两个缘由:

  1. 在 Heroku 上托管的后端很是简洁,它没法提供 NSFileProviderItem 所需的全部信息,所以你须要在其余地方获取它。
  2. 这个 File Provider 扩展也很简单,更完整的 File Provider 扩展须要使用诸如 Core Data 之类的东西在本地持久化存储后端返回的数据,让它能在该扩展的生命周期结束后引用它。

为了将教程的重心放到 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)")!)
}
复制代码

如下是代码的详解:

  1. 在本教程中,URL 将包含 NSFileProviderItem 所需的大部分信息。
  2. 此计算属性判断当前项是不是文件系统的根目录。
  3. 你将此初始化方法设为私有以防止从模型外部调用。
  4. 从后端读取数据时,你将调用此初始化方法。若是文件名不包含文件后缀,则它必定是个文件夹,由于初始化方法并不能自动推断其类型。

在添加最终初的始化器以前,请把文件顶部的 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())
}
复制代码

你须要知道记住如下几点:

  1. 对于 FileProvider 管理的每一项,itemIdentifier 必须是惟一的。若是是根目录,那么它使用 NSFileProviderItemIdentifier.rootContainer,不然从 URL 建立一个标识符。
  2. 这里它根据拓展路径的 URL 建立一个标识符,看上去很奇怪的 UTTypeCreatePreferredIdentifierForTag 其实是一个返回给定输入的 UTI 类型的 C 函数。
  3. 在处理目录型结构时,对于父级的引用很是有用。这个属性表示了包含当前引用的文件夹。它是一个可选类型,由于根目录是没有父级的。

你在此处添加了一些其余属性,这些属性不须要太多解释,但在建立 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 并实现了全部必须的属性。以上代码的详解以下:

  1. 大多数必须的属性映射了你以前添加到 MediaItemReference 的逻辑。
  2. NSFileProviderItemCapabilities 表示能够对文档浏览器中的项目执行哪些操做,例如读取和删除。对于该 App,你只须要容许读取和枚举目录。在实际项目中,你可能会使用 .allowsAll,由于用户但愿全部操做均可以进行。
  3. 本教程不会用到文档的大小,把它包含在里面以防止 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 单例获取指定路径的内容。请求成功后,枚举器的观察者经过调用 didEnumeratefinishEnumerating(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)
}
复制代码

如下是代码详解:

  1. 验证一下来确保给定的 identifier 能解析为扩展模型的实例。而后返回一个文件 URL,它是将项目存储在文件管理器里的位置。
  2. 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)
复制代码

以上代码完成的功能以下:

  1. 首先你从提供的 URL 建立 identifier 和 reference。若是失败了则抛出错误。
  2. 建立占位符时,必须确保这个目录是存在的,不然就会遇到问题,使用 NSFileManager 来执行此操做。
  3. 这个 url 参数是用来显示图像的,而不是占位符。所以你要使用 placeholderURL(for:) 来建立占位符 URL,并获取此占位符将表示的 NSFileProviderItem
  4. 将占位符写入文件系统。

接下来把 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 的拓展。

注意:若是找不到 更多位置 展开的项目不能点击,你能够再点击一下右上角的 编辑 按钮。

First look at the extension.

你如今有一个有效的 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
}
复制代码

虽然这种方法很是冗长,但其逻辑很简单:

  1. 此方法返回一个 Progress 对象,该对象会记录每一个缩略图请求的状态。
  2. 它为每一个 itemIdentifier 定义了一个 completion 闭包,该闭包将负责调用此方法所需的每一个项的闭包以及最后调用最后一个闭包。
  3. 使用 starter 项目附带的 NetworkClient 将缩略图文件从服务器下载到临时文件。在下载完成后,completion handler 将下载的 data 传递给 itemCompletion 闭包。
  4. 每一个下载任务都做为依赖项添加到父进程对象。

注意:在处理较大的数据时,为每一个占位符都发出单独的网络请求可能须要耗费一些时间。所以若是可能的话,你的后端应提供单个请求中的批量下载图像方法。

编译并运行。打开 文件 里的拓展就能看到你的缩略图了:

The thumbnails are now working.

显示完整图片

如今当你选择一个项目时,该 App 将会显示一个没有完整图像的空白视图:

No content.

到目前为止,你只实现了预览缩略图的显示,还须要添加完整图片的显示。

与缩略图生成同样,让完整的图片显示只须要一个方法,即 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)
        }
    }
}
复制代码

以上的代码功能是:

  1. 检查指定 URL 中是否已存在某项,防止再次请求相同的数据。在实际项目中,你应该检查修改日期和文件版本号,确保你得到的是最新数据。可是,在本教程中没有必要这样作,由于它并不支持版本控制。
  2. 获取相关 URLMediaItemReference 来确认须要从后端请求哪一个文件。
  3. 从 reference 中提取文件名称和路径,而后进行请求。
  4. 若是下载文件时出错,则把错误传给错误处理闭包。
  5. 将文件从其临时下载目录移动到扩展名指定的文档存储 URL。

编译并运行,打开扩展后选择任何一张图,你能够看到完整的图片。

A full image is loaded.

当你打开更多文件时,该扩展程序须要删除已经下载了的文件,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 连接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索