컴포즈 공식 가이드 읽고 분석하기 — (6)

김종식
11 min readMar 15, 2022

(목차로 돌아가기)

Jetpack Compose Phases

대부분의 다른 UI 툴킷처럼, 컴포즈는 몇 가지 단계를 통해 프레임을 렌더링합니다. 안드로이드 뷰 시스템을 본다면, 세 가지 주요 단계가 있었습니다. : measure, layout, drawing. 컴포즈는 이와 매우 유사하지만, 시작 부분에서 구성(Composition) 이라고 부르는 매우 중요한 단계가 있습니다.

구성(Composition)은 Thinking in Compose(번역 문서), State and Jetpack Compose(번역 문서) 를 포함한 컴포즈의 공식 문서 전반에 걸쳐 묘사되고 있습니다.

The three phases of a frame

컴포즈는 세 가지 주요 단계를 가지고 있습니다 :

  1. Composition : UI가 무엇을 보여줄지를 의미합니다. 컴포즈는 컴포저블 함수들을 실행하고 UI 의 설명을 만들게 됩니다.
  2. Layout : UI가 어디에서 위치하는지를 의미합니다. 이 단계는 두 단계로 구성됩니다 : 측정 과 배치(measurement and placement). 레이아웃 요소들은 레이아웃 트리의 각 노드에 대해 자신과 하위요소를 측정하고 2D 좌표에 배치합니다.
  3. Drawing : 어떻게 렌더링 될지를 의미합니다. UI 요소는 보통 디바이스 화면인 캔버스에 그립니다.

이 단계들의 순서는 일반적으로 동일하고, 데이터가 구성부터 레이아웃, 드로잉, 프레임을 만들기 까지 단방향으로 흐를 수 있습니다(단방향 데이터 흐름이라고도 함). BoxWithConstraintsLazyColumnLazyRow 는 부모의 레이아웃 단계가 자식의 구성이 달라지는 의존관계가 있어, 주목할 만한 예외입니다.

모든 프레임에 대해 세 단계가 가상으로 발생하는것을 가정해도 무방하지만, 성능의 이유 때문에 컴포즈는 모든 단계에서 동일한 입력에 대해 동일한 결과를 계산하는 반복작업은 피합니다. 컴포즈는 이전의 결과를 재사용 할 수 있을 경우 컴포저블 함수 실행을 건너뛰고, 컴포즈 UI는 필요하지 않을 경우 트리 전체에 대해 다시 배치하거나 그리지 않습니다. 컴포즈는 UI를 갱신하기 위해 필요한 최소한의 작업만 수행합니다. 이 최적화는 컴포즈가 서로 다른 단계안에 있는 상태를 추적하기 때문에 가능합니다.

State reads

위에 나열된 단계 중 하나에서 스냅샷 상태의 값을 읽는다면, 컴포즈는 그 값이 읽혔을 때 자동적으로 그것을 추적합니다. 이 추적이 상태가 변경될 때 판독기을 재실행하도록 허용하며, 이것이 컴포즈의 상태 식별(state observability)의 기본입니다.

상태는 보통 mutableStateOf() 를 이용하여 생성하고 두 가지 방법중 하나로 접근합니다: value 프로퍼티에 직접접근 또는 코틀린 프로퍼티 델리게이트를 이용합니다. 좀 더 자세한 내용은 State in Composables 을 참조하세요. 이 가이드의 목적은, 이러한(=아래 예시와 같은) 메소드 엑세스로 “상태 읽기” 를 알아보는 것입니다.

프로퍼티 델리게이트를 자세히 살펴보면, “getter” 와 “setter” 함수는 상태의 value 에 접근하거나 갱신하는데 사용됩니다. 이러한 getter, setting 함수는 프로퍼티를 값으로 참조할 때만 호출되며, 생성될 때는 호출되지 않으므로 위의 두 가지 방법이 동일합니다.

읽기 상태가 변경되어 재실행 되어야 하는 각 코드블록은 재시작 스코프(restart scope) 입니다. 컴포즈는 상태 값이 변경되서 서로 다른 단계의 재시작 스코프들을 계속 추적합니다.

Phased state reads

위에 언급한 대로, 컴포즈는 세 가지 주요 단계가 있고, 각 단계 내에서 읽기 상태를 추적합니다. 컴포즈는 UI에 영향을 주는 각 요소에 대해 작업을 수행해야 하는 것을 특정 단계에만 알리는것을 허용합니다.

참조 : 상태 인스턴스가 생성되고 저장되는 것은 단계에서는 거의 관련이 없습니다, 오직 상태가 언제 그리고 어디서 읽히는 지가 중요합니다.

각 단계와 그 안에서 상태 값이 읽힐 때 어떤 일이 발생되는지 확인해 보겠습니다.

Phase 1: Composition

@Compose 함수 또는 람다 블록에서 상태를 읽는 것은 다음 단계에 잠재적으로 영향을 줄 수 있습니다. 상태가 변경되면, 재구성자(recomposer)는 이 상태를 읽는 모든 컴포저블 함수가 재실행되도록 계획합니다. 실행중에는 입력이 변경되지 않은 일부 또는 모든 컴포저블 함수를 생략할 수 있는 것을 참조하세요. 좀 더 자세한 내용은 Skipping if the inputs haven’t changed 에서 확인하세요.

컴포즈 UI 는 구성 결과에 따라 레이아웃과 그리기 단계를 실행합니다. (컴포즈 UI의) 콘텐츠나 크기가 같거나 레이아웃에서 변경을 원치 않을 경우 이 단계는 생략될 수 있습니다.

Phase 2: Layout

레이아웃은 두 단계로 구성됩니다: 측정배치 (measurement and placement). 측정 단계는 Layout 컴포저블에 전달된 LayoutModifier 인터페이스의 MeasureScope.measure 방법 등등 의 측정 람다를 실행합니다. 배치 단계는 Modifier.offset { … } 람다 블록 등등 의 layout 함수의 배치 블록을 실행합니다.

이러한 각 상태에서 상태를 읽는 것은 그리기 단계에 잠재적으로 영향을 줄 수 있습니다. 상태 값이 변경되면, 컴포즈 UI 는 레이아웃 단계를 계획합니다. 또한 크기나 위치가 변경된 경우 그리기 단계를 실행합니다.

좀 더 정확하게 말하자면, 측정 단계와 배치 단계는 재실행 스코프를 각각 가지고 있고, 이는 배치 단계에서 상태를 읽는 것은 측정 단계에서 재실행 하지 않는 것을 의미합니다. 하지만, 이 두 단계는 종종 얽혀있어 배치 단계에서 상태를 읽는 것은 측정 단계가 속한 재시작 스코프에 영향을 줄 수 있습니다.

Phase 3: Drawing

그리기 단계 중 상태를 읽는 것은 그리는 단계에 영향을 미칩니다. 일반적인 예로 Canvas(), Modifier.drawBehind, Modifier.drawWithContent 가 있습니다. 상태 값이 변경되면, 컴포즈 UI 는 오직 그리기 단계만 실행합니다.

Optimizing state reads

컴포즈는 로컬화된 상태 읽기 추적을 수행하기 때문에, 각각 상태를 적절한 단계에서 읽음으로써 수행할 작업량을 최소화 합니다.

예를 한번 들어보겠습니다. 여기 사용자 스크롤 차이에 따라 효과를 주기위해 offset modifier 를 사용하는 Image() 가 있습니다.

이 코드는 잘 동작하지만, 최적화되지 못한 결과를 보여줍니다. 작성한대로, 코드는 firstVisibleItemScrollOffset 상태 값을 읽고 이를(상태값을) Modifier.offset(offset: Dp) 함수로 전달합니다. 사용자가 스크롤을 하면 firstVisibleItemScrollOffset 값은 변경됩니다. 알다시피, 컴포즈는 어떠한 상태던지 추적하기 때문에 코드를 재시작(재실행)하고, 이 예제에서는 Box 의 컨텐츠입니다.

이것은 상태가 읽히는 것이 구성 단계에서 발생하는 예 입니다. 데이터 변경이 새로운 UI를 방출하는 것을 따르는 재구성의 기본으로 비추어 볼 때 이것은 꼭 나쁜것 이라고 할 수 없습니다.

비록 예제이긴 하지만, 모든 스크롤 이벤트가 컴포저블 전체를 재평가를 하고, 그 뒤 측정하며, 배치 후 그려지기 때문에 최적화 되어있지 않습니다. 비록 우리가 보는 것은 그대로이나 어디서 보여질 지만 변경되었음에도, 모든 스크롤 이벤트에 대하여 컴포즈의 단계를 트리거 합니다. 레이아웃 단계만 다시 트리거될 수 있도록 상태를 읽는 것을 최적화 할 수 있습니다.

offset modifier 적용이 가능한 다른 방법이 있습니다: Modifier.offset(offset: Density.() -> IntOffset).

이 버전에서는 람다 블록으로 결과 오프셋을 반환하는 람다 파라메터를 사용합니다. 한번 코드를 수정해 보겠습니다.

왜 이게 좀 더 개선된 걸까요? modifier 의 람다 블록은 레이아웃 단계에서 호출됩니다(특히 레이아웃의 배치 단계 중), 이는 firstVisibleItemScrollOffset 상태가 더 이상 구성 중 읽혀지지 않는것을 의미합니다. 왜냐하면 컴포즈가 상태를 읽을 때, 이렇게 변경된 경우 firstVisibleItemScrollOffset 값이 변경되면 레이아웃 및 그리기 단계만 재시작하면 된다는 것을 의미합니다.

참조 : 람다 파라미터를 취하는 것이 단순 값을 취하는 것 보다 추가 비용이 발생하는 것에 대해 궁금해 할 수 있습니다. 하지만 이 경우에는 레이아웃 단계에서 상태를 읽는것을 제한하는 것이 비용적으로 더 이점이 있습니다. firstVisibleItemScrollOffset 값은 스크롤 중 매 프레임마다 변경되며, 상태를 레이아웃 단계에서 읽도록 지연함으로써 전체적으로 재구성하는 것을 방지할 수 있습니다.

이 예제는 offset modifier 을 변경하여 최종 코드로 최적화 할 수 있지만, 기본적인 개념은 다음과 같습니다 : 상태 읽는것을 가장 낮은 단계로 국한시켜, 컴포즈가 최소한의 작업을 수행하도록 해야 합니다.

물론, 구성 단계에서 상태를 읽는 종종 절대적으로 필요합니다. 그렇다 하더라도, 상태 변경을 필터링하여 재구성 횟수를 최소화 할 수 있는 경우가 있습니다. 좀 더 자세한 내용은 derivedStateOf: convert one or multiple state objects into another state 를 참조하세요.

Recomposition loop (cyclic phase dependency)

이전에서 Compose 의 단계는 동일한 순서로 호출되며, 동일 프레임 안에서 이를 되돌릴 방법은 없다고 언급했습니다. 하지만, 앱이 서로 다른 프레임 간 구성 루프에 들어가는 것은 막을 수 없습니다. 다음 예를 생각해 보겠습니다 :

여기 이미지가 상단에 있고 텍스트가 그 아래에 수직으로 배치되도록 (나쁘게) 구현했습니다. Modifier.onSizeChanged() 를 이용하여 이미지의 변경 완료된 상태를 확인하고, 이를 Modifier.padding() 을 사용하여 텍스트로 전달합니다. Px 에서 Dp 로의 비정상적인 변환은 이 코드가 문제가 있음을 나타냅니다.

이 예시에서 이슈는 “최종” 레이아웃이 단일 프레임으로 도달하지 못하는 것입니다. 이 코드는 여러 프레임이 발생되도록 하며, 이는 불필요한 작업을 수행하고, 사용자 화면에서 UI 가 뛰어다니는 결과가 발생합니다.

각 프레임에서 무슨 일이 일어나는지 살펴보겠습니다 :

첫 번째 프레임의 구성 단계에서, imageHeightPx 는 0 이고, 텍스트에는 Modifier.padding(top = 0) 이 제공됩니다. 이 후, 레이아웃 단계가 진행되면서, modifier 의 onSizeChanged 콜백이 호출됩니다. 컴포즈는 다음 프레임을 위하여 재구성을 스케쥴링합니다. 그리는 단계에서, 텍스트는 아직 값이 반영되지 않은 상태이므로 0 의 패딩으로 렌더링됩니다.

컴포즈는 imageHeightPx 변경으로 인해 스케쥴된 두 번째 프레임을 시작합니다. Box 코드블록에서 상태를 읽히는 것은 컴포지션 단계 안에서 호출됩니다. 이 때, 텍스트는 이미지 높이에 맞는 패딩이 제공됩니다. 레이아웃 단계에서 코드는 imageHeightPx 값을 다시 설정합니다, 하지만 이전(컴포지션 단계에서 설정된 값)과 동일한 값이므로 재구성을 스케쥴링하지 않습니다.

최종적으로 우리는 텍스트에 원했던 패딩이 적용된 것을 얻었지만, 패딩 값을 뒤로 전달하기 위해 다른 단계의 추가 프레임을 소비해 그 결과 중복된 컨텐츠 프레임을 생성하는 결과를 얻어 비효율적 이었습니다.

이 예제는 조금 부자연스러워 보이지만, 아래의 일반적인 패턴에 주의해야 합니다:

  • Modifier.onSizeChanged(), onGloballyPositioned() 또는 다른 레이아웃 연산
  • 몇몇 상태를 갱신하는 행위
  • 레이아웃 수정자(padding(),height() 또는 이와 유사한) 에 입력한 상태를 사용
  • 잠재적인 반복

위 예시들을 수정하는 것은 제대로 레이아웃 원시요소(layout primitives)를 사용하는 것입니다. (=레이아웃의 가장 단순한, 가장 작은 처리 단위) 이 예제에서는 Column()으로 단순히 구현 가능할 수 있지만, 커스텀 레이아웃을 작성해야 하는 더 많은 경우가 있을 수 있습니다. 좀 더 자세한 내용은 Custom layouts 을 참조하세요.

여기서 확인한 일반적인 원칙은 여러 UI 요소를 서로 측정 하고 배치해야하는 위한 단일 진실 원천(sinlge source of truth) 입니다. 제대로 레이아웃 원시요소를 사용하거나 커스텀레이아웃을 생성하는 것은 부모에 최소 공유로 여러 요소간의 관계를 조직화하여 단일 진실 원천을 제공하는 것을 의미합니다. 동적 상태를 도입한다면 이 원칙은 깨집니다.

--

--

김종식

앱 개발자 / 꿈은 축구선수 / 쌍둥이 아빠