refactor(live-room): BIG_HEART 메시지 수신 애니메이션이 여러번 실행되면 버벅거림과 발열이 생기던 문제 수정

- DispatchQueue로 Concurrent 처리
This commit is contained in:
Yu Sung
2025-11-17 18:47:50 +09:00
parent 31319e4292
commit af42fd074f
2 changed files with 128 additions and 64 deletions

View File

@@ -217,7 +217,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
// HEART_DONATION/BIG_HEART_DONATION // HEART_DONATION/BIG_HEART_DONATION
private let heartDonationDisplayDuration: TimeInterval = 1.5 private let heartDonationDisplayDuration: TimeInterval = 1.5
private let bigHeartDonationDisplayDuration: TimeInterval = 3.0 private let bigHeartDonationDisplayDuration: TimeInterval = 5.0
private var currentHeartMessageDuration: TimeInterval = 1.5 private var currentHeartMessageDuration: TimeInterval = 1.5
private var menuId = 0 private var menuId = 0
@@ -250,6 +250,12 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
private var shouldSpawnRainAfterExplosionEnds: Bool = false private var shouldSpawnRainAfterExplosionEnds: Bool = false
// () ( 1 + 6 = 7) // () ( 1 + 6 = 7)
private var pendingExplosionBursts: Int = 0 private var pendingExplosionBursts: Int = 0
// 퀀 (Dispatch )
private var explosionSequenceTimer: DispatchSourceTimer?
private var lastSequenceStartedAt: Date = .distantPast
private let sequenceMinInterval: TimeInterval = 1.0
// ( 0.5s )
private var rainEligibleAt: Date? = nil
var signatureImageUrls = [String]() var signatureImageUrls = [String]()
var signatureList = [LiveRoomDonationResponse]() var signatureList = [LiveRoomDonationResponse]()
@@ -2110,11 +2116,18 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
// 1 // 1
if suppressNextRemoteWaterFill { if suppressNextRemoteWaterFill {
suppressNextRemoteWaterFill = false suppressNextRemoteWaterFill = false
scheduleExplosionBursts()
startParticlesTimer() startParticlesTimer()
startExplosionSequence()
return return
} }
// : 0 0.5 // :
let now = Date()
if now.timeIntervalSince(lastSequenceStartedAt) < sequenceMinInterval {
DEBUG_LOG("BIG_HEART: throttled to protect performance")
return
}
lastSequenceStartedAt = now
// : 0 1.0~1.5
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
self.remoteWaterTimer?.cancel() self.remoteWaterTimer?.cancel()
@@ -2134,8 +2147,8 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
self.remoteWaterProgress = 0 self.remoteWaterProgress = 0
self.remoteWavePhase = 0 self.remoteWavePhase = 0
self.remoteHeartScale = 1.0 self.remoteHeartScale = 1.0
self.scheduleExplosionBursts()
self.startParticlesTimer() self.startParticlesTimer()
self.startExplosionSequence()
} }
} }
} }
@@ -2219,36 +2232,64 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
} }
} }
/// 0.7 7( 1 + 6) /// 0.7 7( 1 + 6) (Dispatch )
private func scheduleExplosionBursts() { private func startExplosionSequence() {
let bursts = 7 // 퀀
let interval: Double = 0.7 explosionSequenceTimer?.cancel()
self.pendingExplosionBursts = bursts explosionSequenceTimer = nil
self.shouldSpawnRainAfterExplosionEnds = true
let bounds = UIScreen.main.bounds DispatchQueue.main.async { [weak self] in
let margin: CGFloat = 80 guard let self = self else { return }
for i in 0..<bursts { self.pendingExplosionBursts = 7
let delay = Double(i) * interval
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in let bounds = UIScreen.main.bounds
let margin: CGFloat = 80
var index = 0
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.main)
timer.schedule(deadline: .now(), repeating: .milliseconds(700)) // 0.7s
timer.setEventHandler { [weak self] in
guard let self = self else { return } guard let self = self else { return }
let origin: CGPoint
if i == 0 { if index >= 7 {
origin = .zero // // 퀀
} else { timer.cancel()
let minX = -bounds.width * 0.5 + margin self.explosionSequenceTimer = nil
let maxX = bounds.width * 0.5 - margin
let minY = -bounds.height * 0.5 + margin // 0.5
let maxY = bounds.height * 0.5 - margin DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
origin = CGPoint(x: CGFloat.random(in: minX...maxX), guard let self = self else { return }
y: CGFloat.random(in: minY...maxY)) let rains = self.spawnHeartRainFromLastExplosion()
if !rains.isEmpty {
self.bigHeartParticles.append(contentsOf: rains)
if self.bigHeartParticleTimer == nil {
self.startParticlesTimer()
}
}
}
return
} }
// 0 , 6
let origin: CGPoint = {
if index == 0 { return .zero }
let minX = -bounds.width * 0.5 + margin
let maxX = bounds.width * 0.5 - margin
let minY = -bounds.height * 0.5 + margin
let maxY = bounds.height * 0.5 - margin
return CGPoint(x: .random(in: minX...maxX), y: .random(in: minY...maxY))
}()
self.spawnHeartExplosion(origin: origin) self.spawnHeartExplosion(origin: origin)
self.pendingExplosionBursts = max(0, self.pendingExplosionBursts - 1)
// ()
if self.bigHeartParticleTimer == nil { if self.bigHeartParticleTimer == nil {
self.startParticlesTimer() self.startParticlesTimer()
} }
self.pendingExplosionBursts = max(0, self.pendingExplosionBursts - 1)
index += 1
} }
timer.resume()
self.explosionSequenceTimer = timer
} }
} }
@@ -2295,57 +2336,77 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
private func startParticlesTimer() { private func startParticlesTimer() {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self = self else { return } guard let self = self else { return }
//
guard self.bigHeartParticleTimer == nil else { return } guard self.bigHeartParticleTimer == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.main)
// , /
let queue = DispatchQueue(label: "bigHeart.particles.loop", qos: .userInitiated)
let timer = DispatchSource.makeTimerSource(queue: queue)
let dt: Double = 1.0 / 60.0 let dt: Double = 1.0 / 60.0
let gravity: CGFloat = 600 // px/s^2 let gravity: CGFloat = 600 // px/s^2
let bounds = UIScreen.main.bounds
let floorY = bounds.height * 0.5 - 60
timer.schedule(deadline: .now(), repeating: dt) timer.schedule(deadline: .now(), repeating: dt)
timer.setEventHandler { [weak self] in timer.setEventHandler { [weak self] in
guard let self = self else { return } guard let self = self else { return }
// ( )
let hadExplosionBefore = self.bigHeartParticles.contains(where: { !$0.isRain }) // ()
var snapshot: [BigHeartParticle] = []
var pending = 0
var hadExplosionBefore = false
let sem = DispatchSemaphore(value: 0)
DispatchQueue.main.async {
snapshot = self.bigHeartParticles
pending = self.pendingExplosionBursts
hadExplosionBefore = snapshot.contains { !$0.isRain }
sem.signal()
}
sem.wait()
// ()
var next: [BigHeartParticle] = [] var next: [BigHeartParticle] = []
next.reserveCapacity(self.bigHeartParticles.count) next.reserveCapacity(snapshot.count)
// ( ) for var p in snapshot {
let bounds = UIScreen.main.bounds
let floorY = bounds.height * 0.5 - 60 //
for var p in self.bigHeartParticles {
//
p.vy += gravity * p.gravityScale * CGFloat(dt) p.vy += gravity * p.gravityScale * CGFloat(dt)
p.x += p.vx * CGFloat(dt) p.x += p.vx * CGFloat(dt)
p.y += p.vy * CGFloat(dt) p.y += p.vy * CGFloat(dt)
// ( )
p.rotation += Double((abs(p.vx) + abs(p.vy)) * 0.02 * CGFloat(dt)) p.rotation += Double((abs(p.vx) + abs(p.vy)) * 0.02 * CGFloat(dt))
// / (/ )
p.life -= dt p.life -= dt
let baseLife: Double = p.isRain ? 4.0 : 0.7 let baseLife: Double = p.isRain ? 4.0 : 0.7
p.opacity = max(0, min(1, p.life / baseLife)) p.opacity = max(0, min(1, p.life / baseLife))
//
p.scale *= 0.995 p.scale *= 0.995
// if p.isRain && p.y >= floorY { continue }
if p.isRain && p.y >= floorY { if p.life > 0 && p.opacity > 0.01 { next.append(p) }
continue
}
if p.life > 0 && p.opacity > 0.01 {
next.append(p)
}
} }
// ( )
let hasExplosionAfter = next.contains(where: { !$0.isRain }) // ( )
// : let maxParticles = 320
if hadExplosionBefore && !hasExplosionAfter && self.shouldSpawnRainAfterExplosionEnds && self.pendingExplosionBursts == 0 { if next.count > maxParticles {
let rains = self.spawnHeartRainFromLastExplosion() next.removeFirst(next.count - maxParticles)
if !rains.isEmpty {
next.append(contentsOf: rains)
}
self.shouldSpawnRainAfterExplosionEnds = false
} }
self.bigHeartParticles = next
// : , // ( )
if next.isEmpty && self.pendingExplosionBursts == 0 { DispatchQueue.main.async { [weak self] in
self.bigHeartParticleTimer?.cancel() guard let self = self else { return }
self.bigHeartParticleTimer = nil //
let snapshotIDs = Set(snapshot.map { $0.id })
var merged = next
if !self.bigHeartParticles.isEmpty {
let delta = self.bigHeartParticles.filter { !snapshotIDs.contains($0.id) }
if !delta.isEmpty { merged.append(contentsOf: delta) }
}
//
let maxParticles = 320
if merged.count > maxParticles {
merged.removeFirst(merged.count - maxParticles)
}
self.bigHeartParticles = merged
if merged.isEmpty && self.pendingExplosionBursts == 0 {
self.bigHeartParticleTimer?.cancel()
self.bigHeartParticleTimer = nil
}
} }
} }
timer.resume() timer.resume()

View File

@@ -767,7 +767,7 @@ struct LiveRoomViewV2: View {
.opacity(showWaterHeart ? 1 : 0) .opacity(showWaterHeart ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: showWaterHeart) .animation(.easeInOut(duration: 0.2), value: showWaterHeart)
// ( ) - 01 (0.5s) // ( ) - 01 (1.0~1.5s )
WaterHeartView(progress: viewModel.remoteWaterProgress, WaterHeartView(progress: viewModel.remoteWaterProgress,
show: viewModel.isShowRemoteBigHeart, show: viewModel.isShowRemoteBigHeart,
phase: viewModel.remoteWavePhase) phase: viewModel.remoteWavePhase)
@@ -790,6 +790,9 @@ struct LiveRoomViewV2: View {
.allowsHitTesting(false) .allowsHitTesting(false)
} }
} }
// drawingGroup (Rect) ,
.frame(maxWidth: .infinity, maxHeight: .infinity)
.drawingGroup(opaque: false, colorMode: .linear)
} }
// //
.offset(y: keyboardHandler.keyboardHeight > 0 ? -(keyboardHandler.keyboardHeight / 2 + 60) : 0) .offset(y: keyboardHandler.keyboardHeight > 0 ? -(keyboardHandler.keyboardHeight / 2 + 60) : 0)