refactor: optimize polling and timer with cancellable tasks and reduced update frequency

This commit is contained in:
Junhui Chen 2025-09-08 16:35:12 +08:00
parent feadfd92a7
commit 794742b6fd
4 changed files with 152 additions and 98 deletions

View File

@ -72,11 +72,11 @@
## 任务清单(同步 todo ## 任务清单(同步 todo
- [x] nav-1 统一导航(移除子页面 NavigationViewRouter 返回) - [x] nav-1 统一导航(移除子页面 NavigationViewRouter 返回)
- [ ] mvvm-1 BlindBox 引入 ViewModel迁移逻辑 - [ ] mvvm-1 BlindBox 引入 ViewModel迁移逻辑
- [ ] timer-1 计时器降频与取消 - [x] timer-1 计时器降频与取消
- [ ] polling-1 轮询可取消化 - [x] polling-1 轮询可取消化
- [ ] media-1 媒体与动画优化GIF->Lottie/可见播放) - [ ] media-1 媒体与动画优化GIF->Lottie/可见播放)
- [ ] concurrency-1 @MainActor 与线程安全 - [ ] concurrency-1 @MainActor 与线程安全
- [ ] netlog-1 网络日志开关与限流 - [x] netlog-1 网络日志开关与限流
- [ ] structure-1 目录重组 - [ ] structure-1 目录重组
- [ ] perf-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/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()` - 将 `wake/View/Feedback.swift``FeedbackView``FeedbackDetailView` 的返回行为从 `dismiss()` 统一为 `Router.shared.pop()`
- 保留预览Preview中的 `NavigationView`,运行时代码已全部依赖顶层 `NavigationStack + Router` - 保留预览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` - 采用顶层 `NavigationStack + Router`,子页面取消 `NavigationView`

View File

@ -287,13 +287,15 @@ class NetworkService {
} }
} }
// // Debug
#if DEBUG
print(""" print("""
🌐 [Network][#\(requestId)][\(method) \(path)] 🌐 [Network][#\(requestId)][\(method) \(path)]
🔗 URL: \(url.absoluteString) 🔗 URL: \(url.absoluteString)
📤 Headers: \(request.allHTTPHeaderFields ?? [:]) 📤 Headers: \(request.allHTTPHeaderFields ?? [:])
📦 Body: \(request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "") 📦 Body: \(request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "")
""") """)
#endif
// //
let startTime = Date() let startTime = Date()
@ -338,10 +340,12 @@ class NetworkService {
// 401 // 401
if statusCode == 401 { if statusCode == 401 {
#if DEBUG
print(""" print("""
🔑 [Network][#\(requestId)][\(method) \(path)] token... 🔑 [Network][#\(requestId)][\(method) \(path)] token...
: \(duration) : \(duration)
""") """)
#endif
// //
let dataResult = data.flatMap { Result<Data, NetworkError>.success($0) } ?? .failure(.noData) let dataResult = data.flatMap { Result<Data, NetworkError>.success($0) } ?? .failure(.noData)
@ -351,10 +355,12 @@ class NetworkService {
do { do {
let decoder = JSONDecoder() let decoder = JSONDecoder()
let result = try decoder.decode(T.self, from: data) let result = try decoder.decode(T.self, from: data)
#if DEBUG
print(""" print("""
[Network][#\(requestId)][\(method) \(path)] [Network][#\(requestId)][\(method) \(path)]
: \(duration) (token刷新时间) : \(duration) (token刷新时间)
""") """)
#endif
completion(.success(result)) completion(.success(result))
} catch let decodingError as DecodingError { } catch let decodingError as DecodingError {
print(""" print("""
@ -389,22 +395,25 @@ class NetworkService {
// //
if !(200...299).contains(statusCode) { if !(200...299).contains(statusCode) {
let errorMessage = data.flatMap { String(data: $0, encoding: .utf8) } ?? "" let errorMessage = data.flatMap { String(data: $0, encoding: .utf8) } ?? ""
let truncated = errorMessage.count > 300 ? String(errorMessage.prefix(300)) + "..." : errorMessage
print(""" print("""
[Network][#\(requestId)][\(method) \(path)] [Network][#\(requestId)][\(method) \(path)]
📊 : \(statusCode) (\(statusMessage)) 📊 : \(statusCode) (\(statusMessage))
: \(duration) : \(duration)
🔍 : \(errorMessage) 🔍 : \(truncated)
""") """)
completion(.failure(.serverError("状态码: \(statusCode), 响应: \(errorMessage)"))) completion(.failure(.serverError("状态码: \(statusCode), 响应: \(truncated)")))
return return
} }
// // Debug
#if DEBUG
print(""" print("""
[Network][#\(requestId)][\(method) \(path)] [Network][#\(requestId)][\(method) \(path)]
📊 : \(statusCode) (\(statusMessage)) 📊 : \(statusCode) (\(statusMessage))
: \(duration) : \(duration)
""") """)
#endif
} }
// //
@ -428,13 +437,15 @@ class NetworkService {
return return
} }
// // Debug
#if DEBUG
if let responseString = String(data: data, encoding: .utf8) { if let responseString = String(data: data, encoding: .utf8) {
print(""" print("""
📥 [Network][#\(requestId)][\(method) \(path)] : 📥 [Network][#\(requestId)][\(method) \(path)] :
\(responseString.prefix(1000))\(responseString.count > 1000 ? "..." : "") \(responseString.prefix(1000))\(responseString.count > 1000 ? "..." : "")
""") """)
} }
#endif
do { do {
// JSON // JSON
@ -442,11 +453,18 @@ class NetworkService {
let result = try decoder.decode(T.self, from: data) let result = try decoder.decode(T.self, from: data)
completion(.success(result)) completion(.success(result))
} catch let decodingError as DecodingError { } catch let decodingError as DecodingError {
#if DEBUG
print(""" print("""
[Network][#\(requestId)][\(method) \(path)] JSON解析失败 [Network][#\(requestId)][\(method) \(path)] JSON解析失败
🔍 : \(decodingError.localizedDescription) 🔍 : \(decodingError.localizedDescription)
📦 : \(String(data: data, encoding: .utf8) ?? "") 📦 : \(String(data: data, encoding: .utf8) ?? "")
""") """)
#else
print("""
[Network][#\(requestId)][\(method) \(path)] JSON解析失败
🔍 : \(decodingError.localizedDescription)
""")
#endif
completion(.failure(.decodingError(decodingError))) completion(.failure(.decodingError(decodingError)))
} catch { } catch {
print(""" print("""
@ -463,7 +481,9 @@ class NetworkService {
isRefreshing = true isRefreshing = true
let refreshStartTime = Date() let refreshStartTime = Date()
#if DEBUG
print("🔄 [Network] 开始刷新Token...") print("🔄 [Network] 开始刷新Token...")
#endif
TokenManager.shared.refreshToken { [weak self] success, _ in TokenManager.shared.refreshToken { [weak self] success, _ in
guard let self = self else { return } guard let self = self else { return }
@ -471,11 +491,13 @@ class NetworkService {
let refreshDuration = String(format: "%.3fs", Date().timeIntervalSince(refreshStartTime)) let refreshDuration = String(format: "%.3fs", Date().timeIntervalSince(refreshStartTime))
if success { if success {
#if DEBUG
print(""" print("""
[Network] Token刷新成功 [Network] Token刷新成功
: \(refreshDuration) : \(refreshDuration)
🔄 \(self.requestsToRetry.count)... 🔄 \(self.requestsToRetry.count)...
""") """)
#endif
// //
let requestsToRetry = self.requestsToRetry let requestsToRetry = self.requestsToRetry
@ -499,11 +521,13 @@ class NetworkService {
task.resume() task.resume()
} }
} else { } else {
#if DEBUG
print(""" print("""
[Network] Token刷新失败 [Network] Token刷新失败
: \(refreshDuration) : \(refreshDuration)
🚪 ... 🚪 ...
""") """)
#endif
// token // token
TokenManager.shared.clearTokens() TokenManager.shared.clearTokens()

View File

@ -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<BlindBoxData, Error> {
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<BlindBoxData, Error> {
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()
}
}
}
}

View File

@ -86,12 +86,13 @@ struct BlindBoxView: View {
// //
@State private var isPolling = false @State private var isPolling = false
@State private var pollingTimer: Timer? @State private var pollingTimer: Timer?
@State private var pollingTask: Task<Void, Never>? = nil
@State private var currentBoxType: String = "" @State private var currentBoxType: String = ""
// //
@State private var videoURL: String = "" @State private var videoURL: String = ""
@State private var imageURL: 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 countdownTimer: Timer?
// //
@State private var displayData: BlindBoxData? = nil @State private var displayData: BlindBoxData? = nil
@ -117,46 +118,45 @@ struct BlindBoxView: View {
// //
private func startCountdown() { private func startCountdown() {
// 36:50:20 // 36:50:20
countdown = (36, 50, 20) countdown = (36, 50, 0)
countdownTimer?.invalidate() countdownTimer?.invalidate()
countdownTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
var (minutes, seconds, milliseconds) = countdown var (minutes, seconds, _) = countdown
// //
milliseconds -= 10 seconds -= 1
if milliseconds < 0 {
milliseconds = 90
seconds -= 1
}
//
if seconds < 0 { if seconds < 0 {
seconds = 59 seconds = 59
minutes -= 1 minutes -= 1
} }
// //
if minutes <= 0 && seconds <= 0 && milliseconds <= 0 { if minutes <= 0 && seconds <= 0 {
countdownTimer?.invalidate() countdownTimer?.invalidate()
countdownTimer = nil countdownTimer = nil
return return
} }
countdown = (minutes, seconds, milliseconds) countdown = (minutes, seconds, 0)
} }
} }
private func loadBlindBox() async { private func loadBlindBox() async {
print("loadMedia called with mediaType: \(mediaType)") print("loadMedia called with mediaType: \(mediaType)")
//
stopPolling()
isPolling = true
if self.currentBoxId != nil { if self.currentBoxId != nil {
print("指定监听某盲盒结果: ", self.currentBoxId! as Any) print("指定监听某盲盒结果: ", self.currentBoxId! as Any)
// pollingTask = Task { @MainActor in
await pollingToQuerySingleBox() await pollingToQuerySingleBox()
}
} else { } else {
// pollingTask = Task { @MainActor in
await pollingToQueryBlindBox() await pollingToQueryBlindBox()
}
} }
// switch mediaType { // switch mediaType {
@ -241,90 +241,47 @@ struct BlindBoxView: View {
} }
private func pollingToQuerySingleBox() async { private func pollingToQuerySingleBox() async {
stopPolling() guard let boxId = self.currentBoxId else { return }
isPolling = true do {
for try await data in BlindBoxPolling.singleBox(boxId: boxId, intervalSeconds: 2.0) {
// Unopened self.blindGenerate = data
while isPolling { if mediaType == .image {
do { self.imageURL = data.resultFile?.url ?? ""
let blindBoxData = try await BlindBoxApi.shared.getBlindBox(boxId: self.currentBoxId!) } else {
self.videoURL = data.resultFile?.url ?? ""
// 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
}
} }
withAnimation { self.animationPhase = .ready }
// 2
try await Task.sleep(nanoseconds: 2_000_000_000)
} catch {
print("❌ 获取盲盒数据失败: \(error)")
//
self.animationPhase = .none
stopPolling() stopPolling()
break break
} }
} catch is CancellationError {
//
} catch {
print("❌ 获取盲盒数据失败: \(error)")
self.animationPhase = .none
stopPolling()
} }
} }
private func pollingToQueryBlindBox() async { private func pollingToQueryBlindBox() async {
stopPolling() do {
isPolling = true for try await blindBox in BlindBoxPolling.firstUnopened(intervalSeconds: 2.0) {
self.blindGenerate = blindBox
while isPolling { if mediaType == .image {
do { self.imageURL = blindBox.resultFile?.url ?? ""
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
} else { } else {
if self.animationPhase != .none { self.videoURL = blindBox.resultFile?.url ?? ""
self.animationPhase = .none
}
} }
// 2 withAnimation { self.animationPhase = .ready }
try await Task.sleep(nanoseconds: 2_000_000_000) print("✅ 成功获取盲盒数据: \(blindBox.name), 状态: \(blindBox.status)")
} catch {
print("❌ 获取盲盒列表失败: \(error)")
stopPolling() stopPolling()
break break
} }
} catch is CancellationError {
//
} catch {
print("❌ 获取盲盒列表失败: \(error)")
stopPolling()
} }
} }
@ -339,6 +296,8 @@ struct BlindBoxView: View {
pollingTimer?.invalidate() pollingTimer?.invalidate()
pollingTimer = nil pollingTimer = nil
isPolling = false isPolling = false
pollingTask?.cancel()
pollingTask = nil
} }
private func checkBlindBoxStatus() { private func checkBlindBoxStatus() {
@ -914,7 +873,7 @@ struct BlindBoxView: View {
} }
}) { }) {
if animationPhase == .loading { 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)) .font(Typography.font(for: .body))
.fontWeight(.bold) .fontWeight(.bold)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)