iOS 如何处理 GB 级大文件?从 FileHandle、分块读写到进度、取消与异常恢复

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

背后到底是怎么工作的。

相关推荐
Json____1 小时前
基于 FastAPI + Vue3 的在线拍卖系统技术解析
spring boot·后端·fastapi·wwwoop.com
创新技术阁1 小时前
FastapiAdmin 实战:二次开发前的准备(环境配置与项目启动)
前端·后端·fastapi
Java内核笔记1 小时前
Spring Boot 4.1 官方 gRPC 支持源码剖析:从社区 Starter 到一等公民
spring boot·后端
拖孩2 小时前
一个人 + AI 做的小程序,一个月赚了 36 块
前端·后端·微信小程序
程序员cxuan2 小时前
为啥 Blender 突然火了?
人工智能·后端·程序员
IT_陈寒2 小时前
React的状态更新竟然不是同步的?!坑了我一整天
前端·人工智能·后端
必须会一定会3 小时前
Spring Boot 3 + PostgreSQL 场景工程持久化:revision、contentHash 与历史回滚
人工智能·spring boot·后端·postgresql·ai编程
object not found3 小时前
Nuxt4去掉body中默认的边距
开发语言·后端·rust
zzzll11115 小时前
Spring Boot 实现数据脱敏:自定义注解 + Jackson 序列化器
java·spring boot·后端