feat: 参考Adobe官方调整分片下载逻辑

This commit is contained in:
X1a0He
2025-07-16 12:48:24 +08:00
parent 3e85652daa
commit c3d328bb5c
4 changed files with 535 additions and 250 deletions

View File

@@ -177,6 +177,7 @@ class Package: Identifiable, ObservableObject, Codable {
var downloadSize: Int64
var downloadURL: String
var packageVersion: String
var validationURL: String?
@Published var downloadedSize: Int64 = 0 {
didSet {
@@ -225,12 +226,13 @@ class Package: Identifiable, ObservableObject, Codable {
}
}
init(type: String, fullPackageName: String, downloadSize: Int64, downloadURL: String, packageVersion: String, condition: String = "", isRequired: Bool = false) {
init(type: String, fullPackageName: String, downloadSize: Int64, downloadURL: String, packageVersion: String, condition: String = "", isRequired: Bool = false, validationURL: String? = nil) {
self.type = type
self.fullPackageName = fullPackageName
self.downloadSize = downloadSize
self.downloadURL = downloadURL
self.packageVersion = packageVersion
self.validationURL = validationURL
self.condition = condition
self.isRequired = isRequired
self.isSelected = isRequired
@@ -306,7 +308,7 @@ class Package: Identifiable, ObservableObject, Codable {
}
enum CodingKeys: String, CodingKey {
case id, type, fullPackageName, downloadSize, downloadURL, packageVersion, condition, isRequired
case id, type, fullPackageName, downloadSize, downloadURL, packageVersion, validationURL, condition, isRequired
}
func encode(to encoder: Encoder) throws {
@@ -317,6 +319,7 @@ class Package: Identifiable, ObservableObject, Codable {
try container.encode(downloadSize, forKey: .downloadSize)
try container.encode(downloadURL, forKey: .downloadURL)
try container.encode(packageVersion, forKey: .packageVersion)
try container.encodeIfPresent(validationURL, forKey: .validationURL)
try container.encode(condition, forKey: .condition)
try container.encode(isRequired, forKey: .isRequired)
}
@@ -329,6 +332,7 @@ class Package: Identifiable, ObservableObject, Codable {
downloadSize = try container.decode(Int64.self, forKey: .downloadSize)
downloadURL = try container.decode(String.self, forKey: .downloadURL)
packageVersion = try container.decode(String.self, forKey: .packageVersion)
validationURL = try container.decodeIfPresent(String.self, forKey: .validationURL)
condition = try container.decodeIfPresent(String.self, forKey: .condition) ?? ""
isRequired = try container.decodeIfPresent(Bool.self, forKey: .isRequired) ?? false
isSelected = isRequired
@@ -338,6 +342,94 @@ class Package: Identifiable, ObservableObject, Codable {
}
}
/* ========== */
struct ValidationInfo {
var segmentSize: Int64
var version: String
var algorithm: String
var segmentCount: Int
var lastSegmentSize: Int64
var packageHashKey: String
var segments: [SegmentInfo]
struct SegmentInfo {
var segmentNumber: Int
var hash: String
}
static func parse(from xmlString: String) -> ValidationInfo? {
guard let data = xmlString.data(using: .utf8) else { return nil }
let parser = ValidationXMLParser()
let xmlParser = XMLParser(data: data)
xmlParser.delegate = parser
guard xmlParser.parse() else { return nil }
return ValidationInfo(
segmentSize: parser.segmentSize,
version: parser.version,
algorithm: parser.algorithm,
segmentCount: parser.segmentCount,
lastSegmentSize: parser.lastSegmentSize,
packageHashKey: parser.packageHashKey,
segments: parser.segments
)
}
}
class ValidationXMLParser: NSObject, XMLParserDelegate {
var segmentSize: Int64 = 0
var version: String = ""
var algorithm: String = ""
var segmentCount: Int = 0
var lastSegmentSize: Int64 = 0
var packageHashKey: String = ""
var segments: [ValidationInfo.SegmentInfo] = []
private var currentElement: String = ""
private var currentSegmentNumber: Int = 0
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {
currentElement = elementName
if elementName == "segment", let segmentNumber = attributeDict["segmentNumber"], let number = Int(segmentNumber) {
currentSegmentNumber = number
}
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
let trimmedString = string.trimmingCharacters(in: .whitespacesAndNewlines)
switch currentElement {
case "segmentSize":
if let size = Int64(trimmedString) {
segmentSize = size
}
case "version":
version = trimmedString
case "algorithm":
algorithm = trimmedString
case "segmentCount":
if let count = Int(trimmedString) {
segmentCount = count
}
case "lastSegmentSize":
if let size = Int64(trimmedString) {
lastSegmentSize = size
}
case "packageHashKey":
packageHashKey = trimmedString
case "segment":
if !trimmedString.isEmpty {
segments.append(ValidationInfo.SegmentInfo(segmentNumber: currentSegmentNumber, hash: trimmedString))
}
default:
break
}
}
}
struct NetworkConstants {
static let downloadTimeout: TimeInterval = 300
static let maxRetryAttempts = 3

View File

@@ -5,6 +5,7 @@
//
import Foundation
import CryptoKit
struct DownloadChunk {
let index: Int
@@ -14,10 +15,19 @@ struct DownloadChunk {
var downloadedSize: Int64 = 0
var isCompleted: Bool = false
var isPaused: Bool = false
let expectedHash: String?
var progress: Double {
return size > 0 ? Double(downloadedSize) / Double(size) : 0.0
}
init(index: Int, startOffset: Int64, endOffset: Int64, size: Int64, expectedHash: String? = nil) {
self.index = index
self.startOffset = startOffset
self.endOffset = endOffset
self.size = size
self.expectedHash = expectedHash
}
}
struct ChunkedDownloadState: Codable {
@@ -28,6 +38,7 @@ struct ChunkedDownloadState: Codable {
let totalDownloadedSize: Int64
let isCompleted: Bool
let destinationURL: String
let validationInfo: ValidationInfo?
struct ChunkInfo: Codable {
let index: Int
@@ -37,6 +48,53 @@ struct ChunkedDownloadState: Codable {
let downloadedSize: Int64
let isCompleted: Bool
let isPaused: Bool
let expectedHash: String?
}
}
extension ValidationInfo: Codable {
enum CodingKeys: String, CodingKey {
case segmentSize, version, algorithm, segmentCount, lastSegmentSize, packageHashKey, segments
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(segmentSize, forKey: .segmentSize)
try container.encode(version, forKey: .version)
try container.encode(algorithm, forKey: .algorithm)
try container.encode(segmentCount, forKey: .segmentCount)
try container.encode(lastSegmentSize, forKey: .lastSegmentSize)
try container.encode(packageHashKey, forKey: .packageHashKey)
try container.encode(segments, forKey: .segments)
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
segmentSize = try container.decode(Int64.self, forKey: .segmentSize)
version = try container.decode(String.self, forKey: .version)
algorithm = try container.decode(String.self, forKey: .algorithm)
segmentCount = try container.decode(Int.self, forKey: .segmentCount)
lastSegmentSize = try container.decode(Int64.self, forKey: .lastSegmentSize)
packageHashKey = try container.decode(String.self, forKey: .packageHashKey)
segments = try container.decode([SegmentInfo].self, forKey: .segments)
}
}
extension ValidationInfo.SegmentInfo: Codable {
enum CodingKeys: String, CodingKey {
case segmentNumber, hash
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(segmentNumber, forKey: .segmentNumber)
try container.encode(hash, forKey: .hash)
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
segmentNumber = try container.decode(Int.self, forKey: .segmentNumber)
hash = try container.decode(String.self, forKey: .hash)
}
}
@@ -85,27 +143,75 @@ class ChunkedDownloadManager {
return (acceptsRanges, contentLength, etag)
}
func createChunkedDownload(packageIdentifier: String, totalSize: Int64, destinationURL: URL) -> [DownloadChunk] {
let numChunks = Int(ceil(Double(totalSize) / Double(chunkSize)))
var chunks: [DownloadChunk] = []
for i in 0..<numChunks {
let startOffset = Int64(i) * chunkSize
let endOffset = min(startOffset + chunkSize - 1, totalSize - 1)
let size = endOffset - startOffset + 1
func createChunkedDownload(packageIdentifier: String, totalSize: Int64, destinationURL: URL, validationInfo: ValidationInfo? = nil) -> [DownloadChunk] {
if let validationInfo = validationInfo {
var chunks: [DownloadChunk] = []
chunks.append(DownloadChunk(
index: i,
startOffset: startOffset,
endOffset: endOffset,
size: size
))
}
for segment in validationInfo.segments {
let segmentIndex = segment.segmentNumber - 1
let startOffset = Int64(segmentIndex) * validationInfo.segmentSize
let isLastSegment = segment.segmentNumber == validationInfo.segmentCount
let segmentSize = isLastSegment ? validationInfo.lastSegmentSize : validationInfo.segmentSize
let endOffset = startOffset + segmentSize - 1
chunks.append(DownloadChunk(
index: segmentIndex,
startOffset: startOffset,
endOffset: endOffset,
size: segmentSize,
expectedHash: segment.hash
))
}
return chunks.sorted { $0.index < $1.index }
} else {
let standardChunkSize: Int64 = 2 * 1024 * 1024
let numChunks = Int(ceil(Double(totalSize) / Double(standardChunkSize)))
var chunks: [DownloadChunk] = []
for i in 0..<numChunks {
let startOffset = Int64(i) * standardChunkSize
let isLastChunk = (i == numChunks - 1)
let chunkSize = isLastChunk ? (totalSize - startOffset) : standardChunkSize
let endOffset = startOffset + chunkSize - 1
chunks.append(DownloadChunk(
index: i,
startOffset: startOffset,
endOffset: endOffset,
size: chunkSize
))
}
return chunks
return chunks
}
}
private func writeDataToFile(data: Data, destinationURL: URL, offset: Int64) async throws {
private func validateChunkHash(data: Data, expectedHash: String) -> Bool {
let hash = Insecure.MD5.hash(data: data)
let hashString = hash.map { String(format: "%02hhx", $0) }.joined()
return hashString.lowercased() == expectedHash.lowercased()
}
private func validateCompleteChunkFromFile(destinationURL: URL, chunk: DownloadChunk) -> Bool {
guard let expectedHash = chunk.expectedHash else {
return true
}
do {
let fileHandle = try FileHandle(forReadingFrom: destinationURL)
defer { fileHandle.closeFile() }
fileHandle.seek(toFileOffset: UInt64(chunk.startOffset))
let chunkData = fileHandle.readData(ofLength: Int(chunk.size))
return validateChunkHash(data: chunkData, expectedHash: expectedHash)
} catch {
return false
}
}
private func writeDataToFile(data: Data, destinationURL: URL, offset: Int64, totalSize: Int64? = nil) async throws {
return try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .utility).async {
do {
@@ -114,18 +220,23 @@ class ChunkedDownloadManager {
try self.fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
}
let fileHandle: FileHandle
if !self.fileManager.fileExists(atPath: destinationURL.path) {
let success = self.fileManager.createFile(atPath: destinationURL.path, contents: nil)
if !success {
throw NetworkError.filePermissionDenied(destinationURL.path)
}
fileHandle = try FileHandle(forWritingTo: destinationURL)
} else {
fileHandle = try FileHandle(forWritingTo: destinationURL)
}
let fileHandle = try FileHandle(forWritingTo: destinationURL)
defer { fileHandle.closeFile() }
defer { fileHandle.closeFile() }
fileHandle.seek(toFileOffset: UInt64(offset))
fileHandle.write(data)
fileHandle.synchronizeFile()
continuation.resume()
} catch {
@@ -154,18 +265,28 @@ class ChunkedDownloadManager {
}
if fileManager.fileExists(atPath: destinationURL.path) {
let existingSize = (try? fileManager.attributesOfItem(atPath: destinationURL.path)[.size] as? Int64) ?? 0
if let expectedHash = chunk.expectedHash {
if validateCompleteChunkFromFile(destinationURL: destinationURL, chunk: chunk) {
modifiedChunk.downloadedSize = chunk.size
modifiedChunk.isCompleted = true
return modifiedChunk
}
modifiedChunk.downloadedSize = 0
} else {
let existingSize = (try? fileManager.attributesOfItem(atPath: destinationURL.path)[.size] as? Int64) ?? 0
if existingSize > chunk.endOffset {
if let fileHandle = try? FileHandle(forReadingFrom: destinationURL) {
defer { fileHandle.closeFile() }
fileHandle.seek(toFileOffset: UInt64(chunk.startOffset))
let data = fileHandle.readData(ofLength: Int(chunk.size))
if existingSize > chunk.endOffset {
modifiedChunk.downloadedSize = chunk.size
modifiedChunk.isCompleted = true
return modifiedChunk
}
let chunkActualStartOffset = chunk.startOffset + chunk.downloadedSize
if existingSize > chunkActualStartOffset {
let alreadyDownloaded = min(existingSize - chunkActualStartOffset, chunk.size - chunk.downloadedSize)
modifiedChunk.downloadedSize += alreadyDownloaded
if !data.allSatisfy({ $0 == 0 }) {
modifiedChunk.downloadedSize = chunk.size
modifiedChunk.isCompleted = true
return modifiedChunk
}
}
}
}
}
@@ -201,12 +322,18 @@ class ChunkedDownloadManager {
}
if httpResponse.statusCode == 206 || httpResponse.statusCode == 200 {
try await writeDataToFile(data: data, destinationURL: destinationURL, offset: actualStartOffset)
try await writeDataToFile(data: data, destinationURL: destinationURL, offset: actualStartOffset, totalSize: nil)
modifiedChunk.downloadedSize += Int64(data.count)
if modifiedChunk.downloadedSize >= chunk.size {
modifiedChunk.isCompleted = true
if let expectedHash = chunk.expectedHash {
if !validateCompleteChunkFromFile(destinationURL: destinationURL, chunk: chunk) {
throw NetworkError.invalidData("分片哈希校验失败: \(chunk.index)")
}
}
}
progressHandler?(modifiedChunk.downloadedSize, chunk.size)
@@ -241,7 +368,7 @@ class ChunkedDownloadManager {
clearChunkedDownloadState(packageIdentifier: packageIdentifier)
}
func saveChunkedDownloadState(packageIdentifier: String, chunks: [DownloadChunk], totalSize: Int64, destinationURL: URL) {
func saveChunkedDownloadState(packageIdentifier: String, chunks: [DownloadChunk], totalSize: Int64, destinationURL: URL, validationInfo: ValidationInfo? = nil) {
let chunkInfos = chunks.map { chunk in
ChunkedDownloadState.ChunkInfo(
index: chunk.index,
@@ -250,18 +377,20 @@ class ChunkedDownloadManager {
size: chunk.size,
downloadedSize: chunk.downloadedSize,
isCompleted: chunk.isCompleted,
isPaused: chunk.isPaused
isPaused: chunk.isPaused,
expectedHash: chunk.expectedHash
)
}
let state = ChunkedDownloadState(
packageIdentifier: packageIdentifier,
totalSize: totalSize,
chunkSize: self.chunkSize,
chunkSize: validationInfo?.segmentSize ?? self.chunkSize,
chunks: chunkInfos,
totalDownloadedSize: chunks.reduce(0) { $0 + $1.downloadedSize },
isCompleted: chunks.allSatisfy { $0.isCompleted },
destinationURL: destinationURL.path
destinationURL: destinationURL.path,
validationInfo: validationInfo
)
let fileName = "\(packageIdentifier.replacingOccurrences(of: "/", with: "_")).chunkstate"
@@ -294,10 +423,16 @@ class ChunkedDownloadManager {
startOffset: chunkInfo.startOffset,
endOffset: chunkInfo.endOffset,
size: chunkInfo.size,
downloadedSize: chunkInfo.downloadedSize,
isCompleted: chunkInfo.isCompleted,
isPaused: chunkInfo.isPaused
expectedHash: chunkInfo.expectedHash
)
}.map { chunk in
var restoredChunk = chunk
if let chunkInfo = state.chunks.first(where: { $0.index == chunk.index }) {
restoredChunk.downloadedSize = chunkInfo.downloadedSize
restoredChunk.isCompleted = chunkInfo.isCompleted
restoredChunk.isPaused = chunkInfo.isPaused
}
return restoredChunk
}
}
@@ -307,11 +442,31 @@ class ChunkedDownloadManager {
try? fileManager.removeItem(at: fileURL)
}
private func fetchValidationInfo(from validationURL: String) async throws -> ValidationInfo? {
guard let url = URL(string: validationURL) else {
throw NetworkError.invalidData("无效的ValidationURL")
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw NetworkError.httpError((response as? HTTPURLResponse)?.statusCode ?? 0, "获取ValidationInfo失败")
}
guard let xmlString = String(data: data, encoding: .utf8) else {
throw NetworkError.invalidData("无法解析ValidationInfo XML")
}
return ValidationInfo.parse(from: xmlString)
}
func downloadFileWithChunks(
packageIdentifier: String,
url: URL,
destinationURL: URL,
headers: [String: String] = [:],
validationURL: String? = nil,
progressHandler: ((Double, Int64, Int64, Double) -> Void)? = nil,
cancellationHandler: (() async -> Bool)? = nil
) async throws {
@@ -322,12 +477,22 @@ class ChunkedDownloadManager {
throw NetworkError.invalidData("无法获取文件大小")
}
var validationInfo: ValidationInfo? = nil
if let validationURL = validationURL {
do {
validationInfo = try await fetchValidationInfo(from: validationURL)
} catch {
print("获取ValidationInfo失败: \(error), 降级到自定义分片")
}
}
var chunks: [DownloadChunk]
if let savedState = loadChunkedDownloadState(packageIdentifier: packageIdentifier) {
let currentChunkSize = validationInfo?.segmentSize ?? self.chunkSize
if savedState.totalSize == totalSize,
savedState.destinationURL == destinationURL.path,
savedState.packageIdentifier == packageIdentifier,
savedState.chunkSize == self.chunkSize {
savedState.chunkSize == currentChunkSize {
chunks = restoreChunksFromState(savedState).map { chunk in
var restoredChunk = chunk
restoredChunk.isPaused = false
@@ -336,11 +501,12 @@ class ChunkedDownloadManager {
} else {
clearChunkedDownloadState(packageIdentifier: packageIdentifier)
if supportsRange && totalSize > chunkSize {
if supportsRange && (validationInfo != nil || totalSize > chunkSize) {
chunks = createChunkedDownload(
packageIdentifier: packageIdentifier,
totalSize: totalSize,
destinationURL: destinationURL
destinationURL: destinationURL,
validationInfo: validationInfo
)
} else {
chunks = [DownloadChunk(
@@ -352,11 +518,12 @@ class ChunkedDownloadManager {
}
}
} else {
if supportsRange && totalSize > chunkSize {
if supportsRange && (validationInfo != nil || totalSize > chunkSize) {
chunks = createChunkedDownload(
packageIdentifier: packageIdentifier,
totalSize: totalSize,
destinationURL: destinationURL
destinationURL: destinationURL,
validationInfo: validationInfo
)
} else {
chunks = [DownloadChunk(
@@ -368,141 +535,48 @@ class ChunkedDownloadManager {
}
}
let incompleteChunks = chunks.filter { !$0.isCompleted && !$0.isPaused }
let incompleteChunks = chunks.filter { chunk in
if chunk.isPaused {
return false
}
if chunk.isCompleted {
return false
}
if chunk.downloadedSize >= chunk.size {
return false
}
if let expectedHash = chunk.expectedHash,
fileManager.fileExists(atPath: destinationURL.path) {
if validateCompleteChunkFromFile(destinationURL: destinationURL, chunk: chunk) {
return false
}
}
return true
}
if incompleteChunks.isEmpty {
clearChunkedDownloadState(packageIdentifier: packageIdentifier)
return
}
let chunkConcurrency = min(maxConcurrentChunks, 5)
actor SpeedTracker {
private var lastProgressTime = Date()
private var lastDownloadedSize: Int64 = 0
func updateSpeed(currentDownloaded: Int64) -> Double {
let now = Date()
let timeDiff = now.timeIntervalSince(lastProgressTime)
if timeDiff >= 0.5 {
let sizeDiff = currentDownloaded - lastDownloadedSize
let speed = timeDiff > 0 ? Double(sizeDiff) / timeDiff : 0
lastProgressTime = now
lastDownloadedSize = currentDownloaded
return speed
}
return 0
}
func initialize(initialSize: Int64) {
lastDownloadedSize = initialSize
lastProgressTime = Date()
}
}
try await downloadChunksSequentially(
chunks: incompleteChunks,
url: url,
destinationURL: destinationURL,
headers: headers,
progressHandler: progressHandler,
cancellationHandler: cancellationHandler,
packageIdentifier: packageIdentifier,
totalSize: totalSize,
validationInfo: validationInfo
)
actor ChunkStateManager {
private var chunks: [DownloadChunk]
init(chunks: [DownloadChunk]) {
self.chunks = chunks
}
func updateChunkProgress(index: Int, downloadedSize: Int64, isCompleted: Bool = false) {
if let chunkIndex = chunks.firstIndex(where: { $0.index == index }) {
chunks[chunkIndex].downloadedSize = downloadedSize
if isCompleted {
chunks[chunkIndex].isCompleted = true
}
}
}
func updateChunk(_ updatedChunk: DownloadChunk) {
if let index = chunks.firstIndex(where: { $0.index == updatedChunk.index }) {
chunks[index] = updatedChunk
}
}
func getAllChunks() -> [DownloadChunk] {
return chunks
}
func getTotalDownloadedSize() -> Int64 {
return chunks.reduce(Int64(0)) { $0 + $1.downloadedSize }
}
}
let speedTracker = SpeedTracker()
await speedTracker.initialize(initialSize: chunks.reduce(Int64(0)) { $0 + $1.downloadedSize })
let chunkStateManager = ChunkStateManager(chunks: chunks)
try await withThrowingTaskGroup(of: DownloadChunk.self) { group in
let semaphore = AsyncSemaphore(value: chunkConcurrency)
let initialChunks = await chunkStateManager.getAllChunks()
saveChunkedDownloadState(packageIdentifier: packageIdentifier, chunks: initialChunks, totalSize: totalSize, destinationURL: destinationURL)
for chunk in incompleteChunks {
if let cancellationHandler = cancellationHandler {
if await cancellationHandler() {
throw NetworkError.cancelled
}
}
group.addTask {
await semaphore.wait()
defer {
Task {
await semaphore.signal()
}
}
if let cancellationHandler = cancellationHandler {
if await cancellationHandler() {
throw NetworkError.cancelled
}
}
return try await self.downloadChunkToFile(
chunk: chunk,
url: url,
destinationURL: destinationURL,
headers: headers,
progressHandler: { downloaded, total in
Task {
await chunkStateManager.updateChunkProgress(
index: chunk.index,
downloadedSize: downloaded,
isCompleted: downloaded >= total
)
let currentTotalDownloaded = await chunkStateManager.getTotalDownloadedSize()
let progress = Double(currentTotalDownloaded) / Double(totalSize)
let speed = await speedTracker.updateSpeed(currentDownloaded: currentTotalDownloaded)
if speed > 0 {
progressHandler?(progress, currentTotalDownloaded, totalSize, speed)
}
}
},
cancellationHandler: cancellationHandler
)
}
}
do {
for try await completedChunk in group {
await chunkStateManager.updateChunk(completedChunk)
let currentChunks = await chunkStateManager.getAllChunks()
saveChunkedDownloadState(packageIdentifier: packageIdentifier, chunks: currentChunks, totalSize: totalSize, destinationURL: destinationURL)
}
} catch {
let currentChunks = await chunkStateManager.getAllChunks()
saveChunkedDownloadState(packageIdentifier: packageIdentifier, chunks: currentChunks, totalSize: totalSize, destinationURL: destinationURL)
throw error
}
if let validationInfo = validationInfo {
try await validateCompleteFile(destinationURL: destinationURL, validationInfo: validationInfo, totalSize: totalSize)
}
clearChunkedDownloadState(packageIdentifier: packageIdentifier)
@@ -520,4 +594,196 @@ class ChunkedDownloadManager {
try await downloadTask.value
}
private func downloadChunksSequentially(
chunks: [DownloadChunk],
url: URL,
destinationURL: URL,
headers: [String: String],
progressHandler: ((Double, Int64, Int64, Double) -> Void)?,
cancellationHandler: (() async -> Bool)?,
packageIdentifier: String,
totalSize: Int64,
validationInfo: ValidationInfo?
) async throws {
let sortedChunks = chunks.sorted { $0.index < $1.index }
actor SpeedTracker {
private var lastProgressTime = Date()
private var lastDownloadedSize: Int64 = 0
func updateSpeed(currentDownloaded: Int64) -> Double {
let now = Date()
let timeDiff = now.timeIntervalSince(lastProgressTime)
if timeDiff >= 0.5 {
let sizeDiff = currentDownloaded - lastDownloadedSize
let speed = timeDiff > 0 ? Double(sizeDiff) / timeDiff : 0
lastProgressTime = now
lastDownloadedSize = currentDownloaded
return speed
}
return 0
}
func initialize(initialSize: Int64) {
lastDownloadedSize = initialSize
lastProgressTime = Date()
}
}
actor ChunkStateManager {
private var chunks: [DownloadChunk]
init(chunks: [DownloadChunk]) {
self.chunks = chunks
}
func updateChunkProgress(index: Int, downloadedSize: Int64, isCompleted: Bool = false) {
if let chunkIndex = chunks.firstIndex(where: { $0.index == index }) {
chunks[chunkIndex].downloadedSize = downloadedSize
if isCompleted {
chunks[chunkIndex].isCompleted = true
}
}
}
func updateChunk(_ updatedChunk: DownloadChunk) {
if let index = chunks.firstIndex(where: { $0.index == updatedChunk.index }) {
chunks[index] = updatedChunk
}
}
func getAllChunks() -> [DownloadChunk] {
return chunks
}
func getTotalDownloadedSize() -> Int64 {
return chunks.reduce(Int64(0)) { $0 + $1.downloadedSize }
}
}
let speedTracker = SpeedTracker()
await speedTracker.initialize(initialSize: chunks.reduce(Int64(0)) { $0 + $1.downloadedSize })
let chunkStateManager = ChunkStateManager(chunks: chunks)
let initialChunks = await chunkStateManager.getAllChunks()
saveChunkedDownloadState(packageIdentifier: packageIdentifier, chunks: initialChunks, totalSize: totalSize, destinationURL: destinationURL, validationInfo: validationInfo)
for chunk in sortedChunks {
if let cancellationHandler = cancellationHandler {
if await cancellationHandler() {
throw NetworkError.cancelled
}
}
let completedChunk = try await downloadChunkToFile(
chunk: chunk,
url: url,
destinationURL: destinationURL,
headers: headers,
progressHandler: { downloaded, total in
Task {
await chunkStateManager.updateChunkProgress(
index: chunk.index,
downloadedSize: downloaded,
isCompleted: downloaded >= total
)
let currentTotalDownloaded = await chunkStateManager.getTotalDownloadedSize()
let progress = Double(currentTotalDownloaded) / Double(totalSize)
let speed = await speedTracker.updateSpeed(currentDownloaded: currentTotalDownloaded)
if speed > 0 {
progressHandler?(progress, currentTotalDownloaded, totalSize, speed)
}
}
},
cancellationHandler: cancellationHandler
)
await chunkStateManager.updateChunk(completedChunk)
let currentChunks = await chunkStateManager.getAllChunks()
saveChunkedDownloadState(packageIdentifier: packageIdentifier, chunks: currentChunks, totalSize: totalSize, destinationURL: destinationURL, validationInfo: validationInfo)
}
}
private func validateCompleteFile(destinationURL: URL, validationInfo: ValidationInfo, totalSize: Int64) async throws {
do {
let fileAttributes = try FileManager.default.attributesOfItem(atPath: destinationURL.path)
if let fileSize = fileAttributes[.size] as? Int64, fileSize != totalSize {
throw NetworkError.invalidData("文件大小不匹配: 期望\(totalSize), 实际\(fileSize)")
}
} catch {
throw NetworkError.invalidData("无法获取文件大小: \(error.localizedDescription)")
}
let fileHandle = try FileHandle(forReadingFrom: destinationURL)
defer { fileHandle.closeFile() }
for segment in validationInfo.segments {
let segmentIndex = segment.segmentNumber - 1
let startOffset = Int64(segmentIndex) * validationInfo.segmentSize
let isLastSegment = segment.segmentNumber == validationInfo.segmentCount
let segmentSize = isLastSegment ? validationInfo.lastSegmentSize : validationInfo.segmentSize
fileHandle.seek(toFileOffset: UInt64(startOffset))
let segmentData = fileHandle.readData(ofLength: Int(segmentSize))
if segmentData.count != Int(segmentSize) {
throw NetworkError.invalidData("分片\(segment.segmentNumber)大小不正确: 期望\(segmentSize), 实际\(segmentData.count)")
}
if !validateChunkHash(data: segmentData, expectedHash: segment.hash) {
throw NetworkError.invalidData("分片\(segment.segmentNumber)哈希校验失败")
}
}
}
private func ensureFilePreallocated(destinationURL: URL, totalSize: Int64) async throws {
return try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .utility).async {
do {
let directory = destinationURL.deletingLastPathComponent()
if !self.fileManager.fileExists(atPath: directory.path) {
try self.fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
}
if self.fileManager.fileExists(atPath: destinationURL.path) {
if let attributes = try? self.fileManager.attributesOfItem(atPath: destinationURL.path),
let existingSize = attributes[.size] as? Int64 {
if existingSize == totalSize {
continuation.resume()
return
} else if existingSize > totalSize {
try? self.fileManager.removeItem(at: destinationURL)
}
}
}
let success = self.fileManager.createFile(atPath: destinationURL.path, contents: nil)
if !success {
throw NetworkError.filePermissionDenied(destinationURL.path)
}
let fileHandle = try FileHandle(forWritingTo: destinationURL)
defer { fileHandle.closeFile() }
if ftruncate(fileHandle.fileDescriptor, off_t(totalSize)) != 0 {
fileHandle.seek(toFileOffset: UInt64(totalSize - 1))
fileHandle.write(Data([0]))
}
fileHandle.synchronizeFile()
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
}
}
}

View File

@@ -843,6 +843,7 @@ class NewDownloadUtils {
url: url,
destinationURL: destinationURL,
headers: NetworkConstants.downloadHeaders,
validationURL: package.validationURL,
progressHandler: { progress, downloadedSize, totalSize, speed in
Task {
await MainActor.run {

View File

@@ -215,14 +215,6 @@ final class GeneralSettingsViewModel: ObservableObject {
objectWillChange.send()
}
}
var chunkSizeMB: Int {
get { StorageData.shared.chunkSizeMB }
set {
StorageData.shared.chunkSizeMB = newValue
objectWillChange.send()
}
}
@Published var automaticallyChecksForUpdates: Bool
@Published var automaticallyDownloadsUpdates: Bool
@@ -487,11 +479,6 @@ struct DownloadSettingsView: View {
ConcurrentDownloadsSettingRow(viewModel: viewModel)
.fixedSize(horizontal: false, vertical: true)
Divider()
ChunkSizeSettingRow(viewModel: viewModel)
.fixedSize(horizontal: false, vertical: true)
}
}
}
@@ -1354,64 +1341,3 @@ struct ConcurrentDownloadsSettingRow: View {
}
}
struct ChunkSizeSettingRow: View {
@ObservedObject var viewModel: GeneralSettingsViewModel
var body: some View {
HStack(spacing: 10) {
Text("分片大小")
.font(.system(size: 14))
.padding(.leading, 5)
Spacer()
HStack(spacing: 8) {
Button(action: {
if viewModel.chunkSizeMB > 2 {
viewModel.chunkSizeMB -= 2
}
}) {
Image(systemName: "minus.circle.fill")
.foregroundColor(viewModel.chunkSizeMB > 2 ? .blue : .gray)
.font(.system(size: 16))
}
.buttonStyle(PlainButtonStyle())
.disabled(viewModel.chunkSizeMB <= 2)
Text("\(viewModel.chunkSizeMB) MB")
.font(.system(size: 14, weight: .medium))
.frame(minWidth: 50)
Button(action: {
if viewModel.chunkSizeMB < 20 {
viewModel.chunkSizeMB += 2
}
}) {
Image(systemName: "plus.circle.fill")
.foregroundColor(viewModel.chunkSizeMB < 20 ? .blue : .gray)
.font(.system(size: 16))
}
.buttonStyle(PlainButtonStyle())
.disabled(viewModel.chunkSizeMB >= 20)
}
.padding(.vertical, 3)
.padding(.horizontal, 8)
.background(Color.secondary.opacity(0.1))
.cornerRadius(5)
.padding(.trailing, 5)
HStack(spacing: 5) {
Image(systemName: "square.and.arrow.down")
.foregroundColor(.secondary)
.font(.system(size: 12))
Text("2-20 MB")
.foregroundColor(.secondary)
.font(.system(size: 12))
}
.padding(.vertical, 3)
.padding(.horizontal, 6)
.background(Color.secondary.opacity(0.05))
.cornerRadius(4)
}
}
}