从零设计一个 iOS 文件浏览器:Sandbox、FileManager、Document Picker 与文件架构

从零设计一个 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 走向真正产品时,迟早都会遇到的问题。

相关推荐
前端兰博1 小时前
04-数据库-MySQL
后端·mysql
她的男孩2 小时前
接口加密做成框架级能力有多难?我扒了 3600 行源码:从 RSA 握手到落库密文迁移
人工智能·后端·架构
掘金挖土2 小时前
前端手摸手跑路之 AI 应用开发(二)
前端·后端
遨翔在知识的海洋里2 小时前
nest(5)-文件上传和静态资源
后端
遨翔在知识的海洋里2 小时前
nest(3)-jwt和RBAC
后端
遨翔在知识的海洋里3 小时前
nest(5)-Middleware
后端
程序员梅雨3 小时前
Linux & Shell 实用干货
linux·运维·服务器·后端·面试·php
大牧师3 小时前
TypeORM 入门教程
后端·sql·mysql·orm·nest·typeorm
IT_陈寒3 小时前
Vite静态资源路径这个大坑害我调了一下午
前端·人工智能·后端