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)