refactor: 将媒体加载和准备逻辑从视图迁移至 ViewModel

This commit is contained in:
Junhui Chen 2025-09-08 19:08:54 +08:00
parent 5c25d0bf4c
commit 552193b4c1
5 changed files with 106 additions and 111 deletions

View File

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

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

@ -1,5 +1,7 @@
import Foundation import Foundation
import Combine import Combine
import UIKit
import AVKit
@MainActor @MainActor
final class BlindBoxViewModel: ObservableObject { final class BlindBoxViewModel: ObservableObject {
@ -19,6 +21,11 @@ final class BlindBoxViewModel: ObservableObject {
@Published var imageURL: String = "" @Published var imageURL: String = ""
@Published var didBootstrap: Bool = false @Published var didBootstrap: Bool = false
@Published var countdownText: String = "" @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 // Tasks
private var pollingTask: Task<Void, Never>? = nil private var pollingTask: Task<Void, Never>? = nil
@ -31,10 +38,12 @@ final class BlindBoxViewModel: ObservableObject {
} }
func load() async { func load() async {
Perf.event("BlindVM_Load_Begin")
await bootstrapInitialState() await bootstrapInitialState()
await startPolling() await startPolling()
loadMemberProfile() loadMemberProfile()
await loadBlindCount() await loadBlindCount()
Perf.event("BlindVM_Load_End")
} }
func startPolling() async { func startPolling() async {
@ -47,6 +56,7 @@ final class BlindBoxViewModel: ObservableObject {
guard let self else { return } guard let self else { return }
do { do {
for try await data in BlindBoxPolling.singleBox(boxId: boxId, intervalSeconds: 2.0) { 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)") print("[VM] SingleBox polled status: \(data.status)")
self.blindGenerate = data self.blindGenerate = data
if self.mediaType == .image { if self.mediaType == .image {
@ -55,6 +65,7 @@ final class BlindBoxViewModel: ObservableObject {
self.videoURL = data.resultFile?.url ?? "" self.videoURL = data.resultFile?.url ?? ""
} }
self.applyStatusSideEffects() self.applyStatusSideEffects()
Task { await self.prepareMedia() }
break break
} }
} catch is CancellationError { } catch is CancellationError {
@ -69,6 +80,7 @@ final class BlindBoxViewModel: ObservableObject {
guard let self else { return } guard let self else { return }
do { do {
for try await item in BlindBoxPolling.firstUnopened(intervalSeconds: 2.0) { 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)") print("[VM] List polled first unopened: id=\(item.id ?? "nil"), status=\(item.status)")
self.blindGenerate = item self.blindGenerate = item
if self.mediaType == .image { if self.mediaType == .image {
@ -77,6 +89,7 @@ final class BlindBoxViewModel: ObservableObject {
self.videoURL = item.resultFile?.url ?? "" self.videoURL = item.resultFile?.url ?? ""
} }
self.applyStatusSideEffects() self.applyStatusSideEffects()
Task { await self.prepareMedia() }
break break
} }
} catch is CancellationError { } catch is CancellationError {
@ -100,6 +113,7 @@ final class BlindBoxViewModel: ObservableObject {
self.videoURL = data.resultFile?.url ?? "" self.videoURL = data.resultFile?.url ?? ""
} }
self.applyStatusSideEffects() self.applyStatusSideEffects()
Task { await self.prepareMedia() }
} }
} catch { } catch {
print("❌ bootstrapInitialState (single) failed: \(error)") print("❌ bootstrapInitialState (single) failed: \(error)")
@ -119,6 +133,7 @@ final class BlindBoxViewModel: ObservableObject {
self.videoURL = item.resultFile?.url ?? "" self.videoURL = item.resultFile?.url ?? ""
} }
self.applyStatusSideEffects() self.applyStatusSideEffects()
Task { await self.prepareMedia() }
} else if let first = list?.first { } else if let first = list?.first {
// Unopened Preparing // Unopened Preparing
self.blindGenerate = first self.blindGenerate = first
@ -130,6 +145,7 @@ final class BlindBoxViewModel: ObservableObject {
} }
// loading/ready // loading/ready
self.didBootstrap = true self.didBootstrap = true
Perf.event("BlindVM_Bootstrap_Done")
} }
func stopPolling() { func stopPolling() {
@ -212,4 +228,35 @@ final class BlindBoxViewModel: ObservableObject {
countdownTask?.cancel() countdownTask?.cancel()
countdownTask = nil 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

@ -80,12 +80,8 @@ struct BlindBoxView: View {
@State private var showScalingOverlay = false @State private var showScalingOverlay = false
@State private var animationPhase: BlindBoxAnimationPhase = .none @State private var animationPhase: BlindBoxAnimationPhase = .none
@State private var scale: CGFloat = 0.1 @State private var scale: CGFloat = 0.1
@State private var videoPlayer: AVPlayer?
@State private var showControls = false @State private var showControls = false
@State private var isAnimating = true @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 @State private var showMedia = false
// - // -
@ -107,90 +103,7 @@ struct BlindBoxView: View {
// ViewModel // ViewModel
private func loadImage() { // ViewModel.prepareMedia()
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()
}
private func startScalingAnimation() { private func startScalingAnimation() {
self.scale = 0.1 self.scale = 0.1
@ -203,18 +116,18 @@ struct BlindBoxView: View {
// MARK: - Computed Properties // MARK: - Computed Properties
private var scaledWidth: CGFloat { private var scaledWidth: CGFloat {
if isPortrait { if viewModel.isPortrait {
return UIScreen.main.bounds.height * scale * 1/aspectRatio return UIScreen.main.bounds.height * scale * 1/viewModel.aspectRatio
} else { } else {
return UIScreen.main.bounds.width * scale return UIScreen.main.bounds.width * scale
} }
} }
private var scaledHeight: CGFloat { private var scaledHeight: CGFloat {
if isPortrait { if viewModel.isPortrait {
return UIScreen.main.bounds.height * scale return UIScreen.main.bounds.height * scale
} else { } 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 { ZStack {
Color.themeTextWhiteSecondary.ignoresSafeArea() Color.themeTextWhiteSecondary.ignoresSafeArea()
.onAppear { .onAppear {
Perf.event("BlindBox_Appear")
print("🎯 BlindBoxView appeared with mediaType: \(mediaType)") print("🎯 BlindBoxView appeared with mediaType: \(mediaType)")
print("🎯 Current thread: \(Thread.current)") print("🎯 Current thread: \(Thread.current)")
@ -269,9 +183,9 @@ struct BlindBoxView: View {
viewModel.stopCountdown() viewModel.stopCountdown()
// Clean up video player // Clean up video player
videoPlayer?.pause() viewModel.player?.pause()
videoPlayer?.replaceCurrentItem(with: nil) viewModel.player?.replaceCurrentItem(with: nil)
videoPlayer = nil viewModel.player = nil
NotificationCenter.default.removeObserver( NotificationCenter.default.removeObserver(
self, self,
@ -282,8 +196,10 @@ struct BlindBoxView: View {
.onChange(of: viewModel.blindGenerate?.status) { status in .onChange(of: viewModel.blindGenerate?.status) { status in
guard let status = status?.lowercased() else { return } guard let status = status?.lowercased() else { return }
if status == "unopened" { if status == "unopened" {
Perf.event("BlindBox_Status_Unopened")
withAnimation { self.animationPhase = .ready } withAnimation { self.animationPhase = .ready }
} else if status == "preparing" { } else if status == "preparing" {
Perf.event("BlindBox_Status_Preparing")
withAnimation { self.animationPhase = .loading } withAnimation { self.animationPhase = .loading }
} }
} }
@ -323,14 +239,14 @@ struct BlindBoxView: View {
.edgesIgnoringSafeArea(.all) .edgesIgnoringSafeArea(.all)
Group { Group {
if mediaType == .all, let player = videoPlayer { if mediaType == .all, viewModel.player != nil {
// Video Player // Video Player
AVPlayerController(player: $videoPlayer) AVPlayerController(player: .init(get: { viewModel.player }, set: { viewModel.player = $0 }))
.frame(width: scaledWidth, height: scaledHeight) .frame(width: scaledWidth, height: scaledHeight)
.opacity(scale == 1 ? 1 : 0.7) .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 View
Image(uiImage: image) Image(uiImage: image)
.resizable() .resizable()
@ -353,7 +269,7 @@ struct BlindBoxView: View {
// BlindOutcomeView // BlindOutcomeView
if mediaType == .all, !viewModel.videoURL.isEmpty, let url = URL(string: viewModel.videoURL) { 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)) 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)) 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 .contentShape(Rectangle()) // Make the entire area tappable
.frame(width: 300, height: 300) .frame(width: 300, height: 300)
.onTapGesture { .onTapGesture {
Perf.event("BlindBox_Open_Tapped")
print("点击了盲盒") print("点击了盲盒")
let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id
@ -544,6 +461,7 @@ struct BlindBoxView: View {
// GIFView // GIFView
Color.clear Color.clear
.onAppear { .onAppear {
Perf.event("BlindBox_Opening_Begin")
print("开始播放开启动画") print("开始播放开启动画")
// 1 // 1
self.scale = 1.0 self.scale = 1.0
@ -563,12 +481,9 @@ struct BlindBoxView: View {
self.scale = 1.0 self.scale = 1.0
// //
Perf.event("BlindBox_Opening_ShowMedia")
self.showScalingOverlay = true self.showScalingOverlay = true
if mediaType == .all { Task { await viewModel.prepareMedia() }
loadVideo()
} else if mediaType == .image {
loadImage()
}
// GIF // GIF
self.showMedia = true self.showMedia = true