perf(live-room): BIG_HEART 메시지 수신 경로를 Path 드로잉으로 전환하여 메모리 절감

This commit is contained in:
2025-11-06 15:56:00 +09:00
parent a52f9425e8
commit b3a17b26dc

View File

@@ -2288,6 +2288,17 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
}, animateDuration) }, animateDuration)
} }
private fun ensureBigHeartFloatView(): HeartFloatView {
val root = binding.flRoot
return HeartFloatView(this).also { view ->
val lp = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
root.addView(view, lp)
}
}
private fun addBigHeartAnimation(fromMessage: Boolean = false) { private fun addBigHeartAnimation(fromMessage: Boolean = false) {
val heart = binding.heartWave val heart = binding.heartWave
@@ -2299,39 +2310,10 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
} catch (_: Throwable) { } catch (_: Throwable) {
} }
// 메시지로 수신된 경우: WaterWaveView 대신 임시 하트 뷰를 중앙에 표시 후 폭발 실행 // 메시지로 수신된 경우: Path 드로잉 기반으로 중앙 하트를 잠깐 표시 후 폭발 실행
if (fromMessage) { if (fromMessage) {
val root = binding.flRoot val floatView = ensureBigHeartFloatView()
// 임시 하트(ImageView) 생성 및 중앙 배치 floatView.emitCenterOnce(sizeDp = 133.3f, showDurationMs = 150L)
val tempHeart = ImageView(this).apply {
setImageResource(R.drawable.ic_heart_pink)
alpha = 0f
scaleX = 0.8f
scaleY = 0.8f
layoutParams = FrameLayout.LayoutParams(
120f.dpToPx().toInt(),
120f.dpToPx().toInt()
).apply {
gravity = android.view.Gravity.CENTER
}
}
root.addView(tempHeart)
// 짧은 페이드/스케일 인 후 폭발 실행 (레이아웃 완료 보장 위해 애니메이션 콜백에서 처리)
tempHeart.animate()
.alpha(1f)
.scaleX(1f)
.scaleY(1f)
.setDuration(150L)
.withEndAction {
// 임시 하트를 기준으로 폭발 위치 계산 및 실행
explodeAndHideHeart(tempHeart)
// 누적 방지: 일정 시간 후 임시 하트 제거
handler.postDelayed({
(tempHeart.parent as? FrameLayout)?.removeView(tempHeart)
}, 1200L)
}
.start()
return return
} }
@@ -2402,6 +2384,203 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
}, rainDelay) }, rainDelay)
} }
// BIG_HEART 수신 시 중앙에 잠깐 하트를 그렸다가, 같은 뷰에서 바로 폭발까지 표현하고 사라지는 뷰
private class HeartFloatView(
context: Context
) : View(context) {
private data class Heart(
var x: Float,
var y: Float,
var scale: Float,
var alpha: Float,
var tMs: Float,
val durationInMs: Long
)
private data class Particle(
var x0: Float,
var y0: Float,
var angle: Float,
var speed: Float,
var scale: Float,
var rotation: Float,
var rotationSpeed: Float
)
private enum class Phase { SHOW, EXPLODE }
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = "#ff959a".toColorInt()
}
private val baseHeartPath: Path = Path().apply {
// 하트 경로(정규화 좌표계 [-1,1])
reset()
moveTo(0f, -0.2f)
cubicTo(
0.85f, -1.0f,
1.35f, 0.2f,
0f, 1f
)
cubicTo(
-1.35f, 0.2f,
-0.85f, -1.0f,
0f, -0.2f
)
close()
}
private val hearts = mutableListOf<Heart>()
private var phase: Phase = Phase.SHOW
// 폭발용 상태
private val particles = mutableListOf<Particle>()
private var explosionAnimator: ValueAnimator? = null
private val explosionDurationMs = 900L
private val gravity = 1400f // px/s^2
private var explosionCenterX = 0f
private var explosionCenterY = 0f
// 화면 중앙에 하트를 잠깐 표시 후 자동 폭발
fun emitCenterOnce(sizeDp: Float = 133.3f, showDurationMs: Long = 150L) {
if (width == 0 || height == 0) {
post { emitCenterOnce(sizeDp, showDurationMs) }
return
}
val cx = width / 2f
val cy = height / 2f
val h = Heart(
x = cx,
y = cy,
scale = sizeDp.dpToPx() / 2f,
alpha = 0f,
tMs = 0f,
durationInMs = showDurationMs
)
hearts.clear()
hearts += h
phase = Phase.SHOW
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
when (phase) {
Phase.SHOW -> drawAndMaybeStartExplosion(canvas)
Phase.EXPLODE -> drawExplosion(canvas)
}
}
private fun drawAndMaybeStartExplosion(canvas: Canvas) {
val iterator = hearts.iterator()
if (!iterator.hasNext()) return
val h = iterator.next()
// 고정 16ms 스텝
h.tMs += 16f
val f = (h.tMs / h.durationInMs).coerceIn(0f, 1f)
val drawAlpha = (f * 255).toInt().coerceIn(0, 255)
paint.alpha = drawAlpha
canvas.withTranslation(h.x, h.y) {
val s = h.scale
scale(s, s)
drawPath(baseHeartPath, paint)
}
if (f >= 1f) {
iterator.remove()
startExplosion(h.x, h.y)
} else {
postInvalidateOnAnimation()
}
}
private fun startExplosion(cx: Float, cy: Float) {
explosionCenterX = cx
explosionCenterY = cy
phase = Phase.EXPLODE
// 파티클 초기화
particles.clear()
val density = resources.displayMetrics.density
val count = 90
val minScalePx = 4f * density
val maxScalePx = 10f * density
val minSpeed = 350f * density
val maxSpeed = 1100f * density
repeat(count) {
val angle = (Math.random() * Math.PI * 2).toFloat()
val speed = minSpeed + (maxSpeed - minSpeed) * Math.random().toFloat()
val scale = minScalePx + (maxScalePx - minScalePx) * Math.random().toFloat()
val rotation = (Math.random().toFloat() * 360f) - 180f
val rotationSpeed = ((Math.random().toFloat() * 360f) - 180f)
particles += Particle(cx, cy, angle, speed, scale, rotation, rotationSpeed)
}
// 폭발과 함께 하트비/우박 애니메이션을 부모에 요청
val parentFL = parent as? FrameLayout
val lp = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
val rainDelay = (10L..100L).random()
postDelayed({
parentFL?.let { p ->
val rainView = HeartRainView(context)
p.addView(rainView, lp)
rainView.start()
}
}, rainDelay)
// 알파 페이드 아웃은 View 자체 alpha로 처리
alpha = 1f
explosionAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
duration = explosionDurationMs
interpolator = AccelerateDecelerateInterpolator()
addUpdateListener { invalidate() }
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
// 애니메이션 종료 시 자기 자신 제거
(parent as? FrameLayout)?.removeView(this@HeartFloatView)
}
})
start()
}
postInvalidateOnAnimation()
}
private fun drawExplosion(canvas: Canvas) {
val fraction = (explosionAnimator?.animatedFraction ?: 0f).coerceIn(0f, 1f)
val t = fraction * (explosionDurationMs / 1000f) // seconds
// 전체 뷰 알파를 1→0으로
alpha = 1f - fraction
particles.forEach { p ->
val vx = kotlin.math.cos(p.angle) * p.speed
val vy0 = kotlin.math.sin(p.angle) * p.speed
val x = p.x0 + vx * t
val y = p.y0 + vy0 * t + 0.5f * gravity * t * t
val rot = p.rotation + p.rotationSpeed * t
canvas.withTranslation(x, y) {
rotate(rot)
val s = p.scale
scale(s, s)
drawPath(baseHeartPath, paint)
}
}
if (fraction < 1f) {
postInvalidateOnAnimation()
}
}
}
private class HeartExplosionView( private class HeartExplosionView(
context: Context, context: Context,
private val centerX: Float, private val centerX: Float,