iOS开发之HTTP断点续传

前言

在APP中经常会遇到文件下载,鉴于用户体验和流量控制,就需要用到断点续传。本文主要对断点续传进行了多线程封装。

效果图

1.gif

原理

HTTP实现断点续传是通过HTTP报文头部header里面设置的两个参数RangeContent-Range实现。

HTTP文件断点续传理论

代码部分

一、文件大小记录

在下载文件的时候,需要先获取到文件的总大小,这里使用URL作为Key,对文件属性进行扩展的方式保存文件总大小

extension URL {
    /// Get extended attribute.
    func extendedAttribute(forName name: String) throws -> Data  {
        let data = try withUnsafeFileSystemRepresentation { fileSystemPath -> Data in
            // Determine attribute size:
            let length = getxattr(fileSystemPath, name, nil, 0, 0, 0)
            guard length >= 0 else { throw URL.posixError(errno) }
            // Create buffer with required size:
            var data = Data(count: length)
            // Retrieve attribute:
            let result =  data.withUnsafeMutableBytes { [count = data.count] in
                getxattr(fileSystemPath, name, $0.baseAddress, count, 0, 0)
            }
            guard result >= 0 else { throw URL.posixError(errno) }
            return data
        }
        return data
    }
    /// Set extended attribute.
    func setExtendedAttribute(data: Data, forName name: String) throws {
        try withUnsafeFileSystemRepresentation { fileSystemPath in
            let result = data.withUnsafeBytes {
                setxattr(fileSystemPath, name, $0.baseAddress, data.count, 0, 0)
            }
            guard result >= 0 else { throw URL.posixError(errno) }
        }
    }
    /// Remove extended attribute.
    func removeExtendedAttribute(forName name: String) throws {
        try withUnsafeFileSystemRepresentation { fileSystemPath in
            let result = removexattr(fileSystemPath, name, 0)
            guard result >= 0 else { throw URL.posixError(errno) }
        }
    }
    /// Get list of all extended attributes.
    func listExtendedAttributes() throws -> [String] {
        let list = try withUnsafeFileSystemRepresentation { fileSystemPath -> [String] in
            let length = listxattr(fileSystemPath, nil, 0, 0)
            guard length >= 0 else { throw URL.posixError(errno) }
            // Create buffer with required size:
            var namebuf = Array<CChar>(repeating: 0, count: length)
            // Retrieve attribute list:
            let result = listxattr(fileSystemPath, &namebuf, namebuf.count, 0)
            guard result >= 0 else { throw URL.posixError(errno) }
            // Extract attribute names:
            let list = namebuf.split(separator: 0).compactMap {
                $0.withUnsafeBufferPointer {
                    $0.withMemoryRebound(to: UInt8.self) {
                        String(bytes: $0, encoding: .utf8)
                    }
                }
            }
            return list
        }
        return list
    }
    /// Helper function to create an NSError from a Unix errno.
    private static func posixError(_ err: Int32) -> NSError {
        return NSError(domain: NSPOSIXErrorDomain, code: Int(err), userInfo: [NSLocalizedDescriptionKey: String(cString: strerror(err))])
    }
}

二、URLSessionDataTask下载文件

URLSessionDataTask下载文件不支持后台下载,为了方便自定义,这里使用代理的方式来实现,主要使用到的几个代理如下

func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {

}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {

}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {

}

三、自定义Operation

关于如何自定义请参考NSOperation的进阶使用和简单探讨,这里将下载全部封装到内部处理

class CLBreakPointResumeOperation: Operation {
    var progressBlock: ((CGFloat) -> ())?
    private (set) var error: CLBreakPointResumeManager.DownloadError?
    private var url: URL!
    private var path: String!
    private var currentBytes: Int64 = 0
    private var session: URLSession!
    private var task: URLSessionDataTask!
    private var outputStream: OutputStream?
    private var taskFinished: Bool = true {
        willSet {
            if taskFinished != newValue {
                willChangeValue(forKey: "isFinished")
            }
        }
        didSet {
            if taskFinished != oldValue {
                didChangeValue(forKey: "isFinished")
            }
        }
    }
    private var taskExecuting: Bool = false {
        willSet {
            if taskExecuting != newValue {
                willChangeValue(forKey: "isExecuting")
            }
        }
        didSet {
            if taskExecuting != oldValue {
                didChangeValue(forKey: "isExecuting")
            }
        }
    }
    override var isFinished: Bool {
        return taskFinished
    }
    override var isExecuting: Bool {
        return taskExecuting
    }
    override var isAsynchronous: Bool {
        return true
    }
    init(url: URL, path: String, currentBytes: Int64) {
        super.init()
        self.url = url
        self.path = path
        self.currentBytes = currentBytes
        
        var request = URLRequest(url: url)
        request.timeoutInterval = 5
        if currentBytes > 0 {
            let requestRange = String(format: "bytes=%llu-", currentBytes)
            request.addValue(requestRange, forHTTPHeaderField: "Range")
        }
        session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
        task = session.dataTask(with: request)
    }
    deinit {
        print("CLBreakPointResumeOperation deinit")
    }
}
extension CLBreakPointResumeOperation {
    override func start() {
        autoreleasepool {
            if isCancelled {
                taskFinished = true
                taskExecuting = false
            }else {
                taskFinished = false
                taskExecuting = true
                startTask()
            }
        }
    }
    override func cancel() {
        if (isExecuting) {
            task.cancel()
        }
        super.cancel()
    }
}
private extension CLBreakPointResumeOperation {
    func startTask() {
        task.resume()
    }
    func complete(_ error: CLBreakPointResumeManager.DownloadError? = nil) {
        self.error = error
        outputStream?.close()
        outputStream = nil
        taskFinished = true
        taskExecuting = false
    }
}
extension CLBreakPointResumeOperation: URLSessionDataDelegate {
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
        if !isCancelled {
            guard let response = dataTask.response as? HTTPURLResponse else {
                complete(.notHTTPURLResponse)
                return
            }
            guard response.statusCode == 200 || response.statusCode == 206 else {
                complete(.statusCode(response.statusCode))
                return
            }
            if response.statusCode == 200,
               FileManager.default.fileExists(atPath: path) {
                do {
                    try FileManager.default.removeItem(atPath: path)
                    currentBytes = 0
                } catch  {
                    complete(.throws(error))
                    return
                }
            }
            outputStream = OutputStream(url: URL(fileURLWithPath: path), append: true)
            outputStream?.open()
            if currentBytes == 0 {
                var totalBytes = response.expectedContentLength
                let data = Data(bytes: &totalBytes, count: MemoryLayout.size(ofValue: totalBytes))
                do {
                    try URL(fileURLWithPath: path).setExtendedAttribute(data: data, forName: "totalBytes")
                } catch {
                    complete(.throws(error))
                    return
                }
            }
            completionHandler(.allow)
        }
    }
    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        session.invalidateAndCancel()
        guard let response = task.response as? HTTPURLResponse else {
            complete(.notHTTPURLResponse)
            return
        }
        if let error = error {
            complete(.download(error))
        }else if (response.statusCode == 200 || response.statusCode == 206) {
            complete()
        }else {
            complete(.statusCode(response.statusCode))
        }
    }
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
        if !isCancelled {
            let receiveBytes = dataTask.countOfBytesReceived + currentBytes
            let allBytes = dataTask.countOfBytesExpectedToReceive + currentBytes
            let currentProgress = min(max(0, CGFloat(receiveBytes) / CGFloat(allBytes)), 1)
            DispatchQueue.main.async {
                self.progressBlock?(currentProgress)
            }
            outputStream?.write(Array(data), maxLength: data.count)
        }
    }
}

四、Operation管理

使用单例持有一个字典,URL作为Key,Operation作为Value来对所有的Operation进行管理

class CLBreakPointResumeManager: NSObject {
    static let shared: CLBreakPointResumeManager = CLBreakPointResumeManager()
    static let folderPath: String = NSHomeDirectory() + "/Documents/CLBreakPointResume/"
    private var operationDictionary = [String : CLBreakPointResumeOperation]()
    private lazy var queue: OperationQueue = {
        let queue = OperationQueue()
        queue.maxConcurrentOperationCount = 3
        return queue
    }()
    private lazy var operationSemap: DispatchSemaphore = {
        let semap = DispatchSemaphore(value: 0)
        semap.signal()
        return semap
    }()
    private override init() {
        super.init()
        if !FileManager.default.fileExists(atPath: CLBreakPointResumeManager.folderPath) {
            try? FileManager.default.createDirectory(atPath: CLBreakPointResumeManager.folderPath, withIntermediateDirectories: true)
        }
    }
}
extension CLBreakPointResumeManager {
    static func download(_ url: URL, progressBlock: ((CGFloat) -> ())? = nil, completionBlock: ((Result<String, DownloadError>) -> ())? = nil) {
        let completion = { result in
            DispatchQueue.main.async {
                completionBlock?(result)
            }
        }
        
        guard operation(url.absoluteString) == nil else {
            completion(.failure(.downloading))
            return
        }
        let fileAttribute = fileAttribute(url)
        guard !isDownloaded(url).0 else {
            progressBlock?(1)
            completion(.success(fileAttribute.path))
            return
        }
        
        let operation = CLBreakPointResumeOperation(url: url, path: fileAttribute.path, currentBytes: fileAttribute.currentBytes)
        operation.progressBlock = progressBlock
        operation.completionBlock = {
            if let error = operation.error {
                completion(.failure(error))
            }else {
                completion(.success(fileAttribute.path))
            }
            removeValue(url.absoluteString)
        }
        shared.queue.addOperation(operation)
        setOperation(operation, for: url.absoluteString)
    }
    static func cancel(_ url: URL) {
        guard let operation = operation(url.absoluteString),
              !operation.isCancelled
        else {
            return
        }
        operation.cancel()
    }
    static func delete(_ url: URL) throws {
        cancel(url)
        try FileManager.default.removeItem(atPath: filePath(url))
    }
    static func deleteAll() throws {
        for operation in shared.operationDictionary.values where !operation.isCancelled {
            operation.cancel()
        }
        try FileManager.default.removeItem(atPath: folderPath)
    }
}
private extension CLBreakPointResumeManager {
    static func operation(_ value: String) -> CLBreakPointResumeOperation? {
        shared.operationSemap.wait()
        let operation = shared.operationDictionary[value]
        shared.operationSemap.signal()
        return operation
    }
    static func setOperation(_ value: CLBreakPointResumeOperation, for key: String) {
        shared.operationSemap.wait()
        shared.operationDictionary[key] = value
        shared.operationSemap.signal()
    }
    static func removeValue(_ value: String) {
        shared.operationSemap.wait()
        shared.operationDictionary.removeValue(forKey: value)
        shared.operationSemap.signal()
    }
}
extension CLBreakPointResumeManager {
    static func isDownloaded(_ url: URL) -> (Bool, String) {
        let fileAttribute = fileAttribute(url)
        return (fileAttribute.currentBytes != 0 && fileAttribute.currentBytes == fileAttribute.totalBytes, fileAttribute.path)
    }
}
extension CLBreakPointResumeManager {
    static func fileAttribute(_ url: URL) -> (path: String, currentBytes: Int64, totalBytes: Int64) {
        return (filePath(url), fileCurrentBytes(url), fileTotalBytes(url))
    }
    static func filePath(_ url: URL) -> String {
        return folderPath + url.absoluteString.md5() + (url.pathExtension.isEmpty ? "" : ".\(url.pathExtension)")
    }
    static func fileCurrentBytes(_ url: URL) -> Int64 {
        let path = filePath(url)
        var downloadedBytes: Int64 = 0
        let fileManager = FileManager.default
        if fileManager.fileExists(atPath: path) {
            let fileDict = try? fileManager.attributesOfItem(atPath: path)
            downloadedBytes = fileDict?[.size] as? Int64 ?? 0
        }
        return downloadedBytes
    }
    static func fileTotalBytes(_ url: URL) -> Int64 {
        var totalBytes : Int64 = 0
        if let sizeData = try? URL(fileURLWithPath: filePath(url)).extendedAttribute(forName: "totalBytes") {
            (sizeData as NSData).getBytes(&totalBytes, length: sizeData.count)
        }
        return totalBytes
    }
}

总结

主要代码已经贴出,其中更多细节请参考详细代码,下载地址----->>>CLDemo,如果对你有所帮助,欢迎Star。

相关文章

当我们远离最新的 iOS 16 更新版本时,我们听到了困扰 Apple...
欧版/美版 特别说一下,美版选错了 可能会永久丧失4G,不过只...
一般在接外包的时候, 通常第三方需要安装你的app进行测...
前言为了让更多的人永远记住12月13日,各大厂都在这一天将应...