iOS 如何实现 ZIP / RAR / 7z 解压?从压缩格式、流式解压到密码压缩架构
在前面的文章中,我们已经把文件浏览器的基础架构逐渐搭起来:
01 iOS 文件浏览器架构
02 GB 级大文件处理
03 Wi-Fi 文件传输
04 SMB / NAS
05 WebDAV
06 FTP
从这一篇开始,我们回到文件浏览器最常见、也是最容易被普通用户感知到的一类功能:
压缩与解压
很多用户安装文件管理器,第一次真正用到它,可能就是因为收到一个:
archive.zip
或者:
project.rar
甚至:
backup.7z
iPhone 上打不开。
于是用户真正的问题非常简单:
这个压缩包怎么打开?
但对于开发者来说,背后并不只是调用一个:
unzip()
真正需要处理的问题包括:
- ZIP、RAR、7z 有什么区别
- TAR、GZIP 为什么又不一样
- 密码压缩如何处理
- 大压缩包如何避免爆内存
- 解压进度如何计算
- 用户取消以后怎么办
- 同名文件如何解决冲突
- 如何防止 Zip Slip 路径穿越
- 如何防止压缩炸弹
- 压缩包里面几万个文件怎么办
- 如何支持预览而不全部解压
- 如何把解压任务纳入 FileOperationManager
所以这一篇我们从架构角度拆解:
一个真正可靠的 iOS 压缩解压模块应该如何设计?
1. “压缩包”其实不是一种格式
从用户角度:
.zip
.rar
.7z
都可以统称:
压缩包。
但是技术上,它们是完全不同的格式。
例如:
ZIP
RAR
7z
TAR
GZIP
XZ
BZIP2
它们在:
- 文件结构
- 压缩算法
- 加密方式
- 索引方式
- 随机访问能力
- 多文件支持
等方面都有明显区别。
因此:
ArchiveService 不应该建立在“所有压缩包都一样”的假设上。
2. ZIP 是什么?
ZIP 是最常见的归档格式之一。
它可以同时:
Archive
+
Compress
也就是说,它既可以:
把多个文件装进一个容器
又可以:
对其中的数据进行压缩。
例如:
project.zip
│
├── README.md
├── Images/
│ ├── 001.jpg
│ └── 002.jpg
└── Documents/
└── report.pdf
ZIP 很大的优势是:
Compatibility
Windows、macOS、iOS 以及各种文件工具都普遍支持。
所以如果用户问:
应该把文件压缩成什么格式方便分享?
很多时候 ZIP 都是很安全的选择。
3. RAR 又是什么?
RAR 同样是一种归档格式。
用户在 Windows 环境中尤其容易遇到:
movie.rar
software.rar
photos.rar
RAR 在压缩率、分卷等方面拥有自己的格式能力。
但对于 App 开发来说,一个重要现实是:
RAR 并不像 ZIP 那样可以简单地认为“系统天然支持”。
开发者通常需要使用相应的解析/解压实现,并且需要认真确认所使用实现的:
License
Distribution Terms
Format Support
尤其如果要把功能集成到商业 App 中,不能只看:
GitHub 上能不能跑。
还要确认:
这个库是否允许以你的商业方式进行分发和使用。
4. 7z 为什么经常压得更小?
7z 是另一种常见归档格式。
它经常搭配:
LZMA
LZMA2
等压缩算法使用。
很多场景中,7z 可以获得比较高的压缩率。
于是用户经常会看到:
1.2GB Folder
↓
7z
↓
700MB Archive
当然:
压缩率不是免费的。
通常需要在:
Compression Ratio
↕
CPU Time
↕
Memory
之间进行权衡。
压得越狠,并不意味着在移动设备上体验一定越好。
5. TAR 和 ZIP 其实不是同一类东西
很多开发者第一次看到:
.tar.gz
会把它理解成一种“压缩格式”。
其实更准确地说:
TAR
主要负责:
Archive
也就是:
把很多文件打包成一个连续的数据流。
例如:
Folder/
├── A
├── B
└── C
先:
TAR
↓
archive.tar
再:
GZIP
↓
archive.tar.gz
所以:
.tar.gz
可以理解为:
Archive
+
Compression
两个步骤组合。
这和 ZIP:
Archive + Compression
集成在同一个格式里的思路并不完全一样。
6. 因此需要统一的 Archive Format 模型
不要在业务代码里到处写:
if ext == "zip" { ... }
else if ext == "rar" { ... }
else if ext == "7z" { ... }
更合理的是先建立统一模型:
enum ArchiveFormat {
case zip
case rar
case sevenZip
case tar
case gzip
case bzip2
case xz
}
然后:
File
↓
ArchiveDetector
↓
ArchiveFormat
↓
ArchiveService
格式判断也不要完全依赖:
File Extension
更可靠的实现可以结合:
Extension
+
Magic Number
+
Header
判断。
因为一个文件叫:
photo.zip
并不代表里面一定真的是 ZIP。
7. ArchiveService 应该和 FileProvider 分开
前几篇我们一直在构建:
FileProvider
它负责:
Local
SMB
WebDAV
FTP
这些“文件来自哪里”的问题。
ArchiveService 解决的是:
拿到文件以后怎么处理。
所以:
FileProvider
↓
FileItem
↓
ArchiveService
而不是:
LocalZipProvider
SMBZipProvider
FTPZipProvider
否则组合会迅速爆炸。
架构应该保持:
Source
↓
FileProvider
↓
FileItem
↓
ArchiveService
8. 可以定义统一的 ArchiveService
例如:
protocol ArchiveService {
func listEntries(
in archive: FileItem
) async throws -> [ArchiveEntry]
func extract(
archive: FileItem,
to destination: FileLocation
) async throws
func createArchive(
from items: [FileItem],
format: ArchiveFormat,
to destination: FileLocation
) async throws
}
核心抽象:
ArchiveService
│
├── List
├── Extract
└── Compress
至于底层:
ZIP
RAR
7z
应该由具体实现决定。
9. 为什么“查看压缩包内容”和“解压”要分开?
很多用户只是想:
看看压缩包里面有什么。
如果一个:
15GB archive.zip
用户点一下就自动全部解压,
体验显然很差。
所以应该支持:
Archive
↓
Read Metadata / Index
↓
Show Entries
例如:
project.zip
│
├── README.md
├── Photos/
├── Documents/
└── source/
用户可以先浏览。
需要某个文件时再:
Extract Selected
这意味着 ArchiveService 至少需要:
List Entries
和:
Extract
两套能力。
10. ArchiveEntry 应该独立建模
例如:
struct ArchiveEntry {
let path: String
let name: String
let isDirectory: Bool
let compressedSize: Int64?
let uncompressedSize: Int64?
let modifiedDate: Date?
}
UI 看到的是:
ArchiveEntry
而不是具体:
ZIP Central Directory Record
RAR Header
7z Entry
这样不同格式就可以共享:
Archive Browser UI
11. 解压大文件也必须考虑内存
假设压缩包里有:
video.mp4
20GB
错误思路:
Compressed Entry
↓
Decompress
↓
20GB Data
↓
Memory
基本不可接受。
正确方向:
Compressed Stream
↓
Decompressor
↓
Chunk
↓
Destination File
核心仍然是:
Streaming。
也就是说:
Archive Size = 30GB
不应该意味着:
Memory = 30GB
12. 解压实际上是一条数据管线
可以把一个文件的解压理解为:
Archive File
↓
Compressed Bytes
↓
Decoder
↓
Uncompressed Bytes
↓
Chunk
↓
Output File
所以可以进一步设计:
ArchiveReader
↓
Decompressor
↓
OutputWriter
这和前面的大文件传输架构非常相似。
区别只是中间多了:
Decompression
13. 解压进度应该怎么算?
表面上很简单:
processed / total
但这里有两个不同的 total:
Compressed Size
和:
Uncompressed Size
例如:
archive.7z
Compressed:
1GB
Uncompressed:
5GB
进度可以基于:
Processed Entries
Processed Compressed Bytes
Processed Uncompressed Bytes
不同库能够提供的信息不同。
对于用户而言,更重要的是:
Extracting...
2.6 GB / 5 GB
52%
所以 Archive Operation 最好把底层进度统一映射成:
0.0 ... 1.0
而不是让 UI 理解每一种格式的内部细节。
14. 一个压缩包可能有几十万个文件
比如:
node_modules.zip
里面可能有海量小文件。
这种压缩包和:
一个 20GB 视频
是完全不同的压力模型。
前者压力来自:
File Count
Directory Creation
Metadata
Small IO
后者压力来自:
Large Sequential IO
因此 ArchiveService 不能只优化:
Large File
还需要考虑:
Huge Entry Count
15. 不要一次把 100,000 个 Entry 全塞进 UI
例如:
100,000 Archive Entries
如果全部:
Parse
↓
Create Model
↓
Render
可能会造成明显性能压力。
所以可以考虑:
Lazy Parsing
Pagination
Batch Updates
Virtualized List
和 NAS 大目录的思路一样:
内容很多,不代表首屏必须全部准备好。
16. 解压必须支持取消
用户开始:
Extract 50GB Archive
到:
61%
发现选错目录。
如果不能取消,只能干等。
所以 Archive Operation 需要:
Cancel
例如每处理一定数量数据:
try Task.checkCancellation()
逻辑:
Entry
↓
Chunk
↓
Check Cancel
↓
Chunk
↓
Check Cancel
最终:
Cancel
↓
Stop Decoder
↓
Close Files
↓
Cleanup
17. 取消以后已经解压的文件怎么办?
这是产品设计必须明确的问题。
假设:
archive.zip
1000 Files
已经解压:
600 Files
用户取消。
应该:
方案 A
全部删除:
Rollback
方案 B
保留已经成功的文件。
方案 C
让用户选择。
对于普通文件管理器,我更倾向于:
默认尽量保证目标目录处于可理解状态。
一种实现方式是:
Extract
↓
Temporary Directory
↓
Complete
↓
Move to Final Destination
失败:
Temporary Directory
↓
Delete
成功:
Temporary Directory
↓
Finalize
这样事务边界更清晰。
18. 但是临时目录会占用双倍空间吗?
这是一个非常现实的问题。
如果压缩包解压后:
40GB
临时目录再移动到最终目录,
如果底层移动可以通过同一卷上的 rename/move 完成,
通常不需要重新复制全部内容。
但如果:
Temporary
和:
Destination
位于不同 Provider 或不同文件系统,
可能就需要真实的数据迁移。
所以:
Transactional Extract
也需要结合:
Destination Provider
设计。
19. 同名文件冲突怎么办?
假设目标目录已经有:
report.pdf
压缩包里也有:
report.pdf
不能偷偷覆盖。
应该明确策略:
Replace
Keep Both
Skip
Cancel
如果是整个压缩包:
Apply to All
也很重要。
例如:
[✓] Apply to all conflicts
否则一个 1000 文件压缩包可能连续弹几十次。
20. Keep Both 要生成安全的新名字
例如:
report.pdf
已经存在。
生成:
report (1).pdf
report (2).pdf
但注意:
Directory
也可能冲突。
所以冲突策略应该属于:
FileOperation / Destination Resolver
而不是 ZIP 解压器内部随便处理。
这样:
Copy
Move
Extract
Download
都可以共享冲突逻辑。
21. 密码压缩怎么处理?
例如用户打开:
private.zip
ArchiveService 发现:
Encrypted
UI 显示:
Enter Password
用户输入:
********
然后:
Password
↓
Archive Decoder
↓
Decrypt
↓
Decompress
密码不要:
写入普通日志
也不要:
长期放在普通 UserDefaults
如果用户选择:
记住这个压缩包密码
可以考虑安全存储。
22. ZIP 加密也有不同类型
看到:
Password Protected ZIP
不能假设所有密码 ZIP 都一样。
历史上 ZIP 存在较旧的传统加密方式。
现代工具也可能使用:
AES
等更强的加密方案。
所以 App 声称支持:
Encrypted ZIP
时,需要明确实际底层实现支持哪些加密方式。
否则很容易出现:
某些密码 ZIP 能开
某些不能开
用户会以为是 Bug。
23. 7z 密码还有一个特殊点:文件名也可能被隐藏
有些加密归档不仅:
File Data
被加密。
连:
File Names
也可能受到保护。
这意味着用户甚至无法先浏览:
Archive Entries
而需要:
Password
↓
Read Header
↓
Show Entries
所以 Archive Browser 需要允许:
Locked
这种状态。
24. 密码错误应该是独立错误类型
不能:
Extract Failed
就结束。
应该区分:
Wrong Password
Unsupported Encryption
Corrupted Archive
Not Enough Space
Cancelled
Permission Denied
例如:
enum ArchiveError: Error {
case wrongPassword
case unsupportedFormat
case unsupportedEncryption
case corruptedArchive
case insufficientStorage
case invalidEntryPath
case cancelled
case unknown(Error)
}
这样 UI 才能告诉用户:
密码错误,请重新输入。
而不是:
解压失败。
25. Zip Slip:压缩解压里非常重要的安全问题
假设一个恶意 ZIP 内部路径:
../../../../Library/secret.txt
如果解压逻辑只是:
destination
.appendingPathComponent(entry.path)
然后直接写文件,
攻击者就可能尝试把文件写出目标目录。
这类问题通常被称为:
Zip Slip / Path Traversal。
26. 每一个 Entry Path 都必须验证
例如目标:
/Documents/Extracted/
Entry:
../../evil.txt
拼接后:
/Documents/Extracted/../../evil.txt
如果直接规范化,
可能逃离:
Extracted/
所以必须:
Entry Path
↓
Normalize
↓
Resolve
↓
Check Destination Root
↓
Allowed?
只有:
Final Path ∈ Destination Root
才允许写入。
核心原则:
压缩包里的路径永远不能被当成可信输入。
27. 绝对路径同样不能信
例如 Entry:
/private/var/...
或者某些奇怪的 Windows 风格路径:
C:\...
都应该在 Archive 层做标准化和拒绝。
不能让归档文件自己决定:
我要写到设备上的哪里。
归档只应该影响:
Destination Root
下面的相对路径。
28. 还要警惕符号链接
假设压缩包中创建:
link -> ../../somewhere
后续 Entry 再写入:
link/file
理论上可能绕过普通字符串路径检查。
因此如果底层格式支持:
Symlink
解压器还需要明确策略:
Reject
Preserve Safely
Resolve Carefully
尤其文件管理 App 不应该默认盲目信任归档里的链接。
29. 什么是压缩炸弹?
压缩包还有另一类风险:
Decompression Bomb。
例如一个非常小的压缩包:
42KB
解压后可能膨胀成:
Several GB
甚至更大。
形式上:
Tiny Archive
↓
Huge Expansion
↓
Disk Full
↓
Resource Exhaustion
这类归档可能是恶意的,也可能只是极端压缩数据。
30. 不能只看压缩包本身大小
错误判断:
Archive = 10MB
所以:
一定没问题。
实际上真正应该尽量估计:
Total Uncompressed Size
例如:
Compressed:
100MB
Uncompressed:
80GB
就需要谨慎。
可以在解压前计算:
Estimated Output Size
↓
Available Storage
↓
Enough?
明显不够就提前失败。
31. 还要限制解压文件数量
压缩炸弹不一定只靠尺寸。
也可能是:
1,000,000 Tiny Files
即使总大小不是特别大,
文件系统操作也可能造成巨大压力。
所以安全策略可以考虑:
Maximum Entry Count
Maximum Expanded Size
Maximum Compression Ratio
Maximum Nested Depth
具体限制要结合产品场景设计。
32. 嵌套压缩包尤其危险
例如:
a.zip
↓
b.zip
↓
c.zip
↓
d.zip
如果 App 自动递归:
Auto Extract Everything
风险会快速放大。
所以普通文件管理器最好不要:
发现压缩包就无限自动继续解。
更安全的是:
Extract Current Archive
完成后由用户决定是否继续打开内部归档。
33. 压缩文件时也需要流式处理
创建:
backup.zip
同样不能:
100 Files
↓
All Data in Memory
↓
Compress
更合理:
File A
↓
Stream
↓
Compressor
↓
Archive
File B
↓
Stream
↓
Compressor
↓
Archive
所以:
Compress
和:
Extract
都应该围绕:
Streaming Pipeline
设计。
34. 压缩等级应该让用户选择吗?
例如:
Fast
Balanced
Maximum
对应:
Speed
↕
Compression Ratio
对于普通用户,直接展示:
Level 0-9
未必友好。
可以转换成:
Fast
Standard
Best Compression
高级设置里再提供更细控制。
这也是:
技术参数不一定应该原样暴露给用户。
35. 压缩视频往往不会小很多
用户可能选择:
movie.mp4
然后 ZIP。
发现:
2.00GB
↓
1.98GB
因为 MP4 内部本身已经使用视频编码压缩。
类似还有:
JPEG
HEIC
MP3
AAC
它们本身已经高度压缩。
所以文件压缩和:
Video Compression
Image Compression
Audio Compression
不是一回事。
ZIP:
对文件数据进行归档/通用压缩。
视频压缩:
重新编码媒体内容。
这一点很适合在产品里给用户解释清楚。
36. ArchiveService 不应该负责视频转码
如果用户:
movie.mp4
想从 2GB 变成 300MB,
应该进入:
MediaService
而不是:
ArchiveService
所以:
ArchiveService
↓
ZIP / 7z / TAR
MediaService
↓
Video / Audio / Image Compression
职责必须分开。
37. 压缩任务应该进入统一 FileOperationManager
例如用户:
Select 500 Files
↓
Compress
↓
archive.zip
这本质仍然是:
Long Running Operation
应该拥有:
Waiting
Running
Progress
Cancel
Completed
Failed
所以可以:
OperationType.compress
OperationType.extract
纳入:
FileOperationManager
任务中心:
Creating archive.zip
2.1 GB / 5.0 GB
42%
38. Archive Operation 可以复用现有基础设施
前几篇已经拥有:
Progress
Cancellation
Conflict Handling
Temporary File
Error Mapping
Storage Check
压缩解压无需重新发明。
例如:
Extract
↓
FileOperationManager
↓
ArchiveService
↓
Temporary Destination
↓
Progress
↓
Finalize
这样架构才能长期维护。
39. 远程压缩包怎么办?
这是统一架构真正有意思的地方。
比如:
NAS
↓
backup.zip
用户想打开。
最简单的方法:
SMB
↓
Download Entire ZIP
↓
Local
↓
ArchiveService
但如果:
backup.zip = 50GB
显然体验不好。
更高级的实现可以考虑:
Remote Range Read
让 ArchiveService 通过:
FileProvider.read(offset:length:)
按需读取。
40. 为什么 ZIP 很适合随机访问?
ZIP 的目录信息通常让客户端能够先获得:
Entry Index
再读取特定 Entry 所需的数据区域。
如果 FileProvider 支持:
Random Access
理论上就可以:
Remote ZIP
↓
Read Archive Index
↓
Show Entries
↓
User selects one file
↓
Read Required Range
而不是:
Download 50GB ZIP
之后才能看到里面有什么。
这对 NAS / WebDAV 文件浏览器非常有价值。
41. 所以 ArchiveService 最终也需要抽象数据源
一开始可能是:
ArchiveService(URL)
更进一步可以变成:
ArchiveDataSource
例如:
protocol ArchiveDataSource {
var size: Int64 { get }
func read(
offset: Int64,
length: Int
) async throws -> Data
}
于是:
Local File
↓
LocalArchiveDataSource
SMB File
↓
RemoteArchiveDataSource
WebDAV
↓
RemoteArchiveDataSource
ArchiveService 不需要知道:
文件到底在哪。
它只需要:
给我指定范围的数据。
42. 这就是前面 FileProvider 抽象的延伸
整个链路可以变成:
SMB / WebDAV / Local
↓
FileProvider
↓
Random Access
↓
ArchiveDataSource
↓
ArchiveService
↓
ArchiveEntry
于是:
Remote Archive Preview
就成为可能。
这也是为什么文件管理器底层抽象设计得好以后,新功能会越来越容易复用。
43. 创建密码 ZIP 时需要把“安全”当作功能的一部分
如果用户选择:
Create Encrypted Archive
UI 不应该只提供:
Password
最好还要处理:
Confirm Password
以及:
- 是否显示密码
- 密码强度提示
- 忘记密码无法恢复的说明
因为压缩包密码和 App 登录密码完全不同。
如果归档格式本身不提供恢复机制:
用户忘记密码,App 通常也无法帮他恢复文件。
这一点必须提前说明。
44. Archive Password 可以进入专门的密码管理层
例如:
Archive Password
↓
Temporary Use
默认只在当前任务中存在。
如果用户明确选择:
Remember Password
再进入安全存储。
例如:
Archive Password Store
↓
Keychain
这样和:
SMB Credential
WebDAV Credential
FTP Credential
在安全基础设施上可以复用。
45. 一个完整的 Archive 架构
最终可以整理成:
┌─────────────────────────────┐
│ UI │
│ │
│ Archive Browser │
│ Compress Sheet │
│ Extract Progress │
└──────────────┬──────────────┘
│
▼
┌─────────────────────────────┐
│ FileOperationManager │
│ │
│ Compress │
│ Extract │
│ Progress │
│ Cancel │
│ Conflict │
└──────────────┬──────────────┘
│
▼
┌─────────────────────────────┐
│ ArchiveService │
│ │
│ Detect │
│ List Entries │
│ Extract │
│ Create │
└──────────────┬──────────────┘
│
┌──────┼───────┐
▼ ▼ ▼
ZIP RAR 7z
│
└──────┬───────┘
▼
ArchiveDataSource
│
┌─────────┼─────────┐
▼ ▼ ▼
Local SMB WebDAV
旁边还需要:
Password Store
Security Validator
Path Validator
Storage Checker
Conflict Resolver
46. TS File Explorer 为什么需要统一 ArchiveService?
从开发者角度:
ZIP
RAR
7z
TAR
GZIP
都是完全不同的格式。
但用户根本不想了解这些。
用户只想:
收到文件以后能打开。
例如:
微信 / 邮件 / 网盘
↓
project.rar
↓
TS File Explorer
↓
查看内容
↓
Extract
↓
使用文件
用户真正需要的不是:
一个 RAR Decoder。
而是:
一个能够处理压缩文件的统一入口。
这也是文件浏览器产品非常重要的一层价值。
47. 压缩功能也应该遵循“协议差异隐藏在底层”
就像:
SMB
WebDAV
FTP
最终统一成:
FileProvider
一样。
ZIP
RAR
7z
TAR
最终也应该统一成:
ArchiveService
于是用户看到的永远只有:
Open
Preview
Extract
Compress
Password
而不是:
这个格式走 A API
那个格式走 B API
48. 开发压缩解压功能前,建议先回答这 20 个问题
如果准备实现完整的 Archive 模块,建议先回答:
1. 支持哪些格式?
2. 格式如何检测?
3. 是否支持只查看内容?
4. 是否支持选择性解压?
5. 大文件是否流式处理?
6. 解压进度如何计算?
7. 压缩进度如何计算?
8. 用户取消以后如何清理?
9. 同名文件怎么处理?
10. 是否支持密码 ZIP?
11. 支持哪些加密方式?
12. 密码如何保存?
13. 如何防止 Zip Slip?
14. 如何处理 Symlink?
15. 如何防止压缩炸弹?
16. 如何处理几十万个 Entry?
17. 是否支持远程 Archive?
18. 是否支持 Random Access?
19. Archive Operation 如何进入任务中心?
20. 第三方库的 License 是否适合商业分发?
如果这些问题没有提前考虑,压缩解压很容易从:
一个简单功能
变成:
性能问题
+
安全问题
+
兼容问题
+
大量格式特殊逻辑
写在最后
ZIP / RAR / 7z 看起来只是:
Compress
Extract
两个按钮。
真正实现以后,会发现它背后实际上包含:
Format Detection
+
Streaming IO
+
Encryption
+
Progress
+
Cancellation
+
Conflict Handling
+
Path Security
+
Resource Protection
所以对于文件浏览器来说:
Archive 模块不应该只是几个第三方解压 API 的集合,而应该是一套独立的文件处理系统。
到目前为止,这个系列已经从:
File Browser
逐渐发展成:
File Platform
整个路线:
01 File Browser Architecture
↓
02 Large File IO
↓
03 Wi-Fi Transfer
↓
04 SMB / NAS
↓
05 WebDAV
↓
06 FTP
↓
07 ZIP / RAR / 7z
下一篇我建议进入另一个和 TS File Explorer 很匹配、同时非常适合普通用户搜索的主题:
《iOS 图片格式转换怎么实现?HEIC、JPEG、PNG、WebP 与图片压缩架构》
下一篇可以系统讲:
HEIC → JPG
PNG → JPG
WebP → PNG
图片压缩
分辨率调整
批量处理
EXIF
内存峰值
这样内容会从“文件协议”进一步扩展到真正的:
文件处理能力。