Jetpack Compose가 UI를 그리기 까지의 여정

컴포즈가 UI를 그리는 방법 (확장판)

Ji Sungbin
성빈랜드
14 min readJun 5, 2022

--

Photo by Andrew Neel on Unsplash

지난번에 “How Jetpack Compose draws the UI: Materialize” 라는 글을 작성하였습니다. 이번엔 이 내용들에 대해 더 확장해서 알아보려고 합니다. 이 글을 읽기 전에 이전 글과 아래 글을 읽고 오시는걸 추천합니다.

컴포즈는 런타임과 UI 의존성이 분리돼 있습니다. 런타임은 멀티 플랫폼 환경에서 돌아갈 수 있게 순수 코틀린만을 이용해 구현됐습니다. 플랫폼 통합은 UI 의존성에서 제공하는 setContent 함수를 통해 진행됩니다. 안드로이드의 경우 ComponentActivity.setContent 를 사용합니다.

ComponentActivity.kt

위 코드를 해석해 봤을때 2가지 의문점이 생길 수 있습니다. CompositionContext 와 Owner 그리고 내부에서 또 호출하는 setContent 는 무엇일까요? 아래와 같은 코드로 그려지는 컴포저블 트리를 상상해 봅시다.

main.kt

이렇게 ComponentActivity.setContent 밑으로 3개의 composable 이 그려지는 트리가 나오게 됩니다. 여기서 ComponentActivity.setContent 는 모든 컴포저블의 진입점이자 최상위 컴포저블이므로 root composable 이라고 부릅니다. ComponentActivity.setContent 만 안드로이드 뷰와 직접적으로 통합이 일어나는 컴포저블 입니다(액티비티 확장함수 라는걸 기억하세요). 즉, root composable 만이 안드로이드 뷰를 다룰 수 있다는걸 의미하고 이렇게 안드로이드 뷰와의 통합 계층을 Owner 라고 부릅니다. Owner 는 안드로이드 뷰 시스템과 컴포저블 트리의 연결을 구현하고, 모든 layout, draw, input, accessibility 는 Owner 를 통해 진행됩니다. 나머지 CompositionContext 에 대해 알아보기 전에 14번째 줄에 있는 setContent 에 대해 먼저 알아보겠습니다. setContent 가 하는 일은 요약하면 크게 3가지가 있습니다.

  • Android CompositionLocal provides
  • 컴포즈와 생명주기 연결 + CompositionContext 생성
  • Composition 생성

차례대로 알아보겠습니다.

Wrapper.android.kt

내부적으로 여러번 다른 setContent 들을 다시 호출하면서 ProvideAndroidCompositionLocals 를 호출하게 됩니다.

AndroidCompositionLocals.android.kt

여기서 보면 context 를 owner.context 로 가져오고 있고, owner 는 AndroidComposeView 입니다. 이는 ComponentActivity.setContent 에서 만들었던 것이며 이로써 context 는 activity-context 라는걸 확인할 수 있습니다. 이러한 이유로 val activity = LocalContext.current as Activity 와 같이 사용해도 문제가 없던 것이였습니다.

https://sungbin.land/composable%EB%81%BC%EB%A6%AC-viewmodel-%EA%B3%B5%EC%9C%A0%ED%95%98%EA%B8%B0-32ef53b24e8c

예전부터 있었던 의문점이 풀리던 순간입니다 🥳. 다시 본몬으로 들어와서, 그렇다면 이렇게 제공되는 CompositionLocal 들은 어떻게 컴포저블 트리로 전파되는 것일까요? 바로 CompositionContext 덕분입니다. CompositionContext 는 무효화와 CompositionLocal 들을 상위 컴포저블과 하위 컴포저블을 연결해주는 역할을 합니다(무효화(invalidation)란 리컴포지션을 유발하는 컴포즈 내부 용어입니다). 이 CompositionContext 는 View.createLifecycleAwareWindowRecomposer() 를 통해 생성됩니다.

WindowRecomposer.android.kt

CompositionContext 를 생성해줌과 동시에 이를 생명주기와 연결시켜주고 있습니다. (Runtime: 또한 무효화 감지가 시작되는 곳이기도 합니다. ) 이 부분을 통해 컴포저블이 화면에서 사라진다면 destroy 됩니다. 이렇게 생성된 CompositionContext 는 Composition 에 연결됩니다.

Wrapper.android.kt

Composition 이란 컴포저블에서 실제로 슬릇 테이블이 구현되고 컴포지션 과정에서 필요한 모든 정보들이 담기는 최상위 객체 입니다. 이 Composition 은 최상위 컴포저블마다 하나씩 생성됩니다. 최상위 컴포저블은 setContent 이므로 만약 여러개의 ComposeView 와 ComponentActivity.setContent 가 있다면 다 개별적으로 Composition 이 생성됩니다. 지금까지 배운 내용들로 위 트리를 다시 그려보면 아래와 같이 나오게 됩니다.

ComponentActivity.setContent 코드를 다시 해석해 보겠습니다.

ComponentActivity.kt

ComposeView 는 컴포즈가 실제로 UI 를 그리는 뷰 입니다. ComposeView 를 만들어 주고

  1. parent CompositionContext 를 설정해 줍니다. 이때 주입되는 parent 는 null 이므로 내부에서 위에서 봤던 View.createLifecycleAwareWindowRecomposer() 를 통해 다시 만들게 됩니다.
  2. ComposeView.setContent() 를 통해 ComposeView 에 content 를 설정합니다.
  3. ComposeView.Owners() 를 통해 ComposeView 를 Owner 로 설정합니다.
  4. 마지막으로 ComponentActivity.setContentView() 를 통해 ComposeView 를 content 로 설정합니다.

이렇게 해서 컴포즈가 UI 를 그리기 위한 모든 준비 과정인 ComponentActivity.setContent 가 마무리됩니다. 이제 실제로 컴포저블이 그려지는 과정을 살펴봅시다. 모든 UI 컴포저블은 공통적으로 Layout 컴포저블을 통해 그려집니다.

Layout.kt

Layout 컴포저블의 10번째 줄을 보면 ReusableComposeNode<T, E> 를 통해 방출 과정을 진행합니다. 이로 인해 함수가 Unit 리턴임에도 불구하고 UI 로 구성될 수 있게 됩니다.

Composables.kt

ReusableComposeNode 의 구현을 보면 factory 와 Applier 을 제네릭으로 받고 있습니다. 이는 컴포즈가 멀티플랫폼 프레임워크이기 때문입니다. 멀티플랫폼이기 때문에 각 환경마다 사용하는 ComposeNode 의 타입이 달라집니다. 따라서 제네릭으로 factory 와 Applier 을 모두 처리해주고 있고, 안드로이드의 경우에는 ComposeUiNode 를 사용합니다. 위 Layout 코드의 10번째 줄을 다시 보면 T로 ComposeUiNode 를 받는것을 확인할 수 있습니다. 이를 통해 모든 유형의 ComposeNode 를 받아서 방출을 진행하는 ReusableComposeNode 는 컴포즈 런타임(androidx.compose.runtime)에 위치하고, ComposeNode 를 안드로이드 환경에 맞게 세부 구현한 ComposeUiNode 는 컴포즈 UI (androidx.compose.ui)에 위치한다는걸 추측해 볼 수 있습니다.

ReusableComposeNode 는 Compose 런타임의 최적화 입니다. 노드의 키가 변경되면 Composer 가 노드 컨텐츠를 삭제하고 새 컨텐츠를 만드는 대신 노드 컨텐츠 자체를 재구성(recompose) 합니다.

ComposeUiNode 는 인터페이스고, 실제로는 LayoutNode 를 사용합니다. 위 Layout 코드의 11번째 줄을 보면 팩토리로 ComposeUiNode.Constructor 를 받고 있고 이는 결국 LayoutNode 를 가르킨다는걸 볼 수 있습니다.

모든 컴포저블이 방출되면 이것을 구체화 하기 위해 컴포즈 UI 에 의해 구현되는 Applier 에 구체화 과정을 위임합니다. Applier 는 컴포저블 트리를 횡단하며 모든 노드를 해석하고 실제 UI 로 그리는 역할을 합니다. Applier 는 구체화하는 과정을 가지고 있으며, 이것 역시 컴포즈 멀티플랫폼을 위해 사용된 노드의 타입을 제네릭으로 받고 있습니다.

Applier.kt

바로 위에서 컴포즈는 UI 에 의해 구현되는 Applier 을 사용한다고 강조했습니다. 따라서 위 Applier는 컴포즈 런타임에 위치한다는걸 예상할 수 있고, 이의 구현은 당연히 컴포즈 UI 에 위치할 것이라고 예상할 수 있습니다. (사실 위 강조가 아니더라도 Applier 가 인터페이스인걸로 유추할 수도 있습니다)

안드로이드의 경우 LayoutNode 에는 UiApplier 라는 구현을 사용합니다. (벡터 그래픽은 완전히 다르게 동작합니다. 이 아티클에서는 다루지 않습니다.)

Applier.kt

위 UiApplier 구현을 보면 2가지를 유추할 수 있습니다.

  1. 컴포저블 트리는 햐향식 또는 상향식으로 구축된다. UiApplier 의 경우 상향식으로 구축된다.
  2. UiApplier 의 실제 작동은 결국 노드에게 위임된다.

첫 번째인 트리 구축 방법에 대해 먼저 알아보도록 하겠습니다. Applier 가 컴포저블 트리를 구축하는 방법은 위에서도 알 수 있듯이 크게 2가지가 있습니다.

하향식

  1. B 를 R 에 삽입한다.
  2. A 를 B 에 삽입한다.
  3. C 를 B 에 삽입한다.

각 아이템이 삽입될 때마다 상위 부모들에게 모두 알려진다. -> 중복 알림이 일어난다.

A 와 C 가 삽입될 때마다 상위 부모인 B 와 R 에게 알려집니다.

상향식

  1. A 를 B 에 삽입한다.
  2. C 를 B 에 삽입한다.
  3. B 를 R 에 삽입한다.

각 아이템이 삽입될 때마다 상위 부모에게만 알리므로 딱 한 번만 알려진다. -> 중복 알림이 없다.

A 와 C 가 삽입될 땐 부모가 B, B 가 삽입될 땐 부모가 R 밖에 없으므로 각가 한 번 씩만 알려집니다.

하향식과 상향식의 트리 구축 성능은 매우 다를 수 있습니다. 이 결정은 새 노드가 삽입될 때마다 알림을 받아야 하는 노드의 수에 따라 Applier 에 의해 결정됩니다. UiApplier 의 경우 insertTopDown 주석에서도 알 수 있듯이 중복 알림을 피하기 위해(상위 부모 딱 한 명에게만 알리기 위해) 상향식 접근을 사용합니다. 만약 노드가 삽입될 때마다 모든 자식에게 알려야 한다면 하향식 접근이 올바를 것입니다.

Applier 가 이렇게 구축된 트리에서 조건이 변경됐을 때 어떻게 순회하고 변경하는지 알아보기 위해 예시를 들어보겠습니다.

main.kt

위 코드는 컴포저블 트리가 아래와 같이 그려집니다.

왜 그림이 징그러울까요…ㅠ

이 트리를 순회하기 위해 아래와 같은 일이 일어납니다.

  1. Column 에 진입하기 위해 down() 호출
  2. Row 에 진입하기 위해 down() 호출
  3. 선택적 노드 Text 를 조건에 따라 삽입하거나 삭제
  4. 상위 Column 으로 돌아가기 위해 up() 호출
  5. 두 번째 선택적 노드 Text 를 조건에 따라 삽입하거나 삭제

이어서 두 번째 주제인 “UiApplier 의 실제 작동은 결국 노드에게 위임된다” 에 대해 알아보도록 하겠습니다. UiApplier 가 사용하는 노드인 LayoutNode 는 Compose UI 가 UI 노드를 모델링하는 방법이므로 상위 노드와 해당 하위 노드에 대한 모든 것들을 알고 있습니다. 따라서 모든 작업이 노드에게 위임이 가능한 것이며, 위임되는 일인 insertAt(노드 삽입), removeAt(특정 위치 노드 제거), move(노드 이동), removeAll(노드 전체 제거) 가 어떻게 작동하는지 알아보겠습니다. (코드의 빠른 이해를 위해 일부 구현 세부 사항을 생략했습니다)

insertAt

LayoutNode.kt

노드가 이미 트리에 연결되지 않았는지 check 후에 현재 노드가 삽입되는 새 노드의 상위 노드로 설정됩니다. 그런 다음 새 노드가 새 부모가 유지 관리하는 자식 목록에 추가됩니다. 또한 Z 인덱스로 정렬된 자식 목록이 무효화됩니다. 이것은 Z 인덱스가 레이아웃에서 placeable.place() 호출의 순서(배치된 순서)에 의해 결정될 뿐만 아니라 Modifier를 통해(예: Modifier.zIndex()) 임의의 값으로 설정할 수 있기 때문에 새 노드를 삽입한 후에 진행됩니다.

마지막으로 instance.attach(owner) 를 통해 새 부모와 동일한 Owner를 할당하여 노드를 연결합니다.

LayoutNode.kt

여기에서 모든 자식 노드가 부모와 동일한 소유자를 할당받도록 check 를 진행합니다. attach 는 자식에 대해 재귀적으로 호출되므로 이 노드에 연결돼 있는 전체 하위 트리는 궁극적으로 동일한 소유자에게 연결됩니다. 이는 동일한 컴포저블 트리의 모든 노드가 동일한 View를 통해 파이프되는 데 필요한 모든 무효화를 적용하여 모든 조정을 처리할 수 있도록 하는 것입니다.

check 후에 Owner 가 지정됩니다. 그런 다음 새 노드와 부모 노드에 대해 재측정(remeasure)이 요청됩니다. 이것은 노드를 효과적으로 구체화하기 때문에 프로세스에서 매우 중요한 단계입니다. 모든 재측정 요청은 Owner를 통해 연결되므로 View 를 사용하여 필요할 때 invalidate 또는 requestLayout을 호출합니다. 이렇게 하면 노드가 마침내 화면에 표시됩니다.

removeAt

LayoutNode.kt

현재 노드는 마지막 노드부터 시작하여 매 자식마다 현재 노드의 자식 목록에서 제거하고 Z 인덱스로 정렬된 자식 목록을 재정렬하고 자식과 모든 자식을 이후에 트리에서 분리합니다. 이를 위해 Owner 를 null로 설정하고 제거의 영향을 받기 때문에 부모에 대한 재측정을 요청합니다.

move

LayoutNode.kt

기존 위치에서 해당 노드를 지우고 새로운 위치에 노드를 다시 삽입합니다. 그리고 Z 인덱스로 정렬된 자식 목록을 무효화 한 다음에, 해당 노드의 부모에게 재측정을 요청합니다.

removeAll

LayoutNode.kt

모든 자식을 반복하며 제거하고 부모에게 재측정을 요청합니다.

이렇게 Applier 에 대해 알아보았습니다. 이 Applier 의 구현인 UiApplier 은 맨 처음에 보았던 ComponentActivity.setContent 에서 Composition 이 만들어질때 같이 생성되고 주입됩니다.

Applier 을 통해 구체화가 되기 위한 모든 과정이 끝나면 마지막으로 setContent 를 하면서 생성된 AndroidCompoeView 를 통해 ViewGroup.dispatchDraw 가 호출되며 화면에 그려지게 됩니다. 노드를 그리기 위해 RenderNodeLayer 라는게 사용되지만, 아직 이 부분까지는 파악이 덜되어 언급하지 않았습니다.

끝!

이번 글도 끝까지 봐주신 분들 모두 감사합니다. 이번엔 10시간이 걸렸네요. 컴포즈 내부를 이해하시는데 도움이 됐다면 clap 해주시면 감사하겠습니다 👏👏

[목차로 돌아가기]

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

--

--

Ji Sungbin
성빈랜드

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