文章

iOS 文件系统:沙盒、备份规则与数据持久化全解析

深入解析 iOS 沙盒目录结构、iCloud 备份规则、NSFileManager 文件操作、UserDefaults 与 Keychain 存储策略,以及 File Protection 加密机制,为跨平台开发者提供完整的 iOS 文件系统知识框架。

iOS 文件系统:沙盒、备份规则与数据持久化全解析

一句话概括

iOS 文件系统通过严格的沙盒(Sandbox)机制将每个应用的读写权限限制在自己的专属目录中,这些目录按照用途被划分为 AppName.app、Documents、Library(含 Caches 和 Preferences)、tmp 四个区域,各自遵循不同的 iCloud 备份和系统清理策略;理解这套目录体系的运作规则,是决定用户数据安全、存储效率和应用审核合规的关键。

背景与意义

对于从 Flutter、React Native 或鸿蒙应用开发转向 iOS 原生扩展的开发者而言,iOS 的文件系统是一座必须跨越的桥梁。跨平台框架通常通过抽象层(如 path_provider 插件)屏蔽了底层文件系统的差异——你只需要调用 getApplicationDocumentsDirectory() 就能获取”正确的”存储位置。但当问题出现时——用户反馈数据丢失、iCloud 备份空间被撑爆、下载的文件找不到、应用被系统清理后数据消失——你就需要深入理解 iOS 文件系统的底层机制。

iOS 的文件系统有三大核心理念,与其他平台截然不同:

  1. 沙盒机制(Sandbox):每个应用都被限制在一个独立的”沙盒”中,无法访问其他应用的目录,也不能随意访问系统目录。这既是安全机制,也是设计约束。

  2. 目录分工明确:iOS 明确定义了每个目录的用途、备份策略和生命周期。选错目录可能导致数据被备份到 iCloud、在系统清理时被删除、或导致应用审核被拒。

  3. 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 空间。不合理的数据存储位置会导致两个问题:

  1. 用户的 iCloud 空间被大量缓存数据撑爆
  2. 应用审核可能被拒绝(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_storagereact-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. 常见误区

误区 1getApplicationDocumentsDirectory() 返回的是 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 数据丢失的常见场景:

  1. 应用升级后的版本不兼容:如果你在更新中修改了 key 名称或数据格式,旧的数据不会被自动迁移
  2. 调用 removePersistentDomain(forName:):某些 SDK 可能会错误地清除整个 UserDefaults
  3. 存储了不支持的类型:UserDefaults 只能存储 Property List 类型,如果你存储了自定义对象(未归档为 Data),数据会丢失
  4. 写入了超出合理大小的数据:不要用 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 备份溢出或安全漏洞等问题。

本文由作者按照 CC BY 4.0 进行授权