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 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
//
// /
if isPlaying {
animationView.play()
} else {
animationView.pause()
}
return animationView
}
func updateUIView(_ uiView: LottieAnimationView, context: Context) {
//
// isPlaying /
if isPlaying {
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 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<Void, Never>? = 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)")
}
}
}
}

View File

@ -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