From feadfd92a7fd0fbdd02f933f7fb8ff4870b5851d Mon Sep 17 00:00:00 2001 From: Junhui Chen Date: Mon, 8 Sep 2025 16:09:56 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E5=AF=BC=E8=88=AA=E5=A4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- specs/refactor_spec.md | 93 +++++++++++++++++++ wake/View/Blind/BlindOutCome.swift | 10 +- wake/View/Components/Upload/MediaUpload.swift | 9 +- wake/View/Credits/CreditsDetailView.swift | 34 ++++--- wake/View/Examples/MediaDemo.swift | 4 +- wake/View/Feedback.swift | 11 +-- wake/View/Memories/MemoriesView.swift | 8 +- wake/View/Owner/SettingsView.swift | 4 +- wake/View/Welcome/SplashView.swift | 37 ++++---- 9 files changed, 142 insertions(+), 68 deletions(-) create mode 100644 specs/refactor_spec.md diff --git a/specs/refactor_spec.md b/specs/refactor_spec.md new file mode 100644 index 0000000..a80a2d7 --- /dev/null +++ b/specs/refactor_spec.md @@ -0,0 +1,93 @@ +# 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.25s–0.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 等 + +## 实施计划(分阶段) +- 第一阶段(1–2 天,先解卡顿): + 1) 统一导航:移除子页面 `NavigationView`,使用顶层 `NavigationStack + Router`。 + 2) 计时器降频:0.25–0.5s;如非必要移除毫秒级显示。 + 3) GIF 限制播放或替换为 Lottie;关/收敛网络大日志。 +- 第二阶段(2–4 天): + 4) 为 `BlindBox` 引入 ViewModel,迁移副作用与状态。 + 5) 轮询改为可取消的异步序列;媒体预热与尺寸探测后台化。 + 6) 视图拆分与体量控制。 +- 第三阶段(持续): + 7) 目录重组;ViewModel 标注 `@MainActor`;保留 `os_signpost` 监测关键路径。 + +## 验收标准(DoD) +- 导航:仅顶层 `NavigationStack`;子页面无 `NavigationView`。 +- 性能:转场掉帧率明显下降;主界面进入/退出动画流畅。 +- 结构:`BlindBoxView` < 300 行,主要状态/副作用位于 ViewModel。 +- 资源:GIF 仅在可见时播放或替换为 Lottie;网络日志按需输出。 + +## 任务清单(同步 todo) +- [x] nav-1 统一导航(移除子页面 NavigationView,Router 返回) +- [ ] mvvm-1 BlindBox 引入 ViewModel,迁移逻辑 +- [ ] timer-1 计时器降频与取消 +- [ ] polling-1 轮询可取消化 +- [ ] media-1 媒体与动画优化(GIF->Lottie/可见播放) +- [ ] concurrency-1 @MainActor 与线程安全 +- [ ] 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`。 + +## 决策记录 +- 采用顶层 `NavigationStack + Router`,子页面取消 `NavigationView`。 +- `BlindBox` 优先落地 MVVM 重构,其它模块随后跟进。 diff --git a/wake/View/Blind/BlindOutCome.swift b/wake/View/Blind/BlindOutCome.swift index ed1ee34..7a07ed0 100644 --- a/wake/View/Blind/BlindOutCome.swift +++ b/wake/View/Blind/BlindOutCome.swift @@ -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) ) diff --git a/wake/View/Components/Upload/MediaUpload.swift b/wake/View/Components/Upload/MediaUpload.swift index 333059e..d96b039 100644 --- a/wake/View/Components/Upload/MediaUpload.swift +++ b/wake/View/Components/Upload/MediaUpload.swift @@ -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 } ) - } } } } diff --git a/wake/View/Credits/CreditsDetailView.swift b/wake/View/Credits/CreditsDetailView.swift index 3fec878..8305d03 100644 --- a/wake/View/Credits/CreditsDetailView.swift +++ b/wake/View/Credits/CreditsDetailView.swift @@ -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 - - // 主积分卡片 - mainCreditsCard - - // 积分历史 - creditsHistorySection - - Spacer(minLength: 100) - } + ScrollView { + VStack(spacing: 0) { + // 导航栏 + navigationHeader + + // 主积分卡片 + mainCreditsCard + + // 积分历史 + creditsHistorySection + + 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() } } diff --git a/wake/View/Examples/MediaDemo.swift b/wake/View/Examples/MediaDemo.swift index 8e9f42c..304ad8c 100644 --- a/wake/View/Examples/MediaDemo.swift +++ b/wake/View/Examples/MediaDemo.swift @@ -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 @@ -125,7 +124,6 @@ struct MediaUploadDemo: View { showUploadAlert = true } } - } } } diff --git a/wake/View/Feedback.swift b/wake/View/Feedback.swift index 18c62cd..2c52073 100644 --- a/wake/View/Feedback.swift +++ b/wake/View/Feedback.swift @@ -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() } } diff --git a/wake/View/Memories/MemoriesView.swift b/wake/View/Memories/MemoriesView.swift index cb2bde6..04688d8 100644 --- a/wake/View/Memories/MemoriesView.swift +++ b/wake/View/Memories/MemoriesView.swift @@ -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() diff --git a/wake/View/Owner/SettingsView.swift b/wake/View/Owner/SettingsView.swift index 8647904..845622d 100644 --- a/wake/View/Owner/SettingsView.swift +++ b/wake/View/Owner/SettingsView.swift @@ -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: - 私有方法 diff --git a/wake/View/Welcome/SplashView.swift b/wake/View/Welcome/SplashView.swift index 6eb0a22..26a9cfd 100644 --- a/wake/View/Welcome/SplashView.swift +++ b/wake/View/Welcome/SplashView.swift @@ -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()) } }