做下载工具的时候,我一度以为文件浏览器就是“拿一个 UITableView 把目录列出来”。直到自己从零实现,才发现它背后牵扯的是 Sandbox 目录策略、FileManager 的元数据读取效率、Document Picker 跨应用文件流转这一整套东西。用户导入的文件、下载器落盘的文件、App 生成的临时文件全混在一起,光是把“哪些文件该让用户看到”理清楚,就花了我不少力气。这篇文章就是把从零设计 iOS 文件浏览器时踩过的问题集中梳理一遍,核心围绕三个主角:Sandbox(文件架构的边界)、FileManager(目录遍历与文件操作)、Document Picker(对外导入导出),适合正在做工具类 App、笔记类 App,或者任何需要让用户管理附件的项目参考。
1. 先想清楚:文件浏览器到底要解决什么问题
1.1 它不只是“列目录 + 表格”
很多教程把文件浏览器讲成“读一个目录 -> 显示列表 -> 点进去再读子目录”,这种理解没错,但做成产品就会发现少了三块:文件放在哪、文件怎么操作、怎么让文件进出沙盒。
- 存储闭环:所有文件落在哪里,哪些需要备份,哪些是临时产物。
- 展示闭环:目录怎么读,元数据怎么取,排序规则怎么定。
- 流转闭环:用户怎么从“文件”App 导入文件,怎么把 App 里的文件导出给别的工具。
缺了第一块,文件会越攒越乱。缺了第二块,用户没法整理。缺了第三块,用户用几次就会放弃。所以设计文件浏览器,第一步不是写 UI,而是先把这三条链路定下来。
1.2 常见误区:把整个 Documents 暴露给用户
我见过好几个项目直接拿FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)当 rootURL,然后contentsOfDirectory的结果直接展示。短期看没什么问题,长期必然乱:App 自身的配置、数据库、临时缓存,全都有意无意散落在 Documents 里,用户看到一堆乱码目录名,体验很糟糕。
我的做法是给文件浏览器一个明确的根目录 rootURL,比如Documents/UserFiles,用户能看到的只有这个目录及其子目录。App 私有数据放到 Application Support,缓存放 Caches,临时文件放 tmp。文件架构从源头分层,界面层永远只需要面对一个干净的根。
2. 沙盒目录:文件架构的地基不能拍脑袋
2.1 沙盒里到底有哪些目录
iOS 给每个 App 划了一块独立空间,这就是 Sandbox。App 只能访问自己的这块空间,以及用户通过系统选中的外部文件。沙盒内部也不是平铺的,系统预定义了几个目录,用途不同,备份策略也不同。
| 目录 | 用途 | 是否参与 iCloud 备份 | 我的使用建议 |
|---|---|---|---|
| Documents | 用户可见数据、需要持久保留的文件 | 是 | 只放用户文件,并统一放一个子目录 |
| Library/Application Support | App 核心数据、数据库、配置 | 是 | 放 App 私有但必须持久化的数据 |
| Library/Caches | 可重建的缓存、临时缩略图 | 否 | 放下载过程中的临时产物 |
| Library/Preferences | NSUserDefaults 存储 | 是 | 一般不需要手动操作 |
| tmp | 临时文件 | 否 | 放短生命周期文件,用完即删 |
这个表格里的重点是:Documents 参与备份,Caches 和 tmp 不参与备份。如果你把几个 G 的下载文件直接丢进 Caches,系统在磁盘紧张时会直接清掉,用户会跟你拼命。如果全部塞进 Documents,又会撑爆 iCloud 备份,而且审核时容易被拒。
2.2 我的目录布局
我最终在实际项目里是按下面这个结构组织的:
Documents/ UserFiles/ // 用户可见文件,文件浏览器的 rootURL Download/ Import/ ExportTemp/ // 导出前的暂存区,用户不可见,定期清理 Library/ Application Support/ AppData/ // 数据库、收藏记录等私有数据 Library/ Caches/ Thumbnails/ // 缩略图缓存,可随时重建 Downloading/ // 下载进行中的临时分片 tmp/对应到代码里,我会用一个AppDirectory枚举统一管理路径,而不是到处拼接 URL:
enum AppDirectory { static var userRoot: URL { let doc = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] return doc.appendingPathComponent("UserFiles", isDirectory: true) } static func ensureUserRoot() throws { var isDir: ObjCBool = false if FileManager.default.fileExists(atPath: userRoot.path, isDirectory: &isDir) { if isDir.boolValue { return } throw FileBrowserError.pathNotDirectory } try FileManager.default.createDirectory(at: userRoot, withIntermediateDirectories: true) } }为什么单独包一层ensureUserRoot?因为第一次启动时Documents/UserFiles是不存在的,不创建就会出现“目录读取成功但文件列表为空”的假象,用户在文件浏览器里新建文件夹也会失败。
2.3 用户可见区和私有区必须隔离
很多人不理解为什么要再套一层 UserFiles。我举一个实际例子:如果你的 App 用 Core Data 存了用户笔记,数据库文件默认放在 Application Support,这没问题。但如果某个版本里你图省事把数据库放在了 Documents,那么文件浏览器一扫描,用户就会看到一个名为Notes.sqlite的文件,点开还乱码,观感极差。
更麻烦的是,用户如果把这个.sqlite文件删了,整个 App 的数据就没了。所以文件架构里最重要的原则就是:用户可见的文件才进 Documents 的 UserFiles,其余一律不进 Documents。我把这个规则写进了团队文档里,新版本开发时谁都不许破例。
2.4 被忽略的备份问题
沙盒目录里,Documents 和 Application Support 默认是会被 iCloud 备份的。如果你的 App 支持用户导入大视频文件,文件默认存在 Documents/UserFiles 里,那么每当用户连接 Wi-Fi 充电,系统就可能把这些大文件上传到 iCloud 备份,既耗流量又可能触发备份超时。
如果某个文件确实不需要备份,比如下载中的临时文件、用户可重新下载的资源,一定要显式标记:
var url = fileURL var values = URLResourceValues() values.isExcludedFromBackup = true try url.setResourceValues(values)这个逻辑需要放在写入文件之后就执行,并且后面每次更新文件也要记得再设置一遍。我踩过坑:文件被替换后,新文件默认是参与备份的,如果替换时忘了调用上面的代码,状态就悄悄变了。
3. 从目录到界面:文件节点模型与列表层
3.1 元数据读取的正确姿势
文件浏览器的列表项需要显示文件名、修改日期、文件大小、文件夹图标,这些都属于文件元数据。最容易做错的是在cellForRowAt里用attributesOfItem(atPath:)实时读取,文件少还好,超过几十个就会明显掉帧,因为每个 cell 都在做同步磁盘 IO。
正确做法是读目录时一次性把需要的属性攒齐。contentsOfDirectory(at:includingPropertiesForKeys:options:)支持批量读取属性键,这种方式能让文件系统一次性返回所有条目的元数据,效率高一个量级:
let keys: [URLResourceKey] = [ .isDirectoryKey, .isHiddenKey, .contentModificationDateKey, .fileSizeKey, .localizedNameKey, .isPackageKey ] let contents = try FileManager.default.contentsOfDirectory( at: directoryURL, includingPropertiesForKeys: keys, options: [.skipsHiddenFiles] )options: [.skipsHiddenFiles]可以顺手过滤以.开头的隐藏文件,这些文件在 iOS 文件浏览场景下几乎都不该让用户看到。注意skipsHiddenFiles只是跳过文件名以点开头的,不保证判断.isHiddenKey的资源,如果你的 App 里存在通过URLResourceValues.isHidden设置过的隐藏文件,还要再手动过滤一次。
3.2 文件节点模型
拿到 URL 之后,我会转成自己的模型,这样后续排序、搜索、复用都更方便:
struct FileNode { enum Kind { case folder case file case package } let url: URL let name: String let kind: Kind let modificationDate: Date? let fileSize: Int64? let isHidden: Bool } extension FileNode { init(url: URL) throws { let values = try url.resourceValues(forKeys: [ .isDirectoryKey, .isHiddenKey, .contentModificationDateKey, .fileSizeKey, .localizedNameKey, .isPackageKey ]) let isDir = values.isDirectory ?? false let isPackage = values.isPackage ?? false self.url = url self.name = values.localizedName ?? url.lastPathComponent self.kind = isDir ? (isPackage ? .package : .folder) : .file self.modificationDate = values.contentModificationDate self.fileSize = values.fileSize.map(Int64.init) self.isHidden = values.isHidden ?? false } }这里有个细节:.localizedNameKey返回的才是用户界面上该显示的名称。直接拿url.lastPathComponent在某些场景下会丢掉显示名逻辑,虽然多数情况下两者一样,但统一走 localizedName 是更稳妥的习惯。
3.3 排序规则:一个容易翻车的小点
文件列表排序看似简单,用系统自带的localizedStandardCompare才能按 Finder 的习惯排序。直接调用compare,会出现file1、file10、file2这种反直觉的顺序:
let sorted = nodes.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }文件夹排前、文件排后也是常规需求。在排序闭包里先判断 kind 是否一致,再比较名称:
nodes.sort { lhs, rhs in if lhs.kind == rhs.kind { return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending } return lhs.kind == .folder }3.4 UI 层的选择
文件浏览器最常见的 UI 是 UINavigationController 栈里连续 push 的 UITableViewController:每进入一个目录就 push 一个列表页面,返回时 pop。每个页面持有自己的 rootURL,数据源就是这个目录下的 FileNode 数组。
关键点在于每个页面只负责加载自己这一层,不要一次性递归加载所有子目录。我曾经天真地想过“把整棵树加载到内存里,切换目录秒开”,结果遇到一个用户导入了一个 5 万个文件的嵌套目录,直接内存爆掉。iOS 文件的层级浏览,本质是“按需加载”。
页面刷新策略也要注意:当用户在当前页面删除或新建了文件,不能只 reload 当前页,返回上一级时上一级列表里的文件夹大小、文件日期可能也变了。我是在每个页面的viewWillAppear里重新读取目录内容,这样保证用户返回时看到的是最新数据。代价是每次进入页面都有一点点 IO,实际体验完全可接受,因为目录列表通常只有几十到几百个文件。
4. FileManager 增删改查:把操作封装成工具类
4.1 基础操作与异常捕获
文件管理器绕不开 FileManager,但不建议在 ViewController 里直接到处调FileManager.default。操作一旦多起来,命名冲突、路径不存在、没有权限,各种异常会散落在业务代码里。我会把增删改查统一收敛到一个FileOperationManager,所有方法返回 Result 或抛错,并且保证在后台队列执行。
final class FileOperationManager { private let fm = FileManager.default private let queue = DispatchQueue(label: "file.operation", qos: .userInitiated) func renameItem(at url: URL, to newName: String) async throws -> URL { let dest = url.deletingLastPathComponent() .appendingPathComponent(newName) try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in queue.async { do { try self.fm.moveItem(at: url, to: dest) continuation.resume() } catch { continuation.resume(throwing: error) } } } return dest } }这里用了 Swift Concurrency 来封装,实际调用时可以直接try await。如果项目还没有迁移到 async/await,用 DispatchQueue + completion 也可以,原理一样:文件 IO 能挪出主线程就挪出去。
4.2 重命名与同名冲突
重命名本质上就是移动:moveItem(at:to:)。如果目标路径和源路径在同一个目录,系统就会把它当作改名处理。
真正麻烦的是同名冲突。用户在文件浏览器里把 A 重命名为 B,而 B 已经存在。系统不会帮你做选择,moveItem会直接抛错NSFileWriteFileExistsError。处理方案有两种:
- 弹窗让用户选择覆盖或取消。
- 自动生成一个
B (1)、B (2)这样的新名字。
我采用后者的场景比较多,因为文件浏览器里连续导入同名文件很常见。自动生成时要注意扩展名不能参与拼接:
func uniqueDestination(for url: URL, in directory: URL) -> URL { let ext = url.pathExtension let base = url.deletingPathExtension().lastPathComponent var index = 1 while true { var candidateName = base if index > 1 { candidateName += " (\(index))" } if !ext.isEmpty { candidateName += "." + ext } let candidate = directory.appendingPathComponent(candidateName) if !fm.fileExists(atPath: candidate.path) { return candidate } index += 1 } }这套命名规则和 mac 上复制文件的逻辑类似,用户看到xxx (2).pdf时基本都能理解。
4.3 大文件复制的进度问题
FileManager.copyItem没有进度回调,对于几百 MB 的导入文件,点击复制按钮后界面直接卡住几秒,体验非常差。系统并没有提供 copy 的进度 API,我的解决思路是手动用 FileHandle 流式复制,在循环里统计已复制的字节数来驱动进度 UI:
func copyFile(from source: URL, to destination: URL, progress: (Double) -> Void) throws { let readHandle = try FileHandle(forReadingFrom: source) defer { try? readHandle.close() } let total = (try fm.attributesOfItem(atPath: source.path)[.size] as? NSNumber)?.int64Value ?? 0 fm.createFile(atPath: destination.path, contents: nil) let writeHandle = try FileHandle(forWritingTo: destination) defer { try? writeHandle.close() } var copied: Int64 = 0 while true { let data = readHandle.readData(ofLength: 1024 * 1024) if data.isEmpty { break } try writeHandle.write(contentsOf: data) copied += Int64(data.count) if total > 0 { progress(Double(copied) / Double(total)) } } }这段代码有几个注意点:必须用try writeHandle.write(contentsOf:)替代旧版的write(_:),后者在新 SDK 里已废弃;读完记得 close;计算进度时判断 total 大于 0,避免除零。
4.4 删除操作与“回收站”思路
iOS 不像 macOS 有系统级废纸篓,removeItem删了就真没了。用户误删文件是高频投诉点,所以我的做法是加一层软删除:删除时先移动到一个.Trash隐藏目录,只有当用户点击“清空回收站”时才真正调用removeItem。
func moveToTrash(url: URL, trashURL: URL) throws { try fm.moveItem(at: url, to: uniqueDestination(for: url, in: trashURL)) }这样实现成本很低,但用户满意度提升很明显。唯一要注意的是.Trash目录也要被文件浏览器忽略,否则用户会在目录列表里看见点开头文件。我会在 FileNode 的初始化里过滤掉任何.lastPathComponent.hasPrefix(".")的路径。
4.5 常见的 FileManager 错误码
| 错误域 | 说明 | 用户提示 |
|---|---|---|
| NSFileNoSuchFileError | 文件已被删除或路径不存在 | 文件不存在,可能已被移动或删除 |
| NSFileWriteFileExistsError | 目标已有同名文件 | 询问用户是否覆盖 |
| NSFileWriteOutOfSpaceError | 磁盘空间不足 | 清理空间后重试 |
| NSFileReadCorruptFileError | 文件读取失败或损坏 | 建议重新导入 |
| NSFileWriteNoPermissionError | 没有写入权限 | 检查保存位置 |
5. Document Picker:App 内外文件流转的闭环
5.1 三种模式对应的用户路径
沙盒保证了 App 数据安全,也意味着用户无法从“文件”App 里浏览你的沙盒。想让文件进出沙盒,标准方案是UIDocumentPickerViewController。它有几种核心模式:
- 导入模式(Import):系统把用户选中的文件复制一份到你的沙盒临时目录,你的 App 拿到这个副本的 URL。适合“读取用户提供的文件”的场景,比如笔记 App 导入 PDF。
- 导出模式(Export):把你的文件复制给系统,用户可保存到“文件”App 或发给其他 App。适合“把文件分享出去”的场景。
- 移动模式(Move):系统把原文件移动到目标位置,移动后原始 URL 失效,你的 App 不再持有该文件。适合“用户主动整理文件”的场景。
5.2 初始化代码与多选
iOS 14 之后的初始化方式是传入 UTType:
if #available(iOS 14.0, *) { let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item]) picker.allowsMultipleSelection = true picker.delegate = self present(picker, animated: true) } else { let picker = UIDocumentPickerViewController(documentTypes: ["public.item"], in: .import) picker.delegate = self present(picker, animated: true) }这里[.item]表示允许所有类型文件。如果 App 只想支持图片和 PDF,可以用[.image, .pdf]。注意别在这段代码里犯 iPad 适配错误:在 iPad 上 Document Picker 以 popover 形式弹出,必须有 sourceView,否则直接 crash。所以真实代码里要加上:
if let popover = picker.popoverPresentationController { popover.sourceView = senderView popover.sourceRect = senderView.bounds }5.3 Security-Scoped URL 的正确使用
从 Document Picker 拿到 URL 后,很多新手直接读文件,结果发现拿不到数据。这是因为返回的 URL 是 security-scoped URL,指向的是系统在沙盒之外为你分配的代理位置,使用前要显式申请访问权限:
func handlePickedURL(_ url: URL) { let accessing = url.startAccessingSecurityScopedResource() defer { if accessing { url.stopAccessingSecurityScopedResource() } } // 此时才能安全读文件 let data = try? Data(contentsOf: url) }startAccessingSecurityScopedResource和stopAccessingSecurityScopedResource必须配对调用。虽然导入模式下 Apple 文档说不一定需要调用,但我在真实项目里发现,凡是涉及 Files App 云文件(比如 iCloud Drive 里的文件)时,不调用就会偶发读取失败。所以统一在进入文件读取前调用是最稳的写法。
5.4 导入后的文件归属与清理
导入模式下,系统把文件复制到沙盒的tmp/目录。这个目录会在系统磁盘紧张时被清理,如果用户导入后过了几天再打开这个文件,它可能已经不在了。所以导入后应该立刻移动到 Documents/UserFiles 目录:
func persistImportedFile(from tempURL: URL) throws -> URL { let destination = uniqueDestination(for: tempURL, in: AppDirectory.userRoot) try FileManager.default.moveItem(at: tempURL, to: destination) try excludeFromBackupIfNeeded(url: destination) return destination }同时一定要记得清理tmp目录下残留的未被导入的文件。我是在每次 App 启动时对 tmp 做一次过期清理,删除超过 24 小时的文件。
5.5 在 Info.plist 里声明可打开的文件类型
很多项目会在导入时遇到“文件 App 里找不到自己的 App”的尴尬:用户选中 PDF 后,系统分享列表里根本看不到你的 App。原因是 Info.plist 里没有声明支持的文档类型。
在 Info.plist 中加入以下配置就能让 App 出现在 PDF、TXT 等文件的打开列表中:
<key>CFBundleDocumentTypes</key> <array> <dict> <key>CFBundleTypeName</key> <string>PDF Document</string> <key>CFBundleTypeRole</key> <string>Viewer</string> <key>LSHandlerRank</key> <string>Alternate</string> <key>LSItemContentTypes</key> <array> <string>com.adobe.pdf</string> </array> </dict> </array>不声明这些,只靠 Document Picker 选择文件是没问题的,但用户没法从“文件”App 里直接长按一个 PDF 选择“打开方式 -> 你的 App”。如果想接住从系统其他入口打开的文件,还要在 AppDelegate / SceneDelegate 里实现对应的 openURL 回调,把外部传入的 URL 移交文件浏览器处理。
5.6 移动模式的一个隐形坑
移动模式返回值的意思是“你的原始文件已经被系统搬走了”,它现在的地址是系统指定的临时位置,最终是否被成功移交给用户目标取决于用户在文件选择器中的完整操作流程。如果你的 App 后续还需要这个文件,执行移动操作前必须先复制一份到自己的沙盒里。否则用户把文件移动到 iCloud Drive 后,你在 Documents 里找不到它,会误以为是自己删了。
所以我的规则是:除非 UI 明确告诉用户“此操作会把文件移出 App,本地将不再保留”,否则默认使用导出模式,而不是移动模式。导出模式安全一点,因为它只复制,不动原文件。
6. 预览、缩略图与后续可扩展的方向
6.1 QLPreviewController 快速预览
文件浏览器的用户预期是“点开文件就能看到内容”。iOS 内置的QLPreviewController是最省事的预览方案,支持 PDF、图片、Office、视频等几十种格式:
extension BrowserViewController: QLPreviewControllerDataSource { func numberOfPreviewItems(in controller: QLPreviewController) -> Int { return 1 } func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { return selectedFileURL as NSURL } }用的时候注意:在 iOS 15 之后,QLPreviewController默认展示方式在 iPhone 上应该用 fullScreen 展示,在 iPad 上则可以 present 成 sheet。如果文件是从外部导入的 security-scoped URL,预览前同样需要调用 start/stop。
6.2 缩略图缓存
如果文件列表要显示图片、PDF 缩略图,直接用UIImage(contentsOfFile:)加载原图会有两个问题:大图会撑爆内存,列表滑动时会反复解码。更专业的做法是使用QuickLookThumbnailing框架生成标准缩略图:
let request = QLThumbnailGenerator.Request( fileAt: url, size: CGSize(width: 100, height: 100), scale: UIScreen.main.scale, representationTypes: .thumbnail ) QLThumbnailGenerator.shared.generateBestRepresentation(for: request) { thumbnail, _, _ in let image = thumbnail?.uiImage }这个框架会异步生成缩略图,还能识别 PDF 的第一页、视频的某一帧。生成的图片要缓存到Library/Caches/Thumbnails,最好再套一层 NSCache 做内存缓存,避免每个 cell 都走磁盘读。
6.3 拖拽与目录监控
如果 App 要支持把外部文件直接拖进沙盒目录,或者支持把沙盒文件拖到“文件”App,需要接入UIDragInteraction和UIDropInteraction。这项功能在 iPad 的文件浏览器里几乎已经是标配。实现拖拽源时,把 FileNode 的 URL 放进NSItemProvider,系统会帮你处理后续导出。
目录监控的需求则比较隐晦。如果你希望文件浏览器在外部导入完成后自动刷新列表,不需要监控整个目录——Document Picker 的 delegate 回调时机已经够了。更复杂的监控场景,比如 App 和小组件共享文件目录后双向同步,才需要考虑DispatchSource.makeFileSystemObjectSource或NSMetadataQuery。
6.4 搜索是文件浏览器迟早要做的功能
文件多了以后,浏览式寻找会变得很痛苦。搜索功能不需要一开始就做,但架构上要留好口子。搜索的实现思路有两类:
- 遍历式搜索:直接递归
enumerator(at:includingPropertiesForKeys:)枚举所有子目录,逐个匹配文件名。实现简单,适合文件数量在几千以内的场景,但要注意把枚举放到后台线程,并使用错误处理闭包跳过无权限目录。 - 基于索引的搜索:对文件名、修改时间、标签构建内存索引,更新时增量修改。适合文件数量过万的项目,复杂度会上一个量级。
我的实际情况是先用遍历式搜索顶着,等文件规模真的大到遍历卡顿,再考虑引入索引。
写到这里,这套文件浏览器的核心闭环已经完整了:沙盒目录规划好,FileManager 遍历和操作封装好,Document Picker 打通外部文件流转,预览和缩略图提升使用体验。最后分享几个我在实际项目中沉淀下来的取舍:第一,永远给文件浏览器指定一个干净的 rootURL,不给用户看整个沙盒;第二,所有文件操作统一走工具类,后台排队执行,界面上给 Loading 和错误提示;第三,导入的文件立刻从 tmp 搬进 Documents/UserFiles,避免系统清理导致用户数据丢失。这套架构我已经在两个项目里完整跑过,稳定性和可维护性都经得住考验,你从零搭建时可以直接照着这个思路落地。