iOS通过Container读取文件问题笔录

问题背景及描述

在Kotlin Multiplatform项目中,实现iOS平台的文件存储时,虽然可以通过导出.xcappdata文件确认文件确实存在于目录下,但在调用NSData.dataWithContentsOfFile读取时却始终返回空。错误信息显示找不到该文件或目录 (No such File or Directory)。

示例代码如下:

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

Note: 为了更好地定位问题,添加了errorPtr以查看具体错误信息

问题定位

  1. 通过错误日志发现,fullPath(这指的是问题描述中kotlin代码上的fullPath)可能是错误的路径。尽管导出的.xcappdata确认文件确实存在,但NSData.dataWithContentsOfFile依旧找不到。
  2. 通过对比fullPath和 Swift 代码获取的目录路径,发现 Application 目录后的 UUID 并不一致。例如:
  • fullPath路径是这样的

    /var/mobile/Containers/Data/Application/3FC1CCE9-A788-47AB-902A-FA133FAA3D30/Documents/60F570BE-2A16-4EFB-96B3-9203C0A0ABCE.jpg

  • 而文件url则是

    file:///private/var/mobile/Containers/Data/Application/C5AC5178-6887-40F4-9EE5-8D56CB830CB3/Documents/60F570BE-2A16-4EFB-96B3-9203C0A0ABCE.jpg

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)
    // process files
} catch {
    print("Error while enumerating files \(documentsURL.path): \(error.localizedDescription)")
}
  1. 经过一番查阅了解,自 iOS 8.0 开始,每次用户卸载并重新安装应用时,应用的 沙盒目录路径Application 目录)中的 UUID 部分都会发生变化。因此,存储完整路径是不可靠的。

解决方案

通过上述,已经确认问题是由于路径不一致导致,只要解决路径问题即可。这里提供两种比较直观的解决方案

方法1. 遍历目录,通过fileName过滤拿到对应的文件

既然存储路径会变化,而文件存在,可以遍历 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
// 读取到NSData中
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
    }
}

方法2. 直接获取 Documents 目录路径并拼接文件名

遍历文件目录可能带来性能开销,优化方案是直接获取 Documents 目录的路径并拼接文件名:

val documentDirectory = NSSearchPathForDirectoriesInDomains(
    directory = NSDocumentDirectory,
    domainMask = NSUserDomainMask,
    expandTilde = true
).first() as NSString
val fullPath = documentDirectory.stringByAppendingPathComponent(fileName)
println(fullPath)

这样可以保证 fullPath 始终指向当前应用实例的正确 Documents 目录,避免了 UUID 变化带来的问题。

总结

  1. 不要存储完整路径,因为 iOS 8.0 之后 Application 目录的 UUID 会发生变化。
  2. 推荐存储文件名,在使用时动态获取 Documents 目录并拼接文件名。
  3. 优先使用 NSSearchPathForDirectoriesInDomains 获取 Documents 目录路径,比遍历文件更高效。

通过上述方法,成功解决了 NSData.dataWithContentsOfFile 无法读取文件的问题。

安装/运行方式 UUID 是否变化
App 卸载后重新安装 ✅ 变化(iOS 8+ 开始引入的安全机制)
App 通过 Xcode 重新安装 ✅ 变化(Xcode 重新部署应用时相当于卸载+安装)
App 通过 Xcode 直接运行(不卸载) ❌ 不变(如果 Xcode 仅执行 Build & Run,不删除 App)
App 在模拟器上运行 ✅ 变化(某些情况下,每次启动可能会变化)
App 通过 TestFlight 或 App Store 安装 ✅ 变化(安装时生成新 UUID)
iOS OTA(无线更新 App) ❌ 不变(更新时不会改变 UUID)