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
- [x] nav-1 统一导航(移除子页面 NavigationViewRouter 返回)
- [ ] 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`

View File

@ -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<Data, NetworkError>.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()

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 pollingTimer: Timer?
@State private var pollingTask: Task<Void, Never>? = 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)