아티클 목록으로 가기

Compose는 어떻게 Android Choreographer와 동기화하는가

skydovesJaewoong Eum (skydoves)||9분 소요

Compose는 어떻게 Android Choreographer와 동기화하는가

Compose 애플리케이션에서 모든 프레임(frame)은 안드로이드 Choreographer의 신호로부터 시작됩니다. Choreographer는 디스플레이의 VSYNC 펄스에 맞춰 작업을 스케줄링하는 시스템 컴포넌트로, UI 업데이트가 일정한 주기(보통 초당 60 또는 120프레임)로 이루어지도록 보장합니다. Compose는 자체적으로 프레임 타이밍을 관리하지 않습니다. 대신, Choreographer의 프레임 콜백(frame callback)을 Compose의 코루틴(coroutine) 기반 런타임(runtime)으로 연결하는 다계층 추상화를 사용합니다. 구체적으로 AndroidUiFrameClock은 다음 VSYNC가 올 때까지 코루틴을 일시 중단하고, AndroidUiDispatcherHandlerChoreographer를 결합하여 디스패치(dispatch)를 조율하며, RecomposerBroadcastFrameClock을 통해 리컴포지션(Recomposition)을 프레임 경계에 맞춥니다. 마지막으로 PausableMonotonicFrameClock이 이 모든 과정을 Activity 생명주기에 연결합니다.

이 글에서는 Compose에서 프레임 타이밍을 정의하는 MonotonicFrameClock 인터페이스(interface), Choreographer.FrameCallback을 코루틴 일시 중단으로 변환하는 AndroidUiFrameClock, HandlerChoreographer를 하나의 디스패처(dispatcher)로 결합하는 AndroidUiDispatcher, Recomposer에서 애니메이션 및 기타 대기자에게 프레임 타임을 분배하는 BroadcastFrameClock, Recomposer가 이러한 클록을 활용하여 한 프레임 내에서 리컴포지션 순서를 제어하는 방식, 그리고 Activity가 중지되었을 때 프레임 전달을 멈추는 PausableMonotonicFrameClock까지 차례대로 살펴보겠습니다.

근본적인 문제, 코루틴에는 프레임 타이밍이 없다

Compose의 런타임은 코틀린 코루틴 위에 구축되어 있습니다. 리컴포지션, 애니메이션, 레이아웃 모두 코루틴 코드로 실행됩니다. 그런데 코루틴에는 "프레임"이라는 개념 자체가 내장되어 있지 않습니다. 코루틴은 언제든지 일시 중단하고 재개할 수 있지만, 부드러운 UI를 위해서는 작업이 디스플레이의 리프레시 주기에 맞춰 동기화되어야 합니다. 안드로이드에서 실제로 60fps나 120fps를 달성하려면 각 프레임이 시작되는 정확한 시점에 작업을 배치해야 하기 때문입니다.

안드로이드에서 이 동기화를 담당하는 것이 바로 Choreographer입니다. Choreographer는 디스플레이 하드웨어로부터 VSYNC 신호를 수신하고, 각 프레임 시작 시점에 프레임 콜백을 디스패치합니다. 디스플레이와 동기화된 작업을 수행하려는 코드는 Choreographer.postFrameCallback()으로 콜백을 등록하고, 콜백이 호출될 때 프레임 타임스탬프를 전달받게 됩니다.

문제의 핵심은 이 두 모델을 어떻게 연결하느냐에 있습니다. Compose에는 코루틴이 "다음 프레임까지 나를 일시 중단해 달라"고 요청하고, Choreographer가 적절한 시점에 코루틴을 깨워주는 메커니즘이 필요합니다. 바로 이 역할을 프레임 클록(frame clock) 추상화가 수행합니다.

MonotonicFrameClock, 프레임 타이밍 인터페이스

MonotonicFrameClockCoroutineContext.Element로서, 단일 연산을 정의합니다. 다음 프레임까지 일시 중단한 후, 프레임 타임스탬프와 함께 콜백을 실행하고 결과를 반환하는 것입니다.

public interface MonotonicFrameClock : CoroutineContext.Element {
    public suspend fun <R> withFrameNanos(
        onFrame: (frameTimeNanos: Long) -> R
    ): R

    public companion object Key : CoroutineContext.Key<MonotonicFrameClock>
}

CoroutineContext.Element이므로 MonotonicFrameClock은 디스패처처럼 코루틴 컨텍스트에 설치할 수 있습니다. 해당 컨텍스트에서 실행되는 모든 코루틴은 withFrameNanos를 호출하여 프레임과 동기화할 수 있습니다. 이 설계가 핵심인데, 코루틴 컨텍스트에 프레임 클록을 주입함으로써 테스트 환경에서는 가짜 클록을, 프로덕션 환경에서는 실제 Choreographer 기반 클록을 자유롭게 교체할 수 있기 때문입니다.

onFrame 콜백은 나노초 단위의 프레임 타임스탬프를 받습니다. 이 타임스탬프가 반드시 "현재 시각"을 의미하지는 않습니다. Choreographer가 제공하는 프레임 목표 시간일 수 있습니다. 다만 값이 엄격하게 단조 증가(monotonically increasing)하므로, 프레임 간 애니메이션 델타를 계산하는 데 안전하게 사용할 수 있습니다.

밀리초 단위의 편의 확장 함수도 제공됩니다.

public suspend inline fun <R> MonotonicFrameClock.withFrameMillis(
    crossinline onFrame: (frameTimeMillis: Long) -> R
): R = withFrameNanos { onFrame(it / 1_000_000L) }

AndroidUiFrameClock, Choreographer를 코루틴으로 연결하는 브릿지

AndroidUiFrameClockMonotonicFrameClock의 안드로이드 구현체입니다. Choreographer 프레임 콜백을 코루틴 재개(resumption)로 변환하는 역할을 합니다. withFrameNanos 메서드는 suspendCancellableCoroutine을 사용하여 호출한 코루틴을 일시 중단하고, Choreographer.FrameCallback을 등록한 다음, 다음 VSYNC가 도착하면 코루틴을 재개합니다.

class AndroidUiFrameClock internal constructor(
    val choreographer: Choreographer,
    private val dispatcher: AndroidUiDispatcher?,
) : MonotonicFrameClock {

    override suspend fun <R> withFrameNanos(onFrame: (Long) -> R): R {
        val uiDispatcher = dispatcher
            ?: coroutineContext[ContinuationInterceptor] as? AndroidUiDispatcher
        return suspendCancellableCoroutine { co ->
            val callback = Choreographer.FrameCallback { frameTimeNanos ->
                co.resumeWith(runCatching { onFrame(frameTimeNanos) })
            }

이 아티클은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 아티클의 전체 내용을 볼 수 있습니다.

구독하기
아티클 목록으로 가기