diff --git a/wake/Assets/Lottie/data.json b/wake/Assets/Lottie/open.json similarity index 100% rename from wake/Assets/Lottie/data.json rename to wake/Assets/Lottie/open.json diff --git a/wake/Components/Lottie/LottieView.swift b/wake/Components/Lottie/LottieView.swift index 6976ff4..f3b7a56 100644 --- a/wake/Components/Lottie/LottieView.swift +++ b/wake/Components/Lottie/LottieView.swift @@ -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() + } } } } \ No newline at end of file diff --git a/wake/Utils/Performance.swift b/wake/Utils/Performance.swift new file mode 100644 index 0000000..8eb4182 --- /dev/null +++ b/wake/Utils/Performance.swift @@ -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) + } +} diff --git a/wake/View/Blind/BlindBoxViewModel.swift b/wake/View/Blind/BlindBoxViewModel.swift index 65c73b5..07db41e 100644 --- a/wake/View/Blind/BlindBoxViewModel.swift +++ b/wake/View/Blind/BlindBoxViewModel.swift @@ -1,5 +1,7 @@ import Foundation import Combine +import UIKit +import AVKit @MainActor final class BlindBoxViewModel: ObservableObject { @@ -19,6 +21,11 @@ final class BlindBoxViewModel: ObservableObject { @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? = nil @@ -31,10 +38,12 @@ final class BlindBoxViewModel: ObservableObject { } func load() async { + Perf.event("BlindVM_Load_Begin") await bootstrapInitialState() await startPolling() loadMemberProfile() await loadBlindCount() + Perf.event("BlindVM_Load_End") } func startPolling() async { @@ -47,6 +56,7 @@ final class BlindBoxViewModel: ObservableObject { 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 { @@ -55,6 +65,7 @@ final class BlindBoxViewModel: ObservableObject { self.videoURL = data.resultFile?.url ?? "" } self.applyStatusSideEffects() + Task { await self.prepareMedia() } break } } catch is CancellationError { @@ -69,6 +80,7 @@ final class BlindBoxViewModel: ObservableObject { 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 { @@ -77,6 +89,7 @@ final class BlindBoxViewModel: ObservableObject { self.videoURL = item.resultFile?.url ?? "" } self.applyStatusSideEffects() + Task { await self.prepareMedia() } break } } catch is CancellationError { @@ -100,6 +113,7 @@ final class BlindBoxViewModel: ObservableObject { self.videoURL = data.resultFile?.url ?? "" } self.applyStatusSideEffects() + Task { await self.prepareMedia() } } } catch { print("❌ bootstrapInitialState (single) failed: \(error)") @@ -119,6 +133,7 @@ final class BlindBoxViewModel: ObservableObject { self.videoURL = item.resultFile?.url ?? "" } self.applyStatusSideEffects() + Task { await self.prepareMedia() } } else if let first = list?.first { // 没有 Unopened,选取第一个用于展示状态(通常是 Preparing) self.blindGenerate = first @@ -130,6 +145,7 @@ final class BlindBoxViewModel: ObservableObject { } // 标记首帧状态已准备,供视图决定是否显示 loading/ready self.didBootstrap = true + Perf.event("BlindVM_Bootstrap_Done") } func stopPolling() { @@ -212,4 +228,35 @@ final class BlindBoxViewModel: ObservableObject { 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)") + } + } + } } diff --git a/wake/View/Blind/ContentView.swift b/wake/View/Blind/ContentView.swift index 28f0078..a4cc1bb 100644 --- a/wake/View/Blind/ContentView.swift +++ b/wake/View/Blind/ContentView.swift @@ -80,12 +80,8 @@ struct BlindBoxView: View { @State private var showScalingOverlay = false @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 // 查询数据 - 简单查询 @@ -107,90 +103,7 @@ struct BlindBoxView: View { // 已迁移至 ViewModel - private func loadImage() { - guard !viewModel.imageURL.isEmpty, let url = URL(string: viewModel.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 !viewModel.videoURL.isEmpty, let url = URL(string: viewModel.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 !viewModel.videoURL.isEmpty, let url = URL(string: viewModel.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 !viewModel.imageURL.isEmpty, let url = URL(string: viewModel.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 @@ -203,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 } } @@ -222,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)") @@ -269,9 +183,9 @@ struct BlindBoxView: View { 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, @@ -282,8 +196,10 @@ struct BlindBoxView: View { .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 } } } @@ -323,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() @@ -353,7 +269,7 @@ struct BlindBoxView: View { // 导航到BlindOutcomeView 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 = displayImage { + } 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)) } }) { @@ -514,6 +430,7 @@ struct BlindBoxView: View { .contentShape(Rectangle()) // Make the entire area tappable .frame(width: 300, height: 300) .onTapGesture { + Perf.event("BlindBox_Open_Tapped") print("点击了盲盒") let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id @@ -544,6 +461,7 @@ struct BlindBoxView: View { // 当显示媒体时,移除 GIFView 避免后台播放 Color.clear .onAppear { + Perf.event("BlindBox_Opening_Begin") print("开始播放开启动画") // 初始缩放为1(原始大小) self.scale = 1.0 @@ -563,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