从零设计一个 iOS 文件浏览器:Sandbox、FileManager、Document Picker 与文件架构
如果让你开发一个 iOS 文件浏览器,第一个想到的可能是:
读取目录 → 展示文件 → 点击进入文件夹。
看起来并不复杂。
但真正开始开发以后,很快就会遇到一系列问题:
- 为什么 App 不能直接访问整个 iPhone 文件系统?
- 为什么拿到了一个文件 URL,却不一定能一直访问?
- "文件"App 里的文件到底属于谁?
- iCloud Drive、第三方文件提供商应该怎么接入?
- 本地文件、SMB、WebDAV 是否应该使用完全不同的 UI?
- 一个几 GB 的文件应该怎样复制?
- 文件移动、覆盖、重命名发生冲突怎么办?
- 文件浏览器到底应该怎样设计数据模型?
我在开发 TS File Explorer 的过程中,也一直在处理这些问题。
这一篇先不讨论 ZIP、RAR、SMB、WebDAV 等扩展能力,而是从最基础的问题开始:
一个 iOS 文件浏览器的底层架构到底应该怎样设计?
1. 首先理解 iOS Sandbox
开发桌面端文件管理器时,我们很容易形成一种思维:
给我一个路径,我就去读取这个路径。
但在 iOS 上,这种思维并不完全成立。
iOS App 默认运行在自己的 Sandbox 中。
可以简单理解成:
text
Application Sandbox
│
├── Documents/
│
├── Library/
│ ├── Application Support/
│ └── Caches/
│
└── tmp/
你的 App 可以自由管理自己容器中的文件。
但不能因为知道:
text
/path/to/file
就随意访问其他 App 的文件。
这实际上决定了 iOS 文件浏览器的第一条设计原则:
文件浏览器不是"遍历整个文件系统",而是在系统允许的文件访问边界内管理资源。
这和 Windows Explorer、Finder 的设计思路有明显区别。
2. FileManager:本地文件管理的基础
在 Foundation 中,本地文件操作最基础的入口是:
swift
FileManager
例如获取 Documents:
swift
let fileManager = FileManager.default
let documentsURL = fileManager.urls(
for: .documentDirectory,
in: .userDomainMask
).first!
读取目录:
swift
let files = try fileManager.contentsOfDirectory(
at: documentsURL,
includingPropertiesForKeys: [
.isDirectoryKey,
.fileSizeKey,
.contentModificationDateKey
],
options: [.skipsHiddenFiles]
)
创建目录:
swift
try fileManager.createDirectory(
at: folderURL,
withIntermediateDirectories: true
)
复制:
swift
try fileManager.copyItem(
at: sourceURL,
to: destinationURL
)
移动:
swift
try fileManager.moveItem(
at: sourceURL,
to: destinationURL
)
删除:
swift
try fileManager.removeItem(at: fileURL)
如果只是做一个 Demo,到这里其实已经能实现:
text
查看文件
↓
进入文件夹
↓
创建目录
↓
复制
↓
移动
↓
删除
但如果准备开发真正的文件管理产品,我并不建议 UI 层到处直接调用:
swift
FileManager.default
原因很简单:
FileManager 是系统 API,但它不应该等于你的业务架构。
3. 不要让 View 直接管理文件
一种很容易出现的代码结构是:
text
FileBrowserView
│
├── FileManager.copyItem()
├── FileManager.moveItem()
├── FileManager.removeItem()
└── FileManager.contentsOfDirectory()
刚开始很方便。
随着功能增加,很快就会出现问题。
比如:
复制文件的时候:
- 文件已经存在怎么办?
- 是否覆盖?
- 是否保留两个?
- 磁盘空间不足怎么办?
- 操作失败怎么展示错误?
- 大文件如何显示进度?
- 用户如何取消操作?
删除的时候:
- 直接永久删除?
- 还是进入回收站?
- 删除失败怎么办?
如果这些逻辑全部进入 View,代码会迅速失控。
更合理的方式是增加业务层:
text
┌─────────────────────┐
│ FileBrowserView │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ FileBrowserViewModel│
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ FileService │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ LocalFileProvider │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ FileManager │
└─────────────────────┘
UI 只关心:
用户想复制一个文件。
至于底层怎么复制、冲突怎么处理、失败如何恢复,应该交给更下面的层。
4. 先统一"文件"的数据模型
文件浏览器中最重要的数据结构之一就是:
FileItem。
最简单可以这样设计:
swift
struct FileItem: Identifiable, Hashable {
let id: UUID
let url: URL
let name: String
let isDirectory: Bool
let size: Int64?
let modificationDate: Date?
}
但随着产品发展,很快还会需要:
swift
struct FileItem: Identifiable, Hashable {
let id: UUID
let name: String
let url: URL
let isDirectory: Bool
let size: Int64?
let creationDate: Date?
let modificationDate: Date?
let contentType: UTType?
let source: FileSource
}
其中:
swift
enum FileSource {
case local
case external
case cloud
case smb
case webDAV
case ftp
}
为什么这里要提前加入 source?
因为文件浏览器发展到后面,文件不一定来自本机。
它可能来自:
text
Local
iCloud
SMB
WebDAV
FTP
Cloud Storage
但对于 UI 来说:
它们最好仍然都是 FileItem。
这会极大降低后面的架构复杂度。
5. 不要只靠扩展名判断文件类型
例如:
text
photo.jpg
video.mp4
document.pdf
archive.zip
当然可以:
swift
url.pathExtension
但如果要建立完整文件类型体系,更适合使用 Uniform Type Identifiers。
Swift 中可以使用:
swift
import UniformTypeIdentifiers
例如:
swift
if let type = UTType(filenameExtension: url.pathExtension) {
if type.conforms(to: .image) {
print("Image")
}
if type.conforms(to: .movie) {
print("Video")
}
if type.conforms(to: .audio) {
print("Audio")
}
if type.conforms(to: .pdf) {
print("PDF")
}
}
这样上层可以进一步抽象:
text
File
│
├── Folder
├── Image
├── Video
├── Audio
├── PDF
├── Archive
├── eBook
└── Unknown
这对后面实现:
- 图标
- 预览
- Quick Look
- 打开方式
- 右键/长按菜单
- 文件转换
- 分享
都非常有用。
6. 那"文件"App里的内容怎么访问?
这时候就到了 iOS 文件浏览器非常关键的一部分:
swift
UIDocumentPickerViewController
前面说过:
App 运行在 Sandbox 中。
因此不能简单理解成:
text
我的 App
↓
扫描整个 iPhone
↓
读取所有文件
实际更接近:
text
我的 App
│
▼
UIDocumentPicker
│
▼
Files / File Provider
│
▼
用户选择文件
│
▼
App 获得对应资源访问能力
例如:
swift
let picker = UIDocumentPickerViewController(
forOpeningContentTypes: [.item],
asCopy: false
)
picker.allowsMultipleSelection = true
用户可以通过系统界面选择文件。
这样做的一个重要优势是:
文件授权仍然由系统和用户控制。
你的 App 不需要、也不应该绕过系统权限模型去扫描其他应用的数据。
7. 拿到 URL 不代表可以随便访问
这是开发文件类 App 时很容易踩坑的地方。
当文件来自 App Sandbox 外部时,可能涉及:
Security-Scoped Resource。
典型操作:
swift
let accessed = url.startAccessingSecurityScopedResource()
defer {
if accessed {
url.stopAccessingSecurityScopedResource()
}
}
为什么需要这一层?
因为 URL 本身只描述:
资源在哪里。
它并不天然意味着:
你的 App 永久拥有访问这个资源的权限。
可以简单理解成:
text
URL
│
├── Location
│
└── Access Permission
↓
并不是一回事
这也是很多文件类 App 在开发初期容易出现:
"刚选择的时候可以打开,之后为什么访问失败?"
的重要原因之一。
8. 如果下次启动还需要访问怎么办?
如果产品允许用户保存某个外部位置,那么还需要继续考虑:
Security-Scoped Bookmark。
大致思路是:
text
用户选择资源
↓
获得 URL
↓
创建 Bookmark Data
↓
保存 Bookmark
↓
App 下次启动
↓
恢复 URL
↓
重新获得访问能力
示意代码:
swift
let bookmarkData = try url.bookmarkData(
options: .minimalBookmark,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
恢复时:
swift
var isStale = false
let restoredURL = try URL(
resolvingBookmarkData: bookmarkData,
options: [],
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
实际产品还需要处理:
text
Bookmark Stale
Permission Lost
File Moved
File Deleted
Provider Offline
这就是为什么一个成熟文件浏览器远远不是:
FileManager + List
这么简单。
9. 真正难的是"文件操作"
显示文件其实只是第一步。
例如用户把:
text
movie.mp4
复制到另外一个目录。
结果目标目录已经存在:
text
movie.mp4
怎么办?
至少需要考虑:
text
File Conflict
│
├── Replace
├── Keep Both
└── Cancel
如果选择:
Keep Both
就需要生成:
text
movie.mp4
movie (1).mp4
movie (2).mp4
movie (3).mp4
再例如:
用户复制一个 8GB 视频。
可能出现:
text
复制到 62%
↓
磁盘空间不足
或者:
text
复制到 40%
↓
用户取消
又或者:
text
SMB → Local
↓
网络断开
真正的文件操作层因此应该考虑:
text
File Operation
│
├── Progress
├── Cancellation
├── Conflict
├── Error
├── Retry
└── Recovery
这些才是文件管理产品越来越复杂的原因。
10. 大文件尤其不能简单粗暴处理
假设用户处理一个:
text
10 GB
的视频。
非常不推荐为了复制文件直接:
swift
let data = try Data(contentsOf: sourceURL)
try data.write(to: destinationURL)
逻辑上相当于:
text
10 GB File
↓
10 GB Data
↓
Memory
↓
💥
更合理的思路应该是:
text
Large File
↓
Input Stream / File Handle
↓
Chunk
↓
Chunk
↓
Chunk
↓
Destination
也就是说:
文件多大,不应该直接决定内存占用多大。
对于文件管理器来说,这是非常重要的工程原则。
11. 从本地文件扩展到网络文件
当产品继续发展以后,很快会出现新的问题。
假设要支持:
text
Local
SMB
WebDAV
FTP
难道要做四套文件浏览器?
当然不应该。
更合理的架构是:
text
File Browser
│
▼
FileService
│
▼
FileProvider
│
┌──────────────┼───────────────┐
│ │ │
▼ ▼ ▼
Local SMB WebDAV
│
│ │
└──────────────┬───────────────┘
▼
FTP
可以进一步定义:
swift
protocol FileProvider {
func contents(of path: String) async throws -> [FileItem]
func createDirectory(
name: String,
at path: String
) async throws
func delete(_ item: FileItem) async throws
func move(
_ item: FileItem,
to destination: String
) async throws
func rename(
_ item: FileItem,
to newName: String
) async throws
}
然后分别实现:
text
LocalFileProvider
SMBFileProvider
WebDAVFileProvider
FTPFileProvider
上层 FileBrowserView 不需要知道:
这个文件到底是通过 FileManager 读取,还是通过 SMB 协议获取。
它只知道:
text
给我当前目录的 FileItem。
这就是抽象文件层的价值。
12. TS File Explorer 为什么最后不只是"文件浏览器"
在开发 TS File Explorer 的过程中,我逐渐发现:
用户真正的需求通常不是"看见文件"。
而是:
文件到了手机以后,我接下来要做什么?
比如:
text
收到 RAR
↓
解压
↓
查看 PDF
或者:
text
电脑上的视频
↓
Wi-Fi Transfer
↓
iPhone
↓
播放 / 压缩 / 转换
再比如:
text
NAS
↓
SMB
↓
找到文件
↓
打开 / 下载 / 分享
所以一个文件管理工具最后很容易从:
text
Browse
逐渐扩展成:
text
Browse
+
Open
+
Transfer
+
Convert
+
Compress
+
Read
+
Share
这也是我现在对文件浏览器产品的理解:
文件浏览器的终点,不应该只是帮助用户找到文件,而应该尽可能帮助用户完成文件接下来的操作。
13. 一个相对完整的架构
如果现在重新整理,一个 iOS 文件浏览器可以大致拆成:
text
┌─────────────────────────────┐
│ UI │
│ SwiftUI / UIKit │
└──────────────┬──────────────┘
│
┌──────────────▼──────────────┐
│ ViewModel │
│ State / Selection / Sort │
└──────────────┬──────────────┘
│
┌──────────────▼──────────────┐
│ FileService │
│ Copy / Move / Delete │
│ Rename / Conflict │
└──────────────┬──────────────┘
│
┌──────────────▼──────────────┐
│ FileProvider │
└───────┬──────┬──────┬──────┘
│ │ │
▼ ▼ ▼
Local SMB WebDAV
│ │
│ FTP
▼
FileManager
旁边再挂载:
text
Archive Service
Media Service
PDF Service
Transfer Service
Thumbnail Service
Search Service
这样文件浏览器才逐渐从一个简单 Demo,变成一个真正可以持续扩展的产品。
写在最后
刚开始做文件浏览器的时候,很容易认为核心工作就是:
text
FileManager
+
List
真正开发以后会发现,它其实涉及:
Sandbox、权限、URL、文件类型、File Provider、大文件IO、异步任务、网络协议、错误恢复以及大量文件操作边界情况。
而架构设计最重要的一件事情,是尽早把:
text
"文件是什么"
和:
text
"文件来自哪里"
分开。
本地文件可能来自 FileManager。
远程文件可能来自 SMB。
网络存储可能来自 WebDAV。
系统外部资源可能来自 Document Picker。
但对于文件浏览器上层来说:
它们最终都应该尽可能成为统一的 FileItem 和 FileProvider。
这样以后增加新的文件来源、新的操作方式、新的文件处理能力时,才不需要重新设计整个文件浏览器。
这也是我在开发 TS File Explorer 过程中越来越明确的一点:
先设计文件抽象,再设计文件功能。
下一篇准备继续聊一个更加实际的问题:
《iOS 如何处理 GB 级大文件?从 FileHandle、分块读写到进度、取消与异常恢复》
这也是文件管理器从 Demo 走向真正产品时,迟早都会遇到的问题。