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

View File

@ -69,8 +69,13 @@ public class ImageUploaderGetID: ObservableObject {
/// ///
/// - Parameters: /// - Parameters:
/// - image: /// - image:
/// - completion: Result /// - progress: (0.0 1.0)
public func uploadImage(_ image: UIImage, completion: @escaping (Result<UploadResult, Error>) -> Void) { /// - completion:
public func uploadImage(
_ image: UIImage,
progress: @escaping (Double) -> Void,
completion: @escaping (Result<UploadResult, Error>) -> Void
) {
print("🔄 开始准备上传图片...") print("🔄 开始准备上传图片...")
// 1. Data // 1. Data
@ -85,11 +90,37 @@ public class ImageUploaderGetID: ObservableObject {
getUploadURL(for: imageData) { [weak self] result in getUploadURL(for: imageData) { [weak self] result in
switch result { switch result {
case .success((let fileId, let uploadURL)): case .success((let fileId, let uploadURL)):
// 3. print("📤 获取到上传URL开始上传文件...")
self?.confirmUpload(fileId: fileId, fileName: "avatar_\(UUID().uuidString).jpg", fileSize: imageData.count) { confirmResult in
completion(confirmResult) // 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): case .failure(let error):
print("❌ 获取上传URL失败: \(error.localizedDescription)")
completion(.failure(error)) completion(.failure(error))
} }
} }
@ -211,6 +242,182 @@ public class ImageUploaderGetID: ObservableObject {
task.resume() 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: - // MARK: -