iOS 文件系统:沙盒、备份规则与数据持久化全解析
深入解析 iOS 沙盒目录结构、iCloud 备份规则、NSFileManager 文件操作、UserDefaults 与 Keychain 存储策略,以及 File Protection 加密机制,为跨平台开发者提供完整的 iOS 文件系统知识框架。
一句话概括
iOS 文件系统通过严格的沙盒(Sandbox)机制将每个应用的读写权限限制在自己的专属目录中,这些目录按照用途被划分为 AppName.app、Documents、Library(含 Caches 和 Preferences)、tmp 四个区域,各自遵循不同的 iCloud 备份和系统清理策略;理解这套目录体系的运作规则,是决定用户数据安全、存储效率和应用审核合规的关键。
背景与意义
对于从 Flutter、React Native 或鸿蒙应用开发转向 iOS 原生扩展的开发者而言,iOS 的文件系统是一座必须跨越的桥梁。跨平台框架通常通过抽象层(如 path_provider 插件)屏蔽了底层文件系统的差异——你只需要调用 getApplicationDocumentsDirectory() 就能获取”正确的”存储位置。但当问题出现时——用户反馈数据丢失、iCloud 备份空间被撑爆、下载的文件找不到、应用被系统清理后数据消失——你就需要深入理解 iOS 文件系统的底层机制。
iOS 的文件系统有三大核心理念,与其他平台截然不同:
沙盒机制(Sandbox):每个应用都被限制在一个独立的”沙盒”中,无法访问其他应用的目录,也不能随意访问系统目录。这既是安全机制,也是设计约束。
目录分工明确:iOS 明确定义了每个目录的用途、备份策略和生命周期。选错目录可能导致数据被备份到 iCloud、在系统清理时被删除、或导致应用审核被拒。
File Protection 加密:iOS 提供文件级别的加密机制,根据文件的敏感程度可以选择不同的保护策略——从”设备解锁即可访问”到”设备首次解锁后才能访问”。
本文将系统性地拆解 iOS 沙盒的每个目录,分析 iCloud 备份规则对存储位置选择的影响,深入对比 UserDefaults 和 Keychain 的存储策略,讲解 NSFileManager 的常见操作和 File Protection 加密机制,并帮助跨平台开发者建立从跨平台 API 到 iOS 原生目录的映射关系。
核心知识点拆解
一、Sandbox 目录结构
每个 iOS 应用都有一个专属的沙盒目录。你可以通过 NSHomeDirectory() 函数获取沙盒根目录路径。沙盒内部的结构如下:
1
2
3
4
5
6
7
8
9
沙盒根目录(/var/mobile/Containers/Data/Application/{UUID}/)
├── AppName.app/ # 应用安装包(只读)
├── Documents/ # 用户数据(会备份)
├── Library/
│ ├── Caches/ # 缓存文件(不会备份,系统可清理)
│ ├── Preferences/ # UserDefaults 存储目录(会备份)
│ ├── Application Support/ # 应用支持文件(会备份)
│ └── ...其他系统子目录
└── tmp/ # 临时文件(不会备份,系统可随时清理)
1. AppName.app/(应用包目录)
这是应用的安装目录,包含了应用的二进制文件、资源文件(图片、声音、xib/storyboard 等)和其他 bundle 资源。这个目录是只读的,应用无法修改自己的安装包内容。
1
2
3
4
5
6
// 获取应用包路径
let bundlePath = Bundle.main.bundlePath
print("Bundle Path: \(bundlePath)")
// 获取包内资源路径
let imagePath = Bundle.main.path(forResource: "logo", ofType: "png")
此外,应用二进制文件本身位于 Bundle.main.executablePath,即 AppName.app/AppName。
2. Documents/(文档目录)
这是存储用户生成内容(user-generated content)的标准位置。该目录的内容会备份到 iCloud 和 iTunes。
应该放在 Documents 中的文件:
- 用户创建/编辑的文档
- 用户照片和视频
- 数据库文件(如 SQLite 数据库)
- 下载的用户内容供离线使用
不应该放在 Documents 中的文件:
- 可以从网络重新下载的数据
- 运行时生成的缓存文件
- 临时文件
- 应用配置和状态(应放在 Library/Preferences)
1
2
3
// 获取 Documents 目录路径
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
print("Documents: \(documentsPath.path)")
3. Library/(库目录)
Library 目录有多个子目录,每个都有不同的用途和备份策略。
Library/Caches/(缓存目录)
存放可以重新生成或下载的数据。不会备份到 iCloud,且在设备存储空间不足时,系统可能会清理此目录(但不会主动清理,只会在触发”设备存储空间优化”时清理)。
1
let cachesPath = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
应该放在 Caches 中的文件:
- 网络请求的响应缓存
- 下载的临时媒体文件
- 缩略图缓存
- 预处理的数据(可以重新计算)
不应该放在 Caches 中的文件:
- 任何无法重新获取的数据
- 用户数据的副本
Library/Preferences/(偏好设置目录)
存储 UserDefaults 数据。会备份到 iCloud。应用不应直接操作这个目录,而应通过 UserDefaults API 来读写。
1
2
// Preferences 目录路径(理论上可用但不应直接操作)
let preferencesPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first!.appendingPathComponent("Preferences")
Library/Application Support/(应用支持目录)
存储应用运行时需要但不应直接暴露给用户的数据。会备份到 iCloud。
1
2
3
4
// 获取 Application Support 目录
let appSupportPath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
// 注意:Application Support 目录可能不存在,需要先创建
try? FileManager.default.createDirectory(at: appSupportPath, withIntermediateDirectories: true)
应该放在 Application Support 中的文件:
- Core Data 的持久化存储文件
- 应用的配置文件
- 离线数据(不是用户直接创建的,但对应用功能必不可少)
4. tmp/(临时目录)
存放临时文件。不会备份到 iCloud,系统可能在任何时候清理此目录。应用退出时也应主动清理。
1
2
3
4
5
6
// 获取临时目录路径
let tmpPath = FileManager.default.temporaryDirectory
print("TMP: \(tmpPath.path)")
// 或者使用 NSTemporaryDirectory()
let tmpPath2 = NSTemporaryDirectory()
适合放在 tmp 中的文件:
- 下载过程中的临时文件(下载完成后移到 Documents 或 Caches)
- 导出文件时的中间文件
- 压缩/解压过程中的临时文件
二、iCloud 备份规则
iOS 的备份规则直接决定了你的应用数据是否会占用用户的 iCloud 空间。不合理的数据存储位置会导致两个问题:
- 用户的 iCloud 空间被大量缓存数据撑爆
- 应用审核可能被拒绝(App Store Review Guidelines 明确要求应用只备份用户数据)
1. 各目录的备份行为
| 目录 | 是否备份 | 系统清理 |
|---|---|---|
| Documents/ | ✅ 是 | ❌ 否 |
| Library/Preferences/ | ✅ 是 | ❌ 否 |
| Library/Application Support/ | ✅ 是 | ❌ 否 |
| Library/Caches/ | ❌ 否 | ✅ 可能 |
| tmp/ | ❌ 否 | ✅ 随时 |
| AppName.app/ | ❌ 否 | ❌ 否(只读) |
2. 标记文件”不备份”
如果你确实需要将一个文件放在 Documents 或 Application Support 中,但希望它不被备份到 iCloud,可以通过设置 isExcludedFromBackup 属性来标记:
1
2
3
4
5
6
7
8
9
10
11
12
13
// 标记文件不备份
var fileURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
fileURL.appendPathComponent("offline_data.db")
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true
try fileURL.setResourceValues(resourceValues)
// 或者使用更直接的方式
var fileURL = ... // 你的文件 URL
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true
try fileURL.setResourceValues(resourceValues)
重要警告:Apple 官方文档明确强调,应该”仅在必要的数据上使用 isExcludedFromBackup 标记”。不要滥用这个标记来逃避备份规则——如果你标记了整个 Documents 目录不备份,但其中存放了大量用户生成内容,用户的设备数据将无法恢复。
3. 备份大小的最佳实践
- 每个应用在 iCloud 备份中的数据应该尽量控制在几十 MB 内
- 可重新下载的数据(缓存、临时文件)应该放在 Caches 或 tmp 中
- 必要的配置数据放在 Preferences 中(UserDefaults 通常只有几 KB)
- 用户文档放在 Documents 中(由用户决定是否备份)
4. 检查备份状态
你可以在代码中检查一个文件/目录是否被排除备份:
1
2
3
4
5
6
func isFileExcludedFromBackup(at url: URL) -> Bool {
guard let values = try? url.resourceValues(forKeys: [.isExcludedFromBackupKey]) else {
return false
}
return values.isExcludedFromBackup ?? false
}
三、NSFileManager 常见操作
NSFileManager 是 iOS 文件操作的核心类,提供了创建、读取、写入、移动、复制、删除文件和目录的能力。
1. 文件路径 vs URL
iOS 推荐使用 URL 而不是字符串路径来操作文件。URL 提供了更丰富的 API 和更好的错误处理:
1
2
3
4
5
6
7
8
9
let fileManager = FileManager.default
// URL 方式(推荐)
let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
let fileURL = documentsURL.appendingPathComponent("data.txt")
// 路径字符串方式(兼容旧代码)
let documentsPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first!
let filePath = (documentsPath as NSString).appendingPathComponent("data.txt")
2. 文件创建与写入
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let fileURL = documentsURL.appendingPathComponent("notes.txt")
// 写入字符串
let content = "Hello, iOS File System!"
try content.write(to: fileURL, atomically: true, encoding: .utf8)
// 写入 Data
let data = Data(content.utf8)
try data.write(to: fileURL, options: .atomic)
// 追加写入
if let fileHandle = try? FileHandle(forWritingTo: fileURL) {
fileHandle.seekToEndOfFile()
fileHandle.write(Data("\n新的一行".utf8))
fileHandle.closeFile()
}
3. 文件读取
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 读取为字符串
let content = try String(contentsOf: fileURL, encoding: .utf8)
// 读取为 Data
let data = try Data(contentsOf: fileURL)
// 检查文件是否存在
if fileManager.fileExists(atPath: fileURL.path) {
print("文件存在")
}
// 获取文件属性
let attributes = try fileManager.attributesOfItem(atPath: fileURL.path)
let fileSize = attributes[.size] as? Int64 ?? 0
let creationDate = attributes[.creationDate] as? Date
let modificationDate = attributes[.modificationDate] as? Date
4. 目录操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
// 创建目录
let subDirectory = documentsURL.appendingPathComponent("images")
try fileManager.createDirectory(at: subDirectory, withIntermediateDirectories: true)
// 列出目录内容
let contents = try fileManager.contentsOfDirectory(at: subDirectory, includingPropertiesForKeys: nil)
for url in contents {
print("Found: \(url.lastPathComponent)")
}
// 深度遍历目录
if let enumerator = fileManager.enumerator(at: documentsURL, includingPropertiesForKeys: [.isRegularFileKey]) {
for case let fileURL as URL in enumerator {
let attributes = try fileURL.resourceValues(forKeys: [.isRegularFileKey])
if attributes.isRegularFile == true {
print("File: \(fileURL.path)")
}
}
}
5. 移动、复制与删除
1
2
3
4
5
6
7
8
9
10
// 移动文件
try fileManager.moveItem(at: sourceURL, to: destinationURL)
// 复制文件
try fileManager.copyItem(at: sourceURL, to: destinationURL)
// 删除文件
try fileManager.removeItem(at: fileURL)
// 安全删除(Trash):iOS 原生没有回收站概念,删除前建议做好备份
四、UserDefaults 与 Keychain 对比
这是 iOS 存储敏感信息时最常出现的二选一。很多跨平台开发者在选择”把 Token 存到哪里”时会困惑。
1. UserDefaults
UserDefaults 是一个轻量级的键值存储系统,适用于存储应用的配置和偏好信息。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 写入
UserDefaults.standard.set("user_token_abc", forKey: "access_token")
UserDefaults.standard.set(true, forKey: "is_logged_in")
UserDefaults.standard.set(42, forKey: "launch_count")
// 读取
let token = UserDefaults.standard.string(forKey: "access_token")
let isLoggedIn = UserDefaults.standard.bool(forKey: "is_logged_in")
let launchCount = UserDefaults.standard.integer(forKey: "launch_count")
// 删除
UserDefaults.standard.removeObject(forKey: "access_token")
// 立即写入磁盘(通常系统会在适当的时机自动同步)
UserDefaults.standard.synchronize() // 现代 iOS 中不再需要显式调用
UserDefaults 的局限性:
- 不适合存储敏感信息:UserDefaults 的数据以明文存储在 Library/Preferences 目录中,可以轻易被查看
- 不适合存储大量数据:UserDefaults 是为小型配置数据设计的,不应该存储二进制数据或大的 JSON
- key 拼写不报错:UserDefaults 不会检查 key 的拼写错误,只会返回 nil/0/false
- 不支持复杂数据结构(无原生 JSON 支持):只能存储 Property List 支持的类型(NSData, NSString, NSNumber, NSDate, NSArray, NSDictionary)
2. Keychain
Keychain 是 iOS 的安全存储系统,为敏感数据提供加密保护。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import Security
// 写入 Keychain
func saveToKeychain(key: String, data: Data) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
// 先删除已存在的项
SecItemDelete(query as CFDictionary)
// 添加新项
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}
// 读取 Keychain
func readFromKeychain(key: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
return status == errSecSuccess ? result as? Data : nil
}
// 删除 Keychain
func deleteFromKeychain(key: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess
}
Keychain 的特性:
- 加密存储:数据在写入时自动加密
- 应用卸载后保留:除非用户擦除设备,否则 Keychain 数据在应用卸载后仍然保留(需要注意:这在 iOS 10.3+ 上有所变化,应用卸载后 Keychain 默认会被清理,但可以通过配置改变)
- iCloud 同步可选:通过
kSecAttrSynchronizable属性控制是否同步到 iCloud - 共享访问:同一开发者账号下的应用可以共享 Keychain 数据(通过
access group) - 支持 Face ID/Touch ID 保护:可以在读取时要求生物认证
3. 何时使用哪一个?
| 场景 | 推荐存储方式 |
|---|---|
| 用户偏好设置(主题、字体大小) | UserDefaults |
| 应用配置(启动次数、版本号) | UserDefaults |
| 简单缓存(上次登录的用户名) | UserDefaults |
| 访问令牌(Auth Token) | Keychain |
| 密码/密钥 | Keychain |
| 支付信息 | Keychain |
| 生物认证保护的数据 | Keychain |
| 大量应用设置(> 100 KB) | Application Support 中的 JSON 文件 |
对于跨平台开发者:大部分 Flutter/RN 的 secure storage 插件(如 flutter_secure_storage、react-native-keychain)底层都使用 iOS Keychain 实现。如果你在调试时发现 Token 丢失,最常见的两个原因是:1) Keychain 在应用卸载时被清理(iOS 10.3+ 不同版本行为不同) 2) App 的 Team ID 变更(如重新签署时使用了不同的开发者账号)。
五、File Protection 机制
iOS 的 File Protection 提供文件级别的加密保护,确保文件在设备锁定时无法被读取。
1. 保护级别
iOS 提供四种文件保护级别:
NSFileProtectionComplete(最高级)
- 文件仅在设备解锁时可访问
- 设备锁定时,即使应用在后台运行也无法访问
- 适用于高度敏感数据
NSFileProtectionCompleteUnlessOpen
- 文件可以在设备解锁时创建和打开
- 打开后,即使设备锁定,文件句柄仍然可用
- 适用于需要在后台写入的敏感数据
NSFileProtectionCompleteUntilFirstUserAuthentication(默认)
- 设备首次解锁后,文件在所有状态下都可以访问
- 这是现代 iOS 系统的默认级别
- 在设备重启到首次解锁之间,文件不可访问
NSFileProtectionNone(无保护)
- 文件始终可访问
- 不提供文件级加密
1
2
3
4
5
6
7
8
9
10
11
12
// 设置文件保护级别
let fileURL = documentsURL.appendingPathComponent("sensitive_data.dat")
let fileData = Data("敏感信息".utf8)
fileManager.createFile(atPath: fileURL.path, contents: fileData, attributes: [
.protectionKey: FileProtectionType.complete
])
// 或者创建文件后修改保护级别
try (fileURL as NSURL).setResourceValue(
FileProtectionType.completeUntilFirstUserAuthentication,
forKey: .fileProtectionKey
)
2. File Protection 的工作原理
File Protection 基于 iOS 的硬件加密引擎。每个文件都有一个独立的数据密钥,该密钥被包装(wrap)在文件元数据中。当设备锁定时,File Protection Complete 文件的包装密钥会被系统丢弃,导致文件不可读。当设备解锁时,系统重新提供包装密钥,文件恢复可访问性。
3. File Protection 对应用的影响
- 后台任务:如果你在后台运行时需要访问受保护的文件,必须选择
completeUntilFirstUserAuthentication级别(默认级别) - 通知扩展:Notification Service Extension 在处理推送时,如果设备是锁定的,只能访问
completeUntilFirstUserAuthentication或更高可用性的文件 - Widget 扩展:同样,Widget 扩展也受到 File Protection 的约束
六、Flutter/RN 中的文件路径转换
当你在跨平台框架中调用 path_provider 或类似插件时,底层映射到的是 iOS 的哪些目录?
1. path_provider(Flutter)的 iOS 映射
1
2
3
4
5
6
7
8
9
// Flutter path_provider 插件
import 'package:path_provider/path_provider.dart';
// iOS 映射目录
final appDir = await getApplicationDocumentsDirectory(); // → Documents/
final tempDir = await getTemporaryDirectory(); // → tmp/
final cacheDir = await getApplicationCacheDirectory(); // → Library/Caches/
final supportDir = await getApplicationSupportDirectory(); // → Library/Application Support/
final libDir = await getLibraryDirectory(); // → Library/
2. RN 中的 iOS 映射
1
2
3
4
5
6
7
8
9
// React Native 中的文件路径
import RNFS from 'react-native-fs';
// iOS 映射目录
const docPath = RNFS.DocumentDirectoryPath; // → Documents/
const cachePath = RNFS.CachesDirectoryPath; // → Library/Caches/
const tempPath = RNFS.TemporaryDirectoryPath; // → tmp/
const libPath = RNFS.LibraryDirectoryPath; // → Library/
const mainBundlePath = RNFS.MainBundlePath; // → AppName.app/
3. 常见误区
误区 1:getApplicationDocumentsDirectory() 返回的是 Documents 目录,适合存放所有文件
真相:Documents 目录会备份到 iCloud。缓存文件和可重新下载的内容应该放在 Caches 目录中。
误区 2:下载的文件应该放在 tmp 目录
真相:tmp 目录可能在任意时刻被系统清理。下载的文件应该放在 Documents 目录(用户内容)或 Application Support(应用数据)。
误区 3:应用卸载后所有文件都会被清理
真相:大部分文件会被清理,但 Keychain 中的数据在某些条件下可能保留。需要注意的是,iOS 10.3+ 的 Keychain 在应用卸载时默认被清理(之前版本不会)。
实战案例
案例一:实现一个安全的文件持久化工具类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
import Foundation
class FileStorageManager {
static let shared = FileStorageManager()
private let fileManager = FileManager.default
// MARK: - 数据存储(根据数据用途自动选择目录)
/// 保存用户文档(会备份到 iCloud)
func saveUserDocument(data: Data, filename: String) throws -> URL {
let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
let fileURL = documentsDir.appendingPathComponent(filename)
try data.write(to: fileURL, options: .atomic)
return fileURL
}
/// 保存缓存文件(不会备份,可被清理)
func saveCache(data: Data, filename: String) throws -> URL {
let cachesDir = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
let fileURL = cachesDir.appendingPathComponent(filename)
try data.write(to: fileURL, options: .atomic)
return fileURL
}
/// 保存敏感数据(加密存储)
func saveSecureData(data: Data, key: String) -> Bool {
return KeychainHelper.save(key: key, data: data)
}
/// 保存应用配置数据(会备份,但不暴露给用户)
func saveAppSupportData(data: Data, filename: String) throws -> URL {
let supportDir = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
// Application Support 目录可能需要手动创建
if !fileManager.fileExists(atPath: supportDir.path) {
try fileManager.createDirectory(at: supportDir, withIntermediateDirectories: true)
}
let fileURL = supportDir.appendingPathComponent(filename)
try data.write(to: fileURL, options: .atomic)
return fileURL
}
/// 保存临时文件(系统可能随时清理)
func saveTempFile(data: Data, filename: String) -> URL {
let tempDir = fileManager.temporaryDirectory
let fileURL = tempDir.appendingPathComponent(filename)
try? data.write(to: fileURL, options: .atomic)
return fileURL
}
// MARK: - 清理操作
/// 清理缓存目录
func clearCache() {
let cachesDir = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
clearDirectory(at: cachesDir)
}
/// 清理临时目录
func clearTemp() {
let tempDir = fileManager.temporaryDirectory
clearDirectory(at: tempDir)
}
private func clearDirectory(at url: URL) {
guard let contents = try? fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) else { return }
for fileURL in contents {
try? fileManager.removeItem(at: fileURL)
}
}
// MARK: - 文件标记
/// 标记文件不备份
func excludeFromBackup(url: inout URL) throws {
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true
try url.setResourceValues(resourceValues)
}
/// 设置文件保护级别
func setFileProtection(url: URL, level: FileProtectionType) throws {
try (url as NSURL).setResourceValue(level, forKey: .fileProtectionKey)
}
}
案例二:Token 的安全存储
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
import Security
class KeychainHelper {
static let serviceName = "com.example.app"
static func save(key: String, data: Data) -> Bool {
// 构建查询字典
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
// 删除已存在的
SecItemDelete(query as CFDictionary)
// 新增
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}
static func read(key: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else {
print("Keychain read failed: \(status)")
return nil
}
return result as? Data
}
static func update(key: String, data: Data) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: key
]
let attributes: [String: Any] = [
kSecValueData as String: data
]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
return status == errSecSuccess
}
static func delete(key: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess
}
// 便捷方法:存储 string
static func saveString(key: String, value: String) -> Bool {
guard let data = value.data(using: .utf8) else { return false }
return save(key: key, data: data)
}
static func readString(key: String) -> String? {
guard let data = read(key: key) else { return nil }
return String(data: data, encoding: .utf8)
}
}
案例三:计算并显示应用缓存大小
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
class CacheManager {
static func calculateCacheSize() -> String {
let fileManager = FileManager.default
// 计算 Caches 目录大小
let cachesDir = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
let cachesSize = directorySize(url: cachesDir)
// 计算 tmp 目录大小
let tempDir = fileManager.temporaryDirectory
let tempSize = directorySize(url: tempDir)
let totalSize = cachesSize + tempSize
return ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file)
}
private static func directorySize(url: URL) -> Int64 {
let fileManager = FileManager.default
guard let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey]) else {
return 0
}
var totalSize: Int64 = 0
for case let fileURL as URL in enumerator {
guard let attributes = try? fileURL.resourceValues(forKeys: [.fileSizeKey]),
let fileSize = attributes.fileSize else { continue }
totalSize += Int64(fileSize)
}
return totalSize
}
static func clearAllCache() -> Bool {
let fileManager = FileManager.default
let cachesDir = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
let tempDir = fileManager.temporaryDirectory
do {
// 清除 Caches
let cachesContents = try fileManager.contentsOfDirectory(at: cachesDir, includingPropertiesForKeys: nil)
for url in cachesContents {
try fileManager.removeItem(at: url)
}
// 清除 Temp
let tempContents = try fileManager.contentsOfDirectory(at: tempDir, includingPropertiesForKeys: nil)
for url in tempContents {
try fileManager.removeItem(at: url)
}
return true
} catch {
print("清除缓存失败: \(error.localizedDescription)")
return false
}
}
}
常见问题
Q1:Documents 目录中的文件什么时候会被清理?
Documents 目录中的文件在正常情况下不会被系统自动清理。只有在用户主动清除应用数据(Settings > 通用 > iPhone 存储空间 > 选择应用),或者应用被卸载时,Documents 中的数据才会被删除。
Q2:标记了 isExcludedFromBackup 的文件放在 Documents 中,会有问题吗?
技术上没有问题,但不符合 Apple 的设计意图。如果你将大量数据放在 Documents 中并标记为不备份,应用审核时可能会被质疑。Apple 的设计意图是:用户数据(应该备份)放在 Documents,可重新生成的数据(不需要备份)放在 Caches。强行将不应备份的数据放在 Documents 并标记不备份,只会让目录结构变得混乱。
Q3:UserDefaults 存储的数据为什么在不该丢失的时候丢失了?
UserDefaults 数据丢失的常见场景:
- 应用升级后的版本不兼容:如果你在更新中修改了 key 名称或数据格式,旧的数据不会被自动迁移
- 调用
removePersistentDomain(forName:):某些 SDK 可能会错误地清除整个 UserDefaults - 存储了不支持的类型:UserDefaults 只能存储 Property List 类型,如果你存储了自定义对象(未归档为 Data),数据会丢失
- 写入了超出合理大小的数据:不要用 UserDefaults 存储大 JSON 或二进制数据
Q4:应用卸载后,Keychain 数据还在吗?
这取决于 iOS 版本和配置:
- iOS 10.2 及之前:Keychain 数据在应用卸载后保留
- iOS 10.3 及之后:Keychain 数据在应用卸载时默认被清理(除非使用了
kSecAttrAccessible设置为kSecAttrAccessibleAlways或使用access group与其他应用共享) - 如果设备开启了 MDM(移动设备管理),行为可能不同
Q5:Flutter 的 path_provider 返回的路径在 iOS 上到底长什么样?
实际路径示例:
1
2
3
Documents: /var/mobile/Containers/Data/Application/1A2B3C4D-.../Documents/
Caches: /var/mobile/Containers/Data/Application/1A2B3C4D-.../Library/Caches/
tmp: /var/mobile/Containers/Data/Application/1A2B3C4D-.../tmp/
UUID 部分每次应用重装都会变化,所以不建议硬编码路径,始终通过 API 获取。
总结
iOS 文件系统的沙盒机制为应用提供了安全的数据隔离环境,但也要求开发者精确理解每个目录的用途和生命周期。核心要点:
- Documents/:用户生成内容,会备份到 iCloud
- Library/Caches/:可重新生成的缓存,不会备份,系统可能清理
- Library/Preferences/:UserDefaults 数据,会备份
- Library/Application Support/:应用运行时数据,会备份
- tmp/:临时文件,不会备份,随时可清理
对于跨平台开发者,关键是要理解 path_provider 等插件提供的 API 背后映射到的是 iOS 的哪个目录,并据此做出正确的存储策略选择。同时,Keychain 是 iOS 上存储敏感信息的标准方式——不要为了图方便将 Auth Token 存入 UserDefaults。
最后,File Protection 机制为文件提供了硬件级别的加密保护,在涉及敏感数据时应选择合适的保护级别。理解这些底层机制,你才能在跨平台开发中做出高效的存储决策,避免用户数据丢失、iCloud 备份溢出或安全漏洞等问题。