iOS通过Container读取文件问题笔录
一、问题现象
在 Kotlin Multiplatform 项目中实现 iOS 平台的文件存储时,出现如下情况:
- 现象:导出
.xcappdata可以确认文件确实在 Documents 目录下,但用NSData.dataWithContentsOfFile读取时始终返回空。 - 报错:
No such File or Directory(找不到该文件或目录)。
当时使用的读取代码如下(为便于排查,增加了 errorPtr 输出错误信息):
val errorPtr: ObjCObjectVar<NSError?> = alloc()
NSData.dataWithContentsOfFile(fullPath, options = 0, error = errorPtr.ptr)?.let { bytes ->
val array = ByteArray(bytes.length.toInt())
bytes.getBytes(array.refTo(0).getPointer(this), bytes.length)
return@withContext array
}
println(errorPtr.value?.description.orEmpty())
return@withContext null
二、问题定位
2.1 怀疑点:路径可能不对
从错误日志看,传入的 fullPath 很可能并不是当前运行时的真实路径,所以即使文件实际存在,NSData.dataWithContentsOfFile 仍然找不到。
2.2 对比验证:Application 目录的 UUID 不一致
用 Swift 在运行时获取 Documents 目录并列出文件,与 Kotlin 里使用的 fullPath 对比:
- Kotlin 中使用的 fullPath(可能是之前保存的完整路径):
/var/mobile/Containers/Data/Application/3FC1CCE9-A788-47AB-902A-FA133FAA3D30/Documents/60F570BE-2A16-4EFB-96B3-9203C0A0ABCE.jpg - Swift 当前拿到的文件 URL:
file:///private/var/mobile/Containers/Data/Application/C5AC5178-6887-40F4-9EE5-8D56CB830CB3/Documents/60F570BE-2A16-4EFB-96B3-9203C0A0ABCE.jpg
可见,Application 后面那一段 UUID 不一致:一个是 3FC1CCE9-...,一个是 C5AC5178-...。
Swift 侧获取 Documents 的示例代码:
let fileManager = FileManager.default
let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
do {
let fileURLs = try fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil)
print(fileURLs)
} catch {
print("Error while enumerating files \(documentsURL.path): \(error.localizedDescription)")
}
2.3 根因结论
自 iOS 8.0 起,应用沙盒里 Application 这一级目录的 UUID 会随安装/部署方式变化(例如卸载重装、Xcode 重新安装等)。若把「完整路径」存下来,下次启动或重装后 UUID 变了,旧路径就失效,导致「文件在,但按旧路径读不到」。
因此:不能依赖存储的完整路径来读文件,只能依赖「相对位置 + 文件名」并在运行时重新解析。
三、解决方案
思路统一为:只存文件名(或相对路径),读的时候先拿到当前 Documents 目录,再拼出完整路径或 URL。 下面两种写法都符合这一思路。
方法一:遍历 Documents,按文件名匹配
不依赖完整路径,先取到 Documents 的 URL,再在目录下按文件名找到对应文件:
// 拿到 Documents 目录的 url
val url = fileManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null
)!!
// 列出目录下所有文件的 url
val fileUrls = fileManager.contentsOfDirectoryAtURL(url = url, includingPropertiesForKeys = null, options = 0, error = errorPtr.ptr)
// 按文件名匹配得到目标文件的 url
val currentFileUrl = fileUrls?.first { it.toString().contains(fileName) } as? NSURL
// 用 URL 读取
currentFileUrl?.let {
NSData.dataWithContentsOfURL(currentFileUrl)
?.let { bytes ->
val array = ByteArray(bytes.length.toInt())
bytes.getBytes(array.refTo(0).getPointer(this), bytes.length)
return@withContext array
}
}
方法二(推荐):直接取 Documents 路径再拼接文件名
遍历有额外开销,更稳妥且高效的方式是:每次读取时用系统 API 取当前 Documents 目录,再拼接文件名:
val documentDirectory = NSSearchPathForDirectoriesInDomains(
directory = NSDocumentDirectory,
domainMask = NSUserDomainMask,
expandTilde = true
).first() as NSString
val fullPath = documentDirectory.stringByAppendingPathComponent(fileName)
// 此处 fullPath 一定是当前应用实例的真实路径
NSData.dataWithContentsOfFile(fullPath, ...)
这样得到的 fullPath 始终对应当前运行时的 Documents,不受 UUID 变化影响。
四、总结
| 要点 | 说明 |
|---|---|
| 不要存完整路径 | iOS 8+ 下 Application 目录 UUID 会变,存完整路径容易失效。 |
| 推荐只存文件名 | 使用时通过 API 获取当前 Documents 目录,再拼接文件名。 |
| 优先用系统 API 取目录 | 使用 NSSearchPathForDirectoriesInDomains 获取 Documents,比遍历目录更简单、高效。 |
按上述方式改造后,NSData.dataWithContentsOfFile 无法读取文件的问题即可解决。
附录:UUID 何时会变化
不同安装/运行方式下,沙盒中 Application 目录的 UUID 是否变化可参考下表:
| 安装/运行方式 | UUID 是否变化 |
|---|---|
| App 卸载后重新安装 | ✅ 变化(iOS 8+ 的安全机制) |
| 通过 Xcode 重新安装 | ✅ 变化(相当于卸载再安装) |
| Xcode 直接运行(不卸载) | ❌ 不变(仅 Build & Run 且未删除 App) |
| 模拟器上运行 | ✅ 可能变化(视情况而定) |
| TestFlight / App Store 安装 | ✅ 变化(安装时生成新 UUID) |
| iOS OTA 更新 App | ❌ 不变(更新不改变 UUID) |