iOS 沙盒路径踩坑:KMP 读取 Documents 文件为什么会报 No such file
在 Kotlin Multiplatform 项目里做 iOS 文件存储时,遇到过一个很典型的问题:
文件明明存在于 Documents 目录,但用 NSData.dataWithContentsOfFile 读取时始终返回空,并报 No such file or directory。
这个问题看起来像是文件没写成功,最后排查下来,真正原因不是文件不存在,而是读取时使用了已经失效的 iOS 沙盒完整路径。
一、问题现象
当时的现象比较明确:
- 导出
.xcappdata后,可以确认图片确实在 App 的Documents目录下 - Kotlin Native 侧用
NSData.dataWithContentsOfFile(fullPath, ...)读取时返回null - 错误信息是
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
fullPath 看起来没问题,文件也确实存在,但运行时就是读不到。
二、定位过程:路径里的 UUID 不一致
第一反应是路径可能不对。
于是用 Swift 在运行时重新获取当前 App 的 Documents 目录,并列出目录下文件,再和 Kotlin 侧保存的 fullPath 对比。
Kotlin 中使用的完整路径类似:
/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 侧验证代码如下:
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)")
}
到这里,问题基本明确了:
文件存在,但之前保存的完整路径已经不是当前运行时的真实路径。
三、根因:不要持久化 iOS 沙盒完整路径
从 iOS 8 开始,App 沙盒中 Application 这一层目录的 UUID 可能会随着安装和部署方式变化。
例如:
- 卸载后重新安装
- 通过 Xcode 重新安装
- TestFlight / App Store 重新安装
如果把完整路径持久化下来,下次启动或重新安装后,路径里的 UUID 可能已经变了。文件名还在,但旧路径不再有效。
所以问题不在 NSData 本身,而在路径策略:
不能依赖持久化的完整路径读取文件,应该只保存文件名或相对路径,并在运行时重新解析当前 Documents 目录。
四、解决方案
核心思路只有一个:
保存文件名,读取时重新获取当前 Documents 目录,再拼出真实路径。
方法一:遍历 Documents 后按文件名匹配
这种方式不依赖旧完整路径,而是先拿到当前 Documents URL,再在目录中查找目标文件:
val url = fileManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null
)!!
val fileUrls = fileManager.contentsOfDirectoryAtURL(
url = url,
includingPropertiesForKeys = null,
options = 0,
error = errorPtr.ptr
)
val currentFileUrl = fileUrls?.first { it.toString().contains(fileName) } as? NSURL
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,再拼接文件名
更推荐的方式是直接获取当前 Documents 路径,然后拼接文件名:
val documentDirectory = NSSearchPathForDirectoriesInDomains(
directory = NSDocumentDirectory,
domainMask = NSUserDomainMask,
expandTilde = true
).first() as NSString
val fullPath = documentDirectory.stringByAppendingPathComponent(fileName)
NSData.dataWithContentsOfFile(fullPath, ...)
这样得到的 fullPath 始终对应当前 App 实例的真实 Documents 路径,不受旧 UUID 影响。
五、排查结论
这次问题可以总结成下面几点:
| 要点 | 说明 |
|---|---|
| 不要保存完整路径 | iOS 沙盒 Application 目录 UUID 可能变化,旧路径会失效 |
| 推荐保存文件名 | 持久化文件名或相对路径,读取时重新解析目录 |
| 优先使用系统 API | 通过 NSSearchPathForDirectoriesInDomains 或 FileManager 获取当前目录 |
| 错误表象容易误导 | No such file 不一定是文件没写入,也可能是路径已经过期 |
改造后,读取逻辑不再依赖旧路径,NSData.dataWithContentsOfFile 无法读取文件的问题也就解决了。
附录:哪些情况下 UUID 可能变化
| 安装 / 运行方式 | UUID 是否变化 |
|---|---|
| App 卸载后重新安装 | 会变化 |
| 通过 Xcode 重新安装 | 会变化 |
| Xcode 直接运行且未卸载 App | 通常不变 |
| 模拟器运行 | 可能变化 |
| TestFlight / App Store 安装 | 会变化 |
| iOS OTA 更新 App | 通常不变 |
所以在 iOS 文件存储里,比较稳妥的原则是:
业务层只保存文件名或相对路径,运行时再解析真实沙盒目录。