Compose Deep Dive — 1. Composition

hongbeom
hongbeomi dev
Published in
10 min readMar 29, 2022

Jetpack Compose Layout의 동작 원리를 Deep Dive into Jetpack Compose Layout(Android Dev Summit21) 영상과 함께 살펴봅시다.

Photo by Hal Gatewood on Unsplash

이 글은 Android Dev Summit21 영상 중 하나인 Deep Dive into Jetpack Compose Layout의 번역 & 정리 글입니다.

💍 Compose의 Layout 시스템

Compose의 Layout 시스템은 커스텀 레이아웃을 쉽게 구성하고, 효과적으로 작동하며, 우수한 성능으로 구현하는 목표를 가지고 설계되었습니다.

Compose의 Layout Model이 어떻게 위 목표를 이루는지 살펴보겠습니다.

Jetpack Compose는 상태를 UI로 변경합니다. 어떻게 이런 방식이 가능할까요?

이는 Composition, Layout, Drawing 3단계로 구현됩니다.

👑 Composition

먼저 Composition 단계에서는 Composable 함수를 실행하고 UI를 내보내서 UI 트리를 구성합니다.

예를 들어 SearchResult 컴포저블 함수를 실행하면 오른쪽과 같은 UI 트리가 구성됩니다. 물론 컴포저블에 로직과 컨트롤 플로우를 포함해서 다양한 상태의 여러 가지 트리를 만들 수도 있습니다.

Composition은 UI가 그려지는 단계 중 하나이지만, 실제로 인터페이스 입니다.

컴포지션 객체는 UI를 처음 구성하는데 사용되는 API에서 리턴됩니다(ex: setContent). 우리는 이 컴포지션 객체를 가지고 임의로 UI 및 컴포지션을 폐기하려면 dispose 메서드를 호출하여 이를 수행할 수 있습니다.

🌲 How to create tree?

어떻게 Composable 함수를 만들면 이를 인지하고 UI 트리를 만들 수 있는 것일까요? Composable 어노테이션을 살펴보겠습니다.

Composable 어노테이션은 함수, 타입, 타입 파라미터, 프로퍼티 게터에 사용될 수 있으며, Compose로 구성되는 애플리케이션의 기본 요소입니다.

우리가 Composable 어노테이션을 함수 혹은 람다에 달아주게 되면, Compose에게 애플리케이션 데이터에서 트리 또는 레이어로의 변환을 나타낸다는 것을 알려주게 됩니다. 또한 해당 함수 또는 람다식의 타입이 변경되는데 이로 인해 Composable 함수는 다른 Composable 함수 내부에서만 호출할 수 있게 됩니다.

이 특성 때문에 ComposableContext가 하위 Composable 함수로 전달되고, 이 ComposableContext를 사용하여 UI 트리의 동일한 로직 부분에서 발생한 함수의 이전 실행 정보를 저장할 수 있게 됩니다.

ComposableContext는 무엇일까요? ComposableContext는 두 개의 컴포지션을 논리적으로 연결하는데 사용되는 abstract 타입의 클래스 입니다

CompositionContext 인스턴스는 해당 컴포지션 트리의 특정 위치에 있는 parent 컴포지션에 대한 참조를 나타내며, 이 인스턴스는 새로운 child 컴포지션에 제공될 수 있습니다. 이 참조로 인해 두 개의 컴포지션이 떨어져 있지않다면 invalidate, 컴포지션 간의 데이터 전달 등의 로직들이 동작할 수 있는 것입니다.

참고로 최상위 루트 컴포지션의 parent는 Recomposer입니다. Recomposer는 recomposition을 수행하고 하나 이상의 컴포지션에 대한 업데이트를 적용하는 스케줄러 클래스입니다. 이 Recomposer의 생김새는 추후에 더 자세히 살펴보겠습니다.

실제로 MainActivity에서 사용되는 setContent 함수를 보면 아래와 같이 작성되어있습니다.

ComponentActivity의 확장 함수로 정의되어 있는 setContentparentcontent를 파라미터로 받습니다.

또한 우리는 setContent 내부에서 existingComposeView 변수를 통해 현재 화면의 최상위 ViewGroup을 찾고 있는 것을 확인할 수 있습니다.

ComposeView의 상위 클래스인 AbstractComposeView에 있는 setParentCompositionContext 메소드를 통해 파라미터로 들어온 CompositionContext를 등록하며, 일반적으로 사용할 경우 parent 파라미터를 넘기지 않기에 이 함수 내부에서 생성된 ComposeView가 루트 ViewGroup이 됩니다.

그렇다면 ComposeViewAbstractComposeView는 어떤 역할

을 수행하고 있을까요?

먼저 AbstractComposeView를 살펴보겠습니다.

AbstractComposeView는 우리에게 익숙한 ViewGroup을 상속받는 Jetpack Compose UI를 사용하여 구현된 커스텀 뷰입니다. 이를 상속받는 하위클래스는 적절한 Content와 함께 Content 함수를 구현해야 합니다.

실제로 ComposeViewContent 함수를 아래와 같은 모습으로 구현하고 있습니다.

하지만 Content를 사용하지 않고 뷰를 추가하려고 하면(addView 혹은 관련된 오버로드 메서드 호출) UnsupportedOperationException 에러를 던지게 됩니다.

이 뷰를 사용하는 host의 라이프 사이클이 파괴되어 컴포지션을 폐기하거나, 컴포지션을 유지하며 반복적으로 뷰에 대한 연결/분리를 위해 AbstractComposeView와 연결된 window에는 ViewTreeLifecycleOwner가 포함되어 있어야 합니다.

또한 기본 컴포지션을 폐기하거나, 뷰가 처음에 window에 연결되지 않은 경우 disposeComposition 호출하여 컴포지션을 없애고 requestLayout()을 호출합니다. (Dialog, Popup에서 사용되며 setViewCompositionStrategy을 사용하여 명시적으로 라이프사이클이 ON_DESTROY 상태에 접어들 때 사용할 수도 있습니다.)

우리는 영상에서 컴포지션으로 인해 UI 트리가 생성된다는 것을 알았습니다. 그렇다면 이 트리는 어떤 구조로 어떻게 만들어져 있을까요?

먼저 간단한 Composable 함수를 하나 살펴보겠습니다.

위 함수는 우리가 처음으로 Compose 프로젝트를 만들면 생성되는 Composable 함수입니다.

Text Composable 함수를 타고 들어가면 Text -> BasicText -> CoreText -> Layout 구조로 이루어져 있는 것을 확인할 수 있습니다.

Layout Composable을 살펴보겠습니다.

Layout은 Compose에서 UI를 표시할 때 사용되는 중요한 핵심 구성요소라는 설명이 주석에 적혀 있습니다.

위의 영상에서 설명한 UI 트리의 Node가 드디어 보이네요. ReusableComposeNode는 이름처럼 재사용 가능한 노드가 생성되는 함수이며, Layout의 파라미터로 받은 modifier, measurePolicy, content를 이 노드에게 넘겨주고 있습니다.

더 깊게 들어가서, ResuableComposeNode 구현부도 살펴보겠습니다.

내부에서 currentComposer에 의해 뭔가 많은 작업이 이루어지고 있습니다. 이 코드를 살펴보면, currentComposer에 의해 노드가 생성되어 트리에 포함되거나, 업데이트되는 등의 처리가 이루어지는 것을 알 수 있습니다.

그렇다면 이 currentComposer는 누구일까요?

해당 파일의 top-level 프로퍼티로 존재하는 currentComposerComposer타입 프로퍼티입니다.

주석을 보면 Composer는 Compose 코틀린 컴파일러 플러그인의 타겟이 되어 코드 생성 도우미가 사용하는 인터페이스라고 설명되어 있습니다.

Compose의 특이한 점은 Compose가 어노테이션 프로세서가 아니라는 점입니다. 이는 우리가 @Composeable 어노테이션을 붙인다고 해서 어노테이션 프로세서가 이를 감지하고 코드를 만들어내는 것이 아니라 코틀린 컴파일러 플러그인의 도움으로 코드가 만들어진다는 겁니다.

이제 우리는 이 Composer에 의해 UI 노드들이 관리되는 것을 알았습니다.

이 노드들은 Composer에 의해 Gap Buffer라는 데이터 구조로 관리되는데, Gap Buffer는 현재 인덱스 또는 커서가 있는 컬렉션을 나타내며, 배열로 메모리에 구현됩니다. 해당 배열 내부에서 사용되지 않는 공간을 Gap이라고 하는데, 이 Gap의 위치를 이동시키면서 노드에 대한 삽입/삭제를 실행시킵니다.

자세한 원리는 아래 링크를 참고해보세요.

위 링크의 설명에서 Gap을 이동하는 것은 O(n)의 시간복잡도를 가지며, Gap을 이동하는 것 외의 모든 작업 (접근/이동/삽입/삭제)은 상수 시간이 걸린다고 합니다. 평균적으로 UI가 구조를 많이 변경하지 않는다고 판단했기에 이 데이터 구조를 선택했다고 합니다. 동적인 UI가 있어도 값이 자주 변경될 뿐 구조는 거의 바뀌지 않는 것처럼 말이죠.

실제로 Composer의 구현체인 ComposerImpl 클래스를 살펴보면, 생성자 파라미터에 SlotTable 타입의 프로퍼티가 있는데 이 프로퍼티가 위에서 언급했던 Gap Buffer를 이용하여 컴포지션 데이터를 저장하고 있습니다.

이상으로 Compose Layout 시스템의 Composition 단계에 대해 살펴보았습니다. 2부 Layout에서 이어서 작성하겠습니다.

참고

--

--