从最早用 Objective-C 写 Documents 目录读写,到后来转向 Swift,再到现在的 SwiftUI 全量接管 UI 层,我在 iOS 上跟 FileManager 打交道的年头不算短了。前阵子做一个小工具,需要在纯 SwiftUI 环境下把用户输入的文本内容落到本地文件,再支持读取回来展示。本来以为就是个write(to:atomically:encoding:)的事,结果真上手才发现,FileManager 那一整套沙盒路径体系、安全作用域、以及 SwiftUI 的视图刷新机制之间,藏着不少如果不实际操作就根本不会注意到的细节。
所以这篇不打算讲那种"复制粘贴就能跑"的 Demo,而是把我在 SwiftUI 里用 FileManager 做文本文件读写的完整思路、代码、以及踩过的坑,按照实际项目推进的顺序写出来。文章会覆盖沙盒目录怎么选、文件读写怎么写才稳、如何和 SwiftUI 的状态管理优雅地配合、以及那些一不留神就让文件"凭空消失"的边界情况。适合刚接触 SwiftUI 文件操作、或者以前只写过 UserDefaults 想进一步落盘的用户。
1. 先把沙盒目录体系捋清楚,不然文件写哪去了都不知道
很多人第一次在 iOS 上操作文件,最容易懵的不是读写 API,而是"文件到底该放哪"。iOS 每个 App 都有自己的沙盒目录,你只能在自己的沙盒里玩,这和 macOS 那种可以随意访问整块磁盘的逻辑完全不同。SwiftUI 本身不管这件事,它只管 UI,真正负责落盘的是 Foundation 框架里的 FileManager。FileManager 负责找目录、建目录、移动文件、删除文件,以及所有和文件系统相关的底层操作。
1.1 四个常用目录,各自的分工完全不同
沙盒里最常用的是这四个目录,我直接列个表对比,大家选型的时候对着看就行:
| 目录 | 获取方式 | 是否备份 | 用途 | 什么时候清理 |
|---|---|---|---|---|
| Documents | .documentDirectory | 会备份 | 用户产生的核心数据、需要长期保留的文件 | 基本不清理 |
| Library/Caches | .cachesDirectory | 不备份 | 临时缓存、可重新下载的数据 | 系统空间不足时自动清理 |
| Library/Application Support | .applicationSupportDirectory | 会备份 | 应用运行需要的支持文件、数据库 | 不清理,但建议放到子目录 |
| tmp | .temporaryDirectory | 不备份 | 临时文件,生命周期极短 | 系统随时可能清理 |
我自己最常用的组合是:用户主动编辑的文本文件进 Documents,因为这类数据代表用户的心血,绝不能丢;应用启动时从网络拉下来、下次启动还要再拉一遍的缓存类文件进 Caches,因为丢了也就丢了,重新下载即可;而如果做的是一个需要维护数据库或者多个支持文件的应用,App Support 才是正解。
提示:判断一个文件该不该放 Documents,最直接的标准就是问自己——如果系统帮你把这个文件备份到了 iCloud,用户换手机之后找回来了,你会不会觉得合理?如果答案是"这文件丢了也无所谓",那就应该放进 Caches。
1.2 URL 和 String 路径,两种方式怎么选
FileManager 的 API 里,"去哪里找文件"这个参数通常有两种表达方式:一种是老式的String路径,比如/var/mobile/Containers/Data/Application/xxxxxx/Documents/note.txt;另一种是URL,比如file:///var/mobile/.../note.txt。
在 SwiftUI 时代我强烈建议全程用URL。原因有两点:
第一,URL自带appendingPathComponent方法,拼目录层级非常安全。你用字符串拼路径时,很容易搞出双斜杠或者忘记拼扩展名,但appendingPathComponent("note.txt")会自动处理这些细节。
第二,很多文件操作 API 的返回值和参数都是URL,比如FileManager.default.urls(for:in:)返回的就是[URL]。如果你全程用URL,代码的类型是统一的,省去反复转换的麻烦。
这是我几乎每个项目都会写的一个扩展,用来快速拿到 Documents 目录 URL:
import Foundation extension FileManager { static var documentsDirectory: URL { `default`.urls(for: .documentDirectory, in: .userDomainMask)[0] } static var cachesDirectory: URL { `default`.urls(for: .cachesDirectory, in: .userDomainMask)[0] } static var tmpDirectory: URL { `default`.temporaryDirectory } }这里有个细节值得解释:为什么是[0]?因为urls(for:in:)返回的是一个数组,理论上某个目录可能对应多个位置。但苹果平台规范保证了这个数组的第一个元素就是主位置,所以取[0]是安全和惯用的写法。如果你用NSSearchPathForDirectoriesInDomains拿到的是字符串数组,同理取第一个。
2. FileManager 文本读写的核心代码与背后的执行逻辑
目录搞明白了,接下来就是真正的读写操作。FileManager 本身并不直接提供"把字符串写到文件"这种一键 API,它主要负责目录和文件的管理动作,而文本内容到底怎么编码、怎么写入,通常是靠String类型的方法或者Data的写入方法完成的。
这句话听起来有点绕,但这是理解整个文件操作的关键:FileManager 是文件系统的管理者,而 Data/String 是内容的搬运工。实际项目中你往往是两者结合使用——FileManager 用来确认目录存在、检查文件是否存在,Data/String 用来真正执行读写。
2.1 写文件的标准姿势:目录检查 + 原子写入
直接写文件最怕什么?最怕目标目录不存在。很多新手第一次调用写入,发现报错NSCocoaErrorDomain,代码 4 或者 516,其实就是目录路径有问题。所以我把写入拆成三步:确认目录存在、组装文件完整路径、写入数据。
import Foundation enum TextFileError: LocalizedError { case directoryCreationFailed(Error) case writeFailed(Error) case readFailed(Error) case fileNotExist var errorDescription: String? { switch self { case .directoryCreationFailed(let error): return "创建目录失败:\(error.localizedDescription)" case .writeFailed(let error): return "写入文件失败:\(error.localizedDescription)" case .readFailed(let error): return "读取文件失败:\(error.localizedDescription)" case .fileNotExist: return "文件不存在" } } } struct TextFileStore { let fileManager: FileManager init(fileManager: FileManager = .default) { self.fileManager = fileManager } private func ensureDirectoryExists(at directory: URL) throws { guard !fileManager.fileExists(atPath: directory.path) else { return } do { try fileManager.createDirectory( at: directory, withIntermediateDirectories: true ) } catch { throw TextFileError.directoryCreationFailed(error) } } func write(text: String, to fileURL: URL, encoding: String.Encoding = .utf8) throws { let directory = fileURL.deletingLastPathComponent() try ensureDirectoryExists(at: directory) do { try text.write(to: fileURL, atomically: true, encoding: encoding) } catch { throw TextFileError.writeFailed(error) } } }withIntermediateDirectories: true这个参数值得单独讲一下。它的意思是,如果中间层级目录不存在,就一并创建。比如你要创建Documents/notes/2025/这个路径,如果notes和2025都不存在,这个参数会一口气全建出来。如果你传了false,那么只要中间任何一层目录缺失,创建就会失败。在实际项目中,我几乎永远传true,省心且符合直觉。
atomically: true这个参数也很关键。它表示系统会先把内容写到一个临时文件,等写入成功后再原子性地替换目标文件。这样做的最大好处是:如果写入过程中 App 被杀掉或者系统崩溃,目标文件不会处于"写了一半"的损坏状态。对于文本文件来说,最坏情况就是你失去这次写入,但绝不会得到一个内容截断的坏文件。
2.2 读文件的标准姿势:先检查再读取
读取就相对简单了,但依然建议先检查文件是否存在。虽然直接从不存在的路径读取同样会抛错,但提前检查能让你区分"文件不存在"和"读取过程中出错"两种不同场景,错误提示更友好。
extension TextFileStore { func readString(from fileURL: URL, encoding: String.Encoding = .utf8) throws -> String { guard fileManager.fileExists(atPath: fileURL.path) else { throw TextFileError.fileNotExist } do { return try String(contentsOf: fileURL, encoding: encoding) } catch { throw TextFileError.readFailed(error) } } }读取这里尤其要注意编码。默认情况下,iOS 上很多文本文件都是 UTF-8 编码,因为这是现代系统的主流选择。但如果你要读取的是从 Windows 或者老系统传过来的文件,很可能是 GBK/GB2312 编码。这种情况下你用 UTF-8 硬解,轻则中文乱码,重则直接抛错。处理这种场景的通用方案是先尝试 UTF-8,失败后回退到 GBK,再不行就换String(contentsOf:usedEncoding:)让它自动检测编码。
func readStringWithEncodingFallback(from fileURL: URL) -> String { if let text = try? String(contentsOf: fileURL, encoding: .utf8) { return text } let gbkEncoding = String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding( CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue) )) if let text = try? String(contentsOf: fileURL, encoding: gbkEncoding) { return text } return "" }这个回退逻辑我在实际项目里用得非常频繁,尤其是做文件导入功能时,用户的文件来自天南海北,编码不确定性极高。
2.3 删除与移动文件时的 FileManager 细节
文本文件操作不止读写,删除和改名也是刚需。最简洁的删除方式:
extension TextFileStore { func deleteFile(at fileURL: URL) throws { guard fileManager.fileExists(atPath: fileURL.path) else { return } do { try fileManager.removeItem(at: fileURL) } catch { throw TextFileError.writeFailed(error) } } }移动文件时要注意一个跨目录场景:Files App 目录和 Documents 目录之间移动时,文件的安全作用域可能会变化,最好用fileManager.moveItem(at:to:)而不是直接读出来再写一遍。直接读出来再写在大文件时既慢又浪费内存,而移动操作在同一个文件系统内是"改个指针"级别的高效操作。
3. 在 SwiftUI 中让读写和界面状态保持同步
文件读写的核心逻辑写完了,但在 SwiftUI 里你马上会遇到另一个问题——读写是命令式的,而 SwiftUI 的界面是声明式的、由状态驱动的。如果你不做一个桥接,就会面临"文件明明写成功了,界面上却没有变化,必须重启 App 才看得见"这种尴尬。
3.1 用 ObservableObject 把文件内容包装成发布状态
标准的做法是把文件内容包装进一个ObservableObject,再配合@Published属性。视图只跟这个属性打交道,属性的 setter 里自动写文件,这样每次修改都会同步落盘。
import SwiftUI import Combine final class NoteViewModel: ObservableObject { @Published var noteText: String = "" { didSet { guard noteText != oldValue else { return } try? saveToFile() } } let fileURL: URL init(fileURL: URL) { self.fileURL = fileURL loadFromFile() } private func loadFromFile() { let store = TextFileStore() guard let text = try? store.readString(from: fileURL) else { return } self.noteText = text } private func saveToFile() { let store = TextFileStore() do { try store.write(text: noteText, to: fileURL) } catch { // 实际项目里可以用一个 @Published var lastError 来兜底提示 print("保存失败:\(error.localizedDescription)") } } }这里有一个需要警醒的性能问题。didSet在每次noteText变化时都会触发,哪怕用户只是在 TextEditor 里多敲了一个字符,都会走一次完整的文件写入。本地文件写入虽然不算特别昂贵,但如果你连续快速输入,就会产生大量无谓的 IO 操作。对于文本文件这种体积很小的数据,影响确实有限,但这是一个坏习惯。
我常用的优化手段是:引入"防抖"机制,停止输入一秒之后才真正落盘。实现方式很简单,用一个Timer或Task.sleep做延迟。
final class DebouncedNoteViewModel: ObservableObject { @Published var noteText: String = "" private var saveTask: Task<Void, Never>? private let fileURL: URL init(fileURL: URL) { self.fileURL = fileURL if let text = try? TextFileStore().readString(from: fileURL) { self.noteText = text } } func userDidEdit(_ newText: String) { noteText = newText saveTask?.cancel() saveTask = Task { [weak self] in try? await Task.sleep(nanoseconds: 1_000_000_000) guard !Task.isCancelled else { return } await self?.persist() } } private func persist() async { try? TextFileStore().write(text: noteText, to: fileURL) } }用户每敲一个字,上一次还没触发的保存任务就被取消,只有停下来 1 秒钟后才执行真正写入。这个策略在真机上有肉眼可见的区别,尤其是在快速输入长文本时,明显不会卡顿。
3.2 视图层怎么监听文件内容变化
除了"在 App 内修改内容并保存",还有一种常见的场景:文件是在 App 外部被修改的。比如用户通过 Files App 共享文件进来,或者从 iCloud 同步更新了一个文本文件。这时即使你的@Published属性没变,文件内容其实已经变了。如果你不做监听,界面就停留在旧数据上。
FileManager提供了NSFileCoordinator和DispatchSource,但对大部分 App 来说用不到那么底层。SwiftUI 里最实用的是使用.onReceive监听一个定时器,在文件被外部修改时重新读取。
struct NoteView: View { @StateObject var viewModel: DebouncedNoteViewModel @State private var refreshTimer = Timer.publish(every: 2, on: .main, in: .common).autoconnect() var body: some View { TextEditor(text: $viewModel.noteText) .onReceive(refreshTimer) { _ in viewModel.reloadIfNeeded() } } }reloadIfNeeded里会做一件事:比较文件当前的修改时间和上次读取时记录的修改时间,如果变了才重新加载内容。不然每次定时器触发都重置用户正在编辑的文本,那体验就灾难了。
extension DebouncedNoteViewModel { func reloadIfNeeded() { guard let attributes = try? FileManager.default.attributesOfItem(atPath: fileURL.path), let modifiedDate = attributes[.modificationDate] as? Date, modifiedDate != lastModifiedDate else { return } lastModifiedDate = modifiedDate if let text = try? TextFileStore().readString(from: fileURL) { noteText = text } } }这个"修改时间比对"的方案,是我在多次遇到"外部改了文件但 App 没感知"的问题后总结出来的。它不是最优雅的,但绝对是最简单、最不容易引入新 Bug 的。
4. 处理 Documents 目录之外的读写:文件导入导出与安全作用域
如果你的 App 只操作自己沙盒里的文件,上面的内容已经足够。但现实项目里,用户一定会要求"把文件导出到 Files App"或者"从 Files App 中选取一个文件导入"。这时候你就不能继续闷头用 FileManager 了,得让位于fileImporter和fileExporter——这是 SwiftUI 提供的系统级文件选择器。它能绕开沙盒限制,让用户自由选择任意位置的文件。
4.1 SwiftUI 文件导入导出的完整实现
从 iOS 14 开始,SwiftUI 原生提供了fileImporter和fileExporter,到 iOS 17 已经相当成熟。这两个 modifier 的使用逻辑和系统级的 DocumentPicker 一致,区别是你再也不用写 UIKit 的 delegate 回调了,直接在 modifier 的闭包里处理结果即可。
struct ImportExportView: View { @State private var importedText = "" @State private var isImporting = false @State private var isExporting = false var body: some View { VStack(spacing: 20) { Button("导入文本文件") { isImporting = true } .fileImporter( isPresented: $isImporting, allowedContentTypes: [.plainText, .text], allowsMultipleSelection: false ) { result in handleImport(result) } Button("导出为文本文件") { isExporting = true } .fileExporter( isPresented: $isExporting, document: TextFileDocument(text: importedText), contentType: .plainText, defaultFilename: "note.txt" ) { result in if case .failure(let error) = result { print("导出失败:\(error.localizedDescription)") } } } } private func handleImport(_ result: Result<[URL], Error>) { switch result { case .success(let urls): guard let url = urls.first else { return } // 关键:从安全作用域读取 let didAccess = url.startAccessingSecurityScopedResource() defer { if didAccess { url.stopAccessingSecurityScopedResource() } } if let text = try? String(contentsOf: url, encoding: .utf8) { importedText = text } case .failure(let error): print("导入失败:\(error.localizedDescription)") } } }这里最关键的一行是url.startAccessingSecurityScopedResource()。文件选择器返回给你的 URL,并不在你的沙盒内,它是一个"安全作用域"下的资源引用。如果你不先声明要访问这个外部资源,直接去读文件,大概率会读不到内容或者得到一个权限错误。用完以后要记得调用stopAccessingSecurityScopedResource()释放对文件的访问权,这个成对出现,忘记任何一个都会出问题。
导出侧我定义了一个TextFileDocument,它是FileDocument协议的实现。FileDocument是 SwiftUI 用来描述"可被文件系统操作的内存文档"的数据类型,必须实现两个方法:一个把内存数据转成文件内容,一个从文件内容恢复内存数据。
import SwiftUI import UniformTypeIdentifiers struct TextFileDocument: FileDocument { static var readableContentTypes: [UTType] { [.plainText] } var text: String init(text: String) { self.text = text } init(configuration: ReadConfiguration) throws { guard let data = configuration.file.regularFileContents, let string = String(data: data, encoding: .utf8) else { throw CocoaError(.fileReadCorruptFile) } text = string } func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { let data = text.data(using: .utf8) ?? Data() return FileWrapper(regularFileWithContents: data) } }4.2 iCloud Drive 和共享文件夹的处理细节
如果用户把文件放在 iCloud Drive 里,除了安全作用域访问,你还需要考虑文件是否正在下载。NSFileCoordinator在这种情况下几乎是强制要求,因为 iCloud 文件可能是占位文件,实际内容还没有下载到本地。普通读取会拿到一个 0 字节的文件或者直接超时。
NSFileCoordinator的用法不复杂,但它是一个比较老的 API,用起来有点像写 ObjC。核心逻辑是:
import Foundation func readTextFromSecurityScopedURL(_ url: URL) throws -> String { var result = "" var coordinatorError: NSError? var readError: Error? let coordinator = NSFileCoordinator() coordinator.coordinate(readingItemAt: url, options: [], error: &coordinatorError) { coordinatedURL in do { let text = try String(contentsOf: coordinatedURL, encoding: .utf8) result = text } catch { readError = error } } if let coordinatorError = coordinatorError { throw coordinatorError } if let readError = readError { throw readError } return result }我再强调一个很容易被忽略的细节:NSFileCoordinator的闭包里拿到的是一个coordinatedURL,你要读的是这个协调后的 URL,而不是闭包外部的原始url。因为文件协调器可能会根据实际情况把 URL 做一层包装,直接读原始 URL 依然会踩坑。
对于 iCloud 的场景,还会遇到一个"文件是不是已经下载到本地"的问题。可以通过FileManager.default.url(for: .ubiquityIdentityToken, in: .allDomainsMask, appropriateFor: nil, create: true)判断当前是否启用了 iCloud,再辅以resourceValues(forKeys:)查询URLResourceKey.ubiquitousItemDownloadingStatusKey。
func isFileDownloaded(_ url: URL) -> Bool { guard let values = try? url.resourceValues(forKeys: [.ubiquitousItemDownloadingStatusKey]), let status = values.ubiquitousItemDownloadingStatus else { return true } switch status { case .downloaded: return true case .notDownloaded, .downloading: return false @unknown default: return true } }不过话说回来,如果你的目标人群不是重度 iCloud 用户,这一节了解即可,不需要一上来就把NSFileCoordinator全套引入项目,否则代码复杂度会直接翻倍。
5. 实战排查:文件读写中最容易翻车的五个细节
操作系统的文件系统是一个"看起来简单、实则处处是边界"的领域。我整理了这些年做 iOS 文件读写最常遇到的五个翻车点,每一个都在真实项目里折磨过我。
5.1 模拟器与真机的沙盒路径完全不同
这是个经典到不能再经典的坑。模拟器上你的沙盒路径是 Mac 里的一个目录,而且 macOS 对大小写不敏感,但 iOS 真机默认是大小写敏感的。这意味着你在模拟器上写文件名note.TXT和note.txt,可能读出来是同一个文件,但到了真机上就可能变成两个不同的文件。
我在项目里遇到过用户导入一个文件,读取时显示为空,排查半天发现是文件名大小写的问题。所以我的建议是:所有文本文件命名统一用小写字母和下划线,扩展名固定为.txt,不要在文件名上搞花样。这样能规避掉一整个类别的跨环境差异。
5.2 原子写入在特定文件系统上的例外表现
前面我说atomically: true很安全,但有一个前提——它在本地 App Sandbox 内确实安全。但如果目标目录是 iCloud Drive 或者外部存储,原子写的语义可能发生变化。String.write(to:atomically:encoding:)在遇到外部存储时,实际上可能会先完整写入一个临时文件再做替换,这在某些文件 Provider 上会变得非常慢。
所以我的原则是:本地沙盒写文件,atomically: true随便用;涉及外部存储(iCloud、Files App 的第三方位置),尽量先用临时目录写,再协调移动到最终位置,并且用NSFileCoordinator保证一致性。
5.3 FileManager 的 fileExists 很不可靠
你没看错。fileExists(atPath:)在沙盒内通常可靠,但一旦涉及 iCloud 占位文件、安全作用域文件,它就可能返回false,哪怕这个文件实际是存在的。原因很简单——文件真的还没有下载到本地,系统层面只有元数据,fileExists看到的是一个不存在的物理文件。
遇到这种情况,不要直接"文件不存在"抛错,可以先尝试用FileManager.default.startDownloadingUbiquitousItem(at:)触发下载,再在下载完成回调里重新检查。iOS 17 里可以用URLSession配合下载任务实现,也可以监听NSMetadataQuery。
5.4 读取大文本文件时不要用 String(contentsOf:)
String(contentsOf:)是一次性把整个文件加载进内存。如果是几百 KB 的文本毫无压力,但如果用户导入的是一个 100 MB 的日志文件,你的 App 内存会瞬间飙升,甚至被系统 kill 掉。
正确做法是用流式读取,InputStream分块读取,或者FileHandle逐步读取。对于纯文本展示这种需求,还可以考虑只读取文件的前 N 行或者后 N 行做预览,而不是整个加载。
func readFirstLines(of url: URL, lineCount: Int = 50) -> String? { guard let fileHandle = try? FileHandle(forReadingFrom: url) else { return nil } defer { try? fileHandle.close() } var lines: [String] = [] var buffer = "" while let data = try? fileHandle.read(upToCount: 1024), !data.isEmpty { guard let chunk = String(data: data, encoding: .utf8) else { continue } buffer += chunk while let newlineRange = buffer.range(of: "\n") { let line = String(buffer[buffer.startIndex..<newlineRange.lowerBound]) lines.append(line) if lines.count >= lineCount { return lines.joined(separator: "\n") } buffer.removeSubrange(buffer.startIndex...newlineRange.lowerBound) } } return lines.joined(separator: "\n") }这段代码的逻辑是:每次只读 1024 字节,遇到换行符就把前面的内容当作一行收集起来,收集够 50 行就返回,绝不把整个大文件吞进内存。
5.5 权限弹窗和安全作用域引用释放时机
当你访问用户通过 DocumentPicker 选择的文件时,iOS 会弹一个权限确认。只要你调用了startAccessingSecurityScopedResource(),系统就认为你正在使用该文件,不会在访问期间撤销权限。但如果你调了stopAccessingSecurityScopedResource()之后还继续读文件,就会得到一个NSFileReadNoPermissionError。
更隐蔽的是,有些人会在异步任务里读取文件,然后立刻在同步代码里释放作用域引用,导致异步读取时权限已经没了。正确的做法是:整个读取过程都保持作用域访问,读完立刻释放。如果需要长期持有这个文件,把它复制到自己的沙盒 Documents 里,不要直接长期访问外部文件。
6. 一个完整的实战案例:做一个带自动保存的纯文本备忘录
到这里,原理性的东西讲得差不多了。我想用一个完全可以跑起来的案例把这些串起来——一个纯文本备忘录,支持新建、编辑、自动保存、读取已有文件,并且可以导出到 Files App。
6.1 项目结构设计
整个 App 只需要三个文件就能说清楚:
TextNotes/ ├── TextFileStore.swift // 文件读写核心逻辑 ├── NoteViewModel.swift // 状态管理与自动保存 └── ContentView.swift // SwiftUI 界面我一直觉得小工具项目不该过度分层。文件存储、状态管理、视图三层已经足够,再多一层抽象只是增加维护成本。
6.2 核心代码实现
TextFileStore.swift的代码在前面已经写出来了,直接复用。NoteViewModel加上防抖保存逻辑。ContentView负责界面和导入导出按钮的展示。
struct ContentView: View { @StateObject private var viewModel = NoteViewModel( fileURL: FileManager.documentsDirectory .appendingPathComponent("notes") .appendingPathComponent("memo.txt") ) @State private var showImporter = false @State private var showExporter = false var body: some View { NavigationStack { TextEditor(text: $viewModel.noteText) .font(.body) .padding(8) .navigationTitle("备忘录") .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { Button { showImporter = true } label: { Image(systemName: "square.and.arrow.down") } Button { showExporter = true } label: { Image(systemName: "square.and.arrow.up") } } } .fileImporter( isPresented: $showImporter, allowedContentTypes: [.plainText], allowsMultipleSelection: false ) { result in handleImport(result) } .fileExporter( isPresented: $showExporter, document: TextFileDocument(text: viewModel.noteText), contentType: .plainText, defaultFilename: "memo.txt" ) { _ in } } } private func handleImport(_ result: Result<[URL], Error>) { guard case .success(let urls) = result, let url = urls.first else { return } var didStartAccess = false if url.startAccessingSecurityScopedResource() { didStartAccess = true } defer { if didStartAccess { url.stopAccessingSecurityScopedResource() } } if let text = try? TextFileStore().readString(from: url) { viewModel.noteText = text viewModel.saveImmediately() } } }导入后我调用了一个saveImmediately(),它的作用是把刚导入的内容立刻复制到沙盒内Documents/notes/memo.txt这个位置。这样后续对这个文件的编辑都发生在自己的沙盒里,不会受外部文件权限释放的影响。这是"导入"和"打开"的本质区别——导入是复制,打开是引用。
6.3 运行效果与扩展方向
跑起来之后,用户看到的界面就是一个干净的 TextEditor,加上右上角导入导出按钮。每次用户停顿输入 1 秒后自动保存,重启 App 后内容还在。导出时会拉起系统文件选择器,存到 iCloud Drive 或者其他位置。
如果你想在这个基础上继续扩展,我觉得这几个方向比较实用:
- 支持多文件:文件列表界面 + 每个备忘录单独对应一个
.txt文件 - 支持重命名和删除:封装一个
renameFile(at:to:)方法,注意检查目标文件名是否已存在 - 支持 Markdown 预览:用系统自带的
AttributedString的 Markdown 解析能力,展示和编辑两个模式切换 - 支持用
UTF-16或GBK编码导入:把前面写的编码回退逻辑加进去
6.4 关于文件写回时机的个人经验
最后分享一个我在多次实践中确认的体会:自动保存的时机选择,比保存本身重要得多。在这个案例里我用的是"停止输入 1 秒后保存",这是我最推荐的方案,因为它兼顾了数据安全和性能。除此之外,你还可以在scenePhase变化时强制保存一次,这样即使用户打完字立刻按 Home 键退到后台,也不会丢失最后几秒的内容。
@Environment(\.scenePhase) private var scenePhase var body: some View { TextEditor(text: $viewModel.noteText) .onChange(of: scenePhase) { _, newPhase in if newPhase != .active { viewModel.saveImmediately() } } }把"防抖保存"和"退后台强制保存"两个策略叠加,是我目前见过最稳的组合。前者解决高频写入的性能问题,后者解决意外退出导致的数据丢失问题。你可以根据自己的场景调整延迟时间,但核心思路建议大家直接抄作业。