From 794742b6fd7998bf5672bfcf966eb6a995c7febb Mon Sep 17 00:00:00 2001 From: Junhui Chen Date: Mon, 8 Sep 2025 16:35:12 +0800 Subject: [PATCH] refactor: optimize polling and timer with cancellable tasks and reduced update frequency --- specs/refactor_spec.md | 12 ++- wake/Utils/NetworkService.swift | 34 ++++++- wake/View/Blind/BlindBoxPolling.swift | 65 ++++++++++++ wake/View/Blind/ContentView.swift | 139 +++++++++----------------- 4 files changed, 152 insertions(+), 98 deletions(-) create mode 100644 wake/View/Blind/BlindBoxPolling.swift diff --git a/specs/refactor_spec.md b/specs/refactor_spec.md index a80a2d7..85a387a 100644 --- a/specs/refactor_spec.md +++ b/specs/refactor_spec.md @@ -72,11 +72,11 @@ ## 任务清单(同步 todo) - [x] nav-1 统一导航(移除子页面 NavigationView,Router 返回) - [ ] mvvm-1 BlindBox 引入 ViewModel,迁移逻辑 -- [ ] timer-1 计时器降频与取消 -- [ ] polling-1 轮询可取消化 +- [x] timer-1 计时器降频与取消 +- [x] polling-1 轮询可取消化 - [ ] media-1 媒体与动画优化(GIF->Lottie/可见播放) - [ ] concurrency-1 @MainActor 与线程安全 -- [ ] netlog-1 网络日志开关与限流 +- [x] netlog-1 网络日志开关与限流 - [ ] structure-1 目录重组 - [ ] perf-1 性能埋点与基线 @@ -87,6 +87,12 @@ - 移除 `wake/View/Credits/CreditsDetailView.swift`、`wake/View/Welcome/SplashView.swift`、`wake/View/Owner/SettingsView.swift`、`wake/View/Components/Upload/MediaUpload.swift`(示例 `MediaUploadExample`)、`wake/View/Examples/MediaDemo.swift` 中的 `NavigationView`。 - 将 `wake/View/Feedback.swift` 中 `FeedbackView` 与 `FeedbackDetailView` 的返回行为从 `dismiss()` 统一为 `Router.shared.pop()`。 - 保留预览(Preview)中的 `NavigationView`,运行时代码已全部依赖顶层 `NavigationStack + Router`。 +- 2025-09-08 16:10 +08: 完成 timer-1 与 netlog-1: + - 倒计时更新频率由 0.1s 改为 1s,移除毫秒级显示;初始值设为 `(36, 50, 0)`,减少 UI 重绘(`wake/View/Blind/ContentView.swift`)。 + - 网络日志:将详细请求/成功响应日志置于 `#if DEBUG`,错误响应体截断至约 300 字符;401 刷新 Token 相关提示仅在 Debug 下打印(`wake/Utils/NetworkService.swift`)。 + - 2025-09-08 16:28 +08: 完成 polling-1: + - 新增 `wake/View/Blind/BlindBoxPolling.swift`,提供 `AsyncThrowingStream` 序列:`singleBox(boxId:)` 与 `firstUnopened()`,内部可取消(`Task.isCancelled`)并使用统一的间隔控制。 + - `wake/View/Blind/ContentView.swift` 轮询改为 `for try await ... in` 形式;通过 `pollingTask` 管理任务,在 `stopPolling()` 与 `onDisappear` 中取消,避免视图中出现 `while + Task.sleep`。 ## 决策记录 - 采用顶层 `NavigationStack + Router`,子页面取消 `NavigationView`。 diff --git a/wake/Utils/NetworkService.swift b/wake/Utils/NetworkService.swift index c3aa9a7..1860bfa 100644 --- a/wake/Utils/NetworkService.swift +++ b/wake/Utils/NetworkService.swift @@ -287,13 +287,15 @@ class NetworkService { } } - // 打印请求信息 + // 打印请求信息(仅 Debug) + #if DEBUG print(""" 🌐 [Network][#\(requestId)][\(method) \(path)] 开始请求 🔗 URL: \(url.absoluteString) 📤 Headers: \(request.allHTTPHeaderFields ?? [:]) 📦 Body: \(request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "") """) + #endif // 创建任务 let startTime = Date() @@ -338,10 +340,12 @@ class NetworkService { // 处理401未授权 if statusCode == 401 { + #if DEBUG print(""" 🔑 [Network][#\(requestId)][\(method) \(path)] 检测到未授权,尝试刷新token... ⏱️ 耗时: \(duration) """) + #endif // 将请求加入重试队列 let dataResult = data.flatMap { Result.success($0) } ?? .failure(.noData) @@ -351,10 +355,12 @@ class NetworkService { do { let decoder = JSONDecoder() let result = try decoder.decode(T.self, from: data) + #if DEBUG print(""" ✅ [Network][#\(requestId)][\(method) \(path)] 重试成功 ⏱️ 总耗时: \(duration) (包含token刷新时间) """) + #endif completion(.success(result)) } catch let decodingError as DecodingError { print(""" @@ -389,22 +395,25 @@ class NetworkService { // 处理其他错误状态码 if !(200...299).contains(statusCode) { let errorMessage = data.flatMap { String(data: $0, encoding: .utf8) } ?? "" + let truncated = errorMessage.count > 300 ? String(errorMessage.prefix(300)) + "..." : errorMessage print(""" ❌ [Network][#\(requestId)][\(method) \(path)] 请求失败 📊 状态码: \(statusCode) (\(statusMessage)) ⏱️ 耗时: \(duration) - 🔍 错误响应: \(errorMessage) + 🔍 错误响应: \(truncated) """) - completion(.failure(.serverError("状态码: \(statusCode), 响应: \(errorMessage)"))) + completion(.failure(.serverError("状态码: \(statusCode), 响应: \(truncated)"))) return } - // 成功响应 + // 成功响应(仅 Debug) + #if DEBUG print(""" ✅ [Network][#\(requestId)][\(method) \(path)] 请求成功 📊 状态码: \(statusCode) (\(statusMessage)) ⏱️ 耗时: \(duration) """) + #endif } // 处理网络错误 @@ -428,13 +437,15 @@ class NetworkService { return } - // 打印响应数据(调试用) + // 打印响应数据(仅 Debug) + #if DEBUG if let responseString = String(data: data, encoding: .utf8) { print(""" 📥 [Network][#\(requestId)][\(method) \(path)] 响应数据: \(responseString.prefix(1000))\(responseString.count > 1000 ? "..." : "") """) } + #endif do { // 解析JSON数据 @@ -442,11 +453,18 @@ class NetworkService { let result = try decoder.decode(T.self, from: data) completion(.success(result)) } catch let decodingError as DecodingError { + #if DEBUG print(""" ❌ [Network][#\(requestId)][\(method) \(path)] JSON解析失败 🔍 错误: \(decodingError.localizedDescription) 📦 原始数据: \(String(data: data, encoding: .utf8) ?? "") """) + #else + print(""" + ❌ [Network][#\(requestId)][\(method) \(path)] JSON解析失败 + 🔍 错误: \(decodingError.localizedDescription) + """) + #endif completion(.failure(.decodingError(decodingError))) } catch { print(""" @@ -463,7 +481,9 @@ class NetworkService { isRefreshing = true let refreshStartTime = Date() + #if DEBUG print("🔄 [Network] 开始刷新Token...") + #endif TokenManager.shared.refreshToken { [weak self] success, _ in guard let self = self else { return } @@ -471,11 +491,13 @@ class NetworkService { let refreshDuration = String(format: "%.3fs", Date().timeIntervalSince(refreshStartTime)) if success { + #if DEBUG print(""" ✅ [Network] Token刷新成功 ⏱️ 耗时: \(refreshDuration) 🔄 准备重试\(self.requestsToRetry.count)个请求... """) + #endif // 重试所有待处理的请求 let requestsToRetry = self.requestsToRetry @@ -499,11 +521,13 @@ class NetworkService { task.resume() } } else { + #if DEBUG print(""" ❌ [Network] Token刷新失败 ⏱️ 耗时: \(refreshDuration) 🚪 清除登录状态... """) + #endif // 清除token并通知需要重新登录 TokenManager.shared.clearTokens() diff --git a/wake/View/Blind/BlindBoxPolling.swift b/wake/View/Blind/BlindBoxPolling.swift new file mode 100644 index 0000000..4fe71e3 --- /dev/null +++ b/wake/View/Blind/BlindBoxPolling.swift @@ -0,0 +1,65 @@ +import Foundation + +// MARK: - BlindBox Async Polling Sequences + +enum BlindBoxPolling { + /// Poll a single blind box until it becomes "Unopened". + /// Yields once when ready, then finishes. + static func singleBox(boxId: String, intervalSeconds: Double = 2.0) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + while !Task.isCancelled { + do { + let result = try await BlindBoxApi.shared.getBlindBox(boxId: boxId) + if let data = result { + if data.status == "Unopened" { + continuation.yield(data) + continuation.finish() + break + } + } + try await Task.sleep(nanoseconds: UInt64(intervalSeconds * 1_000_000_000)) + } catch is CancellationError { + continuation.finish() + break + } catch { + continuation.finish(throwing: error) + break + } + } + } + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + /// Poll blind box list and yield first unopened box when available. + /// Yields once when found, then finishes. + static func firstUnopened(intervalSeconds: Double = 2.0) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + while !Task.isCancelled { + do { + let list = try await BlindBoxApi.shared.getBlindBoxList() + if let item = list?.first(where: { $0.status == "Unopened" }) { + continuation.yield(item) + continuation.finish() + break + } + try await Task.sleep(nanoseconds: UInt64(intervalSeconds * 1_000_000_000)) + } catch is CancellationError { + continuation.finish() + break + } catch { + continuation.finish(throwing: error) + break + } + } + } + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } +} diff --git a/wake/View/Blind/ContentView.swift b/wake/View/Blind/ContentView.swift index 916dbdb..5edafea 100644 --- a/wake/View/Blind/ContentView.swift +++ b/wake/View/Blind/ContentView.swift @@ -86,12 +86,13 @@ struct BlindBoxView: View { // 轮询接口 @State private var isPolling = false @State private var pollingTimer: Timer? + @State private var pollingTask: Task? = nil @State private var currentBoxType: String = "" // 盲盒链接 @State private var videoURL: String = "" @State private var imageURL: String = "" // 按钮状态 倒计时 - @State private var countdown: (minutes: Int, seconds: Int, milliseconds: Int) = (36, 50, 20) + @State private var countdown: (minutes: Int, seconds: Int, milliseconds: Int) = (36, 50, 0) @State private var countdownTimer: Timer? // 盲盒数据 @State private var displayData: BlindBoxData? = nil @@ -117,46 +118,45 @@ struct BlindBoxView: View { // 倒计时 private func startCountdown() { // 重置为36:50:20 - countdown = (36, 50, 20) + countdown = (36, 50, 0) countdownTimer?.invalidate() - countdownTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in - var (minutes, seconds, milliseconds) = countdown + countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + var (minutes, seconds, _) = countdown - // 更新毫秒 - milliseconds -= 10 - if milliseconds < 0 { - milliseconds = 90 - seconds -= 1 - } - - // 更新秒 + // 每秒更新 + seconds -= 1 if seconds < 0 { seconds = 59 minutes -= 1 } // 如果倒计时结束,停止计时器 - if minutes <= 0 && seconds <= 0 && milliseconds <= 0 { + if minutes <= 0 && seconds <= 0 { countdownTimer?.invalidate() countdownTimer = nil return } - countdown = (minutes, seconds, milliseconds) + countdown = (minutes, seconds, 0) } } private func loadBlindBox() async { print("loadMedia called with mediaType: \(mediaType)") + // 启动轮询任务(可取消) + stopPolling() + isPolling = true if self.currentBoxId != nil { print("指定监听某盲盒结果: ", self.currentBoxId! as Any) - // 启动轮询查询盲盒状态 - await pollingToQuerySingleBox() + pollingTask = Task { @MainActor in + await pollingToQuerySingleBox() + } } else { - // 启动轮询查询普通盲盒列表 - await pollingToQueryBlindBox() + pollingTask = Task { @MainActor in + await pollingToQueryBlindBox() + } } // switch mediaType { @@ -241,90 +241,47 @@ struct BlindBoxView: View { } private func pollingToQuerySingleBox() async { - stopPolling() - isPolling = true - - // 轮询查询盲盒状态,直到状态为Unopened - while isPolling { - do { - let blindBoxData = try await BlindBoxApi.shared.getBlindBox(boxId: self.currentBoxId!) - - // 更新UI - if let data = blindBoxData { - self.blindGenerate = data - - // 根据盲盒类型设置媒体URL - if mediaType == .image { - self.imageURL = data.resultFile?.url ?? "" - } - else { - self.videoURL = data.resultFile?.url ?? "" - } - - print("✅ 成功获取盲盒数据: \(data.name), 状态: \(data.status)") - - // 检查状态是否为Unopened,如果是则停止轮询 - if data.status == "Unopened" { - print("✅ 盲盒已准备就绪,停止轮询") - self.animationPhase = .ready - stopPolling() - break - } + guard let boxId = self.currentBoxId else { return } + do { + for try await data in BlindBoxPolling.singleBox(boxId: boxId, intervalSeconds: 2.0) { + self.blindGenerate = data + if mediaType == .image { + self.imageURL = data.resultFile?.url ?? "" + } else { + self.videoURL = data.resultFile?.url ?? "" } - - // 等待2秒后继续轮询 - try await Task.sleep(nanoseconds: 2_000_000_000) - } catch { - print("❌ 获取盲盒数据失败: \(error)") - // 处理错误情况 - self.animationPhase = .none + withAnimation { self.animationPhase = .ready } stopPolling() break } + } catch is CancellationError { + // 任务被取消 + } catch { + print("❌ 获取盲盒数据失败: \(error)") + self.animationPhase = .none + stopPolling() } } private func pollingToQueryBlindBox() async { - stopPolling() - isPolling = true - - while isPolling { - do { - let blindBoxList = try await BlindBoxApi.shared.getBlindBoxList() - print("✅ 获取盲盒列表: \(blindBoxList?.count ?? 0) 条") - - // 统计未开启盲盒数量 - self.blindCount = BlindCount(availableQuantity: blindBoxList?.filter({ $0.status == "Unopened" }).count ?? 0) - - // 设置第一个未开启的盲盒 - if let blindBox = blindBoxList?.first(where: { $0.status == "Unopened" }) { - self.blindGenerate = blindBox - self.animationPhase = .ready - - // 更新UI - // 根据盲盒类型设置媒体URL - if mediaType == .image { - self.imageURL = blindBox.resultFile?.url ?? "" - } - else { - self.videoURL = blindBox.resultFile?.url ?? "" - } - - print("✅ 成功获取盲盒数据: \(blindBox.name), 状态: \(blindBox.status)") - stopPolling() - break + do { + for try await blindBox in BlindBoxPolling.firstUnopened(intervalSeconds: 2.0) { + self.blindGenerate = blindBox + if mediaType == .image { + self.imageURL = blindBox.resultFile?.url ?? "" } else { - if self.animationPhase != .none { - self.animationPhase = .none - } + self.videoURL = blindBox.resultFile?.url ?? "" } - // 等待2秒后继续轮询 - try await Task.sleep(nanoseconds: 2_000_000_000) - } catch { - print("❌ 获取盲盒列表失败: \(error)") + withAnimation { self.animationPhase = .ready } + print("✅ 成功获取盲盒数据: \(blindBox.name), 状态: \(blindBox.status)") stopPolling() break } + } catch is CancellationError { + // 任务被取消 + } catch { + print("❌ 获取盲盒列表失败: \(error)") + stopPolling() } } @@ -339,6 +296,8 @@ struct BlindBoxView: View { pollingTimer?.invalidate() pollingTimer = nil isPolling = false + pollingTask?.cancel() + pollingTask = nil } private func checkBlindBoxStatus() { @@ -914,7 +873,7 @@ struct BlindBoxView: View { } }) { if animationPhase == .loading { - Text("Next: \(countdown.minutes):\(String(format: "%02d", countdown.seconds)).\(String(format: "%02d", countdown.milliseconds))") + Text("Next: \(countdown.minutes):\(String(format: "%02d", countdown.seconds))") .font(Typography.font(for: .body)) .fontWeight(.bold) .frame(maxWidth: .infinity)