refactor: 将媒体加载和准备逻辑从视图迁移至 ViewModel
This commit is contained in:
parent
5c25d0bf4c
commit
552193b4c1
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
wake/Utils/Performance.swift
Normal file
21
wake/Utils/Performance.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user