iOS開發之HTTP斷點續傳
阿新 • • 發佈:2021-06-17
前言
在APP中經常會遇到檔案下載,鑑於使用者體驗和流量控制,就需要用到斷點續傳。本文主要對斷點續傳進行了多執行緒封裝。
效果圖
原理
HTTP實現斷點續傳是通過HTTP報文頭部header裡面設定的兩個引數Range
和Content-Range
實現。
程式碼部分
一、檔案大小記錄
在下載檔案的時候,需要先獲取到檔案的總大小,這裡使用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。