feat: 上传进度

This commit is contained in:
jinyaqiu 2025-08-19 19:17:11 +08:00
parent f40828484c
commit 84db43f3b5
4 changed files with 335 additions and 127 deletions

View File

@ -1,52 +1,60 @@
import SwiftUI
import PhotosUI
///
struct UploadResults {
let original: ImageUploaderGetID.UploadResult
let compressed: ImageUploaderGetID.UploadResult
// MARK: - Data Models
///
public struct UploadProgress {
public let current: Int
public let total: Int
public let progress: Double
public let isOriginal: Bool
public init(current: Int, total: Int, progress: Double, isOriginal: Bool) {
self.current = current
self.total = total
self.progress = progress
self.isOriginal = isOriginal
}
}
///
public struct UploadResults {
public let original: ImageUploaderGetID.UploadResult
public let compressed: ImageUploaderGetID.UploadResult
public init(original: ImageUploaderGetID.UploadResult,
compressed: ImageUploaderGetID.UploadResult) {
self.original = original
self.compressed = compressed
}
}
// MARK: - Photo Picker
///
/// 使UIViewControllerRepresentablePHPickerViewControllerSwiftUI
struct PhotoPicker: UIViewControllerRepresentable {
// MARK: - Properties
///
@Binding var selectedImages: [UIImage]
/// 1
let selectionLimit: Int
///
let filter: PHPickerFilter
///
var onImageUploaded: ((Result<UploadResults, Error>) -> Void)?
var onUploadProgress: ((UploadProgress) -> Void)?
// MARK: - Initialization
///
/// - Parameters:
/// - selectedImages:
/// - selectionLimit: 1
/// - filter:
/// - onImageUploaded:
init(
selectedImages: Binding<[UIImage]>,
init(selectedImages: Binding<[UIImage]>,
selectionLimit: Int = 1,
filter: PHPickerFilter = .images,
onImageUploaded: ((Result<UploadResults, Error>) -> Void)? = nil
) {
onImageUploaded: ((Result<UploadResults, Error>) -> Void)? = nil,
onUploadProgress: ((UploadProgress) -> Void)? = nil) {
self._selectedImages = selectedImages
self.selectionLimit = selectionLimit
self.filter = filter
self.onImageUploaded = onImageUploaded
self.onUploadProgress = onUploadProgress
}
// MARK: - UIViewControllerRepresentable
/// PHPickerViewController
func makeUIViewController(context: Context) -> PHPickerViewController {
var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())
configuration.filter = filter
@ -58,42 +66,30 @@ struct PhotoPicker: UIViewControllerRepresentable {
return picker
}
///
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
/// PHPickerViewController
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
// MARK: - Coordinator
/// PHPickerViewController
class Coordinator: NSObject, PHPickerViewControllerDelegate {
///
let parent: PhotoPicker
///
private let uploader = ImageUploaderGetID()
init(_ parent: PhotoPicker) {
self.parent = parent
}
///
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
//
parent.selectedImages.removeAll()
// 使DispatchGroup
let group = DispatchGroup()
var loadedImages: [Int: UIImage] = [:] //
var loadedImages: [Int: UIImage] = [:]
var uploadResults: [Int: (original: ImageUploaderGetID.UploadResult?,
compressed: ImageUploaderGetID.UploadResult?)] = [:]
//
for (index, result) in results.enumerated() {
group.enter() //
group.enter()
if result.itemProvider.canLoadObject(ofClass: UIImage.self) {
result.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] (image, error) in
@ -102,17 +98,28 @@ struct PhotoPicker: UIViewControllerRepresentable {
return
}
// 1.
loadedImages[index] = image
// 2. 50%
guard let compressedImage = image.jpegData(compressionQuality: 0.5).flatMap(UIImage.init(data:)) else {
group.leave()
return
}
// 3.
self.uploader.uploadImage(image) { [weak self] originalResult in
self.uploader.uploadImage(
image,
progress: { [weak self] progress in
let progressInfo = UploadProgress(
current: Int(progress * 100),
total: 100,
progress: progress,
isOriginal: true
)
DispatchQueue.main.async {
self?.parent.onUploadProgress?(progressInfo)
}
print("📤 原图上传进度: \(Int(progress * 100))%")
},
completion: { [weak self] originalResult in
guard let self = self else {
group.leave()
return
@ -120,13 +127,25 @@ struct PhotoPicker: UIViewControllerRepresentable {
switch originalResult {
case .success(let originalUploadResult):
// 4.
self.uploader.uploadImage(compressedImage) { compressedResult in
self.uploader.uploadImage(
compressedImage,
progress: { [weak self] progress in
let progressInfo = UploadProgress(
current: Int(progress * 100),
total: 100,
progress: progress,
isOriginal: false
)
DispatchQueue.main.async {
self?.parent.onUploadProgress?(progressInfo)
}
print("📊 压缩图上传进度: \(Int(progress * 100))%")
},
completion: { compressedResult in
defer { group.leave() }
switch compressedResult {
case .success(let compressedUploadResult):
//
uploadResults[index] = (originalUploadResult, compressedUploadResult)
print("✅ 原图和压缩图上传成功!")
print("📂 原图信息:")
@ -136,7 +155,6 @@ struct PhotoPicker: UIViewControllerRepresentable {
print(" - 文件ID: \(compressedUploadResult.fileId)")
print(" - 文件大小: \(compressedUploadResult.fileSize) 字节")
// 使MaterialService
MaterialService.shared.uploadMaterialInfo(
fileId: originalUploadResult.fileId,
previewFileId: compressedUploadResult.fileId
@ -153,41 +171,39 @@ struct PhotoPicker: UIViewControllerRepresentable {
uploadResults[index] = (originalUploadResult, nil)
}
}
)
case .failure(let error):
print("❌ 原图上传失败: \(error.localizedDescription)")
group.leave()
}
}
)
}
} else {
group.leave()
}
}
//
group.notify(queue: .main) { [weak self] in
guard let self = self else { return }
// 1.
let sortedImages = loadedImages.sorted { $0.key < $1.key }.map { $0.value }
self.parent.selectedImages.append(contentsOf: sortedImages)
// 2.
if let firstResult = uploadResults.first?.value,
let original = firstResult.original,
let compressed = firstResult.compressed {
// 3.
let results = UploadResults(original: original, compressed: compressed)
self.parent.onImageUploaded?(.success(results))
} else {
// 4.
self.parent.onImageUploaded?(.failure(NSError(domain: "com.wake.upload",
self.parent.onImageUploaded?(.failure(NSError(
domain: "com.wake.upload",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "上传过程中出现错误"])))
userInfo: [NSLocalizedDescriptionKey: "上传过程中出现错误"]
)))
}
// 5.
picker.dismiss(animated: true)
}
}
@ -197,39 +213,23 @@ struct PhotoPicker: UIViewControllerRepresentable {
// MARK: - Avatar Uploader
///
///
struct AvatarUploader: View {
// MARK: - Properties
///
@Binding var selectedImage: UIImage?
///
let size: CGFloat
///
var onUploadComplete: ((Result<UploadResults, Error>) -> Void)?
// MARK: - State
///
@State private var isImagePickerPresented = false
// MARK: - Body
var body: some View {
//
Button(action: { isImagePickerPresented = true }) {
ZStack {
if let selectedImage = selectedImage {
//
Image(uiImage: selectedImage)
.resizable()
.scaledToFill()
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: size * 0.1))
} else {
//
Color.gray.opacity(0.1)
.frame(width: size, height: size)
.overlay(
@ -244,11 +244,10 @@ struct AvatarUploader: View {
}
}
.frame(width: size, height: size)
.contentShape(Rectangle()) //
.contentShape(Rectangle())
}
.buttonStyle(PlainButtonStyle()) // 使
.buttonStyle(PlainButtonStyle())
.sheet(isPresented: $isImagePickerPresented) {
//
PhotoPicker(
selectedImages: Binding(
get: { [selectedImage].compactMap { $0 } },
@ -256,10 +255,12 @@ struct AvatarUploader: View {
selectedImage = images.first
}
),
selectionLimit: 1, //
selectionLimit: 1,
onImageUploaded: { result in
//
onUploadComplete?(result)
},
onUploadProgress: { progress in
print("上传进度:\(progress.current)/\(progress.total),进度:\(progress.progress * 100)%")
}
)
}

View File

@ -69,8 +69,13 @@ public class ImageUploaderGetID: ObservableObject {
///
/// - Parameters:
/// - image:
/// - completion: Result
public func uploadImage(_ image: UIImage, completion: @escaping (Result<UploadResult, Error>) -> Void) {
/// - progress: (0.0 1.0)
/// - completion:
public func uploadImage(
_ image: UIImage,
progress: @escaping (Double) -> Void,
completion: @escaping (Result<UploadResult, Error>) -> Void
) {
print("🔄 开始准备上传图片...")
// 1. Data
@ -85,11 +90,37 @@ public class ImageUploaderGetID: ObservableObject {
getUploadURL(for: imageData) { [weak self] result in
switch result {
case .success((let fileId, let uploadURL)):
// 3.
self?.confirmUpload(fileId: fileId, fileName: "avatar_\(UUID().uuidString).jpg", fileSize: imageData.count) { confirmResult in
completion(confirmResult)
}
print("📤 获取到上传URL开始上传文件...")
// 3.
_ = self?.uploadFile(
fileData: imageData,
to: uploadURL,
mimeType: "image/jpeg",
onProgress: { uploadProgress in
print("📊 上传进度: \(Int(uploadProgress * 100))%")
progress(uploadProgress)
},
completion: { uploadResult in
switch uploadResult {
case .success:
// 4.
self?.confirmUpload(
fileId: fileId,
fileName: "avatar_\(UUID().uuidString).jpg",
fileSize: imageData.count,
completion: completion
)
case .failure(let error):
print("❌ 文件上传失败: \(error.localizedDescription)")
completion(.failure(error))
}
}
)
case .failure(let error):
print("❌ 获取上传URL失败: \(error.localizedDescription)")
completion(.failure(error))
}
}
@ -211,6 +242,182 @@ public class ImageUploaderGetID: ObservableObject {
task.resume()
}
/// URL
/// - Parameters:
/// - fileData:
/// - uploadURL: URL
/// - mimeType: MIME
/// - onProgress: 0.0 1.0
/// - completion:
public func uploadFile(
fileData: Data,
to uploadURL: URL,
mimeType: String = "application/octet-stream",
onProgress: @escaping (Double) -> Void,
completion: @escaping (Result<Void, Error>) -> Void
) -> URLSessionUploadTask {
var request = URLRequest(url: uploadURL)
request.httpMethod = "PUT"
request.setValue(mimeType, forHTTPHeaderField: "Content-Type")
let task = session.uploadTask(
with: request,
from: fileData
) { _, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
completion(.failure(UploadError.serverError("上传失败,状态码: \(statusCode)")))
return
}
completion(.success(()))
}
//
if #available(iOS 11.0, *) {
let progressObserver = task.progress.observe(\.fractionCompleted) { (progressValue, _) in
DispatchQueue.main.async {
onProgress(progressValue.fractionCompleted)
}
}
task.addCompletionHandler { [weak task] in
progressObserver.invalidate()
task?.progress.cancel()
}
} else {
// Fallback for earlier iOS versions
var lastProgress: Double = 0
let timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
let bytesSent = task.countOfBytesSent
let totalBytes = task.countOfBytesExpectedToSend
let currentProgress = totalBytes > 0 ? Double(bytesSent) / Double(totalBytes) : 0
// UI
if abs(currentProgress - lastProgress) > 0.01 || currentProgress >= 1.0 {
lastProgress = currentProgress
DispatchQueue.main.async {
onProgress(min(currentProgress, 1.0))
}
}
if currentProgress >= 1.0 {
timer.invalidate()
}
}
task.addCompletionHandler {
timer.invalidate()
}
}
task.resume()
return task
}
// MARK: -
///
public struct FileStatus {
public let file: Data
public var status: UploadStatus
public var progress: Double
public enum UploadStatus {
case pending
case uploading
case completed
case failed(Error)
}
public init(file: Data, status: UploadStatus = .pending, progress: Double = 0) {
self.file = file
self.status = status
self.progress = progress
}
}
}
// MARK: - URLSessionTask
private class TaskObserver: NSObject {
private weak var task: URLSessionTask?
private var handlers: [() -> Void] = []
init(task: URLSessionTask) {
self.task = task
super.init()
task.addObserver(self, forKeyPath: #keyPath(URLSessionTask.state), options: .new, context: nil)
}
func addHandler(_ handler: @escaping () -> Void) {
handlers.append(handler)
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
guard keyPath == #keyPath(URLSessionTask.state),
let task = task,
task.state == .completed else {
return
}
//
DispatchQueue.main.async { [weak self] in
self?.handlers.forEach { $0() }
self?.cleanup()
}
}
private func cleanup() {
task?.removeObserver(self, forKeyPath: #keyPath(URLSessionTask.state))
handlers.removeAll()
}
deinit {
cleanup()
}
}
private extension URLSessionTask {
private static var taskObserverKey: UInt8 = 0
private var taskObserver: TaskObserver? {
get {
return objc_getAssociatedObject(self, &Self.taskObserverKey) as? TaskObserver
}
set {
objc_setAssociatedObject(self, &Self.taskObserverKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
func addCompletionHandler(_ handler: @escaping () -> Void) {
if #available(iOS 11.0, *) {
if let observer = taskObserver {
observer.addHandler(handler)
} else {
let observer = TaskObserver(task: self)
observer.addHandler(handler)
taskObserver = observer
}
} else {
// iOS 11 使
let name = NSNotification.Name("TaskCompleted\(self.taskIdentifier)")
NotificationCenter.default.addObserver(
forName: name,
object: self,
queue: .main
) { _ in
handler()
}
}
}
}
// MARK: -