Compare commits

..

8 Commits

53 changed files with 934 additions and 545 deletions

149
specs/perf_baseline.md Normal file
View File

@ -0,0 +1,149 @@
# perf-1 基线测试指南与记录模板
更新时间2025-09-09 12:25 +08
本文件用于后续进行盲盒主路径的性能基线采集与分析,包含目标范围、事件口径、采集步骤、分析方法、优化建议以及可直接填写的记录模板。
---
## 1. 目标与范围
- 场景 AImage 盲盒):冷启动 → 准备Unopened→ 开启 → 展示图片
- 场景 BVideo 盲盒):冷启动 → 准备Unopened→ 开启 → 播放视频
- 输出关键阶段耗时T_*)、帧率/掉帧、CPU 热点、潜在瓶颈与改进建议
## 2. 现有埋点OS Signpost
Perf 工具位于 `wake/Core/Diagnostics/Performance.swift``subsystem: app.wake``category: performance`)。以下事件可在 Instruments 的 OS Signpost 中看到:
- 视图(`wake/Features/BlindBox/View/BlindBoxView.swift`
- `BlindBox_Appear`
- `BlindBox_Status_Unopened`
- `BlindBox_Status_Preparing`
- `BlindBox_Opening_Begin`
- `BlindBox_Opening_ShowMedia`
- 视图模型(`wake/Features/BlindBox/ViewModel/BlindBoxViewModel.swift`
- `BlindVM_Load_Begin` / `BlindVM_Load_End`
- `BlindVM_Bootstrap_Done`
- `BlindVM_Poll_Single_Yield` / `BlindVM_Poll_List_Yield`
- `BlindVM_Open`begin/end 包裹 openBlindBox 调用)
## 3. 指标口径(阶段耗时定义)
- `T_bootstrap = BlindBox_Appear → BlindVM_Bootstrap_Done`
- `T_ready = 首次出现 BlindBox_Status_Unopened`(或 `.Preparing → .Unopened` 的跃迁)
- `T_open_api = BlindVM_Open(begin) → BlindVM_Open(end)`(开盒 API 往返)
- `T_open_anim = BlindBox_Opening_Begin → BlindBox_Opening_ShowMedia`(动画到媒体展示)
- `T_prepare_media`(建议新增埋点后再测,见第 7 节)
建议阈值(参考):
- Image`T_bootstrap ≤ 800ms``T_prepare_media ≤ 300ms`
- Video`T_bootstrap ≤ 1200ms``首帧可播放 ≤ 1.0s`
- Core Animation每 1 秒内掉帧 < 3主线程占用峰值 < 80%
## 4. 环境与准备
- 设备:实体机(建议 iPhone 13 及以上),保持温度和电量稳定
- 构建Release/ProfileXcode → Product → Scheme → Edit Scheme → Run = Release
- 关闭调试开关(如 Metal API Validation
## 5. 数据采集Xcode Instruments
1) Xcode 菜单 `Product → Profile`,选择模板:
- OS Signpost主时间轴
- Time ProfilerCPU/主线程占用)
- Core AnimationFPS 与掉帧)
- 选配Network若要看请求耗时
2) 运行路径:
- 场景 AImage
- 冷启动 App → 进入 `BlindBoxView` → 等待状态到 Unopened → 点击开启 → 等待图片展示 → 停止录制
- 场景 BVideo
- 冷启动 App → 进入 `BlindBoxView` → 等待状态到 Unopened → 点击开启 → 视频开始播放 → 停止录制
3) 标注与导出:
- 在 OS Signpost 轨道上对齐事件(见第 2 节),测量 T_* 并记录
- 导出 A/B 两条 trace 作为基线归档
## 6. 可选 CLIxctrace
- 查看模板与设备:
```bash
xcrun xctrace list templates
xcrun xctrace list devices
```
- 采集 Time Profiler将占位符替换为实际 Bundle ID
```bash
xcrun xctrace record \
--template "Time Profiler" \
--output "~/Desktop/wake_timeprofiler.trace" \
--launch com.your.bundle.id
```
## 7. 分析方法
- OS Signpost筛选 `subsystem = app.wake``category = performance`,沿时间轴读取 T_*。
- Time Profiler关注主线程热点Lottie 渲染、SVG 绘制、SwiftUI 布局、图片解码、AVAsset 初始化等)。
- Core Animation查看帧时间直方图与掉帧分布对应时间截面回到 Time Profiler 交叉验证 CPU 热点。
## 8. 建议新增埋点(便于下一轮更精细分析)
- `prepareMedia()``BlindBoxViewModel`)中增加 begin/end
- 图片:`BlindVM_PrepareMedia_Image`
- 视频:`BlindVM_PrepareMedia_Video`
- `BlindBoxView` 的开启动画链路,如需更细,可在 `BlindBox_Opening_Begin → BlindBox_Opening_ShowMedia` 之间插入阶段性事件(例如某帧/进度阈值)。
## 9. 常见瓶颈与建议
- 图片解码在主线程:
- 现状:`prepareMedia()` 标注了 `@MainActor``UIImage(data:)` 可能阻塞主线程。
- 建议:使用后台队列/`Task.detached(priority: .userInitiated)` 解码,回主线程赋值;或使用 `CGImageSource` 增量解码。
- AVAsset 初始化与尺寸计算:
- 建议:使用异步属性加载(如 `await asset.load(.tracks)`),后台计算宽高比后再回主线程设置。
- Lottie 渲染:
- 建议:仅在可见时播放(当前已实现),检查 JSON 体量与层数,必要时优化资源。
- SVG 渲染:
- 建议:大面积静态背景预栅格化为 PNG交互区域保留矢量或增加缓存层。
- OnBoarding 去重:
- 现状:用 `uiImage.pngData()` 做去重,计算较重。
- 建议:改用轻量哈希(缩略图 + 平均哈希/pHash或文件 URL/尺寸 + 字节总量近似判重。
- 网络与缓存:
- 建议:图片设置合理 `URLCache`;视频首帧使用低码率预览或占位图,降低等待感;网络层收集 `URLSessionTaskMetrics` 做 RTT/吞吐量观测。
## 10. 记录模板(直接复制并填写)
```markdown
# perf-1 基线日期____ / 设备____ / 系统____ / 构建Release
## 场景 AImage
- T_bootstrap____ ms
- T_ready____ ms
- T_open_api____ ms
- T_open_anim____ ms
- T_prepare_media若有____ ms
- Core Animation平均 FPS / 掉帧____ / ____
- Time Profiler 热点(主线程 Top3
- 1) ________%
- 2) ________%
- 3) ________%
- 结论与问题:
## 场景 BVideo
- T_bootstrap____ ms
- T_ready____ ms
- T_open_api____ ms
- T_open_anim____ ms
- T_prepare_media若有____ ms
- Core Animation平均 FPS / 掉帧____ / ____
- Time Profiler 热点(主线程 Top3
- 1) ________%
- 2) ________%
- 3) ________%
- 结论与问题:
## 归纳与下一步
- 瓶颈总结:
- 优化优先级P0/P1/P2
- 行动项:
```
## 11. 下次继续(执行清单)
- 采集 A/B 各 1 条 trace 并填写第 10 节模板
- (可选)为 `prepareMedia()` 增加图片/视频的 begin/end 埋点
- 将图片解码移至后台线程,回主线程赋值
- 使用 AVAsset 异步加载 track/时长并后台计算尺寸
- 检查 Lottie/SVG 资源与渲染负载(必要时优化)
- 调整 OnBoarding 去重逻辑,避免 `pngData()` 重计算
- 配置 `URLCache` 与视频首帧占位方案
---
附注:本指南依赖现有 `Perf` 工具(`Performance.swift`)与相关事件;若需要我直接提交“后台解码 + 细粒度埋点”的实现,请在下次迭代时告知,我会以最小改动提交补丁。

172
specs/refactor_spec.md Normal file
View File

@ -0,0 +1,172 @@
# Wake iOS 重构与性能优化规格说明
版本: v0.1
创建时间: 2025-09-08 15:41 +08
## 背景与目标
- 现状问题:代码组织结构一般、页面间切换卡顿(特别是在重动画/媒体加载/网络日志时)。
- 目标:
- 提升导航一致性与可维护性(仅保留顶层 NavigationStack + Router
- 降低页面切换卡顿(减少主线程压力、控制刷新频率、优化媒体与动画负载、收敛网络日志)。
- 推动 Feature-Oriented 结构与 MVVM降低视图体量与重绘范围。
## 架构调整总览
- 统一导航:顶层 `NavigationStack(path: $router.path)`(见 `wake/WakeApp.swift`),子页面不再嵌套 `NavigationView`;使用 `Router.shared.navigate/pop/popToRoot`
- MVVM优先对 `BlindBoxView` 引入 `BlindBoxViewModel`,将轮询、计时器、媒体预处理、会员信息等迁至 VM。
- 并发与取消:轮询改 `AsyncSequence`/`Task` 可取消;倒计时改 Combine/AsyncTimer统一在 `onDisappear`/路由变化处取消。
- 媒体与动画GIF 优先替换为 Lottie 或仅在可见态播放;模糊与缩放动画范围与时机控制;媒体元数据后台计算。
- 网络日志Debug 可控、限流Release 关闭大段打印;使用 `os_log/Logger` 分类。
- 工程结构Feature-Oriented`Core/``Features/*``SharedUI/`);延续 Theme/Typography/Spacing 设计系统。
## 导航设计规范
- 顶层:`WakeApp` 中唯一 `NavigationStack`;其它页面不使用 `NavigationView`
- 路由:统一通过 `Router.shared.navigate(to:)``Router.shared.pop()``Router.shared.popToRoot()`
- 返回按钮:子页面通过 `Router.shared.pop()` 而非 `presentationMode.dismiss()`
## BlindBox 模块重构要点
- `BlindBoxViewModel`@MainActor
- 状态:盲盒列表/单盒数据、会员信息、计时与轮询状态、媒体 URL/尺寸/播放器句柄。
- 行为:`loadBlindBox()``start/stopPolling()``startCountdown()``prepareVideo/Image()`、资源清理。
- 视图拆分:
- `BlindBoxHeader``BlindBoxAnimationArea`Loading/Ready/Opening/None`BlindBoxActionButton``BlindBoxScalingOverlay`
- 视图仅订阅少量 `@Published`,降低 body 重绘。
## 并发与轮询规范
- 轮询:使用 `Task { for await ... in pollSequence }` + `task.cancel()`,严禁无 cancel 的 `while + Task.sleep`
- 倒计时:优先 0.25s0.5s 频率;必要时“毫秒展示”不落地 state严格在主线程更新 UI 状态。
## 媒体与动画规范
- GIF -> Lottie 优先;若保留 GIF仅在可见态播放避免与大范围模糊+缩放并发。
- 媒体预热与尺寸探测走后台,回主线程赋值。
- 播放器生命周期集中管理,页面切换前暂停并释放。
## 网络日志策略
- Debug按需与限长打印错误优先可通过开关关闭详细日志。
- Release关闭大段请求/响应体打印。
## 工程结构规划(建议)
- `Core/``Utils/``Network/``Auth/``Router/``Theme/``Typography/`
- `Features/BlindBox/``View/``ViewModel/``Models/``API/``Components/`
- `Features/Subscribe/`:含 `CreditsInfoCard``PlanCompare`
- `Features/Memories/`
- `SharedUI/`Buttons、LottieView、SVGImage、SheetModal 等
## 实施计划(分阶段)
- 第一阶段12 天,先解卡顿):
1) 统一导航:移除子页面 `NavigationView`,使用顶层 `NavigationStack + Router`
2) 计时器降频0.250.5s;如非必要移除毫秒级显示。
3) GIF 限制播放或替换为 Lottie关/收敛网络大日志。
- 第二阶段24 天):
4) 为 `BlindBox` 引入 ViewModel迁移副作用与状态。
5) 轮询改为可取消的异步序列;媒体预热与尺寸探测后台化。
6) 视图拆分与体量控制。
- 第三阶段(持续):
7) 目录重组ViewModel 标注 `@MainActor`;保留 `os_signpost` 监测关键路径。
## 验收标准DoD
- 导航:仅顶层 `NavigationStack`;子页面无 `NavigationView`
- 性能:转场掉帧率明显下降;主界面进入/退出动画流畅。
- 结构:`BlindBoxView` < 300 主要状态/副作用位于 ViewModel
- 资源GIF 仅在可见时播放或替换为 Lottie网络日志按需输出。
## 任务清单(同步 todo
- [x] nav-1 统一导航(移除子页面 NavigationViewRouter 返回)
- [x] mvvm-1 BlindBox 引入 ViewModel迁移逻辑
- [x] timer-1 计时器降频与取消
- [x] polling-1 轮询可取消化
- [x] media-1 媒体与动画优化GIF->Lottie/可见播放)
- [x] concurrency-1 @MainActor 与线程安全
- [x] netlog-1 网络日志开关与限流
- [ ] structure-1 目录重组(进行中)
- [ ] perf-1 性能埋点与基线(进行中)
## 进度记录(每次执行后更新)
- 2025-09-08 15:41 +08: 创建 specs 目录与本说明v0.1)。
- 2025-09-08 15:41 +08: 完成 nav-1第一步移除 `wake/View/Blind/BlindOutCome.swift``wake/View/Memories/MemoriesView.swift` 中的 `NavigationView`,改为使用 `Router.shared.pop()` 返回;依赖顶层 `NavigationStack`
- 2025-09-08 15:51 +08: 继续完成 nav-1
- 移除 `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`
- 2025-09-08 18:05 +08: 推进 mvvm-1 与 media-1
- ViewModel`BlindBoxViewModel`)新增 `applyStatusSideEffects()`,将状态联动的副作用集中处理:`Preparing` 开始 1s 倒计时(`countdownText`),其它状态停止倒计时;在 `bootstrapInitialState()``startPolling()` 的数据落地后调用。
- 倒计时迁移至 VM新增 `startCountdown/stopCountdown``countdownText`,视图使用 `viewModel.countdownText` 展示;`ContentView` 移除本地倒计时与定时器。
- 首帧无闪烁:`ContentView` 初始 `animationPhase = .none`,监听 `viewModel.didBootstrap` 后按初始状态(大小写不敏感)切换 `loading/ready`;操作按钮在 `didBootstrap` 前隐藏。
- 媒体优化起步:将 `loading` 阶段的 GIF 替换为 Lottie`LottieView(name: "loading")`,资源位于 `wake/Assets/Lottie/loading.json`)。
- 2025-09-08 19:19 +08: 完成 media-1
- Loading/Ready/Opening 全部替换为 Lottie`loading.json`/`data.json`/`open.json`),并使用 `isPlaying` 仅在可见时播放Opening 使用 `.playOnce`
- 引入 `Perf` 工具类并在关键路径打点Appear、状态切换、开盒点击、开启动画以支持 perf-1。
- 2025-09-08 19:19 +08: 启动 structure-1进行中
- 创建目录骨架:`Core/``SharedUI/``Features/BlindBox/``Features/Subscribe/` 等,仅添加 README不改变构建后续在 Xcode 内移动文件以保持引用正确。
- 2025-09-09 11:26 +08: 推进 structure-1
- 完成盲盒批次文件迁移View/ViewModel/API/Models/Components
- 完成 SharedUI 与 Core 的第 13 步迁移:
- SharedUI`LottieView.swift``GIFView.swift``SVGImage.swift``SheetModal.swift` 已迁移至 `wake/SharedUI/...`(当前均放在 `Animation/` 分组,后续可按需细分 `Media/``Modals/`)。
- Core`Router.swift` 已迁至 `Core/Navigation/``NetworkService.swift` 已迁至 `Core/Network/``Theme.swift``Typography.swift` 已迁至 `Core/DesignSystem/`
- 2025-09-09 11:30 +08: 更新 structure-1
- 已完成 `Performance.swift``Core/Diagnostics/``APIConfig.swift``Core/Network/`
- 已完成 `SVGImageHtml.swift``SharedUI/Media/`
- 待优化(可选):将 `GIFView.swift``SVGImage.swift``SharedUI/Animation/` 细分到 `SharedUI/Media/`;将 `SheetModal.swift``SharedUI/Animation/` 移至 `SharedUI/Modals/`
## structure-1 目录重构计划与迁移步骤
### 目标结构
- `wake/Core/`
- `DesignSystem/``Theme.swift``Typography.swift`
- `Navigation/``Router.swift`
- `Diagnostics/``Performance.swift`
- `Network/``NetworkService.swift``APIConfig.swift`、通用 ApiClient/*
- `wake/SharedUI/`
- `Animation/``LottieView.swift`
- `Media/``GIFView.swift``SVGImage.swift`/`SVGImageHtml.swift`
- `Modals/``SheetModal.swift` 等)
- `Controls/`(通用按钮、输入框等)
- `Graphics/`(共享图形资源包装)
- `wake/Features/BlindBox/`
- `View/``ContentView.swift` -> 建议更名 `BlindBoxView.swift`
- `ViewModel/``BlindBoxViewModel.swift`
- `API/``BlindBoxApi.swift``BlindBoxPolling.swift`
- `Models/``BlindModels.swift`
- `Components/`(与盲盒强相关的子视图)
- `wake/Features/Subscribe/`
- `Components/``CreditsInfoCard.swift``PlanCompare.swift` 等)
- `View/``SubscribeView.swift` 及其子视图)
### 迁移建议
1. 在 Xcode 的 Project Navigator 中先创建“Group without folder”形式的虚拟分组稳定编译再选择是否同步到磁盘。
2. 若需要调整磁盘目录:
- 建议在 Xcode 中使用拖拽将文件移动到对应 Group同时勾选“Move files”与“Add to targets”避免红色引用。
- 一次迁移一个 Feature迁移后立即编译验证。
3. 资源文件Lottie JSON/SVG 等)保持在 `wake/Assets/` 下;仅调整其使用方的源文件位置。
4. 网络与工具类尽量放入 `Core/`Feature 专用 API 可放在 `Features/<Feature>/API/`
### 第一批建议迁移(盲盒)
- `wake/View/Blind/ContentView.swift``wake/Features/BlindBox/View/BlindBoxView.swift`(建议改名)
- `wake/View/Blind/BlindBoxViewModel.swift``wake/Features/BlindBox/ViewModel/BlindBoxViewModel.swift`
- `wake/View/Blind/BlindBoxPolling.swift``wake/Features/BlindBox/API/BlindBoxPolling.swift`
- `wake/Utils/ApiClient/BlindBoxApi.swift``wake/Features/BlindBox/API/BlindBoxApi.swift`
- `wake/Models/BlindModels.swift``wake/Features/BlindBox/Models/BlindModels.swift`
### 公共组件迁移
- `wake/Components/Lottie/LottieView.swift``wake/SharedUI/Animation/LottieView.swift`
- `wake/Utils/GIFView.swift``wake/SharedUI/Media/GIFView.swift`
- `wake/Utils/SVGImage.swift``wake/Utils/SVGImageHtml.swift``wake/SharedUI/Media/`
- `wake/View/Components/SheetModal.swift``wake/SharedUI/Modals/SheetModal.swift`
- `wake/View/Components/Button.swift`/`Buttons/``wake/SharedUI/Controls/`
### 核心模块迁移
- `wake/Utils/Router.swift``wake/Core/Navigation/Router.swift`
- `wake/Utils/Performance.swift``wake/Core/Diagnostics/Performance.swift`
- `wake/Utils/NetworkService.swift``wake/Utils/APIConfig.swift``wake/Core/Network/`
- `wake/Theme.swift``wake/Typography.swift``wake/Core/DesignSystem/`
## 决策记录
- 采用顶层 `NavigationStack + Router`,子页面取消 `NavigationView`
- `BlindBox` 优先落地 MVVM 重构,其它模块随后跟进。

View File

@ -38,7 +38,25 @@
AB4FA8642E4F7074005D9955 /* Exceptions for "wake" folder in "wake" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Core/DesignSystem/README.md,
Core/Diagnostics/README.md,
Core/Navigation/README.md,
Core/Network/README.md,
Core/README.md,
Features/BlindBox/API/README.md,
Features/BlindBox/Components/README.md,
Features/BlindBox/Models/README.md,
Features/BlindBox/README.md,
Features/BlindBox/View/README.md,
Features/BlindBox/ViewModel/README.md,
Features/Subscribe/README.md,
Info.plist,
SharedUI/Animation/README.md,
SharedUI/Controls/README.md,
SharedUI/Graphics/README.md,
SharedUI/Media/README.md,
SharedUI/Modals/README.md,
SharedUI/README.md,
);
target = ABB4E2072E4B75D900660198 /* wake */;
};

View File

@ -0,0 +1,2 @@
# Core/DesignSystem
存放 `Theme.swift``Typography.swift` 等设计系统文件。

View File

@ -0,0 +1,21 @@
import Foundation
import os
enum Perf {
private static let log = OSLog(subsystem: "app.wake", category: "performance")
static func event(_ name: StaticString) {
os_signpost(.event, log: log, name: name)
}
@discardableResult
static func begin(_ name: StaticString) -> OSSignpostID {
let id = OSSignpostID(log: log)
os_signpost(.begin, log: log, name: name, signpostID: id)
return id
}
static func end(_ name: StaticString, id: OSSignpostID) {
os_signpost(.end, log: log, name: name, signpostID: id)
}
}

View File

@ -0,0 +1,2 @@
# Core/Diagnostics
性能与诊断相关:`Performance.swift`,以及后续埋点/日志工具。

View File

@ -0,0 +1,2 @@
# Core/Navigation
存放路由与导航相关:`Router.swift`

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,2 @@
# Core/Network
通用网络层与配置:`NetworkService.swift``APIConfig.swift`、ApiClient 公共代码。

9
wake/Core/README.md Normal file
View File

@ -0,0 +1,9 @@
# Core
跨特性共享的核心能力:
- DesignSystem主题、字体、间距
- Navigation路由/导航栈)
- Diagnostics性能与日志
- Network网络与配置
建议通过 Xcode Group 先完成组织,再视需要同步到磁盘。

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.lowercased() == "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.lowercased() == "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

@ -0,0 +1,2 @@
# Features/BlindBox/API
盲盒相关 API 封装与轮询:`BlindBoxApi.swift``BlindBoxPolling.swift`

View File

@ -0,0 +1,2 @@
# Features/BlindBox/Components
盲盒专属子组件与片段视图。

View File

@ -0,0 +1,2 @@
# Features/BlindBox/Models
盲盒业务模型:`BlindModels.swift` 等。

View File

@ -0,0 +1,2 @@
# Features/BlindBox
盲盒业务代码View / ViewModel / API / Models / Components。

View File

@ -71,39 +71,17 @@ struct AVPlayerController: UIViewControllerRepresentable {
struct BlindBoxView: View {
let mediaType: BlindBoxMediaType
let currentBoxId: String?
@StateObject private var viewModel: BlindBoxViewModel
@State private var showModal = false //
@State private var showSettings = false //
@State private var isMember = false //
@State private var memberDate = "" //
@State private var showLogin = false
@State private var memberProfile: MemberProfile? = nil
@State private var blindCount: BlindCount? = nil
@State private var blindList: [BlindList] = [] // Changed to array
//
@State private var blindGenerate: BlindBoxData?
@State private var showLottieAnimation = true
//
@State private var isPolling = false
@State private var pollingTimer: Timer?
@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 countdownTimer: Timer?
// ViewModel countdownText
//
@State private var displayData: BlindBoxData? = nil
@State private var showScalingOverlay = false
@State private var animationPhase: BlindBoxAnimationPhase = .loading
@State private var animationPhase: BlindBoxAnimationPhase = .none
@State private var scale: CGFloat = 0.1
@State private var videoPlayer: AVPlayer?
@State private var showControls = false
@State private var isAnimating = true
@State private var aspectRatio: CGFloat = 1.0
@State private var isPortrait: Bool = false
@State private var displayImage: UIImage?
@State private var showMedia = false
// -
@ -112,375 +90,20 @@ struct BlindBoxView: View {
init(mediaType: BlindBoxMediaType, blindBoxId: String? = nil) {
self.mediaType = mediaType
self.currentBoxId = blindBoxId
_viewModel = StateObject(wrappedValue: BlindBoxViewModel(mediaType: mediaType, currentBoxId: blindBoxId))
}
//
private func startCountdown() {
// 36:50:20
countdown = (36, 50, 20)
// ViewModel
countdownTimer?.invalidate()
countdownTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
var (minutes, seconds, milliseconds) = countdown
// ViewModel
//
milliseconds -= 10
if milliseconds < 0 {
milliseconds = 90
seconds -= 1
}
// ViewModel
//
if seconds < 0 {
seconds = 59
minutes -= 1
}
// ViewModel
//
if minutes <= 0 && seconds <= 0 && milliseconds <= 0 {
countdownTimer?.invalidate()
countdownTimer = nil
return
}
// ViewModel
countdown = (minutes, seconds, milliseconds)
}
}
private func loadBlindBox() async {
print("loadMedia called with mediaType: \(mediaType)")
if self.currentBoxId != nil {
print("指定监听某盲盒结果: ", self.currentBoxId! as Any)
//
await pollingToQuerySingleBox()
} else {
//
await pollingToQueryBlindBox()
}
// switch mediaType {
// case .video:
// loadVideo()
// currentBoxType = "Video"
// startPolling()
// case .image:
// loadImage()
// currentBoxType = "Image"
// startPolling()
// case .all:
// print("Loading all content...")
// // First/Second
// // 使NetworkService.shared.getasync/await
// NetworkService.shared.get(
// path: "/blind_boxs/query",
// parameters: nil
// ) { (result: Result<APIResponse<[BlindList]>, NetworkError>) in
// DispatchQueue.main.async {
// switch result {
// case .success(let response):
// if response.data.count == 0 {
// // -First
// print(" -First")
// // return
// }
// if response.data.count == 1 && response.data[0].boxType == "First" {
// // -Second
// print(" First-Second")
// // return
// }
// self.blindList = response.data ?? []
// // none
// if self.blindList.isEmpty {
// self.animationPhase = .none
// }
// print(" \(self.blindList.count) ")
// case .failure(let error):
// self.blindList = []
// self.animationPhase = .none
// print(" :", error.localizedDescription)
// }
// }
// }
//
NetworkService.shared.get(
path: "/membership/personal-center-info",
parameters: nil
) { (result: Result<MemberProfileResponse, NetworkError>) in
DispatchQueue.main.async {
switch result {
case .success(let response):
self.memberProfile = response.data
self.isMember = response.data.membershipLevel == "Pioneer"
self.memberDate = response.data.membershipEndAt ?? ""
print("✅ 成功获取会员信息:", response.data)
print("✅ 用户ID:", response.data.userInfo.userId)
case .failure(let error):
print("❌ 获取会员信息失败:", error)
}
}
}
//
// NetworkService.shared.get(
// path: "/blind_box/available/quantity",
// parameters: nil
// ) { (result: Result<APIResponse<BlindCount>, NetworkError>) in
// DispatchQueue.main.async {
// switch result {
// case .success(let response):
// self.blindCount = response.data
// print(" :", response.data)
// case .failure(let error):
// print(" :", error)
// }
// }
// }
// }
}
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
}
}
// 2
try await Task.sleep(nanoseconds: 2_000_000_000)
} catch {
print("❌ 获取盲盒数据失败: \(error)")
//
self.animationPhase = .none
stopPolling()
break
}
}
}
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
} else {
if self.animationPhase != .none {
self.animationPhase = .none
}
}
// 2
try await Task.sleep(nanoseconds: 2_000_000_000)
} catch {
print("❌ 获取盲盒列表失败: \(error)")
stopPolling()
break
}
}
}
//
private func startPolling() {
stopPolling()
isPolling = true
checkBlindBoxStatus()
}
private func stopPolling() {
pollingTimer?.invalidate()
pollingTimer = nil
isPolling = false
}
private func checkBlindBoxStatus() {
guard !currentBoxType.isEmpty else {
stopPolling()
return
}
// NetworkService.shared.postWithToken(
// path: "/blind_box/generate/mock",
// parameters: ["box_type": currentBoxType]
// ) { (result: Result<GenerateBlindBoxResponse, NetworkError>) in
// DispatchQueue.main.async {
// switch result {
// case .success(let response):
// let data = response.data
// self.blindGenerate = data
// print(": \(data?.status ?? "Unknown")")
// //
// if self.mediaType == .all, let firstItem = self.blindList.first {
// self.displayData = BlindBoxData(from: firstItem)
// } else {
// self.displayData = data
// }
//
// //
// if let status = data?.status {
// NotificationCenter.default.post(
// name: .blindBoxStatusChanged,
// object: nil,
// userInfo: ["status": status]
// )
// }
//
// if data?.status != "Preparing" {
// self.stopPolling()
// print(" : \(data?.status ?? "Unknown")")
// if self.mediaType == .video {
// self.videoURL = data?.resultFile?.url ?? ""
// } else if self.mediaType == .image {
// self.imageURL = data?.resultFile?.url ?? ""
// }
// } else {
// self.pollingTimer = Timer.scheduledTimer(
// withTimeInterval: 2.0,
// repeats: false
// ) { _ in
// self.checkBlindBoxStatus()
// }
// }
// case .failure(let error):
// print(" : \(error.localizedDescription)")
// self.stopPolling()
// }
// }
// }
}
private func loadImage() {
guard !imageURL.isEmpty, let url = URL(string: imageURL) else {
print("⚠️ 图片URL无效或为空")
return
}
URLSession.shared.dataTask(with: url) { data, _, _ in
if let data = data, let image = UIImage(data: data) {
DispatchQueue.main.async {
self.displayImage = image
self.aspectRatio = image.size.width / image.size.height
self.isPortrait = image.size.height > image.size.width
self.showScalingOverlay = true //
}
}
}.resume()
}
private func loadVideo() {
guard !videoURL.isEmpty, let url = URL(string: videoURL) else {
print("⚠️ 视频URL无效或为空")
return
}
let asset = AVAsset(url: url)
let playerItem = AVPlayerItem(asset: asset)
let player = AVPlayer(playerItem: playerItem)
let videoTracks = asset.tracks(withMediaType: .video)
if let videoTrack = videoTracks.first {
let size = videoTrack.naturalSize.applying(videoTrack.preferredTransform)
let width = abs(size.width)
let height = abs(size.height)
aspectRatio = width / height
isPortrait = height > width
}
//
videoPlayer = player
videoPlayer?.play()
showScalingOverlay = true //
}
private func prepareVideo() {
guard !videoURL.isEmpty, let url = URL(string: videoURL) else {
print("⚠️ 视频URL无效或为空")
return
}
let asset = AVAsset(url: url)
let playerItem = AVPlayerItem(asset: asset)
let player = AVPlayer(playerItem: playerItem)
let videoTracks = asset.tracks(withMediaType: .video)
if let videoTrack = videoTracks.first {
let size = videoTrack.naturalSize.applying(videoTrack.preferredTransform)
let width = abs(size.width)
let height = abs(size.height)
aspectRatio = width / height
isPortrait = height > width
}
//
videoPlayer = player
}
private func prepareImage() {
guard !imageURL.isEmpty, let url = URL(string: imageURL) else {
print("⚠️ 图片URL无效或为空")
return
}
URLSession.shared.dataTask(with: url) { data, _, _ in
if let data = data, let image = UIImage(data: data) {
DispatchQueue.main.async {
self.displayImage = image
self.aspectRatio = image.size.width / image.size.height
self.isPortrait = image.size.height > image.size.width
}
}
}.resume()
}
// ViewModel.prepareMedia()
private func startScalingAnimation() {
self.scale = 0.1
@ -493,18 +116,18 @@ struct BlindBoxView: View {
// MARK: - Computed Properties
private var scaledWidth: CGFloat {
if isPortrait {
return UIScreen.main.bounds.height * scale * 1/aspectRatio
if viewModel.isPortrait {
return UIScreen.main.bounds.height * scale * 1/viewModel.aspectRatio
} else {
return UIScreen.main.bounds.width * scale
}
}
private var scaledHeight: CGFloat {
if isPortrait {
if viewModel.isPortrait {
return UIScreen.main.bounds.height * scale
} else {
return UIScreen.main.bounds.width * scale * 1/aspectRatio
return UIScreen.main.bounds.width * scale * 1/viewModel.aspectRatio
}
}
@ -512,6 +135,7 @@ struct BlindBoxView: View {
ZStack {
Color.themeTextWhiteSecondary.ignoresSafeArea()
.onAppear {
Perf.event("BlindBox_Appear")
print("🎯 BlindBoxView appeared with mediaType: \(mediaType)")
print("🎯 Current thread: \(Thread.current)")
@ -551,18 +175,17 @@ struct BlindBoxView: View {
// }
//
Task {
await loadBlindBox()
await viewModel.load()
}
}
.onDisappear {
stopPolling()
countdownTimer?.invalidate()
countdownTimer = nil
viewModel.stopPolling()
viewModel.stopCountdown()
// Clean up video player
videoPlayer?.pause()
videoPlayer?.replaceCurrentItem(with: nil)
videoPlayer = nil
viewModel.player?.pause()
viewModel.player?.replaceCurrentItem(with: nil)
viewModel.player = nil
NotificationCenter.default.removeObserver(
self,
@ -570,6 +193,44 @@ struct BlindBoxView: View {
object: nil
)
}
.onChange(of: viewModel.blindGenerate?.status) { _, status in
guard let status = status?.lowercased() else { return }
if status == "unopened" {
Perf.event("BlindBox_Status_Unopened")
withAnimation { self.animationPhase = .ready }
} else if status == "preparing" {
Perf.event("BlindBox_Status_Preparing")
withAnimation { self.animationPhase = .loading }
}
}
.onChange(of: animationPhase) { _, phase in
if phase != .loading {
// VM
}
}
.onChange(of: viewModel.videoURL) { _, url in
if !url.isEmpty {
withAnimation { self.animationPhase = .ready }
}
}
.onChange(of: viewModel.imageURL) { _, url in
if !url.isEmpty {
withAnimation { self.animationPhase = .ready }
}
}
.onChange(of: viewModel.didBootstrap) { _, done in
guard done else { return }
// loading ready
let initialStatus = viewModel.blindGenerate?.status.lowercased() ?? ""
if initialStatus == "unopened" {
withAnimation { self.animationPhase = .ready }
} else if initialStatus == "preparing" {
withAnimation { self.animationPhase = .loading }
} else {
// none onChange
self.animationPhase = .none
}
}
if showScalingOverlay {
ZStack {
@ -578,14 +239,14 @@ struct BlindBoxView: View {
.edgesIgnoringSafeArea(.all)
Group {
if mediaType == .all, let player = videoPlayer {
if mediaType == .all, viewModel.player != nil {
// Video Player
AVPlayerController(player: $videoPlayer)
AVPlayerController(player: .init(get: { viewModel.player }, set: { viewModel.player = $0 }))
.frame(width: scaledWidth, height: scaledHeight)
.opacity(scale == 1 ? 1 : 0.7)
.onAppear { player.play() }
.onAppear { viewModel.player?.play() }
} else if mediaType == .image, let image = displayImage {
} else if mediaType == .image, let image = viewModel.displayImage {
// Image View
Image(uiImage: image)
.resizable()
@ -606,10 +267,10 @@ struct BlindBoxView: View {
HStack {
Button(action: {
// BlindOutcomeView
if mediaType == .all, !videoURL.isEmpty, let url = URL(string: videoURL) {
Router.shared.navigate(to: .blindOutcome(media: .video(url, nil), time: blindGenerate?.name ?? "Your box", description:blindGenerate?.description ?? "", isMember: self.isMember))
} else if mediaType == .image, let image = displayImage {
Router.shared.navigate(to: .blindOutcome(media: .image(image), time: blindGenerate?.name ?? "Your box", description:blindGenerate?.description ?? "", isMember: self.isMember))
if mediaType == .all, !viewModel.videoURL.isEmpty, let url = URL(string: viewModel.videoURL) {
Router.shared.navigate(to: .blindOutcome(media: .video(url, nil), time: viewModel.blindGenerate?.name ?? "Your box", description:viewModel.blindGenerate?.description ?? "", isMember: viewModel.isMember))
} else if mediaType == .image, let image = viewModel.displayImage {
Router.shared.navigate(to: .blindOutcome(media: .image(image), time: viewModel.blindGenerate?.name ?? "Your box", description:viewModel.blindGenerate?.description ?? "", isMember: viewModel.isMember))
}
}) {
Image(systemName: "chevron.left")
@ -688,7 +349,7 @@ struct BlindBoxView: View {
// LoginView()
// }
NavigationLink(destination: SubscribeView()) {
Text("\(memberProfile?.remainPoints ?? 0)")
Text("\(viewModel.memberProfile?.remainPoints ?? 0)")
.font(Typography.font(for: .subtitle))
.fontWeight(.bold)
.padding(.horizontal, 12)
@ -735,7 +396,7 @@ struct BlindBoxView: View {
SVGImage(svgName: "BlindCount")
.frame(width: 100, height: 60)
Text("\(blindCount?.availableQuantity ?? 0) Boxes")
Text("\(viewModel.blindCount?.availableQuantity ?? 0) Boxes")
.font(Typography.font(for: .body, family: .quicksandBold))
.foregroundColor(.white)
.offset(x: 6, y: -18)
@ -749,7 +410,7 @@ struct BlindBoxView: View {
VStack(spacing: 20) {
switch animationPhase {
case .loading:
GIFView(name: "BlindLoading")
LottieView(name: "loading", isPlaying: animationPhase == .loading && !showScalingOverlay)
.frame(width: 300, height: 300)
// .onAppear {
// DispatchQueue.main.asyncAfter(deadline: .now() + 6) {
@ -761,7 +422,7 @@ struct BlindBoxView: View {
case .ready:
ZStack {
GIFView(name: "BlindReady")
LottieView(name: "data", isPlaying: animationPhase == .ready && !showScalingOverlay)
.frame(width: 300, height: 300)
// Add a transparent overlay to capture taps
@ -769,23 +430,14 @@ struct BlindBoxView: View {
.contentShape(Rectangle()) // Make the entire area tappable
.frame(width: 300, height: 300)
.onTapGesture {
Perf.event("BlindBox_Open_Tapped")
print("点击了盲盒")
//
if let boxId = self.currentBoxId {
let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id
if let boxId = boxIdToOpen {
Task {
do {
try await BlindBoxApi.shared.openBlindBox(boxId: boxId)
print("✅ 盲盒开启成功")
} catch {
print("❌ 开启盲盒失败: \(error)")
}
}
}
if let boxId = self.blindGenerate?.id {
Task {
do {
try await BlindBoxApi.shared.openBlindBox(boxId: boxId)
try await viewModel.openBlindBox(for: boxId)
print("✅ 盲盒开启成功")
} catch {
print("❌ 开启盲盒失败: \(error)")
@ -801,11 +453,15 @@ struct BlindBoxView: View {
case .opening:
ZStack {
GIFView(name: "BlindOpen")
.frame(width: 300, height: 300)
.scaleEffect(scale)
.opacity(showMedia ? 0 : 1) // GIF
if !showMedia {
LottieView(name: "open", loopMode: .playOnce, isPlaying: !showMedia)
.frame(width: 300, height: 300)
.scaleEffect(scale)
}
// GIFView
Color.clear
.onAppear {
Perf.event("BlindBox_Opening_Begin")
print("开始播放开启动画")
// 1
self.scale = 1.0
@ -825,12 +481,9 @@ struct BlindBoxView: View {
self.scale = 1.0
//
Perf.event("BlindBox_Opening_ShowMedia")
self.showScalingOverlay = true
if mediaType == .all {
loadVideo()
} else if mediaType == .image {
loadImage()
}
Task { await viewModel.prepareMedia() }
// GIF
self.showMedia = true
@ -843,8 +496,8 @@ struct BlindBoxView: View {
.frame(width: 300, height: 300)
case .none:
// FIXME: 使 BlindLoading GIF
GIFView(name: "BlindLoading")
//
Color.clear
.frame(width: 300, height: 300)
// SVGImage(svgName: "BlindNone")
// .frame(width: 300, height: 300)
@ -858,10 +511,10 @@ struct BlindBoxView: View {
if !showScalingOverlay && !showMedia {
VStack(alignment: .leading, spacing: 8) {
// blindGeneratedescription
Text(blindGenerate?.name ?? "Some box")
Text(viewModel.blindGenerate?.name ?? "Some box")
.font(Typography.font(for: .body, family: .quicksandBold))
.foregroundColor(Color.themeTextMessageMain)
Text(blindGenerate?.description ?? "")
Text(viewModel.blindGenerate?.description ?? "")
.font(.system(size: 14))
.foregroundColor(Color.themeTextMessageMain)
}
@ -881,25 +534,15 @@ struct BlindBoxView: View {
.animation(.easeInOut(duration: 1.5), value: showScalingOverlay)
// TODO
if mediaType == .all {
if mediaType == .all, viewModel.didBootstrap {
Button(action: {
if animationPhase == .ready {
//
//
if let boxId = self.currentBoxId {
let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id
if let boxId = boxIdToOpen {
Task {
do {
try await BlindBoxApi.shared.openBlindBox(boxId: boxId)
print("✅ 盲盒开启成功")
} catch {
print("❌ 开启盲盒失败: \(error)")
}
}
}
if let boxId = self.blindGenerate?.id {
Task {
do {
try await BlindBoxApi.shared.openBlindBox(boxId: boxId)
try await viewModel.openBlindBox(for: boxId)
print("✅ 盲盒开启成功")
} catch {
print("❌ 开启盲盒失败: \(error)")
@ -914,7 +557,7 @@ struct BlindBoxView: View {
}
}) {
if animationPhase == .loading {
Text("Next: \(countdown.minutes):\(String(format: "%02d", countdown.seconds)).\(String(format: "%02d", countdown.milliseconds))")
Text("Next: \(viewModel.countdownText)")
.font(Typography.font(for: .body))
.fontWeight(.bold)
.frame(maxWidth: .infinity)
@ -922,9 +565,6 @@ struct BlindBoxView: View {
.background(Color.white)
.foregroundColor(.black)
.cornerRadius(32)
.onAppear {
startCountdown()
}
} else if animationPhase == .ready {
Text("Ready")
.font(Typography.font(for: .body))
@ -963,8 +603,8 @@ struct BlindBoxView: View {
UserProfileModal(
showModal: $showModal,
showSettings: $showSettings,
isMember: $isMember,
memberDate: $memberDate
isMember: .init(get: { viewModel.isMember }, set: { viewModel.isMember = $0 }),
memberDate: .init(get: { viewModel.memberDate }, set: { viewModel.memberDate = $0 })
)
}
.offset(x: showSettings ? UIScreen.main.bounds.width : 0)

View File

@ -7,7 +7,7 @@ struct BlindOutcomeView: View {
let time: String?
let description: String?
let isMember: Bool
@Environment(\.presentationMode) var presentationMode
// Removed presentationMode; use Router.shared.pop() for back navigation
@State private var isFullscreen = false
@State private var isPlaying = false
@State private var showControls = true
@ -22,15 +22,14 @@ struct BlindOutcomeView: View {
}
var body: some View {
NavigationView {
ZStack {
ZStack {
Color.themeTextWhiteSecondary.ignoresSafeArea()
VStack(spacing: 0) {
//
HStack {
Button(action: {
presentationMode.wrappedValue.dismiss()
Router.shared.pop()
}) {
HStack(spacing: 4) {
Image(systemName: "chevron.left")
@ -159,9 +158,6 @@ struct BlindOutcomeView: View {
.navigationBarHidden(true)
.navigationBarBackButtonHidden(true)
.statusBar(hidden: isFullscreen)
}
.navigationViewStyle(StackNavigationViewStyle())
.navigationBarHidden(true)
.overlay(
JoinModal(isPresented: $showIPListModal)
)

View File

@ -0,0 +1,2 @@
# Features/BlindBox/View
盲盒界面视图文件(例如 `BlindBoxView.swift`)。

View File

@ -0,0 +1,264 @@
import Foundation
import Combine
import UIKit
import AVKit
@MainActor
final class BlindBoxViewModel: ObservableObject {
// Inputs
let mediaType: BlindBoxMediaType
let currentBoxId: String?
// Published state
@Published var isMember: Bool = false
@Published var memberDate: String = ""
@Published var memberProfile: MemberProfile? = nil
@Published var blindCount: BlindCount? = nil
@Published var blindGenerate: BlindBoxData? = nil
@Published var videoURL: String = ""
@Published var imageURL: String = ""
@Published var didBootstrap: Bool = false
@Published var countdownText: String = ""
// Media prepared for display
@Published var player: AVPlayer? = nil
@Published var displayImage: UIImage? = nil
@Published var aspectRatio: CGFloat = 1.0
@Published var isPortrait: Bool = false
// Tasks
private var pollingTask: Task<Void, Never>? = nil
private var countdownTask: Task<Void, Never>? = nil
private var remainingSeconds: Int = 0
init(mediaType: BlindBoxMediaType, currentBoxId: String?) {
self.mediaType = mediaType
self.currentBoxId = currentBoxId
}
func load() async {
Perf.event("BlindVM_Load_Begin")
await bootstrapInitialState()
await startPolling()
loadMemberProfile()
await loadBlindCount()
Perf.event("BlindVM_Load_End")
}
func startPolling() async {
// Unopened
if blindGenerate?.status.lowercased() == "unopened" { return }
stopPolling()
if let boxId = currentBoxId {
// Poll a single box until unopened
pollingTask = Task { @MainActor [weak self] in
guard let self else { return }
do {
for try await data in BlindBoxPolling.singleBox(boxId: boxId, intervalSeconds: 2.0) {
Perf.event("BlindVM_Poll_Single_Yield")
print("[VM] SingleBox polled status: \(data.status)")
self.blindGenerate = data
if self.mediaType == .image {
self.imageURL = data.resultFile?.url ?? ""
} else {
self.videoURL = data.resultFile?.url ?? ""
}
self.applyStatusSideEffects()
Task { await self.prepareMedia() }
break
}
} catch is CancellationError {
// cancelled
} catch {
print("❌ BlindBoxViewModel polling error (single): \(error)")
}
}
} else {
// Poll list and yield first unopened
pollingTask = Task { @MainActor [weak self] in
guard let self else { return }
do {
for try await item in BlindBoxPolling.firstUnopened(intervalSeconds: 2.0) {
Perf.event("BlindVM_Poll_List_Yield")
print("[VM] List polled first unopened: id=\(item.id ?? "nil"), status=\(item.status)")
self.blindGenerate = item
if self.mediaType == .image {
self.imageURL = item.resultFile?.url ?? ""
} else {
self.videoURL = item.resultFile?.url ?? ""
}
self.applyStatusSideEffects()
Task { await self.prepareMedia() }
break
}
} catch is CancellationError {
// cancelled
} catch {
print("❌ BlindBoxViewModel polling error (list): \(error)")
}
}
}
}
private func bootstrapInitialState() async {
if let boxId = currentBoxId {
do {
let data = try await BlindBoxApi.shared.getBlindBox(boxId: boxId)
if let data = data {
self.blindGenerate = data
if mediaType == .image {
self.imageURL = data.resultFile?.url ?? ""
} else {
self.videoURL = data.resultFile?.url ?? ""
}
self.applyStatusSideEffects()
Task { await self.prepareMedia() }
}
} catch {
print("❌ bootstrapInitialState (single) failed: \(error)")
}
} else {
do {
let list = try await BlindBoxApi.shared.getBlindBoxList()
//
let count = (list ?? []).filter { $0.status.lowercased() == "unopened" }.count
self.blindCount = BlindCount(availableQuantity: count)
if let item = list?.first(where: { $0.status.lowercased() == "unopened" }) {
self.blindGenerate = item
if mediaType == .image {
self.imageURL = item.resultFile?.url ?? ""
} else {
self.videoURL = item.resultFile?.url ?? ""
}
self.applyStatusSideEffects()
Task { await self.prepareMedia() }
} else if let first = list?.first {
// Unopened Preparing
self.blindGenerate = first
self.applyStatusSideEffects()
}
} catch {
print("❌ bootstrapInitialState (list) failed: \(error)")
}
}
// loading/ready
self.didBootstrap = true
Perf.event("BlindVM_Bootstrap_Done")
}
func stopPolling() {
pollingTask?.cancel()
pollingTask = nil
}
func openBlindBox(for id: String) async throws {
let sp = Perf.begin("BlindVM_Open")
defer { Perf.end("BlindVM_Open", id: sp) }
try await BlindBoxApi.shared.openBlindBox(boxId: id)
}
private func loadMemberProfile() {
NetworkService.shared.get(
path: "/membership/personal-center-info",
parameters: nil
) { [weak self] (result: Result<MemberProfileResponse, NetworkError>) in
Task { @MainActor in
guard let self else { return }
switch result {
case .success(let response):
self.memberProfile = response.data
self.isMember = response.data.membershipLevel == "Pioneer"
self.memberDate = response.data.membershipEndAt ?? ""
print("✅ 成功获取会员信息:", response.data)
print("✅ 用户ID:", response.data.userInfo.userId)
case .failure(let error):
print("❌ 获取会员信息失败:", error)
}
}
}
}
private func loadBlindCount() async {
do {
let list = try await BlindBoxApi.shared.getBlindBoxList()
let count = (list ?? []).filter { $0.status.lowercased() == "unopened" }.count
self.blindCount = BlindCount(availableQuantity: count)
} catch {
print("❌ 获取盲盒列表失败: \(error)")
}
}
// MARK: -
private func applyStatusSideEffects() {
let status = blindGenerate?.status.lowercased() ?? ""
if status == "preparing" {
// 36:50
if countdownTask == nil || remainingSeconds <= 0 {
startCountdown(minutes: 36, seconds: 50)
}
} else {
stopCountdown()
}
}
func startCountdown(minutes: Int = 36, seconds: Int = 50) {
stopCountdown()
remainingSeconds = max(0, minutes * 60 + seconds)
countdownText = String(format: "%02d:%02d", remainingSeconds / 60, remainingSeconds % 60)
countdownTask = Task { [weak self] in
while let self, !Task.isCancelled, self.remainingSeconds > 0 {
do {
try await Task.sleep(nanoseconds: 1_000_000_000)
} catch {
break
}
await MainActor.run {
self.remainingSeconds -= 1
self.countdownText = String(
format: "%02d:%02d",
self.remainingSeconds / 60,
self.remainingSeconds % 60
)
}
}
}
}
func stopCountdown() {
countdownTask?.cancel()
countdownTask = nil
}
// MARK: - Media Preparation
func prepareMedia() async {
if mediaType == .all {
// Video path
guard !videoURL.isEmpty, let url = URL(string: videoURL) else { return }
let asset = AVAsset(url: url)
let item = AVPlayerItem(asset: asset)
let player = AVPlayer(playerItem: item)
if let track = asset.tracks(withMediaType: .video).first {
let size = track.naturalSize.applying(track.preferredTransform)
let width = abs(size.width)
let height = abs(size.height)
self.aspectRatio = height == 0 ? 1.0 : width / height
self.isPortrait = height > width
}
self.player = player
} else if mediaType == .image {
guard !imageURL.isEmpty, let url = URL(string: imageURL) else { return }
do {
let (data, _) = try await URLSession.shared.data(from: url)
if let image = UIImage(data: data) {
self.displayImage = image
self.aspectRatio = image.size.height == 0 ? 1.0 : image.size.width / image.size.height
self.isPortrait = image.size.height > image.size.width
}
} catch {
print("⚠️ prepareMedia image load failed: \(error)")
}
}
}
}

View File

@ -0,0 +1,2 @@
# Features/BlindBox/ViewModel
盲盒视图模型,如 `BlindBoxViewModel.swift`

View File

@ -0,0 +1,2 @@
# Features/Subscribe
订阅相关页面与组件:`SubscribeView``CreditsInfoCard``PlanCompare` 等。

View File

@ -5,11 +5,13 @@ struct LottieView: UIViewRepresentable {
let name: String
let loopMode: LottieLoopMode
let animationSpeed: CGFloat
let isPlaying: Bool
init(name: String, loopMode: LottieLoopMode = .loop, animationSpeed: CGFloat = 1.0) {
init(name: String, loopMode: LottieLoopMode = .loop, animationSpeed: CGFloat = 1.0, isPlaying: Bool = true) {
self.name = name
self.loopMode = loopMode
self.animationSpeed = animationSpeed
self.isPlaying = isPlaying
}
func makeUIView(context: Context) -> LottieAnimationView {
@ -31,16 +33,26 @@ struct LottieView: UIViewRepresentable {
animationView.contentMode = .scaleAspectFit
animationView.backgroundBehavior = .pauseAndRestore
//
animationView.play()
// /
if isPlaying {
animationView.play()
} else {
animationView.pause()
}
return animationView
}
func updateUIView(_ uiView: LottieAnimationView, context: Context) {
//
if !uiView.isAnimationPlaying {
uiView.play()
// isPlaying /
if isPlaying {
if !uiView.isAnimationPlaying {
uiView.play()
}
} else {
if uiView.isAnimationPlaying {
uiView.pause()
}
}
}
}

View File

@ -0,0 +1,2 @@
# SharedUI/Animation
Lottie 等动画封装:`LottieView.swift`

View File

@ -0,0 +1,2 @@
# SharedUI/Controls
通用按钮、输入控件等。

View File

@ -0,0 +1,2 @@
# SharedUI/Graphics
共享图形包装。

View File

@ -0,0 +1,2 @@
# SharedUI/Media
媒体通用视图:`GIFView.swift``SVGImage.swift`/`SVGImageHtml.swift` 等。

View File

@ -0,0 +1,2 @@
# SharedUI/Modals
通用弹层与模态:`SheetModal.swift` 等。

2
wake/SharedUI/README.md Normal file
View File

@ -0,0 +1,2 @@
# SharedUI
跨特性共享 UI 组件与资源包装Animation、Media、Modals、Controls、Graphics 等。

View File

@ -99,20 +99,20 @@ public struct MultiImageUploader<Content: View>: View {
}
}
}
.onChange(of: isImagePickerPresented) { newValue in
.onChange(of: isImagePickerPresented) { _, newValue in
if newValue {
showingImagePicker = true
}
}
.onChange(of: showingImagePicker) { newValue in
.onChange(of: showingImagePicker) { _, newValue in
if !newValue {
isImagePickerPresented = false
}
}
.onChange(of: selectedImages) { newValue in
.onChange(of: selectedImages) { _, newValue in
selectedImagesBinding = newValue
}
.onChange(of: needsViewUpdate) { _ in
.onChange(of: needsViewUpdate) { _, _ in
// Trigger view update
}
}

View File

@ -261,8 +261,7 @@ struct MediaUploadExample: View {
}
var body: some View {
NavigationView {
VStack(spacing: 20) {
VStack(spacing: 20) {
//
Button(action: { showMediaPicker = true }) {
Label("选择媒体", systemImage: "photo.on.rectangle")
@ -307,9 +306,8 @@ struct MediaUploadExample: View {
.disabled(uploadManager.selectedMedia.isEmpty)
Spacer()
}
.navigationTitle("媒体上传")
.sheet(isPresented: $showMediaPicker) {
}
.sheet(isPresented: $showMediaPicker) {
MediaPicker(
selectedMedia: Binding(
get: { self.uploadManager.selectedMedia },
@ -324,7 +322,6 @@ struct MediaUploadExample: View {
videoSelectionLimit: videoSelectionLimit,
onDismiss: { showMediaPicker = false }
)
}
}
}
}

View File

@ -62,7 +62,7 @@ struct CreditTransaction {
// MARK: -
struct CreditsDetailView: View {
@Environment(\.presentationMode) var presentationMode
// Removed presentationMode; use Router.shared.pop() for back navigation
@State private var showRules = false
//
@ -77,30 +77,28 @@ struct CreditsDetailView: View {
]
var body: some View {
NavigationView {
ScrollView {
VStack(spacing: 0) {
//
navigationHeader
ScrollView {
VStack(spacing: 0) {
//
navigationHeader
//
mainCreditsCard
//
mainCreditsCard
//
creditsHistorySection
//
creditsHistorySection
Spacer(minLength: 100)
}
Spacer(minLength: 100)
}
.background(Theme.Colors.background)
.navigationBarHidden(true)
}
.background(Theme.Colors.background)
.navigationBarHidden(true)
}
// MARK: -
private var navigationHeader: some View {
NaviHeader(title: "Credits") {
presentationMode.wrappedValue.dismiss()
Router.shared.pop()
}
}

View File

@ -7,8 +7,7 @@ struct MediaUploadDemo: View {
@State private var isUploading = false
var body: some View {
NavigationView {
VStack(spacing: 20) {
VStack(spacing: 20) {
//
Button(action: {
showMediaPicker = true
@ -112,7 +111,7 @@ struct MediaUploadDemo: View {
dismissButton: .default(Text("确定"))
)
}
.onChange(of: uploadManager.uploadStatus) { _ in
.onChange(of: uploadManager.uploadStatus) { _, _ in
//
let allFinished = uploadManager.uploadStatus.values.allSatisfy { status in
if case .completed = status { return true }
@ -125,7 +124,6 @@ struct MediaUploadDemo: View {
showUploadAlert = true
}
}
}
}
}

View File

@ -1,7 +1,7 @@
import SwiftUI
struct FeedbackView: View {
@Environment(\.dismiss) private var dismiss
// Use Router for navigation instead of dismiss
@EnvironmentObject private var router: Router
@State private var selectedFeedback: FeedbackType? = FeedbackType.allCases.first
@ -29,7 +29,7 @@ struct FeedbackView: View {
// Custom Navigation Bar
HStack {
// Back Button
Button(action: { dismiss() }) {
Button(action: { router.pop() }) {
Image(systemName: "chevron.left")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(.primary)
@ -140,14 +140,13 @@ struct FeedbackDetailView: View {
let feedbackType: FeedbackView.FeedbackType
@State private var feedbackText = ""
@State private var contactInfo = ""
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 0) {
// Navigation Bar
HStack {
// Back Button
Button(action: { dismiss() }) {
Button(action: { Router.shared.pop() }) {
Image(systemName: "chevron.left")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(.primary)
@ -248,8 +247,8 @@ struct FeedbackDetailView: View {
print("Contact: \(contactInfo)")
}
// Dismiss back to feedback type selection
dismiss()
// Navigate back to feedback type selection
Router.shared.pop()
}
}

View File

@ -62,7 +62,7 @@ enum MemoryMediaType: Equatable {
}
struct MemoriesView: View {
@Environment(\.dismiss) private var dismiss
// Removed dismiss environment; use Router.shared.pop() for back navigation
@State private var memories: [MemoryItem] = []
@State private var isLoading = false
@State private var errorMessage: String?
@ -74,13 +74,12 @@ struct MemoriesView: View {
]
var body: some View {
NavigationView {
ZStack {
ZStack {
VStack(spacing: 0) {
// Top navigation bar
HStack {
Button(action: {
self.dismiss()
Router.shared.pop()
}) {
Image(systemName: "chevron.left")
.foregroundColor(.themeTextMessageMain)
@ -121,7 +120,6 @@ struct MemoriesView: View {
.zIndex(1)
}
}
}
.navigationBarBackButtonHidden(true)
.onAppear {
fetchMemories()

View File

@ -85,7 +85,7 @@ struct MediaUploadView: View {
//
mediaPickerView
}
.onChange(of: uploadManager.uploadResults) { newResults in
.onChange(of: uploadManager.uploadResults) { _, newResults in
handleUploadCompletion(results: newResults)
}
}

View File

@ -291,7 +291,7 @@ struct UserInfo: View {
isKeyboardVisible = isVisible
}
}
.onChange(of: isTextFieldFocused) { newValue in
.onChange(of: isTextFieldFocused) { _, newValue in
withAnimation(.easeInOut(duration: 0.2)) {
isKeyboardVisible = newValue
}

View File

@ -23,8 +23,7 @@ struct SettingsView: View {
// MARK: -
var body: some View {
NavigationView {
ZStack {
ZStack {
// Theme background color
Color.themeTextWhiteSecondary.edgesIgnoringSafeArea(.all)
@ -84,7 +83,6 @@ struct SettingsView: View {
}
}
.navigationBarHidden(true)
}
}
// MARK: -

View File

@ -156,7 +156,7 @@ public struct AvatarPicker: View {
}
)
}
.onChange(of: uploadManager.uploadStatus) { status in
.onChange(of: uploadManager.uploadStatus) { _, status in
print("🔄 Upload status changed: ", status)
//

View File

@ -109,11 +109,11 @@ struct SubscribeView: View {
await store.loadProducts()
await store.refreshEntitlements()
}
.onChange(of: store.isPurchasing) { newValue in
.onChange(of: store.isPurchasing) { _, newValue in
// Bind purchasing state to button loading
isLoading = newValue
}
.onChange(of: store.errorMessage) { newValue in
.onChange(of: store.errorMessage) { _, newValue in
if let message = newValue, !message.isEmpty {
errorText = message
showErrorAlert = true

View File

@ -6,28 +6,25 @@ struct SplashView: View {
@EnvironmentObject private var authState: AuthState
var body: some View {
NavigationView {
ZStack {
//
LinearGradient(
gradient: Gradient(colors: [
Theme.Colors.primary, // Primary color with some transparency
Theme.Colors.primaryDark, // Darker shade of the primary color
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.edgesIgnoringSafeArea(.all)
VStack(spacing: 50) {
// FilmAnimation()
}
.padding()
}
.onAppear {
isAnimating = true
ZStack {
//
LinearGradient(
gradient: Gradient(colors: [
Theme.Colors.primary, // Primary color with some transparency
Theme.Colors.primaryDark, // Darker shade of the primary color
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.edgesIgnoringSafeArea(.all)
VStack(spacing: 50) {
// FilmAnimation()
}
.padding()
}
.onAppear {
isAnimating = true
}
.navigationViewStyle(StackNavigationViewStyle())
}
}