iOS 如何处理 GB 级大文件?从 FileHandle、分块读写到进度、取消与异常恢复
在上一篇《从零设计一个 iOS 文件浏览器》中,我们讨论了 Sandbox、FileManager、UIDocumentPicker、Security-Scoped Resource,以及如何通过 FileProvider 抽象不同的文件来源。
但当文件浏览器真正开始处理用户文件时,很快会遇到另一个问题:
文件可能非常大。
几十 KB 的文本文件和一个 15GB 的 4K 视频,在文件列表里看起来都只是一个 FileItem。
但对于底层 IO 来说,它们完全不是一回事。
例如用户执行:
text
15 GB Movie
↓
Copy
↓
Another Directory
如果实现方式不合理,可能出现:
- 内存瞬间上涨
- App 被系统终止
- UI 卡死
- 无法展示进度
- 无法取消任务
- 复制失败留下残缺文件
- 磁盘空间不足时无法正确恢复
- 用户退出页面后任务状态丢失
所以这一篇我们继续解决一个文件管理器迟早都会遇到的问题:
iOS 应该如何安全地处理 GB 级大文件?
1. 最危险的写法:一次性把整个文件读入内存
假设我们需要复制一个文件。
最直观的 Swift 写法可能是:
swift
let data = try Data(contentsOf: sourceURL)
try data.write(to: destinationURL)
对于一个几 KB 或几 MB 的文件,这段代码看起来没有什么问题。
但是如果文件是:
text
10 GB
逻辑就变成:
text
10 GB File
↓
Data(contentsOf:)
↓
尝试加载大量数据到内存
↓
Memory Pressure
↓
App 可能被系统终止
这里需要建立文件类 App 非常重要的一个概念:
文件大小不应该直接等于内存占用。
一个 20GB 文件并不意味着我们需要 20GB 内存才能处理它。
真正需要做的是:
Streaming / Chunked IO。
2. 什么是分块读写?
思路其实非常简单。
不要:
text
10GB
↓
Memory
↓
Write
而是:
text
10GB File
│
▼
┌────────┐
│ 1 MB │
└────────┘
↓
Write
↓
┌────────┐
│ 1 MB │
└────────┘
↓
Write
↓
...
↓
Destination
假设每次只读取:
text
1 MB
那么无论文件是:
text
100 MB
1 GB
10 GB
50 GB
核心工作内存都不需要随着文件大小线性增长。
这就是处理大文件最基础的原则之一。
3. 使用 FileHandle
Swift/Foundation 提供了:
swift
FileHandle
我们可以使用它进行更加底层的文件读取和写入。
一个简化的大文件复制示例:
swift
func copyLargeFile(
from sourceURL: URL,
to destinationURL: URL
) throws {
let fileManager = FileManager.default
fileManager.createFile(
atPath: destinationURL.path,
contents: nil
)
let reader = try FileHandle(
forReadingFrom: sourceURL
)
let writer = try FileHandle(
forWritingTo: destinationURL
)
defer {
try? reader.close()
try? writer.close()
}
let chunkSize = 1024 * 1024
while true {
let data = try reader.read(
upToCount: chunkSize
)
guard let data,
!data.isEmpty else {
break
}
try writer.write(contentsOf: data)
}
}
这里:
text
1024 × 1024
也就是:
text
1 MB
每次只处理一小块数据。
整体过程变成:
text
Source
│
├── 1MB
│
├── 1MB
│
├── 1MB
│
└── ...
↓
Destination
这样即使源文件非常大,也不需要一次加载全部内容。
4. Chunk Size 应该设置多大?
既然要分块,下一个问题自然就是:
每一块到底多大?
例如:
text
64 KB
256 KB
1 MB
4 MB
16 MB
是不是越大越快?
不一定。
Chunk 太小:
text
Chunk ↓
IO 调用次数 ↑
可能增加调用开销。
Chunk 太大:
text
Chunk ↑
Memory ↑
如果同时存在多个任务,内存压力也会继续增加。
例如同时复制:
text
Task A → 16MB
Task B → 16MB
Task C → 16MB
Task D → 16MB
实际内存占用就不只是一个 Chunk。
因此 Chunk Size 更适合被视为一个:
性能参数。
例如:
swift
let chunkSize = 1024 * 1024
可以先从 1MB 这种相对保守的值开始,然后根据:
- 设备
- 文件来源
- 文件大小
- 本地/网络
- 并发数量
进行实际 Benchmark。
不要仅仅因为:
"16MB 比 1MB 大"
就认为一定更快。
5. 有了分块,就可以计算进度
文件浏览器复制一个 10GB 文件,如果 UI 只是显示:
text
Copying...
用户完全不知道:
- 复制了多少
- 还需要多久
- App 是不是卡住了
所以需要:
text
Progress
首先获取源文件大小:
swift
let values = try sourceURL.resourceValues(
forKeys: [.fileSizeKey]
)
let totalBytes = Int64(
values.fileSize ?? 0
)
然后维护:
swift
var copiedBytes: Int64 = 0
每写入一个 Chunk:
swift
copiedBytes += Int64(data.count)
进度就是:
swift
let progress =
Double(copiedBytes) /
Double(totalBytes)
例如:
text
Total: 10 GB
Copied: 4 GB
Progress = 40%
于是 UI 可以展示:
text
Copying movie.mp4
████████░░░░░░░░░░░░
40%
这就是分块 IO 带来的另一个优势:
任务变得可观察。
6. 不要每复制 1MB 就疯狂刷新 UI
这里又会出现一个隐藏问题。
假设 Chunk Size:
text
1 MB
复制一个:
text
20 GB
文件意味着可能发生大量进度更新。
如果每一个 Chunk 都直接:
swift
await MainActor.run {
progress = newProgress
}
就可能产生大量 UI 更新。
实际上用户并不需要看到:
text
40.0001%
40.0002%
40.0003%
40.0004%
更合理的方式是:
Throttle Progress Updates。
例如:
text
每 100ms 更新一次
或者:
text
变化超过 0.5% 再更新
这样:
text
IO Thread
↓
大量 Chunk Progress
↓
Throttle
↓
少量 UI Update
文件复制保持高效,同时 UI 仍然足够流畅。
7. 大文件任务必须支持取消
假设用户正在复制:
text
18GB Video
复制到:
text
73%
突然发现选错了文件。
如果没有取消能力,只能:
等它复制完再删除。
这是非常差的体验。
所以文件任务应该从最开始就考虑:
text
Cancellation
如果使用 Swift Concurrency,可以定期检查:
swift
try Task.checkCancellation()
例如:
swift
while true {
try Task.checkCancellation()
let data = try reader.read(
upToCount: chunkSize
)
guard let data,
!data.isEmpty else {
break
}
try writer.write(contentsOf: data)
copiedBytes += Int64(data.count)
}
于是:
text
Copy Task
│
├── Chunk
├── Check Cancellation
├── Chunk
├── Check Cancellation
├── Chunk
│
└── Cancel
↓
Stop
用户终于可以真正停止任务。
8. Cancel 之后,残缺文件怎么办?
这是非常容易被忽略的问题。
假设:
text
Source = 10GB
复制到:
text
Destination = 4.7GB
用户取消。
这时候目标目录已经存在:
text
movie.mp4
但它其实只是一个:
Incomplete File。
如果什么都不做,用户可能以为:
文件已经复制成功。
因此取消任务之后通常需要进行清理:
swift
catch is CancellationError {
try? FileManager.default.removeItem(
at: destinationURL
)
throw CancellationError()
}
逻辑:
text
Cancel
↓
Close Handle
↓
Delete Partial File
↓
Restore UI State
这里体现了文件操作非常重要的一条原则:
失败不仅要停止,还要恢复到一个可理解的状态。
9. 更安全的方法:先写临时文件
如果我们进一步提高可靠性,可以不要直接写:
text
movie.mp4
而是先写:
text
.movie.mp4.tmp
过程:
text
Source
↓
.movie.mp4.tmp
↓
复制完成
↓
校验
↓
Rename
↓
movie.mp4
这样用户不会在复制过程中看到一个看似正常、实际上不完整的文件。
如果任务失败:
text
.tmp
↓
Delete
成功以后:
text
.tmp
↓
Atomic Finalization
↓
Final File
这类思想在很多文件系统、下载器、数据库和同步系统中都非常常见。
10. 磁盘空间不足怎么办?
假设用户复制:
text
12 GB
文件。
设备只剩:
text
4 GB
如果什么都不检查:
text
Copy
↓
1GB
↓
2GB
↓
3GB
↓
4GB
↓
No Space
↓
Failure
用户白等了很长时间。
所以在开始大型本地文件操作之前,可以尽量检查目标卷的可用空间。
例如通过 URL Resource Values 获取容量相关信息。
逻辑上:
text
File Size
↓
Compare
↓
Available Capacity
↓
Enough?
/ \
Yes No
↓ ↓
Copy Fail Early
这里的关键不是:
"一定能够百分之百预测磁盘状态。"
因为复制过程中其他 App 也可能继续占用空间。
而是:
能提前发现明显无法完成的任务,就不要让用户等到 90% 才失败。
11. 错误应该分类,而不是只有 failed
真实文件管理器里:
swift
catch {
show("Copy failed")
}
远远不够。
因为失败可能是:
text
No Space
Permission Denied
Source Missing
Destination Exists
Network Lost
Provider Offline
Cancelled
Unknown IO Error
它们对应的用户操作完全不同。
例如:
No Space
提示:
存储空间不足。
Destination Exists
提示:
text
Replace
Keep Both
Cancel
Network Lost
可能提供:
text
Retry
Permission Lost
可能要求:
text
重新授权文件访问
所以文件操作层最好拥有自己的错误模型:
swift
enum FileOperationError: Error {
case insufficientSpace
case permissionDenied
case sourceNotFound
case destinationExists
case networkUnavailable
case cancelled
case ioError(Error)
}
这样 UI 才能做真正有意义的反馈。
12. 文件冲突也是大文件操作的一部分
例如目标目录已经存在:
text
movie.mp4
我们不能简单覆盖。
应该进入:
text
Destination Exists
│
┌─────┼─────┐
↓ ↓ ↓
Replace Keep Cancel
Both
Keep Both 可以生成:
text
movie.mp4
movie (1).mp4
movie (2).mp4
但这里还有一个很容易忽略的问题:
检查文件存在和真正写入之间存在时间差。
例如:
text
Check
↓
movie.mp4 不存在
↓
另一个 Task 创建 movie.mp4
↓
当前 Task 开始写入
所以当文件浏览器支持并发任务时,冲突处理需要考虑 Race Condition。
这已经不是 UI 问题,而是文件操作层的问题。
13. 本地文件和网络文件不能完全一样处理
前一篇我们设计过:
text
FileProvider
│
├── Local
├── SMB
├── WebDAV
└── FTP
当复制变成:
text
SMB
↓
iPhone
问题会明显增加。
本地磁盘可能是:
text
FileHandle
↓
Read
↓
Write
网络文件则是:
text
Remote File
↓
Network
↓
Buffer
↓
Local File
这时候需要考虑:
text
Latency
Bandwidth
Timeout
Reconnect
Authentication
Partial Transfer
Server Error
例如一个:
text
20GB Movie
从 NAS 下载到 iPhone。
下载到:
text
78%
Wi-Fi 断开。
到底应该:
text
Delete
还是:
text
Resume
这就是下一层架构问题。
14. Resume 为什么比想象中复杂?
如果要断点续传,必须知道:
text
已经传输了多少?
例如:
text
Remote File
20 GB
Local Temp
12 GB
理论上可以:
text
Remote Offset = 12 GB
继续读取。
但还需要确认:
远程文件还是原来的那个文件吗?
假设服务器上的文件已经被替换。
那么继续拼接可能产生:
text
前 12GB = Old File
后 8GB = New File
最终得到一个损坏文件。
因此成熟的 Resume 机制还可能需要:
text
File Size
Modification Date
ETag
Checksum
Remote Identifier
验证资源是否发生变化。
所以:
Resume 并不是"记住 Offset"这么简单。
15. 大文件操作最好变成独立任务模型
如果复制逻辑直接绑定到某个页面:
text
FileBrowserView
↓
Copy
用户离开页面以后怎么办?
所以成熟一点的设计应该把文件操作抽象成:
text
FileOperation
例如:
swift
struct FileOperation {
let id: UUID
let type: OperationType
let source: FileItem
let destination: FileLocation
var progress: Double
var state: OperationState
}
状态:
swift
enum OperationState {
case waiting
case running
case paused
case completed
case failed
case cancelled
}
整个系统变成:
text
FileOperationManager
│
┌─────────────────┼─────────────────┐
↓ ↓ ↓
Task A Task B Task C
Copy 10GB Download NAS Move Files
│ │ │
42% 78% 15%
这时候文件操作就不再属于某个页面。
它属于:
整个 App。
16. 为什么需要 FileOperationManager?
有了独立任务管理器以后,就可以统一处理:
text
Queue
Concurrency
Progress
Cancel
Pause
Retry
Conflict
Error
Completion
例如:
text
User Action
↓
FileService
↓
Create Operation
↓
Operation Manager
↓
Queue
↓
Execute
↓
Progress
↓
Complete / Error
UI 只订阅状态。
于是:
文件列表页面可以显示进度。
任务中心也可以显示进度。
用户切换目录,任务仍然继续。
这对于真正的文件管理器非常重要。
17. 并发越多不代表越快
假设用户同时复制 10 个大文件。
一种错误思路:
text
10 Files
↓
10 Concurrent Tasks
↓
一定更快?
实际上可能:
text
Disk IO Competition
Network Competition
Memory ↑
CPU ↑
Thermal ↑
Performance ↓
尤其是 NAS / SMB:
text
Task 1 ─┐
Task 2 ─┤
Task 3 ─┼── Wi-Fi ── NAS
Task 4 ─┤
Task 5 ─┘
并发过高可能反而降低总体吞吐量。
所以 Operation Manager 应该允许:
text
Max Concurrent Operations
例如:
text
Local Copy: 2
Network Transfer: 2
Thumbnail: 4
具体数字不能凭感觉决定。
应该通过真实设备 Benchmark 调整。
18. 不要让缩略图抢大文件任务的资源
文件浏览器还有一种很常见的后台 IO:
Thumbnail。
进入一个包含 500 个视频的目录:
text
500 Files
↓
Generate Thumbnail
与此同时用户正在复制:
text
20GB Video
如果没有任务优先级:
text
Copy
Thumbnail
Metadata
Search Index
Network
全部一起跑。
用户可能发现:
为什么复制突然变慢了?
所以可以进一步建立:
text
Priority
│
├── User Operation High
├── Visible Thumbnail Medium
├── Prefetch Low
└── Indexing Background
真正成熟的文件管理器最终会变成一个:
IO 调度系统。
19. 在 TS File Explorer 中,我越来越重视"任务"而不是"按钮"
刚开始做文件功能时,很容易从 UI 出发:
text
复制按钮
移动按钮
下载按钮
解压按钮
后来会发现这些表面上不同的按钮,本质上都有非常类似的生命周期:
text
Prepare
↓
Start
↓
Progress
↓
Pause / Cancel
↓
Complete
↓
Error / Retry
所以从架构角度,我更倾向于把它们理解成:
text
Operation
│
├── Copy
├── Move
├── Download
├── Upload
├── Compress
├── Extract
└── Convert
TS File Explorer 中很多看起来完全不同的功能,其实都可以逐渐建立在统一的:
Task / Operation 思维
之上。
这也是复杂文件工具能够继续扩展的重要基础。
20. 一个更完整的大文件处理架构
最终可以形成类似:
text
┌─────────────────────────────┐
│ UI │
│ File Browser / Task Center │
└──────────────┬──────────────┘
│
▼
┌─────────────────────────────┐
│ FileService │
└──────────────┬──────────────┘
│
▼
┌─────────────────────────────┐
│ FileOperationManager │
│ │
│ Queue │
│ Concurrency │
│ Priority │
│ Progress │
│ Cancellation │
│ Retry │
└──────────────┬──────────────┘
│
┌───────┼─────────┐
▼ ▼ ▼
Local SMB WebDAV
│ │ │
▼ ▼ ▼
FileHandle Network Network
│ │ │
└───────┼─────────┘
▼
Temporary File
│
▼
Validation
│
▼
Finalization
│
▼
Destination
整个流程最重要的思想是:
不要把"大文件复制"理解成一个 API 调用,而应该把它理解成一个具有完整生命周期的任务。
21. 一个大文件任务至少要回答 8 个问题
如果准备开发文件管理器,我建议在写代码之前先回答:
text
1. 如何读取?
2. 如何控制内存?
3. 如何展示进度?
4. 如何取消?
5. 失败后如何清理?
6. 是否支持重试?
7. 是否支持断点续传?
8. App 生命周期变化后怎么办?
如果涉及网络,还需要继续回答:
text
9. 网络断开怎么办?
10. 服务端文件变化怎么办?
11. 如何验证文件完整性?
12. 如何控制并发?
这些问题想清楚以后,再开始写:
swift
copy()
通常会少走很多弯路。
写在最后
处理一个 10GB 文件和处理一个 10MB 文件,API 表面上可能非常相似。
但工程设计完全不同。
真正可靠的大文件处理需要考虑:
text
Chunked IO
+
Progress
+
Cancellation
+
Temporary File
+
Error Recovery
+
Retry
+
Concurrency
+
Resource Management
其中最重要的原则仍然是:
文件有多大,不应该决定 App 需要占用多大的内存。
而当文件操作进一步涉及 SMB、WebDAV、FTP、Wi-Fi Transfer 等网络环境后,问题又会从:
Large File IO
升级为:
Reliable File Transfer。
所以这个系列的第三篇,我准备继续讨论:
《iPhone 和电脑如何通过 Wi-Fi 传文件?从局域网 HTTP Server 到文件上传下载架构》
这也是我开发 TS File Explorer 时非常值得单独拆出来讨论的一项能力。
我们会继续研究:
text
iPhone
↕
Wi-Fi / LAN
↕
Browser
↕
PC / Mac
背后到底是怎么工作的。