| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256 |
- //
- // LNFileDownloader.swift
- // Lanu
- //
- // Created by OneeChan on 2025/12/5.
- //
- import Foundation
- private class LNFileDownloadTask {
- let sourceUrl: String
- let destinationPath: URL
- var isRunning = false
- var progressHandler: [((Float) -> Void)] = []
- var completionHandler: [((Result<URL, Error>) -> Void)] = []
- var totalBytesWritten: Int64 = 0
- var totalBytesExpectedToWrite: Int64 = 0
-
- init(url: String, destinationPath: URL) {
- self.sourceUrl = url
- self.destinationPath = destinationPath
- }
- }
- class LNFileDownloader: NSObject {
- static let shared = LNFileDownloader()
- private let maxConcurrentCount: Int = 3
-
- private lazy var session: URLSession = {
- let config = URLSessionConfiguration.default
- config.httpMaximumConnectionsPerHost = maxConcurrentCount
- return URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue())
- }()
-
- private let lock = NSLock()
- private var taskList: [LNFileDownloadTask] = []
- private var runningTaskCount: Int = 0
-
- enum LNFileDownloadError: Error {
- case invalidURL
- case fileMoveFailed
- case beCancelled
- case networkError(Error)
-
- var errorDesc: String? {
- switch self {
- case .invalidURL: return "无效的下载链接"
- case .fileMoveFailed: return "文件保存失败"
- case .beCancelled: return "被取消"
- case .networkError: return "网络错误"
- }
- }
- }
-
- func startDownload(
- from urlString: String,
- destinationPath: URL? = nil,
- progressHandler: ((Float) -> Void)? = nil,
- completionHandler: ((Result<URL, Error>) -> Void)? = nil
- ) {
- guard let url = URL(string: urlString) else {
- completionHandler?(.failure(LNFileDownloadError.invalidURL))
- return
- }
-
- DispatchQueue.global().async { [weak self] in
- guard let self else { return }
- lock.lock()
- defer { lock.unlock() }
- let task = taskList.first { $0.sourceUrl == urlString }
- if let task {
- if let progressHandler {
- task.progressHandler.append(progressHandler)
- }
- if let completionHandler {
- task.completionHandler.append(completionHandler)
- }
- return
- }
- let targetPath = destinationPath ?? defaultDestinationPath(for: url)
- let newTask = LNFileDownloadTask(url: urlString, destinationPath: targetPath)
- if let progressHandler {
- newTask.progressHandler.append(progressHandler)
- }
- if let completionHandler {
- newTask.completionHandler.append(completionHandler)
- }
- taskList.append(newTask)
-
- if runningTaskCount >= maxConcurrentCount { return }
-
- let downloadTask = session.downloadTask(with: url)
- downloadTask.taskDescription = urlString
- runningTaskCount += 1
- newTask.isRunning = true
- downloadTask.resume()
- }
- }
-
- func cancelDownload(urlString: String) {
- DispatchQueue.global().async { [weak self] in
- guard let self else { return }
- lock.lock()
- defer { lock.unlock() }
-
- guard let task = taskList.first(where: { $0.sourceUrl == urlString }) else { return }
- session.getAllTasks(completionHandler: { tasks in
- tasks.first { $0.taskDescription == urlString }?.cancel()
- })
- removeTask(urlString: urlString)
-
- let handlers = task.completionHandler
- runOnMain {
- handlers.forEach { $0(.failure(LNFileDownloadError.beCancelled)) }
- }
- }
- }
-
- /// 取消所有下载任务
- func cancelAllDownloads() {
- DispatchQueue.global().async { [weak self] in
- guard let self else { return }
- lock.lock()
- defer { lock.unlock() }
-
- session.getAllTasks(completionHandler: { tasks in
- tasks.forEach { $0.cancel() }
- })
- taskList.removeAll()
- runningTaskCount = 0
- }
- }
- }
- extension LNFileDownloader {
- /// 生成默认保存路径(Documents目录+原文件名)
- private func defaultDestinationPath(for url: URL) -> URL {
- let fileName = url.lastPathComponent
- return URL.documentsDir.appendingPathComponent(fileName)
- }
-
- private func removeTask(urlString: String) {
- DispatchQueue.global().async { [weak self] in
- guard let self else { return }
- lock.lock()
- defer { lock.unlock() }
- taskList.removeAll(where: { $0.sourceUrl == urlString })
- runningTaskCount = max(0, self.runningTaskCount - 1)
-
- if let nextTask = taskList.first(where: { $0.isRunning == false }) {
- let downloadTask = session.downloadTask(with: URL(string: nextTask.sourceUrl)!)
- downloadTask.taskDescription = urlString
- runningTaskCount += 1
- nextTask.isRunning = true
- downloadTask.resume()
- }
- }
- }
- }
- extension LNFileDownloader: URLSessionDownloadDelegate {
- func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
- lock.lock()
- defer { lock.unlock() }
- guard let urlString = downloadTask.taskDescription,
- let taskModel = taskList.first(where: { $0.sourceUrl == urlString }) else {
- return
- }
-
- taskModel.totalBytesWritten = totalBytesWritten
- taskModel.totalBytesExpectedToWrite = totalBytesExpectedToWrite
-
- let progress = totalBytesExpectedToWrite > 0 ? Float(totalBytesWritten) / Float(totalBytesExpectedToWrite) : 0.0
- let handlers = taskModel.progressHandler
- runOnMain {
- handlers.forEach { $0(progress) }
- }
- }
-
- func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
- lock.lock()
- defer { lock.unlock() }
-
- guard FileManager.default.fileExists(atPath: location.path) else { return }
-
- guard let urlString = downloadTask.taskDescription,
- let taskModel = taskList.first(where: { $0.sourceUrl == urlString }) else {
- return
- }
-
- // 2. 移动临时文件到目标路径
- do {
- let destinationPath = taskModel.destinationPath
-
- // 检查目标文件是否存在,存在则删除(避免覆盖失败)
- if FileManager.default.fileExists(atPath: destinationPath.path) {
- try FileManager.default.removeItem(at: destinationPath)
- }
-
- // 移动文件
- let destinationDir = destinationPath.deletingLastPathComponent()
- if !FileManager.default.fileExists(atPath: destinationDir.path) {
- try FileManager.default.createDirectory(
- at: destinationDir,
- withIntermediateDirectories: true,
- attributes: nil
- )
- }
- try FileManager.default.moveItem(at: location, to: destinationPath)
-
- // 回调成功结果
- let handlers = taskModel.completionHandler
- runOnMain {
- handlers.forEach { $0(.success(destinationPath)) }
- }
- } catch {
- // 回调文件移动失败
- let handlers = taskModel.completionHandler
- runOnMain {
- handlers.forEach { $0(.failure(LNFileDownloadError.fileMoveFailed)) }
- }
- }
- removeTask(urlString: urlString)
- }
- }
- extension LNFileDownloader: URLSessionTaskDelegate {
- /// 任务完成(含错误处理)
- func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
- lock.lock()
- defer { lock.unlock() }
- guard let urlString = task.taskDescription,
- let taskModel = taskList.first(where: { $0.sourceUrl == urlString }) else {
- return
- }
-
- // 处理错误(排除用户取消的情况)
- if let error = error {
- let nsError = error as NSError
- // 取消下载在取消操作进行了回调,这里不处理
- if nsError.code == NSURLErrorCancelled {
- removeTask(urlString: urlString)
- return
- }
- // 其他错误:回调网络错误
- let handlers = taskModel.completionHandler
- runOnMain {
- handlers.forEach { $0(.failure(LNFileDownloadError.networkError(error))) }
- }
- }
-
- // 移除任务(无论成功与否,任务完成后都要移除)
- removeTask(urlString: urlString)
- }
- }
|