做过 iOS 开发的人都会懂用户从来不会关心你 App 的沙盒边界在哪里他只知道手机里有文件、系统“文件”App 里能看到东西那就应该能上传、能保存。上个月我接到一个挺典型的小需求在一款企业工作台 App 里加“附件”功能用户要从系统自带的“文件”App 中挑一份 PDF、Word 或者图片上传到后端服务器同时App 生成的统计报表要能导出并保存到系统“文件”App 里的 iCloud Drive 或本机目录。这个需求刚开始听起来真的很常规但真正动手写才发现坑比想象中多安全作用域、沙盒路径、URL 过期、大文件直接压爆内存、iPad 上弹出选择器崩溃…… 我前后折腾了两三天才把链路理顺。这篇文章就把“从系统文件 App 选择文件上传”和“保存文件到系统文件 App”这两件事一次性讲透所有代码都是我在真实项目里跑过的版本你可以直接照着抄再根据自己后端接口调整细节就行。1. 机制先行为什么“文件”App 拿到的 URL 不能当普通路径用1.1 沙盒机制决定了文件访问方式iOS 每个 App 都有自己独立的沙盒容器容器里又分 Documents、Library、tmp 等几个目录。正常情况下你的 App 只能读写自己沙盒内的文件不能直接访问其他 App 的目录更不能扫描整个手机文件系统。这一点和 Android 那种“给个存储权限就能到处读文件”的思路完全不同。系统“文件”App 本质上是一个文件查看器背后连接了 iCloud Drive、本机“我的 iPhone”、各种第三方网盘存储能浏览的文件范围横跨多个来源。如果 iOS 直接把这些文件的底层路径暴露给开发者那 App 拿到底层路径就等于拿到了整个文件系统的访问入口安全边界也就形同虚设了。所以 Apple 的做法是由系统接管文件浏览界面用户主动选中某个文件后系统生成一个带“安全作用域”的 file URL 给你。这个 URL 的权限是临时的、受控的它不代表你可以无限期访问那个文件。理解这一层很重要因为很多“上传失败”“文件读取失败”的诡异 bug追根溯源都是因为没有搞懂这个临时权限的生命周期。1.2 安全作用域的正确打开方式当你在UIDocumentPickerDelegate回调里拿到url之后通常需要这样处理let didAccess url.startAccessingSecurityScopedResource() defer { if didAccess { url.stopAccessingSecurityScopedResource() } } // 在这里读取或复制文件startAccessingSecurityScopedResource()表示“我要开始使用这个受保护的文件了”stopAccessingSecurityScopedResource()表示“我用完了”。这两个方法必须成对出现。我见过不少同事只调 start 不调 stop当时看起来没问题但用久了会出现权限句柄泄漏最典型的表现是后面再选其他文件时访问权异常或者文件明明存在却有可能提示找不到。不过更稳的做法是在回调里拿到 URL 后不要直接依赖这个 URL 做异步上传而是立刻把它复制到 App 自己的 tmp 目录然后再去上传复制后的副本。原因很简单文档选择器返回的原始 URL 的访问权在回调结束后不一定还可靠尤其在退到后台、网络请求排队、等待用户确认这种场景下很容易踩到权限失效的坑。复制到沙盒以后再操作你面对的就是自己地盘里的文件权限问题彻底消失。复制到哪里也有讲究。临时中转用tmp目录最合适因为系统会帮你清理也不会进入 iCloud 备份如果文件需要长期保留再放进 Documents 目录。2. 从“文件”App 选择文件并上传完整实操2.1 拉起文件选择器代码与参数选择从 iOS 14 开始推荐用UIDocumentPickerViewController(forOpeningContentTypes:)系列初始化方法需要导入UniformTypeIdentifiersimport UIKit import UniformTypeIdentifiers final class FilePickerViewController: UIViewController { private lazy var pickFileButton: UIButton { let button UIButton(type: .system) button.setTitle(选择文件上传, for: .normal) button.addTarget(self, action: #selector(didTapPickFile), for: .touchUpInside) return button }() override func viewDidLoad() { super.viewDidLoad() view.addSubview(pickFileButton) pickFileButton.center view.center } objc private func didTapPickFile() { let picker UIDocumentPickerViewController( forOpeningContentTypes: [.item], asCopy: false ) picker.delegate self picker.allowsMultipleSelection false present(picker, animated: true) } }这里有两个参数值得单独说。第一个是contentTypes。.item是抽象类型表示所有文件都能选如果你只想让用户选 PDF就传[.pdf]只想选图片就传[.image]。实际业务里我建议按需限制用户能少选就少选既降低误操作概率也减少服务端处理意外格式的麻烦。第二个是asCopy。如果设为true系统会把用户选中的文件复制一份到你的沙盒里并把副本的 URL 返回给你这样你就不需要担心安全作用域了但代价是系统多一次完整拷贝大文件时会有明显的等待和磁盘占用。如果设为false默认的便利初始化方法也是这个语义返回的是原始文件 URL需要配合上一节的安全作用域处理。我习惯用false 回调里立即复制到 tmp 的方式因为这样流程我能完全控制不会出现系统复制到一半我这边又做别的事情导致状态混乱。2.2 Delegate 处理复制文件、清理权限、启动上传接下来是核心的代理回调extension FilePickerViewController: UIDocumentPickerDelegate { func documentPicker( _ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL] ) { guard let url urls.first else { return } // 1. 启动安全作用域访问 let didAccess url.startAccessingSecurityScopedResource() defer { // 函数结束时统一停止访问 if didAccess { url.stopAccessingSecurityScopedResource() } } do { // 2. 构造一个不会重名的 tmp 文件路径 let tmpURL FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) .appendingPathExtension(url.pathExtension) // 3. 立刻复制到沙盒 try FileManager.default.copyItem(at: url, to: tmpURL) // 4. 发起上传 uploadFile(at: tmpURL, fileName: url.lastPathComponent) } catch { // 提示用户选择文件失败 print(拷贝文件失败: \(error)) } } func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { // 用户取消不需要特殊处理 } }这段代码有两个容易被忽略的细节。第一tmp 文件名用UUID().uuidString而不是原始文件名。因为不同 App 共享系统的 tmp 目录概念虽然是沙盒内同一时刻可能存在来自不同来源的文件人工命名的文件也可能重名。用 UUID 可以彻底避开文件覆盖问题。真正的文件名通过lastPathComponent单独拿到上传时作为 multipart 的 filename 字段传给后端。第二copyItem(at:to:)而不是moveItem(at:to:)。因为你拿到的原始 URL 可能指向 iCloud Drive 或第三方存储直接移动不一定有权限甚至会把用户文件搞丢。复制是最稳妥的源文件不动副本进沙盒。2.3 multipart/form-data 上传实现文件上传最常遇到的接口形式是 multipart/form-data也就是把文件作为表单里的一个字段提交。下面给一个中小文件可以用的完整实现func uploadFile( at fileURL: URL, fileName: String, completion: escaping (ResultVoid, Error) - Void ) { // 假设 uploadURL 是你的后端接口地址 let uploadURL URL(string: https://api.example.com/upload)! let boundary Boundary-\(UUID().uuidString) var request URLRequest(url: uploadURL) request.httpMethod POST request.setValue( multipart/form-data; boundary\(boundary), forHTTPHeaderField: Content-Type ) var body Data() body.append(--\(boundary)\r\n.data(using: .utf8)!) body.append(Content-Disposition: form-data; name\file\; filename\\(fileName)\\r\n.data(using: .utf8)!) // 根据扩展名推断 MIME Type let mimeType: String if let type UTType(filenameExtension: fileURL.pathExtension) { mimeType type.preferredMIMEType ?? application/octet-stream } else { mimeType application/octet-stream } body.append(Content-Type: \(mimeType)\r\n\r\n.data(using: .utf8)!) body.append(try! Data(contentsOf: fileURL)) body.append(\r\n--\(boundary)--\r\n.data(using: .utf8)!) let task URLSession.shared.uploadTask(with: request, from: body) { _, response, error in DispatchQueue.main.async { if let error error { completion(.failure(error)) } else { completion(.success(())) } } } task.resume() }注意Data(contentsOf:)这种方式适合几十 MB 以内的文件再大就会有内存压力。我后面专门用一节讲大文件的处理。还有一个点很容易被忽视multipart 里的filename字段如果包含换行、引号等特殊字符可能让服务端解析出错。我通常在拼接前做一次过滤把\r、\n、替掉宁可文件名少几个字符也不要让整个请求挂掉。2.4 多个选择与取消处理如果业务上允许一次选多个文件把allowsMultipleSelection设为true然后遍历urls数组逐个复制、逐个上传。上传多个文件时我建议建立一个小队列串行上传不要同时开几十个 URLSession 任务否则很容易触发系统连接池限制也会让服务端压力很大。批量上传时还要考虑“部分成功”的情况。我的做法是每个文件单独回调结果界面逐条显示成功或失败不要做成“全部成功才算成功”。如果失败把沙盒里的 tmp 文件保留一段时间提供重新上传入口如果成功立刻删掉对应的 tmp 文件防止 Documents 和 tmp 被无用的上传临时文件塞满。3. 保存文件到“文件”App三种方案对照3.1 方案A写到 Documents用户从文件 App 直接查看最简单粗暴的方式是把生成的文件写到沙盒 Documents 目录然后在 Info.plist 里打开两个开关UIFileSharingEnabled YES LSSupportsOpeningDocumentsInPlace YES效果是用户在系统“文件”App 的“我的 iPhone”下能看到你的 App 文件夹进入后就能看到 App 生成的文件。代码只需要这样let documents FileManager.default .urls(for: .documentDirectory, in: .userDomainMask) .first! let fileURL documents.appendingPathComponent(report.pdf) try data.write(to: fileURL)这个方案的优势是零额外交互文件生成后用户直接去文件 App 就能看到。但限制也很明显文件只能待在当前 App 的文件夹里用户不能一键复制到 iCloud Drive 或其他网盘如果把 App 卸载整个文件夹连带里面的文件都会不见。如果需求只是“导出报表用户能从文件 App 查看”这个方案够用但如果用户期望“选择位置存放”就必须用方案B。3.2 方案B通过导出选择器让用户自定义保存位置更符合“保存文件到文件 App”字面意思的是UIDocumentPickerViewController(forExporting:)。它会弹出系统“存储到文件”界面用户可以选择 iCloud Drive、我的 iPhone、第三方存储位置然后确认保存。// 1. 先生成要导出的文件放 tmp 即可 let tmpURL FileManager.default.temporaryDirectory .appendingPathComponent(report-\(Date().timeIntervalSince1970).pdf) try data.write(to: tmpURL) // 2. 创建导出选择器 let picker UIDocumentPickerViewController(forExporting: [tmpURL], asCopy: true) picker.delegate self // 3. iPad 上必须设置 popover 锚点否则会崩溃 if let pop picker.popoverPresentationController { pop.sourceView self.view pop.sourceRect exportButton.frame } present(picker, animated: true)这里asCopy: true表示系统把 tmp 里的文件复制到用户选择的位置源文件仍然保留安全如果设为false系统可能直接把源文件“移动”走tmp 里的文件会消失你后续还要判断文件是否还在麻烦。导出结束后的回调func documentPicker( _ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL] ) { // 此时 urls 是用户选择的目标位置 URL // 我们一般不需要读取这些 URL只是确认保存成功 // 清理掉 tmp 里的源文件 let tmpURL FileManager.default.temporaryDirectory .appendingPathComponent(report-xxx.pdf) try? FileManager.default.removeItem(at: tmpURL) }特别注意回调里返回的 URL 指向用户存储的新位置它同样带安全作用域但你不要尝试去删除或者改写它这是用户主动选择保存的文件App 只需要知道“保存成功”就够了。方案B是三种方案里我实际最推荐的一种因为它把“存在哪里”的选择权交还给用户又不需要额外申请任何权限。3.3 方案C直接写 iCloud Drive不推荐理论上也可以直接往 iCloud Drive 写入用FileManager.default.url(forUbiquityContainerIdentifier: nil)拿 iCloud 容器路径然后写文件。但这件事的隐藏成本很高需要工程里开启 iCloud capability依赖开发者账号的 iCloud 容器配置而且 iCloud 同步是异步的写完后文件不一定立即可见还容易受到用户 iCloud 空间、网络状态的影响。如果只是做一个“导出/备份”功能我不建议碰 iCloud 容器。系统文件选择器里用户本来就能选择 iCloud Drive你用方案B把文件交给用户让系统去处理 iCloud 同步既可靠又不用你写任何 iCloud 代码。3.4 三种方案怎么选简单总结一下我的选择逻辑用户只想在文件 App 里看到 App 生成的文件不要求选位置用方案A最省事。用户希望把文件保存到指定位置iCloud Drive、本机、第三方网盘用方案B让系统选择器接管。需要程序自动同步到 iCloud完全没有用户交互才考虑方案C而且要做好 iCloud 状态监控和错误提示。大多数“保存文件到文件 App”的需求方案B一次就能满足。4. 大文件上传与断点续传内存危机的解法4.1 一次性读入内存的问题我在第 2 节给的 multipart 实现里用了Data(contentsOf:)它会把整个文件读进内存。对于一份 PDF、一个 Word 文档完全没问题但用户从文件 App 里选的很可能是一个视频几个 GB 也不奇怪。这种情况下Data(contentsOf:)会瞬间把内存打满App 轻则被系统 kill重则整个设备卡死。所以大文件场景必须放弃“全量读入内存再拼 body”的思路。4.2 用 uploadTask(fromFile:) 做流式上传如果后端接口支持 raw body 传文件比如对象存储的 PUT 直传或者后端接口把请求体直接当文件内容那么最简单的大文件上传方案是var request URLRequest(url: fileServerURL) request.httpMethod POST request.setValue(application/octet-stream, forHTTPHeaderField: Content-Type) let session URLSession(configuration: .default) let task session.uploadTask(with: request, fromFile: fileURL) { data, response, error in // 处理响应 } task.resume()uploadTask(with:fromFile:)是系统直接从磁盘读取文件去上传不会把整个文件加载进 App 内存机制上和流式读取类似对开发者也最友好。文件名、自定义 header 都可以放进 request 里服务端能分辨。4.3 multipart 大文件的现实做法麻烦的是很多后端只认 multipart/form-data。这时候继续用内存拼接一个大体量 body 就不现实了。我的做法是在本地把 multipart 的头、文件数据、尾部分别准备好然后拼成一个完整的临时 multipart 文件再用uploadTask(with:fromFile:)上传整个临时文件。拼接时注意“文件数据”这段千万别用Data(contentsOf:)读进内存而是用InputStream或者FileHandle逐块写入目标文件。简单实现思路func createMultipartFile( from fileURL: URL, fileName: String, boundary: String, fieldName: String, outputURL: URL ) throws { // 先写 multipart 头 var header Data() header.append(--\(boundary)\r\n.data(using: .utf8)!) header.append(Content-Disposition: form-data; name\\(fieldName)\; filename\\(fileName)\\r\n.data(using: .utf8)!) header.append(Content-Type: application/octet-stream\r\n\r\n.data(using: .utf8)!) try header.write(to: outputURL) // 再追加文件内容直接用 FileHandle 追加不读进内存 let handle try FileHandle(forWritingTo: outputURL) let input try FileHandle(forReadingFrom: fileURL) while autoreleasepool(invoking: { let chunk input.readData(ofLength: 1024 * 1024) return !chunk.isEmpty }) { let chunk autoreleasepool { input.readData(ofLength: 1024 * 1024) } if !chunk.isEmpty { handle.write(chunk) } } try? input.close() // 最后写结尾 let tail --\(boundary)--\r\n.data(using: .utf8)! handle.write(tail) try? handle.close() }严格来说上面这个简版代码还可以再优化但思路已经清楚头部、正文、尾部三段拼成一个临时文件全程不把大文件主体放进内存。拼好后再调用前面的uploadTask(with:fromFile:)既满足 multipart 协议又不会内存爆炸。4.4 后台上传与断点续传如果网络不稳定或者用户可能在上传途中把 App 切到后台可以考虑用URLSessionConfiguration.background创建后台会话然后同样用uploadTask(with:fromFile:)提交任务。系统会在后台继续上传应用即使被挂起也能把任务跑完等下次启动或系统唤醒时回调结果。需要注意两个限制后台会话不支持httpBodyStream所以还是得先把 multipart 文件拼好另外后台会话回调需要实现AppDelegate里的application(_:handleEventsForBackgroundURLSession:completionHandler:)否则完成回调可能丢失。断点续传方面如果服务端支持分片协议可以自己切文件片段逐个上传并记录偏移量但这是另一个量级的复杂度普通业务需求里用后台整文件上传通常已经够了。5. 高频踩坑记录与排查方案5.1 回调没触发或拿不到 URL最常见的原因是同时实现了旧版 delegate 方法documentPicker(_:didPickDocumentAt:)和新版documentPicker(_:didPickDocumentsAt:)。在 iOS 14 以上系统可能会走新版但你也不能保证旧代码不会被调用。我的建议是只保留新版方法把所有逻辑集中到didPickDocumentsAt里不要两套同时存在。另一个可能选择器被 present 的时候当前 ViewController 正处于转场过程。用DispatchQueue.main.async延迟一下再 present 往往能解决。5.2 URL 权限与文件读取失败表现为copyItem报错或者读取文件时抛NSCocoaErrorDomain错误。多数是因为没有调用startAccessingSecurityScopedResource()或者是在回调函数结束之后异步读取原始 URL。解决方案就是文章前面强调的先 start再复制复制完立刻 stop后续全部操作沙盒副本。5.3 文件名、类型和乱码问题中文文件名在 multipart 的 filename 字段里会被很多老后端解析成乱码。这是“看起来成功了但服务端文件名不对”的高频原因。合理的做法是multipart 内部传一个安全文件名比如把中文做 URL 编码同时额外传一个原始文件名参数交给后端保存。另外服务端如果只看扩展名判断文件类型遇到重命名过的文件会直接拦截我建议客户端在上传时也把UTType.preferredMIMEType算好放到Content-Type里降低服务端误判概率。5.4 导出保存后源文件消失如果你用了forExporting:asCopy: false系统会认为你允许“移动”文件而不是“复制”。用户保存后你 temp 里的原始文件可能就没了。如果你还有后续要处理就会出现“文件不存在”。所以导出保存时一定要用asCopy: true然后把删除源文件的动作主动控制在自己手里别让系统替你决定。5.5 多选、iPad 弹窗与模拟器差异iPad 上如果没设置popoverPresentationController的sourceView和sourceRect直接 present 文件选择器会崩溃。这不是 bug是 UIPopoverPresentationController 的硬性要求。模拟器上从“文件”App 选择大文件时性能比真机差很多尤其在使用 iCloud Drive 文件时还可能弹登录框。排查问题时优先用真机。多选模式下urls的顺序不保证和用户点击顺序一致如果业务对顺序敏感得自己记录选择顺序或让用户逐个选择。5.6 常见问题速查表问题现象核心原因建议处理选择器不出现present 时机不对等当前转场结束再 present回调没执行新旧 delegate 方法冲突只保留 didPickDocumentsAt文件读取失败安全作用域未管理start - 复制 - stop文件名为空/乱码服务端对 multipart 解析不完整传安全文件名 原始文件名大文件上传内存暴涨Data(contentsOf:) 全量读入改用 uploadTask(fromFile:)iPad 崩溃popover 缺少锚点设置 sourceView/sourceRect导出后源文件消失asCopy 设成了 false改回 asCopy: true6. 封装建议与一点个人体会6.1 用单例统一管理选择、上传、导出这类功能在项目里往往不止一个页面会用到最好一开始就封装成独立的管理器而不是把代码散落在各个 ViewController 里。我习惯做一个FileTransferManagerfinal class FileTransferManager: NSObject, UIDocumentPickerDelegate { static let shared FileTransferManager() private var pickCompletion: ((ResultSelectedFileInfo, Error) - Void)? private var exportCompletion: ((ResultVoid, Error) - Void)? struct SelectedFileInfo { let localURL: URL let fileName: String } private override init() { super.init() } /// 从文件 App 选择文件 func pickFile( from presenter: UIViewController, contentTypes: [UTType] [.item], completion: escaping (ResultSelectedFileInfo, Error) - Void ) { pickCompletion completion let picker UIDocumentPickerViewController( forOpeningContentTypes: contentTypes, asCopy: false ) picker.delegate self presenter.present(picker, animated: true) } /// 导出文件到文件 App func exportFile( from presenter: UIViewController, sourceURL: URL, asCopy: Bool true ) { let picker UIDocumentPickerViewController(forExporting: [sourceURL], asCopy: asCopy) picker.delegate self presenter.present(picker, animated: true) } // MARK: - UIDocumentPickerDelegate func documentPicker( _ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL] ) { guard let url urls.first else { return } let didAccess url.startAccessingSecurityScopedResource() defer { if didAccess { url.stopAccessingSecurityScopedResource() } } do { let tmpURL FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) .appendingPathExtension(url.pathExtension) try FileManager.default.copyItem(at: url, to: tmpURL) pickCompletion?(.success(SelectedFileInfo(localURL: tmpURL, fileName: url.lastPathComponent))) } catch { pickCompletion?(.failure(error)) } pickCompletion nil } func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { // 按业务需要处理通常不需要额外动作 } }封装好之后调用方只需要一行代码就能从文件 App 里拿文件内部负责权限、拷贝、清理后面换上传 SDK 或改导出逻辑也不会影响业务页面。6.2 我的几条实战建议最后分享几条我在实际项目中沉淀下来的习惯不要在UIDocumentPickerDelegate回调里直接发网络请求先把文件复制到沙盒再异步上传这样无论用户是快速切后台还是网络超时文件来源都是可控的上传成功后要主动清理 tmp 目录别把临时文件留在用户手机上导出保存时始终用asCopy: true文件的保留和删除由自己的业务逻辑决定不要交给系统选择器。我还想特别强调一点这些功能看起来简单但“从文件 App 选文件”和“保存文件到文件 App”本质上是围绕系统安全机制设计的交互真正影响体验的往往不是那几行核心代码而是对文件所有权、生命周期和异常情况的处理。把临时文件、权限、回调时机这些细节想清楚这套功能才能真正经得起生产环境考验。