Jetpack Compose 런타임에서 일어나는 마법 완전히 파헤치기 — Recomposer

#1 컴포즈 런타임 분석 — 컴포즈가 작동하기 위한 뼈대

Ji Sungbin
성빈랜드
15 min readJul 13, 2022

--

Photo by Nicole Wreyford on Unsplash

이 글의 타이틀과 커버 이미지를 정하고 draft 로 만들어 둔지 2달 만에 드디어 작성되는 글 입니다. 그만큼 과정을 이해하고 글로 작성하기 까지 엄청 많은 이해가 필요했습니다. 컴포즈는 멀티 모듈로 구성된 프로젝트 입니다.

토스팀에서 제공한 토스페이스가 적용돼 있습니다 (이모지 폰트)

실제로는 더 많은 모듈로 나뉘어져 있으며 핵심이 되는 모듈만 나타내 보았습니다. 이 글은 Runtime 모듈에 대해 설명합니다. 가장 낮은 계층을 다루므로 이해를 위해 사전 지식들이 필요합니다.

이렇게 6개의 글을 먼저 읽고 오시는걸 강력하게 추천합니다. 아래 그래프는 이번 글에서 알아볼 전체 흐름이자, 컴포즈 런타임의 가장 기초가 되는 흐름 입니다.

중요하게 볼 부분은 오른쪽 상단에 별표 표시 해두었으며, 아래 링크에서 원본을 보실 수 있습니다. 댓글도 자유롭게 익명으로 달 수 있으니 궁금하신 부분이나 개선 사항이 있으면 댓글 달아주시면 확인하는대로 답변 드리겠습니다.

내부 코드를 보면 핵심 부분들은 주석이 100줄 가까이 아주 잘 돼있는 반면에 구현 세부 사항 같은 부분은 주석이 아예 없는 경우가 꽤 있습니다. 이런 구현 세부 사항들 까지 다 파악하기엔 너무 어려워서 핵심 부분만 Gibson Ruitiari 와 같이 파악해 보았습니다.

위 그래프를 보면 양이 아주 많습니다. 따라서 이번 글에서는 Recomposer 단계만 알아 보겠습니다.

Compose Runtime 과 UI 의 연결

런타임을 설명하기에 있어 가장 좋은 시작점은 UI 와 연결되는 지점일 것입니다. ComponentActivity.setContent 로 UI 통합이 시작되고 있으며, 다시 여러 setContent 를 거치며 Composition 을 만드는 과정이 시작됩니다.

AbstractComposeView.setContent 을 호출하기 위해 첫 번째 인자로 CompositionContext 가 필요하며, 여기에 사용할 CompositionContext 를 만들기 위해 resolveParentCompositionContext() 를 호출합니다.

초기 컴포지션 시점에선 이전에 만들어진 CompositionContext 가 없기 때문에 windowRecomposer.cacheIfAlive() 를 통해 CompositionContext 를 생성하고 캐싱하게 됩니다.

CompositionContext 생성

View#windowRecomposer 는 아래와 같은 함수를 호출하고 있습니다.

이 함수는 이 부분에서 가장 중요한 Choreographer 생성으로 시작됩니다.

인자로 받는 coroutineContext 는 기본값인 EmptyCoroutineContext 를 받고 있으므로 어떠한 element 도 존재하지 않습니다. 따라서 baseContext 의 if 문에서 true 가 반환되어 AndroidUiDispatcher.CurrentThread + coroutineContext 가 호출됩니다.

AndroidUiDispatcher.CurrentThread 는 결국 Choreographer 와 Handler 를 가져와서 AndroidUiDispatcher 를 호출함으로써 AndroidUiDispatcher 인스턴스를 만들어 주고 있습니다. 그리고 이렇게 만든 dispatcher 에 dispatcher.frameClock 을 더해줘서 반환하고 있습니다. 이 frameClock 이 중요합니다.

frameClock 은 AndroidUiFrameClock 을 사용하고 있으며, AndroidUiFrameClock 은 MonotonicFrameClock 을 상속받아서 withFrameNanos 를 구현하고 있습니다.

MonotonicFrameClock 을 보면 CoroutineContext.Element 을 상속받아서 withFrameNanos 만 존재하며, 즉, Choreographer 의 CoroutineContext.Element 래퍼라고 볼 수 있습니다.

AndroidUiFrameClock 의 withFrameNanos 구현을 보면 Choreographer 콜백을 만들어 주고, AndroidUiDispatcher#postFrameCallback 혹은 Choreographer#postFrameCallback 를 호출하고 있습니다.

AndroidUiDispatcher#postFrameCallback 은 choreographer.postFrameCallback 을 사용하고 있습니다. 이렇듯 AndroidUiDispatcher 는 Choreographer 를 CoroutineContext 로 전달하여 자유자제로 사용하기 위한 래퍼라고 볼 수 있습니다.

이렇게 Choreographer 생성이 끝나면 PausableMonotonicFrameClock 을 생성하는 단계가 시작됩니다.

위에서 만든 MonotonicFrameClock 을 가져와서 PausableMonotonicFrameClock 으로 전달하고, 이어서 pause() 를 해주고 있습니다.

PausableMonotonicFrameClock 는 Latch 를 이용하여 Latch 가 열려 있을때만 withFrameNanos 를 진행하는 MonotonicFrameClock 의 래퍼입니다. Latch 코드는 아주 쉬우니 설명을 생략하겠습니다.

이렇게 만든 PausableMonotonicFrameClock 을 baseContext 와 합쳐서 Recomposer 와 runRecomposeScope 가 생성됩니다.

Recomposer 는 리컴포지션을 수행하고 하나 이상의 컴포저블에 업데이트를 적용하기 위한 스케줄러이며, CompositionContext 를 상속받고 있습니다.

Recomposer 와 생명주기의 연결

이후 Owner 가 detach 되면 recomposer.cancel 을 해주고 있습니다. Recomposer#cancel 은 추후 알아보겠습니다.

Recomposer#cancel 스포일러: Recomposer 의 기능을 중지합니다.

또한 Owner 에 LifecycleEventObserver 를 등록시켜 생명주기에 맞게 크게 4가지 작업을 해주고 있습니다.

  1. Recomposer#runRecomposeAndApplyChanges()
  2. PausableMonotonicFrameClock#resume
  3. PausableMonotonicFrameClock#pause
  4. Recomposer#cancel

ON_CREATE 일 때 recompose.runRecomposeAndApplyChanges, ON_START 와 ON_STOP 일 때 차례대로 pausableClock.pause, pausableClock.resume, 마지막으로 ON_DESTROY 일 때 recomposer.cancel 을 해주고 있습니다.

이 과정에서 가장 중요한 recompose.runRecomposeAndApplyChanges 을 알아보겠습니다.

Recomposer#runRecomposeAndApplyChanges()

Recomposer 의 마지막 부분 입니다.

이 함수는 연결된 Recomposer 에서 무효화가 요청되면 리컴포지션한 다음 성공하면 기록된 Change 를 적용하는 함수 입니다.

recompositionRunner 와 함께 시작되고 있으므로 recompositionRunner 를 먼저 보겠습니다.

가장 먼저 MonotonicFrameClock 을 가져오고 있습니다. 이어서 Snapshot#registerApplyObserver 를 통해 글로벌 스냅샷에 apply 가 호출될 때마다 현재 Recomposer 의 상태가 Idle 이상이라면 스냅샷 무효화 목록에 스냅샷 변경 사항을 넣어주고, deriveStateLocked() 에 resume 을 하고 있습니다.

Recomposer 은 6가지의 상태를 가지고 있습니다.

  • ShutDown: Recomposer 가 cancel 되고 정리 작업이 완료되었습니다. 더 이상 사용할 수 없습니다.
  • ShuttingDown: Recomposer 가 cancel 되고 있습니다. 더 이상 사용할 수 없습니다.
  • Inactive: Recomposer 는 Composer 의 무효화 요청을 감지하지 않습니다. 무효화 감지를 시작하려면 runRecomposeAndApplyChanges 를 호출해야 합니다.
  • InactivePendingWork: Recomposer 가 비활성화돼 있지만 프레임을 기다리는 보류 중인 효과가 이미 있을 가능성이 있습니다.
  • Idle: Recomposer 는 무효화를 추적하고 있지만 현재 수행할 작업이 없습니다.
  • PendingWork: Recomposer 는 보류 중인 작업에 대한 알림을 받았고 이미 수행 중이거나 수행할 기회를 기다리고 있습니다.

_state 로 Recomposer 의 상태에 접근할 수 있고, 만약 Idle 상태에서 무효화를 요청받아서 무효화를 진행해야 한다면 PendingWork 상태로 바뀝니다. 이때 요청된 무효화를 진행하는 작업을 보류 중인 작업 이라고 부릅니다.

deriveStateLocked() 에 대해선 추후 알아보도록 하겠습니다. 이어서 밑을 보면 알려진 모든 컴포지션에 대해 무효화를 진행하고 있습니다.

이는 composing snapshot 이 활성화 되기 전에 StateObject 에 대한 변경 사항이 있을 수 있다고 가정하고 모든 컴포지션을 리컴포지션 함으로써 초기 상태로 만드는 과정 입니다.

이후 가져온 MonotonicFrameClock 을 인자로 넣어서 block() 인자를 호출하고 있고,

위 2개의 과정(try 문 내부)이 종료될 때 다시 한 번 deriveStateLocked() 호출과 위에서 등록한 Snapshot#registerApplyObserver 를 dispose 해주고 있습니다.

이렇게 recompositionRunner 는 끝납니다. 이제 runRecomposeAndApplyChanges 를 보겠습니다.

무효화가 필요한 Composition 들을 담는 배열로 함수가 시작됩니다. 이후 Recomposer 가 살아 있는 동안(shouldKeepRecomposing) while 문을 돌고 있습니다.

while 문 내부에는 awaitWorkAvailable() 이 먼저 등장합니다.

awaitWorkAvailable 은 보류 중인 작업이 있다면 바로 resume 하고 그렇지 않으면 suspend 상태로 유지하고, CancellableContinuation 을 workContinuation 으로 지정합니다.

다음으로 보류 중입 작업이 있다면(awaitWorkAvailable 에서 resume 됐다면) 인자로 받은 parentFrameClock 으로 MonotonicFrameClock#withFrameNanos 를 시작하고, toRecompose 초기화를 시작합니다. 이어서 toRecompose 에 반복하며 performRecompose 를 호출하고 있습니다.

performRecompose 는 이름에서부터 알 수 있듯이 리컴포지션을 진행하는 함수이며, doCompose 을 composing 으로 감싸서 진행하고 있습니다.

doCompose 도 추후 알아보도록 하겠습니다. 다음으로 composition 에 Change 적용을 요청합니다.

doCompose() 스포일러: 컴포저블 함수를 실행합니다.

차례대로 Change 와 Late Change 에 대해 apply 를 요청하고 있습니다. Change 와 Late Change 의 차이점을 알아보겠습니다.

Column 에 있는 Box 를 Row 로 옮겨야 하는 상황을 상상해 봅시다. 이를 만족하기 위해선 크게 2가지 작업이 필요합니다.

  1. Column 에 있는 Box 를 제거합니다.
  2. Row 에 Box 를 배치합니다.

우리가 원하던 결과대로 작동을 보장하려면 항상 1번과 2번의 순서가 보장돼야 합니다. 이렇게 먼저 적용돼야 하는 변경 사항(1번 단계)을 Change 라고 하고, 이후 나중에 적용돼야 하는 변경 사항(2번 단계)을 Late Change 라고 합니다.

ControlledComposition#apply[Late]Change 의 구현도 추후 알아보도록 하겠습니다.

ControlledComposition#apply[Late]Change 스포일러: Change 를 적용합니다.

// ControlledComposition#apply[Late]Change 구현 핵심 코드changes.forEach { change -> 
change(applier, slots, manager)
}

이렇게 해서 runRecomposeAndApplyChanges() 의 과정이 끝납니다. 이 모든 과정들이 MonotonicFrameClock#withFrameNanos 안에서 진행되고 있다는게 중요합니다. 이 부분 덕분에 매 프레임에 맞춰서 무효화가 진행되어 컴포즈에서 애니메이션이 아주 부드럽게 작동할 수 있는 것입니다. Choreographer 에게 아주 감사함을 느끼는 순간입니다.

하지만 지금까지는 “보류 중입 작업이 있다면(awaitWorkAvailable 에서 resume 됐다면)” 라는 가정 하에 진행되는 과정입니다. 그렇다면 만약 보류 중인 작업이 없어서 suspend 가 유지된다면 어디서 resume 이 될까요?

지금이 deriveStateLocked() 을 알아보기 좋은 시점입니다.

deriveStateLocked() 는 보류 중인 작업이 있다면 awaitWorkAvailable 에서 생성된 workContinuation 을 반환하는 함수 입니다. 따라서 무효화가 필요한 상황(=> 보류 중인 작업이 있는 상황)마다 deriveStateLocked()?.resume(Unit) 를 사용하여 awaitWorkAvailable 를 resume 해주고 있습니다.

recompositionRunner 에서도 이러한 목적으로 deriveStateLocked 이Snapshot#registerApplyObserver 에서 사용됐습니다.

마무리

지금까지 Recomposer#runRecomposeAndApplyChanges 의 구현을 보았습니다. 마지막으로 이렇게 만든 Recomposer 가 반환되면서 View#createLifecycleAwareWindowRecomposer 가 끝납니다.

아까 건너뛰었던 Recomposer#cancel 을 이젠 설명할 수 있을거 같습니다.

현재 Recomposer 의 상태를 ShuttingDown 으로 설정하는 간단한 구현 입니다.

끝!

이번 글에서는 컴포즈 런타임 과정에서 일어나는 단계중 Recomposer 생성 단계에 대해 보았습니다. 원래는 이렇게 만들어진 Recomposer 를 받고 초기 컴포지션을 담당하는 Recomposer#composeInitial 까지 보려고 했으나, 너무 글이 길어지는걸 방지하기 위해 2편으로 나누기로 했습니다.

중간에 몇몇 함수들 설명을 건너뛰었습니다. 건너뛴 함수들을 설명하기엔 아직 좋은 시점이 아니거나, 다른 개념을 추가로 이해해야 설명할 수 있는 함수에 속합니다. 다른 개념이 추가로 필요한 함수는 이 글이 올라가고 2편이 작성되기 전에 해당 개념을 설명하는 글이 작성될 예정입니다.

또한 첨부한 코드에서 중요한 부분들 의외에 아주 많은 부분들이 생략됐습니다. 이는 글의 스크롤 길어짐 방지와 빠른 이해를 위한 것이며, 위 그래프 흐름대로 직접 컴포즈 내부 코드를 봐보시는걸 추천드립니다.

이상 글을 마치겠습니다. 2편도 조만간 올라오니 많은 관심 부탁드립니다. 끝까지 읽어주셔서 감사합니다.

[목차로 돌아가기]

안드로이드 개발자 분들을 위한 카카오톡 오픈 채팅방을 운영하고 있습니다.

(놀랍게도 여기까지 작성하는데 43시간이 걸렸습니다)

[2편이 작성되었습니다]

--

--

Ji Sungbin
성빈랜드

Experience Engineers for us. I love development that creates references.