You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

iOS端从PHAsset上传大文件至Google可恢复服务是否需复制到临时文件?

问题解答

1. 是否需要将PHAsset复制到临时文件再上传?

是的,必须这么做。你遇到的沙箱权限错误,是因为PhotoKit返回的系统相册文件URL(/var/mobile/Media/DCIM/...)属于系统沙箱范畴,你的App后台Session没有权限直接访问这些文件。只有将Asset导出到App自身的沙箱目录(临时目录或Documents子目录),才能让URLSession获得合法的访问权限。

2. GB级视频文件的磁盘空间管理方案

(1)优先选择「流式分块上传」,避免复制整个文件

对于大体积视频,最优方案是不复制完整文件到本地,直接通过PHAssetResourceManager读取Asset的数据流,结合Google可恢复上传的分块机制,分块读取并上传。这种方式完全不会占用额外磁盘空间,从根源上避免空间占用问题。

(2)若必须复制文件,做好以下空间管控

  • 复制前先做空间校验:计算文件大小,对比设备剩余空间,预留至少10%的冗余空间(防止上传过程中系统临时占用空间导致不足)
  • 使用临时目录存储:将文件导出到NSTemporaryDirectory(),系统会在设备空间不足时自动清理临时文件,但上传完成/失败后必须手动删除,避免长期占用
  • 上传完成立即清理:在URLSession任务的completionHandler中主动删除临时文件
  • 控制并发上传数:避免同时导出多个大文件,防止磁盘空间骤降

3. 极端空间不足的处理方案

  • 上传前拦截:检查剩余空间不足时,直接提示用户释放设备空间,终止上传流程
  • 上传过程中监控:通过NSNotificationCenter监听NSFileSystemSizeDidChangeNotification,实时监控磁盘空间,一旦剩余空间低于阈值(比如小于当前上传块大小的2倍),立即暂停/终止上传,清理已生成的临时文件
  • 提供用户引导:提示用户删除无用照片、视频或卸载不常用App,释放空间后再尝试上传

代码修改示例

(1)导出Asset到临时文件的方法

func exportPHAssetToTempFile(assetIdentifier: String, completion: @escaping (URL?) -> Void) {
    guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetIdentifier], options: nil).firstObject else {
        completion(nil)
        return
    }
    
    // 生成唯一临时文件名
    let tempDir = NSTemporaryDirectory()
    let fileExtension = asset.mediaType == .video ? ".mov" : ".jpg"
    let tempFileUrl = URL(fileURLWithPath: tempDir).appendingPathComponent("\(UUID().uuidString)\(fileExtension)")
    
    let resourceOptions = PHAssetResourceRequestOptions()
    resourceOptions.isNetworkAccessAllowed = true // 允许从iCloud下载
    
    guard let resource = PHAssetResource.assetResources(for: asset).first else {
        completion(nil)
        return
    }
    
    // 导出Asset到临时文件
    PHAssetResourceManager.default().writeData(for: resource, toFile: tempFileUrl, options: resourceOptions) { error in
        if let error = error {
            print("导出Asset失败: \(error.localizedDescription)")
            try? FileManager.default.removeItem(at: tempFileUrl)
            completion(nil)
        } else {
            completion(tempFileUrl)
        }
    }
}

(2)空间检查与上传流程

extension ExampleViewController: PHPickerViewControllerDelegate {
    public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
        picker.dismiss(animated: true)
        
        let identifiers = results.compactMap { $0.assetIdentifier }
        identifiers.forEach { identifier in
            exportPHAssetToTempFile(assetIdentifier: identifier) { tempFileUrl in
                guard let tempFileUrl = tempFileUrl else {
                    // 提示用户导出失败
                    return
                }
                
                // 检查剩余空间是否足够
                guard self.hasEnoughSpace(forFileAt: tempFileUrl) else {
                    try? FileManager.default.removeItem(at: tempFileUrl)
                    // 提示用户设备空间不足
                    return
                }
                
                // 构建Google上传请求
                guard let uploadUrl = URL(string: googleSessionUriToUpload) else { return }
                var request = URLRequest(url: uploadUrl)
                request.httpMethod = "PUT"
                
                // 创建上传任务,完成后清理临时文件
                let task = URLSession.shared.uploadTask(with: request, fromFile: tempFileUrl) { _, _, error in
                    // 无论成功失败,都删除临时文件
                    try? FileManager.default.removeItem(at: tempFileUrl)
                    
                    if let error = error {
                        print("上传失败: \(error.localizedDescription)")
                        // 提示用户上传失败
                    } else {
                        // 提示用户上传成功
                    }
                }
                task.resume()
            }
        }
    }
    
    // 检查剩余空间是否足够
    private func hasEnoughSpace(forFileAt url: URL, extraReserve: Int64 = 1024 * 1024 * 100) -> Bool {
        do {
            let fileAttributes = try FileManager.default.attributesOfItem(atPath: url.path)
            let fileSize = fileAttributes[.size] as? Int64 ?? 0
            
            let fsAttributes = try FileManager.default.attributesOfFileSystem(forPath: NSTemporaryDirectory())
            let freeSpace = fsAttributes[.systemFreeSize] as? Int64 ?? 0
            
            // 预留100MB额外空间,避免上传过程中空间不足
            return freeSpace >= fileSize + extraReserve
        } catch {
            print("空间检查失败: \(error.localizedDescription)")
            return false
        }
    }
}

(3)流式分块上传思路(核心伪代码)

func uploadAssetInChunks(assetIdentifier: String, sessionUri: String) {
    guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetIdentifier], options: nil).firstObject else { return }
    guard let resource = PHAssetResource.assetResources(for: asset).first else { return }
    
    let chunkSize: Int64 = 10 * 1024 * 1024 // 10MB每块
    var offset: Int64 = 0
    var currentChunkData = Data()
    
    let options = PHAssetResourceRequestOptions()
    options.isNetworkAccessAllowed = true
    
    // 读取Asset数据流,分块上传
    PHAssetResourceManager.default().requestData(for: resource, options: options) { data in
        currentChunkData.append(data)
        // 当累计数据达到分块大小,上传该块
        if currentChunkData.count >= chunkSize {
            self.uploadChunk(data: currentChunkData, offset: offset, sessionUri: sessionUri)
            offset += Int64(currentChunkData.count)
            currentChunkData.removeAll()
        }
    } completionHandler: { error in
        if let error = error {
            print("读取Asset数据失败: \(error.localizedDescription)")
        } else {
            // 上传最后一块剩余数据
            if !currentChunkData.isEmpty {
                self.uploadChunk(data: currentChunkData, offset: offset, sessionUri: sessionUri)
            }
            // 标记上传完成
        }
    }
}

// 上传单个分块到Google可恢复会话
private func uploadChunk(data: Data, offset: Int64, sessionUri: String) {
    guard let url = URL(string: sessionUri) else { return }
    var request = URLRequest(url: url)
    request.httpMethod = "PUT"
    request.setValue("bytes \(offset)-\(offset + Int64(data.count) - 1)/\(totalFileSize)", forHTTPHeaderField: "Content-Range")
    
    let task = URLSession.shared.uploadTask(with: request, from: data) { _, response, error in
        // 处理分块上传响应,更新上传进度
    }
    task.resume()
}

内容的提问来源于stack exchange,提问作者hjh5696

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.23 15:14:57