对齐旅拍相册详情页 Android UI,并修复网格预览与布局问题。
重构相册详情为信息卡片、Tab 筛选、排序、批量删除与底部上传栏;修复网格重叠、禁用按钮蒙层,并支持点击预览大图。同步扩展素材列表 API 与 ViewModel 分页逻辑,并优化有线传图缩略图与传输性能。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -16,9 +16,14 @@ final class CameraTransferPipeline {
|
||||
private let cameraService: any CameraServiceProtocol
|
||||
private var uploadSink: (any CameraAssetUploadSink)?
|
||||
private var uploadEnabled = true
|
||||
private var isProcessing = false
|
||||
private var activeUploadAssetIDs: Set<String> = []
|
||||
private var pendingDeferredNotifyTask: Task<Void, Never>?
|
||||
private var lastDeferredNotifyDate = Date.distantPast
|
||||
private var lastNotifiedProgressByAssetID: [String: Int] = [:]
|
||||
private var inFlightAssetIDs: Set<String> = []
|
||||
private let maxRetries = 3
|
||||
private let deferredNotifyInterval: TimeInterval = 0.15
|
||||
private let maxConcurrentUploads = 3
|
||||
|
||||
/// 初始化传输管道,并注入相机服务。
|
||||
init(cameraService: any CameraServiceProtocol) {
|
||||
@ -67,25 +72,62 @@ final class CameraTransferPipeline {
|
||||
tasks[index].errorMessage = nil
|
||||
}
|
||||
notify()
|
||||
await processUploadQueue()
|
||||
processUploadQueue()
|
||||
}
|
||||
|
||||
/// 手动上传指定 asset 列表(拍完再传模式)。
|
||||
func uploadAssets(withIDs assetIDs: [String]) async {
|
||||
guard uploadEnabled, uploadSink != nil else { return }
|
||||
guard uploadSink != nil else { return }
|
||||
for assetID in assetIDs {
|
||||
if activeUploadAssetIDs.contains(assetID) { continue }
|
||||
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { continue }
|
||||
if tasks[index].status == .uploaded { continue }
|
||||
if tasks[index].localURL == nil {
|
||||
await downloadAsset(at: index)
|
||||
await downloadAsset(assetID: assetID)
|
||||
}
|
||||
if tasks[index].status == .downloaded || tasks[index].status == .failed {
|
||||
tasks[index].status = .downloaded
|
||||
await uploadTask(at: index)
|
||||
guard let refreshedIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { continue }
|
||||
if tasks[refreshedIndex].status == .downloaded || tasks[refreshedIndex].status == .failed {
|
||||
tasks[refreshedIndex].status = .downloaded
|
||||
tasks[refreshedIndex].errorMessage = nil
|
||||
await uploadTask(assetID: assetID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 重传单张照片;若管道中无任务则根据本地相对路径恢复。
|
||||
func retryUpload(assetID: String, filename: String, localPath: String) async {
|
||||
guard uploadSink != nil else { return }
|
||||
guard !activeUploadAssetIDs.contains(assetID) else { return }
|
||||
|
||||
if let index = tasks.firstIndex(where: { $0.assetID == assetID }) {
|
||||
if tasks[index].status == .uploaded { return }
|
||||
if tasks[index].localPath?.isEmpty != false, !localPath.isEmpty {
|
||||
tasks[index].localPath = localPath
|
||||
}
|
||||
tasks[index].status = .downloaded
|
||||
tasks[index].errorMessage = nil
|
||||
} else {
|
||||
guard !localPath.isEmpty,
|
||||
let localURL = CameraDownloadStorage.resolveLocalURL(from: localPath),
|
||||
FileManager.default.fileExists(atPath: localURL.path) else {
|
||||
return
|
||||
}
|
||||
tasks.insert(
|
||||
CameraTransferTask(
|
||||
assetID: assetID,
|
||||
filename: filename,
|
||||
localPath: localPath,
|
||||
capturedAt: CameraTransferTask.capturedAtString(forLocalPath: localPath),
|
||||
status: .downloaded
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
}
|
||||
|
||||
notify()
|
||||
await uploadTask(assetID: assetID)
|
||||
}
|
||||
|
||||
/// 同步相机内已有照片到任务列表(不自动上传)。
|
||||
func syncExistingPhotos() async {
|
||||
guard cameraService.connectionState.isConnected else { return }
|
||||
@ -128,28 +170,33 @@ final class CameraTransferPipeline {
|
||||
inFlightAssetIDs.insert(asset.id)
|
||||
defer { inFlightAssetIDs.remove(asset.id) }
|
||||
|
||||
let taskIndex: Int
|
||||
if let existingIndex = tasks.firstIndex(where: { $0.assetID == asset.id }) {
|
||||
taskIndex = existingIndex
|
||||
tasks[taskIndex].status = .downloading
|
||||
tasks[taskIndex].errorMessage = nil
|
||||
let assetID = asset.id
|
||||
if let existingIndex = tasks.firstIndex(where: { $0.assetID == assetID }) {
|
||||
tasks[existingIndex].status = .downloading
|
||||
tasks[existingIndex].errorMessage = nil
|
||||
} else {
|
||||
let task = CameraTransferTask(assetID: asset.id, filename: asset.filename, status: .downloading)
|
||||
let task = CameraTransferTask(
|
||||
assetID: asset.id,
|
||||
filename: asset.filename,
|
||||
capturedAt: CameraTransferTask.capturedAtString(from: asset.creationDate),
|
||||
status: .downloading
|
||||
)
|
||||
tasks.insert(task, at: 0)
|
||||
taskIndex = 0
|
||||
}
|
||||
notify()
|
||||
|
||||
await downloadAsset(at: taskIndex, asset: asset)
|
||||
await downloadAsset(assetID: assetID, asset: asset)
|
||||
|
||||
if uploadEnabled, uploadSink != nil, tasks[taskIndex].status == .downloaded {
|
||||
await processUploadQueue()
|
||||
if uploadEnabled,
|
||||
uploadSink != nil,
|
||||
tasks.first(where: { $0.assetID == assetID })?.status == .downloaded {
|
||||
processUploadQueue()
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadAsset(at index: Int, asset: CameraAsset? = nil) async {
|
||||
guard tasks.indices.contains(index) else { return }
|
||||
let assetID = tasks[index].assetID
|
||||
/// 下载指定 asset 到本地;始终按 assetID 定位任务,避免连拍时 index 失效。
|
||||
private func downloadAsset(assetID: String, asset: CameraAsset? = nil) async {
|
||||
guard tasks.contains(where: { $0.assetID == assetID }) else { return }
|
||||
|
||||
do {
|
||||
let resolvedAsset: CameraAsset
|
||||
@ -174,31 +221,44 @@ final class CameraTransferPipeline {
|
||||
try FileManager.default.moveItem(at: localURL, to: destination)
|
||||
}
|
||||
|
||||
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||
tasks[index].localPath = CameraDownloadStorage.relativePath(for: destination)
|
||||
if tasks[index].capturedAt?.isEmpty != false {
|
||||
tasks[index].capturedAt = CameraTransferTask.capturedAtString(from: resolvedAsset.creationDate)
|
||||
}
|
||||
tasks[index].status = .downloaded
|
||||
tasks[index].progress = 0
|
||||
notify()
|
||||
} catch {
|
||||
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||
tasks[index].status = .failed
|
||||
tasks[index].errorMessage = error.localizedDescription
|
||||
notify()
|
||||
}
|
||||
}
|
||||
|
||||
private func processUploadQueue() async {
|
||||
/// 并发上传已下载任务;边拍边传时限制并发数,避免网络与内存被打满。
|
||||
private func processUploadQueue() {
|
||||
guard uploadEnabled, uploadSink != nil else { return }
|
||||
guard !isProcessing else { return }
|
||||
isProcessing = true
|
||||
defer { isProcessing = false }
|
||||
|
||||
let pendingIndices = tasks.indices.filter { tasks[$0].status == .downloaded }
|
||||
for index in pendingIndices {
|
||||
await uploadTask(at: index)
|
||||
while activeUploadAssetIDs.count < maxConcurrentUploads,
|
||||
let task = tasks.first(where: { $0.status == .downloaded && !activeUploadAssetIDs.contains($0.assetID) }) {
|
||||
startConcurrentUpload(assetID: task.assetID)
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadTask(at index: Int) async {
|
||||
guard tasks.indices.contains(index),
|
||||
private func startConcurrentUpload(assetID: String) {
|
||||
guard !activeUploadAssetIDs.contains(assetID) else { return }
|
||||
activeUploadAssetIDs.insert(assetID)
|
||||
Task { @MainActor in
|
||||
await self.uploadTask(assetID: assetID)
|
||||
self.activeUploadAssetIDs.remove(assetID)
|
||||
self.processUploadQueue()
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadTask(assetID: String) async {
|
||||
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }),
|
||||
let sink = uploadSink,
|
||||
let localURL = tasks[index].localURL
|
||||
else { return }
|
||||
@ -207,33 +267,41 @@ final class CameraTransferPipeline {
|
||||
tasks[index].errorMessage = nil
|
||||
notify()
|
||||
|
||||
let fileType = tasks[index].filename.lowercased().hasSuffix(".mp4") ? 1 : 2
|
||||
let filename = tasks[index].filename
|
||||
let fileType = filename.lowercased().hasSuffix(".mp4") ? 1 : 2
|
||||
var attempt = 0
|
||||
|
||||
while attempt < maxRetries {
|
||||
do {
|
||||
let remoteURL = try await sink.upload(
|
||||
localURL: localURL,
|
||||
fileName: tasks[index].filename,
|
||||
fileName: filename,
|
||||
fileType: fileType,
|
||||
progress: { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
guard let self, self.tasks.indices.contains(index) else { return }
|
||||
self.tasks[index].progress = progress
|
||||
self.notify()
|
||||
guard let self,
|
||||
let taskIndex = self.tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||
let lastNotifiedProgress = self.lastNotifiedProgressByAssetID[assetID] ?? -1
|
||||
guard progress == 100 || progress - lastNotifiedProgress >= 10 else { return }
|
||||
self.lastNotifiedProgressByAssetID[assetID] = progress
|
||||
self.tasks[taskIndex].progress = progress
|
||||
self.notify(progress == 100 ? .immediate : .deferred)
|
||||
}
|
||||
}
|
||||
)
|
||||
tasks[index].status = .uploaded
|
||||
tasks[index].remoteURL = remoteURL
|
||||
tasks[index].progress = 100
|
||||
guard let taskIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||
tasks[taskIndex].status = .uploaded
|
||||
tasks[taskIndex].remoteURL = remoteURL
|
||||
tasks[taskIndex].progress = 100
|
||||
lastNotifiedProgressByAssetID.removeValue(forKey: assetID)
|
||||
notify()
|
||||
return
|
||||
} catch {
|
||||
attempt += 1
|
||||
guard let taskIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||
if attempt >= maxRetries {
|
||||
tasks[index].status = .failed
|
||||
tasks[index].errorMessage = error.localizedDescription
|
||||
tasks[taskIndex].status = .failed
|
||||
tasks[taskIndex].errorMessage = error.localizedDescription
|
||||
lastNotifiedProgressByAssetID.removeValue(forKey: assetID)
|
||||
notify()
|
||||
} else {
|
||||
let delay = UInt64(pow(2.0, Double(attempt)))
|
||||
@ -243,7 +311,33 @@ final class CameraTransferPipeline {
|
||||
}
|
||||
}
|
||||
|
||||
private func notify() {
|
||||
onTasksUpdated?(tasks)
|
||||
private func notify(_ mode: CameraTransferNotifyMode = .immediate) {
|
||||
switch mode {
|
||||
case .immediate:
|
||||
pendingDeferredNotifyTask?.cancel()
|
||||
pendingDeferredNotifyTask = nil
|
||||
onTasksUpdated?(tasks)
|
||||
case .deferred:
|
||||
let elapsed = Date().timeIntervalSince(lastDeferredNotifyDate)
|
||||
if elapsed >= deferredNotifyInterval {
|
||||
lastDeferredNotifyDate = Date()
|
||||
onTasksUpdated?(tasks)
|
||||
return
|
||||
}
|
||||
guard pendingDeferredNotifyTask == nil else { return }
|
||||
let delay = UInt64((deferredNotifyInterval - elapsed) * 1_000_000_000)
|
||||
pendingDeferredNotifyTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: delay)
|
||||
guard !Task.isCancelled else { return }
|
||||
self.lastDeferredNotifyDate = Date()
|
||||
self.pendingDeferredNotifyTask = nil
|
||||
self.onTasksUpdated?(self.tasks)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum CameraTransferNotifyMode {
|
||||
case immediate
|
||||
case deferred
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user