返回

iOS 18 文件App不显示App创建的文件?修复Beta版Bug

IOS

iOS 18 更新后,“文件”App 里看不到 App 创建的文件和目录了?这咋回事?

搞 iOS 开发的朋友,特别是用 React Native (或者原生开发也一样)跟文件系统打交道的,最近可能碰上个头疼事儿:升级到 iOS 18 Beta 版(模拟器或真机),你的 App 辛辛苦苦在用户指定的“我的 iPhone”目录下创建了文件和文件夹,代码执行没报错,甚至用 ls 命令去模拟器路径下看,文件也确实在那儿躺着。可是一打开系统自带的“文件”App,到那个目录下一瞅——嘿,空的!啥也没有!

别慌,你不是一个人。苹果开发者论坛和 Stack Overflow 上已经有不少人报怨这事儿了,看起来是 iOS 18 Beta 特有的新“惊喜”。老版本 iOS 17、15 什么的都好好的。

咱们先看看通常遇到这种问题,大家都是怎么捣鼓的。

老规矩:Info.plist 里加点料

过去,如果想让你的 App 创建的文件能被“文件”App 看见,或者能在原地被其他 App 打开,通常需要在 Info.plist 文件里加上这两个“开关”:

<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIFileSharingEnabled</key>
<true/>
  • UIFileSharingEnabled (Application supports iTunes file sharing): 允许用户通过 Finder (macOS) 或 iTunes (Windows) 访问 App Documents 目录下的文件。
  • LSSupportsOpeningDocumentsInPlace (Supports opening documents in place): 允许其他 App 直接在你 App 的容器内打开和编辑文件,而不需要复制一份。

理论上,这两个键,特别是后者,对于提高文件的可见性和可交互性有帮助。但这次在 iOS 18 上,不少人反馈,加上了也照样“隐身”。看来问题不在这儿,或者说,光靠这个不够了。

确保目录里有东西

还有个老经验是,一个空目录可能在“文件”App 里不会显示。你的代码片段里:

const create = async (path: string, content: string): Promise<void> => {
    const exists = await RNFS.exists(path); // 这里的 path 应该是目录路径
    if(!exists){
        // 修正: 应该先检查 `${path}Directory` 是否存在
        const dirPath = `${path}Directory`; // 假设 path 是 "/var/mobile/Containers/Data/Application/XXX/Documents/foo/"
        const dirExists = await RNFS.exists(dirPath);
        if (!dirExists) {
            await RNFS.mkdir(dirPath); // 创建目录
        }
        // 然后在目录里写文件
        await RNFS.writeFile(`${dirPath}/file.txt`, content, 'utf8');
    } else {
       // 如果目录已存在,直接写文件
       await RNFS.writeFile(`${path}Directory/file.txt`, content, 'utf8'); // 确保文件名和路径正确
    }
}

(稍微调整了下原始代码逻辑,确保目录存在再写入)。

你的代码确实创建了目录并且写入了文件 (file.{format}),所以“空目录不显示”这个原因也排除了。

那么,问题到底出在哪儿呢?

刨根问底:iOS 18 可能变了啥?

既然老办法不好使,文件也确实创建成功了,那矛头很可能指向 iOS 18 对文件系统访问、权限管理或者“文件”App 的索引机制做了某些调整。

考虑到你的场景:

  1. 用了 DocumentPicker 选择“我的 iPhone”下的目录 :这意味着你操作的不是 App 自己的沙盒容器目录,而是用户明确授权的外部存储位置。对这类位置的访问,iOS 向来管得比较严。
  2. 文件“物理”存在,但“逻辑”上不可见 :“文件”App 作为一个应用,它可能需要特定的系统通知、文件元数据或者遵循某种协调机制才能“发现”并展示这些由其他 App 创建的文件,尤其是在沙盒外的目录。
  3. Obsidian 等应用可以正常显示 :这说明肯定有办法做到,只是可能需要采用 iOS 18 更推荐(甚至强制)的新方式。Obsidian 这种严重依赖本地文件系统的 App,肯定对文件 I/O 处理得非常小心,可能用到了更底层的、符合苹果规范的 API。

结合这些线索,咱们可以猜测几个可能的原因和对应的解决方案:

  • 文件协调(File Coordination)机制的变化 :iOS 一直推荐使用 NSFileCoordinator 来访问共享文件或沙盒外文件,确保多进程访问时数据一致性。会不会 iOS 18 对未使用文件协调机制写入的外部文件,干脆就不通知“文件”App 了?
  • 安全域访问书签(Security-Scoped Bookmarks)的问题 :通过 DocumentPicker 获取到外部目录的访问权限后,这个权限默认是临时的。App 重启后可能就失效了。要想持久访问,并且让系统(包括“文件”App)知道你拥有合法权限,通常需要创建和使用安全域访问书签。会不会是这个环节处理不当,导致“文件”App 认为你无权或者干脆忽略了这些文件?
  • 元数据或 UTI(Uniform Type Identifier)问题 :文件要被正确识别和显示,可能需要合适的元数据或者正确的 UTI 类型信息。会不会 react-native-fs 在创建文件时,没有设置好这些信息,导致“文件”App 不认识?
  • 纯粹的 iOS 18 Beta Bug :毕竟是 Beta 版,出点 Bug 也正常。也许就是“文件”App 或者相关的系统服务在索引这部分外部文件时出了岔子。

可行的解决方案探索

针对上面的猜测,咱们来试试几条路:

方案一:拥抱文件协调(File Coordination)

原理:
NSFileCoordinator 是 Apple 提供的一套机制,用于协调不同进程(或者同一进程的不同线程)对同一文件的访问。当你需要读写文件时,先通过 NSFileCoordinator “打个招呼”,告诉系统你要干嘛。系统会确保在你操作期间,其他访问者(比如“文件”App 的后台进程)会适当等待或得到通知,避免冲突,同时也让系统清楚地知道文件的状态变化。对于沙盒外的文件操作,这尤为重要。

怎么做 (概念性 - React Native 可能需要 Native Module):
react-native-fs 这个库底层是调用了原生的文件操作 API。但它是否充分利用了 NSFileCoordinator,尤其是在处理 DocumentPicker 返回的这种外部 URL 时?这得看库的实现了。

如果 react-native-fs 没做或者做得不够,你可能需要:

  1. 自己动手写 Native Module: 创建一个原生的模块 (Swift 或 Objective-C),专门用来处理通过 DocumentPicker 获取到的目录 URL 下的文件创建和写入。

  2. 在 Native Module 中使用 NSFileCoordinator:

    • 获取 DocumentPicker 返回的目录 URL (directoryURL)。
    • 使用 NSFileCoordinator 实例。
    • 进行写操作时,调用 coordinate(writingItemAt: options: error: byAccessor:) 方法。

    Swift 示例 (概念):

    import Foundation
    
    func createDirectoryAndWriteFile(directoryURL: URL, fileName: String, content: String) {
        let coordinator = NSFileCoordinator()
        var coordinationError: NSError?
    
        // 1. 确保目录存在 (使用 File Coordinator)
        let directoryToCreateURL = directoryURL.appendingPathComponent("MyDirectory") // 你的目标子目录
        coordinator.coordinate(writingItemAt: directoryToCreateURL, options: .forDeleting, error: &coordinationError) { (urlForCoordination) in
            do {
                // 在协调块内部检查和创建目录
                var isDir: ObjCBool = false
                if !FileManager.default.fileExists(atPath: urlForCoordination.path, isDirectory: &isDir) || !isDir.boolValue {
                   try FileManager.default.createDirectory(at: urlForCoordination, withIntermediateDirectories: true, attributes: nil)
                   print("Directory created via coordinator at \(urlForCoordination.path)")
                } else {
                   print("Directory already exists at \(urlForCoordination.path)")
                }
            } catch {
                print("Error creating directory: \(error)")
                // Handle error appropriately
            }
        }
    
        if let error = coordinationError {
            print("Coordination error (directory creation): \(error)")
            return // Or handle error
        }
        coordinationError = nil // Reset error
    
        // 2. 在目录下写入文件 (再次使用 File Coordinator)
        let fileURL = directoryToCreateURL.appendingPathComponent(fileName)
        let dataToWrite = content.data(using: .utf8)!
    
        coordinator.coordinate(writingItemAt: fileURL, options: [], error: &coordinationError) { (urlForWriting) in
            do {
                try dataToWrite.write(to: urlForWriting, options: .atomic) // atomic 保证文件完整性
                print("File written successfully via coordinator to \(urlForWriting.path)")
    
                // ★ 关键点: 通知系统文件已更改 (有时协调器会自动处理,但显式通知可能更可靠)
                 try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: urlForWriting.path)
    
            } catch {
                print("Error writing file: \(error)")
                // Handle error
            }
        }
    
        if let error = coordinationError {
            print("Coordination error (file writing): \(error)")
            // Handle error
        }
    }
    
    // --- 如何在 React Native 中调用 ---
    // 你需要将这个 Swift 函数包装成 Native Module 的方法
    // 并在 RN 端获取 DocumentPicker 的结果 (通常是一个带有 bookmark data 的 URL 字符串)
    // 然后将 URL 字符串和书签数据传递给 Native Module 去解析和使用。
    

安全建议: 文件协调本身不直接涉及安全,但它是正确处理共享文件访问的一部分。确保你的错误处理到位。

进阶技巧:
NSFileCoordinator 有很多选项 (options),比如 .forDeleting, .forMoving, .contentIndependentMetadataOnly 等,可以根据具体操作选择最合适的,提高效率和准确性。对于目录操作,有时可能还需要协调其父目录。

方案二:妥善处理安全域访问书签(Security-Scoped Bookmarks)

原理:
当用户通过 UIDocumentPickerViewController (或 RN 里的封装) 选择了一个沙盒外的目录时,你的 App 会获得一个临时的、具有特殊权限的 URL。为了在 App 重启后还能继续访问这个目录(并且让系统的其他部分,比如“文件”App,也认可你的访问权),你需要把这个临时的 URL 转换成一个“安全域访问书签”。这个书签本质上是一小块加密数据,可以安全地存储起来(比如存在 UserDefaults 或 文件里)。下次需要访问时,再从书签数据解析回 URL,并 明确地 开始访问 (startAccessingSecurityScopedResource),用完后 明确地 结束访问 (stopAccessingSecurityScopedResource)。

怎么做 (同样可能需要 Native Module):

react-native-fsreact-native-document-picker 是否自动处理了书签的创建和解析?你需要确认这一点。

  1. 获取带书签数据的 URL: DocumentPicker.pickDirectory() 返回的结果通常会包含必要的信息来创建书签。你需要拿到这个原始的 URL 对象 (在原生层)。
  2. 创建书签数据: 调用 URL 的 bookmarkData(options:includingResourceValuesForKeys:relativeTo:) 方法来生成书签数据 (Data)。通常使用 .withSecurityScope 选项。
    // 假设 pickedDirectoryURL 是 DocumentPicker 返回的 URL
    do {
        let bookmarkData = try pickedDirectoryURL.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil)
        // ★ 将 bookmarkData 存起来 (e.g., UserDefaults, Keychain, or a file in your app's container)
        UserDefaults.standard.set(bookmarkData, forKey: "userSelectedDirectoryBookmark")
    } catch {
        print("Error creating bookmark: \(error)")
        // Handle error
    }
    
  3. 在需要访问时解析书签:
    // 读取存储的 bookmarkData
    guard let bookmarkData = UserDefaults.standard.data(forKey: "userSelectedDirectoryBookmark") else { return }
    
    var isStale = false // 书签是否已过期
    do {
        // 解析书签,获取安全的 URL
        let secureURL = try URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale)
    
        if isStale {
            // 书签过期了,需要重新让用户选择目录来获取新书签
            print("Bookmark is stale, need to re-pick directory.")
            // 可能需要重新生成书签并保存
        } else {
            // ★ 开始访问 ★
            if secureURL.startAccessingSecurityScopedResource() {
                // 在这里执行文件操作 (可以用方案一的 File Coordinator)
                // e.g., createDirectoryAndWriteFile(directoryURL: secureURL, ...)
    
                // ★ 操作完成后,结束访问 ★
                secureURL.stopAccessingSecurityScopedResource()
            } else {
                print("Failed to start accessing security scoped resource.")
                // Handle error (权限可能丢失)
            }
        }
    } catch {
        print("Error resolving bookmark: \(error)")
        // Handle error
    }
    

安全建议:
书签数据包含了访问权限信息,要妥善保管。startAccessingSecurityScopedResourcestopAccessingSecurityScopedResource 必须成对调用,否则可能导致资源泄露或者权限问题。确保在 App 生命周期事件(如进入后台、终止)中也正确停止访问。

进阶技巧:
书签可以设置成只读 (.securityScopeAllowOnlyReadAccess)。判断书签是否 isStale 很重要,如果过期,需要引导用户重新授权。

方案三:检查或添加文件元数据 / UTI

原理:
iOS 使用统一类型标识符 (Uniform Type Identifiers, UTI) 来识别文件类型,这会影响文件图标、默认打开方式以及 Spotlight 索引等。“文件”App 可能也依赖这个信息。如果你创建的文件没有合适的 UTI,或者元数据不完整,“文件”App 可能会“看不懂”。

怎么做:
直接通过 react-native-fs 设置精细的 UTI 或文件属性比较困难。这通常是原生 API 的范畴 (FileManager.setAttributes(_:ofItemAtPath:))。

  1. 确认 UTI: 你写入的文件是什么格式 (.txt, .jpg, .pdf 等)?系统是否能根据扩展名自动推断出正确的 UTI?对于标准格式通常没问题。如果是自定义格式,你需要在 App 的 Info.plist 中声明自定义的 UTI。
  2. 尝试设置基本属性 (可能通过 Native Module):
    即使用 react-native-fs 写入,也可以尝试之后用原生代码去更新一下文件的元数据,比如修改日期 modificationDate。这有时能“唤醒”文件系统的索引器。
    // 假设 fileURL 指向你刚创建的文件
    do {
        let attributes: [FileAttributeKey: Any] = [
            .modificationDate: Date() // 设置为当前时间
            // 你可以尝试设置其他属性,但 UTI 设置比较复杂
        ]
        try FileManager.default.setAttributes(attributes, ofItemAtPath: fileURL.path)
        print("Set modification date for \(fileURL.path)")
    } catch {
        print("Error setting file attributes: \(error)")
    }
    
    即使只是更新修改日期,也可能触发“文件”App 重新扫描。

进阶技巧:
了解 Apple 的 UTI 系统 (com.apple.developer.uniform-type-identifiers),特别是如何声明导出(Exported)或导入(Imported)的 UTI 类型,这对需要处理特殊文件格式的 App 很重要。

方案四:向 Apple 报告 Bug

原理:
我们做的所有努力都是基于“假设”iOS 18 的行为变化是有意为之且有对应解决方法的。但完全有可能是个纯粹的 Bug。特别是在 Beta 阶段,系统行为不稳定很常见。

怎么做:

  1. 使用 Feedback Assistant: 在你的 iPhone 或 Mac 上找到 Feedback Assistant (反馈助手) App。
  2. 提交详细报告:
    • 清晰问题:App 通过 DocumentPicker 选择“我的 iPhone”目录,使用文件系统 API (如 react-native-fs 或原生 API) 创建文件/目录后,在“文件”App 中不可见,但在终端或通过 App 内 API 可以确认文件存在。
    • 注明仅在 iOS 18 Beta (提供具体版本号) 下出现,iOS 17 等旧版本正常。
    • 提供复现步骤,越简单越好。如果可以,附上最小可复现代码片段或示例项目。
    • 附上相关诊断信息 (Sysdiagnose),Feedback Assistant 通常会引导你收集。
    • 引用你在开发者论坛上找到的帖子链接(比如问题中提到的 765329),说明这不是个例。
  3. 保持关注: Apple 可能会更新 Beta 版本修复此问题,或者在后续文档中给出说明。

这是目前最应该做的事情之一,尤其是当多个开发者遇到相同问题时。


目前来看,对于 iOS 18 Beta 上这个问题,结合方案二(安全域书签)方案一(文件协调) 的思路,通过 Native Module 实现,可能是最接近 Obsidian 这类 App 做法的路径。即使 react-native-fs 未来更新以适应 iOS 18,理解这两个核心概念对处理 iOS 上的文件 I/O 也是非常有益的。

同时,别忘了方案四 ,积极反馈给 Apple。你的反馈可能直接推动问题的解决。