如何在后台线程高效地将大文件写入磁盘(Swift)

45

更新

我已经解决并移除了令人分心的错误。请阅读整篇文章,如有任何问题,请随意留言。

背景

我正在尝试使用Swift 2.0、GCD和完成处理程序将相对较大的文件(视频)写入iOS磁盘。我想知道是否有更有效的方法来执行此任务。该任务需要在不阻塞主UI的情况下完成,并使用完成逻辑,同时确保操作尽可能快地进行。我有带有NSData属性的自定义对象,因此我目前正在尝试使用NSData的扩展。例如,另一种解决方案可能包括使用NSFilehandle或NSStreams,再加上某种形式的线程安全行为,从而比我当前基于NSData writeToURL函数的解决方案具有更快的吞吐量。

NSData有什么问题?

请注意以下讨论摘自NSData类参考文档(保存数据)。我确实会向我的临时目录写入内容,但我遇到问题的主要原因是在处理大文件时可以明显感受到UI的延迟。这种延迟正是因为NSData不是异步的(苹果文档指出原子写操作可能会对“大”文件(> 1MB)造成性能问题)。因此,在处理大文件时,我们需要依赖于NSData方法内部的机制。

我进行了更深入的调查,并从苹果公司找到了这些信息..."使用此方法将 data:// URL 转换为 NSData 对象非常理想,也可以用于同步读取短文件。如果需要读取可能很大的文件,请使用 inputStreamWithURL: 打开流,然后分段读取文件"(NSData Class Reference, Objective-C, +dataWithContentsOfURL)。这些信息似乎暗示我可以尝试使用流在后台线程上将文件写出,如果将 writeToURL 移动到后台线程(如 @jtbandes 建议)不足以解决问题。

NSData类及其子类提供了快速和简便的方法将它们的内容保存到磁盘上。为了最大程度地减少数据丢失的风险,这些方法提供了原子保存数据的选项。原子写入保证数据要么完全保存,要么完全失败。原子写开始时将数据写入一个临时文件中。如果此写操作成功,则该方法将临时文件移动到其最终位置。
虽然原子写操作最大限度地减少了由于损坏或部分写入的文件而导致的数据丢失的风险,但在写入临时目录、用户主目录或其他公共可访问目录时可能不适用。任何时候当您使用公共可访问文件时,都应将该文件视为不受信任的和潜在危险的资源。攻击者可能会破坏或损坏这些文件。攻击者还可以替换这些文件以硬链接或符号链接的形式,导致您的写操作覆盖或损坏其他系统资源。
在公共可访问目录内工作时,请避免使用writeToURL:atomically:方法(及相关方法)。相反,使用现有文件描述符初始化NSFileHandle对象,并使用NSFileHandle方法安全地写入文件。 其他替代方案

有一篇关于并发编程的文章Concurrent Programming在objc.io上提供了有趣的选项,其中之一是“高级:后台文件I/O”。其中一些选项还涉及使用InputStream。苹果公司也有一些旧的参考资料异步读写文件。我发布这个问题是为了期待Swift的替代方案。

适当回答的示例

以下是一个适当的回答示例,可能会满足这类问题。(取自流编程指南,写入输出流)

使用NSOutputStream实例向输出流写入数据需要几个步骤:

  1. 创建并初始化一个NSOutputStream实例,用于写入数据的存储库。同时设置代理。
  2. 将流对象安排在运行循环中并打开流。
  3. 处理流对象向其代理报告的事件。
  4. 如果流对象已将数据写入内存,请通过请求NSStreamDataWrittenToMemoryStreamKey属性来获取数据。
  5. 当没有更多数据可写时,销毁流对象。

Nota Bene

我理解下面的信息错误。为了完整起见,它被包含在内。 这个问题正在询问是否有更好的算法可用于使用Swift、API或可能甚至是C/ObjC将大型文件写入iOS。如果有,请提供足够的信息(描述/样本),以便我重建相关的Swift 2.0兼容代码。请告知我是否遗漏了任何有助于回答问题的信息。

关于扩展的说明

我已经在基本的writeToURL方法中添加了一个完成处理程序,以确保不会发生意外的资源共享。使用文件的我的依赖任务应该永远不会面临竞态条件。

extension NSData {

    func writeToURL(named:String, completion: (result: Bool, url:NSURL?) -> Void)  {

       let filePath = NSTemporaryDirectory() + named
       //var success:Bool = false
       let tmpURL = NSURL( fileURLWithPath:  filePath )
       weak var weakSelf = self


      dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), {
                //write to URL atomically
                if weakSelf!.writeToURL(tmpURL, atomically: true) {

                        if NSFileManager.defaultManager().fileExistsAtPath( filePath ) {
                            completion(result: true, url:tmpURL)                        
                        } else {
                            completion (result: false, url:tmpURL)
                        }
                    }
            })

        }
    }

这种方法用于使用控制器处理自定义对象数据:
var items = [AnyObject]()
if let video = myCustomClass.data {

    //video is of type NSData        
    video.writeToURL("shared.mp4", completion: { (result, url) -> Void in
        if result {
            items.append(url!)
            if items.count > 0 {

                let sharedActivityView = UIActivityViewController(activityItems: items, applicationActivities: nil)

                self.presentViewController(sharedActivityView, animated: true) { () -> Void in
                //finished
    }
}
        }
     })
}

结论

苹果文档中Core Data Performance提供了一些处理内存压力和管理BLOBs的好建议。这是一篇非常棒的文章,提供了许多关于行为和如何控制应用程序中大文件问题的线索。尽管它是特定于Core Data而不是文件的,但对原子写入的警告告诉我,我应该非常谨慎地实现具有原子性的写入方法。

对于大文件,管理写入的唯一安全方式似乎是添加完成处理程序(到写入方法中)并在主线程上显示活动视图。无论读者是使用流还是通过修改现有API添加完成逻辑来完成此操作都可以。我过去已经两种方式都尝试过,并正在测试以获得最佳性能。

直到那时,我将更改解决方案,从Core Data中删除所有二进制数据属性,并用字符串替换它们以保存磁盘上的资源URL。我还利用了来自Assets Library和PHAsset的内置功能来获取和存储所有相关的资源URL。如果需要复制任何资源,则会使用标准API方法(PHAsset / Asset Library上的导出方法),并使用完成处理程序在主线程上通知用户完成状态。 (来自Core Data性能文章的非常有用的代码片段)
减少内存开销
有时您可能想临时使用托管对象,例如计算特定属性的平均值。这会导致对象图和内存消耗增加。您可以通过重新使不再需要的单个托管对象失效或重置托管对象上下文以清除整个对象图来减少内存开销。您还可以使用适用于Cocoa编程的模式。
您可以使用NSManagedObjectContext的refreshObject:mergeChanges:方法使单个托管对象失效。这样可以清除其内存中的属性值,从而减少内存开销。(请注意,这与将属性值设置为nil不同 - 如果触发了故障,则将按需检索值 - 请参见故障和唯一性。)
当您创建获取请求时,可以将includesPropertyValues设置为NO以避免创建表示属性值的对象,从而减少内存开销。但是,只有在确定要么不需要实际属性数据,要么已经拥有行缓存中的信息时,才应该这样做,否则您将会产生多次访问持久存储的开销。
您可以使用NSManagedObjectContext的reset方法删除与上下文关联的所有托管对象,并“重新开始”,就像刚刚创建它一样。请注意,任何与该上下文关联的托管对象都将无效,因此您需要丢弃任何引用并重新获取与您仍然感兴趣的该上下文相关的任何对象。如果您迭代了大量对象,则可能需要使用本地自动释放池块,以确保尽快释放临时对象。
如果您不打算使用Core Data的撤消功能,则可以通过将上下文的撤消管理器设置为nil来减少应用程序的资源需求。对于后台工作线程以及大型导入或批处理操作,这可能特别有益。
最后,Core Data默认情况下不会保留托管对象的强引用(除非它们具有未保存的更改)。如果您有很多对象在内存中,请确定拥有引用。托管对象通过关系彼此保持强引用,这很容易创建强引用循环。您可以通过重新使对象失效(再次使用NSManagedObjectContext的refreshObject:mergeChanges:方法)来打破循环。
大数据对象(BLOBs)
如果您的应用程序使用大型BLOB(“二进制大对象”例如图像和声音数据),则需要注意最小化开销。 “小”,“适度”和“大”的确切定义是流体的,并取决于应用程序的使用情况。粗略的经验法则是,大小在千字节左右的对象属于“适度”大小,而大小在兆字节左右的对象则属于“大”大小。一些开发人员已经在数据库中使用10MB BLOB获得了良好的性能。另一方面,如果应用程序在表中有数百万行,则即使128字节也可能是需要规范化到单独表中的“适度”大小CLOB(字符大对象)。
通常,如果您需要将BLOB存储在持久存储中,则应使用SQLite存储。 XML和二进制存储需要整个对象图驻留在内存中,并且存储写入是原子的(请参见持久存储功能),这意味着它们无法有效地处理大型数据对象。 SQLite可以扩展以处理极其大型的数据库。正确使用,SQLite为高达100GB的数据库提供良好的性能,单个行可以容纳高达1GB的数据(尽管将1GB的数据读入内存是一个昂贵的操作,无 注意:

我已将下面的逻辑移入完成处理程序中(请参见上面的代码),我不再看到任何错误。如前所述,这个问题是关于在iOS中使用Swift处理大文件是否有更高效的方法。

当尝试处理生成的项目数组以传递给UIActvityViewController时,使用以下逻辑:

如果items.count > 0 {
let sharedActivityView = UIActivityViewController(activityItems: items, applicationActivities: nil) self.presentViewController(sharedActivityView, animated: true) { () -> Void in //finished} }

我看到以下错误:通信错误:{count = 1,contents =“XPCErrorDescription” => {length = 22,contents =“连接中断”}}>(请注意,我正在寻找更好的设计,而不是这个错误消息的答案)


4
@Gary 代码审查 明确要求代码按预期工作。这段代码不符合这一标准。 - nhgrif
1
这里不太清楚你想要什么。数据从哪里来?你发布的代码有什么问题? - jtbandes
不太清楚你想做什么。但是请注意,文件从主线程中永远不会被写入,这就是为什么在 writeToURL 上有完成处理程序的原因。dispatch_async 不是必需的。此外,您不必检查文件是否存在,只需检查 writeToURL 的结果即可。 - Sulthan
@jtbandes - 刚刚注意到你的评论,建议不要在主线程上使用它以实现所需的结果。这是一个很好的建议。我将不得不看看在处理大文件时它在实践中的工作情况。例如,即使我将处理放在后端,我仍然可能需要等待相当长的时间才能在主线程上使用文件(例如,当我尝试共享文件时)。 - Tommie C.
@TommieC。没有理由认为NSData的writeToURL比任何其他方法慢。如果操作确实涉及将大量数据写入文件,则无法避免它。也许您可以重新考虑并尽早开始编写数据。 - jtbandes
显示剩余7条评论
3个回答

24

性能取决于数据是否适合放在RAM中。如果是,则应该使用打开了atomically功能的NSData writeToURL,这就是你正在做的。

关于“写入公共目录”时存在危险的苹果注释在iOS上完全不相关,因为没有公共目录。那一部分只适用于OS X。而且说实话,在那里也不是很重要。

所以,只要视频适合RAM(约100MB是安全限制),您编写的代码就尽可能高效。

对于不适合放在RAM中的文件,您需要使用流,否则应用程序将在内存中持有视频时崩溃。要从服务器下载大型视频并将其写入磁盘,应使用NSURLSessionDownloadTask

通常,流式传输(包括NSURLSessionDownloadTask)的速度将慢几个数量级,比NSData.writeToURL()慢得多。因此,除非必须,不要使用流。所有NSData的操作都非常快速,在OS X上具有极好的性能,可以处理多TB大小的文件(iOS显然无法拥有如此大的文件,但这是相同的类,具有相同的性能)。


您的代码存在一些问题。

这是错误的:

let filePath = NSTemporaryDirectory() + named

改为以下做法:

let filePath = NSTemporaryDirectory().stringByAppendingPathComponent(named)

但这也并不是理想的方式,你应该避免使用路径(它们容易出错而且速度较慢)。相反,使用像这样的URL:

let tmpDir = NSURL(fileURLWithPath: NSTemporaryDirectory())!
let fileURL = tmpDir.URLByAppendingPathComponent(named)

另外,你正在使用路径检查文件是否存在... 不要这样做:

if NSFileManager.defaultManager().fileExistsAtPath( filePath ) {

使用NSURL来检查它是否存在:

if fileURL.checkResourceIsReachableAndReturnError(nil) {

3
关于RAM限制,只将您的测试用作指导。实际可用的RAM数量取决于可用硬件和运行在该硬件上的其他应用程序的状态。如果您的视频大小“大约”100MB或更大,则不要将其保存在RAM中 - 而是使用流式传输。但如果它们远远低于这个值,RAM和NSData(使用原子写入和在后台线程上使用dispatch_async())是最有效的选择。您还可以考虑使用max concurrent operations设置为1的NSOperationQueue,因为一般最好不要同时操作两个文件。 - Abhi Beckert
听起来你的性能问题是将数据转换为NSData,而不是实际的写入磁盘组件。 - Abhi Beckert

10

最新解决方案(2018)

当缓冲区被填满时(或者如果您使用了定时记录),另一个有用的可能性是在闭包中使用它来附加数据并宣布数据流结束。与一些照片API相结合,这可以导致良好的结果。因此,在处理过程中可以触发以下类似于声明性代码:

var dataSpoolingFinished: ((URL?, Error?) -> Void)?
var dataSpooling: ((Data?, Error?) -> Void)?

处理这些闭包可以让您的管理对象简洁地处理任何大小的数据,同时控制内存。

将这个想法与使用递归方法聚合工作片段到单个 dispatch_group 的方法相结合,可能会有一些令人兴奋的可能性。

Apple文档说明:

DispatchGroup 允许对工作进行聚合同步。您可以使用它们提交多个不同的工作项并跟踪它们全部完成的情况,即使它们可能在不同的队列上运行。当所有指定的任务都完成后才能取得进展时,此行为可能会有所帮助。

其他值得注意的解决方案(~2016)

我毫不怀疑我会进一步完善这个问题,但这个主题足够复杂,需要一个单独的自我回答。我决定采纳其他答案的建议,并利用 NSStream 子类。这个解决方案基于一个 Obj-C sample (NSInputStream inputStreamWithURL example ios, 2013, May 12),发布在 SampleCodeBank 博客上。

苹果文档指出,使用 NSStream 子类时,您无需一次性将所有数据加载到内存中。这是能够管理任何大小的多媒体文件(不超过可用磁盘或 RAM 空间)的关键。

NSStream 是表示流的对象的抽象类。它的接口适用于所有 Cocoa 流类,包括其具体子类 NSInputStream 和 NSOutputStream。

NSStream 对象提供了一种以设备无关方式读取和写入各种介质的简便方法。您可以为位于内存、文件或网络(使用套接字)上的数据创建流对象,并且您可以在不一次性加载所有数据到内存中的情况下使用流对象。

文件系统编程指南

苹果公司在 FSPG 上的 使用流线性处理整个文件 文章也提到,NSInputStreamNSOutputStream 应该天生就是线程安全的。

file-processing-with-streams

进一步改进

这个对象没有使用流委托方法。还有很多其他的改进空间,但这是我将采取的基本方法。iPhone 的主要重点是通过缓冲区 (TBD-利用内存中的 outputStream 缓冲区) 使大文件管理变得容易,同时限制内存。需要明确的是,苹果确实提到了他们的 convenience 函数 writeToURL 只适用于较小的文件大小(但让我想知道为什么他们不处理更大的文件——这些不是边缘情况,请注意——我会将问题作为 bug 提交)。

结论

我将进一步测试在后台线程集成时是否会干扰任何 NSStream 内部排队。我有一些其他的对象使用类似的思路来管理极大的数据文件。在 iOS 中,保持文件尺寸尽可能小以节省内存并防止应用程序崩溃是最好的方法。API 是根据这些约束条件建立的(这就是为什么尝试无限制的视频不是一个好主意),所以我必须调整整体期望。

(Gist Source, 检查 gist 获取最新更改)

import Foundation
import Darwin.Mach.mach_time

class MNGStreamReaderWriter:NSObject {

    var copyOutput:NSOutputStream?
    var fileInput:NSInputStream?
    var outputStream:NSOutputStream? = NSOutputStream(toMemory: ())
    var urlInput:NSURL?

    convenience init(srcURL:NSURL, targetURL:NSURL) {
        self.init()
        self.fileInput  = NSInputStream(URL: srcURL)
        self.copyOutput = NSOutputStream(URL: targetURL, append: false)
        self.urlInput   = srcURL

    }

    func copyFileURLToURL(destURL:NSURL, withProgressBlock block: (fileSize:Double,percent:Double,estimatedTimeRemaining:Double) -> ()){

        guard let copyOutput = self.copyOutput, let fileInput = self.fileInput, let urlInput = self.urlInput else { return }

        let fileSize            = sizeOfInputFile(urlInput)
        let bufferSize          = 4096
        let buffer              = UnsafeMutablePointer<UInt8>.alloc(bufferSize)
        var bytesToWrite        = 0
        var bytesWritten        = 0
        var counter             = 0
        var copySize            = 0

        fileInput.open()
        copyOutput.open()

        //start time
        let time0 = mach_absolute_time()

        while fileInput.hasBytesAvailable {

            repeat {

                bytesToWrite    = fileInput.read(buffer, maxLength: bufferSize)
                bytesWritten    = copyOutput.write(buffer, maxLength: bufferSize)

                //check for errors
                if bytesToWrite < 0 {
                    print(fileInput.streamStatus.rawValue)
                }
                if bytesWritten == -1 {
                    print(copyOutput.streamStatus.rawValue)
                }
                //move read pointer to next section
                bytesToWrite -= bytesWritten
                copySize += bytesWritten

            if bytesToWrite > 0 {
                //move block of memory
                memmove(buffer, buffer + bytesWritten, bytesToWrite)
                }

            } while bytesToWrite > 0

            if fileSize != nil && (++counter % 10 == 0) {
                //passback a progress tuple
                let percent     = Double(copySize/fileSize!)
                let time1       = mach_absolute_time()
                let elapsed     = Double (time1 - time0)/Double(NSEC_PER_SEC)
                let estTimeLeft = ((1 - percent) / percent) * elapsed

                block(fileSize: Double(copySize), percent: percent, estimatedTimeRemaining: estTimeLeft)
            }
        }

        //send final progress tuple
        block(fileSize: Double(copySize), percent: 1, estimatedTimeRemaining: 0)


        //close streams
        if fileInput.streamStatus == .AtEnd {
            fileInput.close()

        }
        if copyOutput.streamStatus != .Writing && copyOutput.streamStatus != .Error {
            copyOutput.close()
        }



    }

    func sizeOfInputFile(src:NSURL) -> Int? {

        do {
            let fileSize = try NSFileManager.defaultManager().attributesOfItemAtPath(src.path!)
            return fileSize["fileSize"]  as? Int

        } catch let inputFileError as NSError {
            print(inputFileError.localizedDescription,inputFileError.localizedRecoverySuggestion)
        }

        return nil
    }


}

委托

这里有一个类似的对象,我从一篇关于后台高级文件I/O的文章中重写了它(Eidhof,C., ObjC.io)。只需进行一些微调,就可以使其模拟上述行为。只需在processDataChunk方法中将数据重定向到NSOutputStream即可。

Gist源代码 - 请查看最新更改)

import Foundation

class MNGStreamReader: NSObject, NSStreamDelegate {

    var callback: ((lineNumber: UInt , stringValue: String) -> ())?
    var completion: ((Int) -> Void)?
    var fileURL:NSURL?
    var inputData:NSData?
    var inputStream: NSInputStream?
    var lineNumber:UInt = 0
    var queue:NSOperationQueue?
    var remainder:NSMutableData?
    var delimiter:NSData?
    //var reader:NSInputStreamReader?

    func enumerateLinesWithBlock(block: (UInt, String)->() , completionHandler completion:(numberOfLines:Int) -> Void ) {

        if self.queue == nil {
            self.queue = NSOperationQueue()
            self.queue!.maxConcurrentOperationCount = 1
        }

        assert(self.queue!.maxConcurrentOperationCount == 1, "Queue can't be concurrent.")
        assert(self.inputStream == nil, "Cannot process multiple input streams in parallel")

        self.callback = block
        self.completion = completion

        if self.fileURL != nil {
            self.inputStream = NSInputStream(URL: self.fileURL!)
        } else if self.inputData != nil {
            self.inputStream = NSInputStream(data: self.inputData!)
        }

        self.inputStream!.delegate = self
        self.inputStream!.scheduleInRunLoop(NSRunLoop.currentRunLoop(), forMode: NSDefaultRunLoopMode)
        self.inputStream!.open()
    }

    convenience init? (withData inbound:NSData) {
        self.init()
        self.inputData = inbound
        self.delimiter = "\n".dataUsingEncoding(NSUTF8StringEncoding)

    }

    convenience init? (withFileAtURL fileURL: NSURL) {
        guard !fileURL.fileURL else { return nil }

        self.init()
        self.fileURL = fileURL
        self.delimiter = "\n".dataUsingEncoding(NSUTF8StringEncoding)
    }

    @objc func stream(aStream: NSStream, handleEvent eventCode: NSStreamEvent){

        switch eventCode {
        case NSStreamEvent.OpenCompleted:
            fallthrough
        case NSStreamEvent.EndEncountered:
            self.emitLineWithData(self.remainder!)
            self.remainder = nil
            self.inputStream!.close()
            self.inputStream = nil

            self.queue!.addOperationWithBlock({ () -> Void in
                self.completion!(Int(self.lineNumber) + 1)
            })

            break
        case NSStreamEvent.ErrorOccurred:
            NSLog("error")
            break
        case NSStreamEvent.HasSpaceAvailable:
            NSLog("HasSpaceAvailable")
            break
        case NSStreamEvent.HasBytesAvailable:
            NSLog("HasBytesAvaible")

            if let buffer = NSMutableData(capacity: 4096) {
                let length = self.inputStream!.read(UnsafeMutablePointer<UInt8>(buffer.mutableBytes), maxLength: buffer.length)
                if 0 < length {
                    buffer.length = length
                    self.queue!.addOperationWithBlock({ [weak self]  () -> Void in
                        self!.processDataChunk(buffer)
                        })
                }
            }
            break
        default:
            break
        }
    }

    func processDataChunk(buffer: NSMutableData) {
        if self.remainder != nil {

            self.remainder!.appendData(buffer)

        } else {

            self.remainder = buffer
        }

        self.remainder!.mng_enumerateComponentsSeparatedBy(self.delimiter!, block: {( component: NSData, last: Bool) in

            if !last {
                self.emitLineWithData(component)
            }
            else {
                if 0 < component.length {
                    self.remainder = (component.mutableCopy() as! NSMutableData)
                }
                else {
                    self.remainder = nil
                }
            }
        })
    }

    func emitLineWithData(data: NSData) {
        let lineNumber = self.lineNumber
        self.lineNumber = lineNumber + 1
        if 0 < data.length {
            if let line = NSString(data: data, encoding: NSUTF8StringEncoding) {
                callback!(lineNumber: lineNumber, stringValue: line as String)
            }
        }
    }
}

2
您应该考虑使用NSStream(NSOutputStream/NSInputStream)。如果您选择这种方法,请记住需要显式地启动(运行)后台线程的运行循环。 NSOutputStream有一个名为outputStreamToFileAtPath:append:的方法,这可能是您正在寻找的方法。
类似问题: 在Swift中将字符串写入NSOutputStream

谢谢你的建议。我选择使用write(_ buffer: UnsafePointer<UInt8>, maxLength len: Int) -> Int,这样我可以更好地管理进程的内存占用。 - Tommie C.

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接