Compose함수 프레임 렌더링 3단계

SSY·2024년 5월 27일
0

Compose

목록 보기
5/15
post-thumbnail

시작하며

Text, Button, Column, Row등, 기본 Compose API만으로 아래와 같은 화면을 개발할 수 없기에 Layout()을 활용한 Custom Layout 구축을 시도할 수 있다. 하지만 이런 Custom한 UI 구축을 위해 Layout()에 대한 이해가 필요하다.

출처 : Google-JetLagged-Sample App

앱을 키고 Composable 함수 UI가 로딩된다. 그러면서 1개의 장면 즉, 1frame을 그리게 되는데, 이들이 빠른 속도로 그려지면서(버벅임 없는 UI 기준, 60fps) 사용자 눈엔 화면이 움직이는걸로 인지한다.

이때, 1개의 frame을 그러기 위해서 Compose함수는 3 단계의 프레임 렌더링 과정을 거친다. 단계로는 크게 Composition - Layout - Draw가 있으며, 어떤 화면을 그릴지?(What To Show) 레이아웃의 크기와 위치는 어떻게 할지?(How To Place) 어떻게 그릴지?(How To Render It)를 각각 결정한다.

출처 : 안드로이드 공홈

1단계 : Composition : What To Show

앱 하나의 화면이 그려지기 위해선 먼저 'Layout Node Tree'가 그려져야 한다. (출처 : 안드로이드 공홈) 위 소제목에도 적어놓았듯이, 이 Tree를 생성하여, What To Show, 즉, 한 화면에 어떤 컴포즈 함수 요소들을 그릴지 결정한다. 이렇게 그려진 'Layout Node Tree'는 추후 '2단계 : Layout'단계때 사용되며, 해당 컴포즈 함수들이 사이즈와 배치 위치가 결정된다.

추가로 compose API에서 제공해주는 여럿 컴포즈 함수들(ex. Text, TextField, Column, Row, Button...)이 있다. 이들은 위 이미지와 마찬가지로 'Layout Node Tree'에 당연히 포함이 되는데, 어떻게 그럴 수 있을까?

'Layout Node Tree'에 등록되기 위해선, Layout()컴포즈 함수를 내부적으로 구현하고 있어야만 하며, 1개의 Layout()컴포즈 함수가 'Layout Node'의 1개의 단위가 된다. 즉, 위해서 말한 컴포저블 함수들은 모두 내부적으로 Layout()컴포즈 함수를 구현하고 있다는 뜻이고, 'Layout Node Tree'에 포함될 수 있으며 컴포즈 화면의 구성요소로 포함될 수 있는 것이다.

출처 : Advanaced Layout concept

2단계 : Layout : Where To Place

1단계에서 만들어진 Layout Node Tree를 수신받은 후, 본인과 자식의Composable함수를의 사이즈를 '측정'하고 x, y좌표를 사용한 '배치'를 수행하는 단계이다.

Tree형태의 Composable함수가 존재한다 가정해보자. 이때, 하나의 Composable함수가 자식 Composable함수를 측정한다. 그 후, 자식 Composable함수는 또 다시 자식을 측정하는데 이때 자식이 없다면, 자신의 size를 결정하고 이를 부모 Composable함수에게 보고한다. 그 후, 부모 Composable함수가 모든 자식들로부터 size를 보고받게 되면 자신의 size를 결정하고 이를 또 다시 부모 Composable함수에게 보고하는 순환을 반복한다.

(출처 : 안드로이드 공홈)

위 소제목에 Where To Show라고 적어놓은 것처럼, Layout 단계는 Layout Node Tree를 사용한 Composable 함수들의 크기 측정이 완료되면 이들이 어디에 배치되는지 결정하는 단계인 것이다. 이렇게 최종적으로 '측정'과 '배치'가 끝나면 '3단계 : Drawing'단계때 최종적으로 UI가 그려지게 된다.

이때, Custom한 Layout을 구현한다면 2가지 방법이 있다. 첫 번째는 Layout Composable함수를 사용하는 것이며, 두 번째는 Modifier.layout 확장 함수를 사용하는 것이다.

[복수개의 Composable함수를 포함한 UI의 구현]
Column()이나 Row()도 그렇듯, Layout() Composable함수를 사용한다면 자식들 Composable 함수들을 배치하도록 할 수 있다. 이를 Sample Code로 나타내면 아래와 같다.

@Composable
fun CustomLayout(
  content: @Composable () -> Unit,
  // …
) {

  // 1. `Layout()`을 호출 및 `measurePolicy`파라미터 내 '측정'과 '배치'코드를 작성 준비
  Layout(
    content = content,
    modifier = modifier
  ) { measurables, constraints ->
  // 1.1. measureables파라미터로 측정 가능 한, 하위 Composable함수를 받으며, constraints파라미터로 상위 Composable함수의 제약을 받음

	// 1.2. 측정 : `measureable`파라미터를 통해 `measure()`를 호출 및 `placeable()`를 반환받음
    val placeables = measurables.map { measurable ->
      // Returns a placeable
      measurable.measure(constraints)
    }
    
    // 1.3. `MeasureScope.layout()` 호출 및 `placementBlock` 내에서 '배치'를 준비
    layout(totalWidth, totalHeight) {
    
      // 1.4. 배치 : `placementBlock`파라미터 안에 `placeable.place()`를 호출하여 측정을 수행
      placeables.map { it.place(xPosition, yPosition) }
    }
  }
}

출처 : advanced layout concept

[1개의 Composable함수에 대한 구현]
Modifier.layout { ... }는 해당 Modifier체인이 적용된 Composable함수에만 적용이 가능하다. (즉, 자식 구조는 가질 수 없다.) 아래와 같은 상황이 발생했다 가정해보자.

Column() 하위 Composable로 4개의 가로 막대기가 있다. 이들 중, 3번째 자식만 부모의 Constraints를 없애고자 한다.

이런 상황엔 기존 Layout()메서드와 사용법이 유사한 Modifier.layout()을 사용함으로써 3번째 자식 컴포즈 함수의 '사이즈'를 변경할 수도 있다

@Composable
fun LayoutModifierExample() {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .background(Color.LightGray)
            .padding(40.dp)
    ) {
        Element()
        Element(modifier = Modifier.layout { measurable, constraints ->
        	// step1. '측정' 수행
            val placeable = measurable.measure(
                constraints.copy(
                    maxWidth = constraints.maxWidth + 80.dp.roundToPx()
                )
            )
            layout(placeable.width, placeable.height) {
            	// step2. '배치' 수행
                placeable.place(0, 0)
            }
        })
        Element()
        Element()
    }
}

위 코드로 인해 아래와 같은 UI가 만들어진다.

어떻게 그럴까? 위에서도 설명했다시피, Layout() Composable함수의 람다 파라미터 중, constraint즉, 부모의 제약사항을 받아오는 파라미터가 기본으로 설정되기에 부모 제약을 그대로 따르는 것이다. 하지만 이를 Custom하게 변경 즉, constraints적용을 그대로 따르는 게 아닌, 부모 제약에 80dp를 더해주고 있다. 이로 인해 부모의 제약을 어길 수 있는 것이다.

3단계 : Drawing : How To Render It

1단계에선 Layout 함수 기반으로 'Layout Node Tree'를 그려 어떤 UI를 그릴지 결정했다. 2단계에선 'Layout Node Tree'의 크기와 위치를 결정했다. 이를 통해 UI의 윤곽이 잡힌 셈인데, 이제 남은 3단계는 실제로 UI를 화면에 렌더링하는 단계이다.

이 단계에서 안드로이드 시스템은 'Lyaout Node Tree'를 재순회하며 각 노드의 위치와 크기 정보를 기반으로 GPU에 그리기 명령을 전달한다. 하지만 이때의 명령만으론 UI가 즉시 표현되지 않는다. 왜 그럴까?

핸드폰엔 몇 천만개의 픽셀이 존재하며, GPU는 이들에 대한 연산을 모두 끝내야만 한다. 이렇게 끝난 연산들은 GPU buffer에 들어가고 이들이 100% 찼을 때, 1 frame 단위의 픽셀 데이터는 Display Pannel로 복사된다.

이는 또 다시 안드로이드 시스템 영역에 있는 프레임 스케줄러인 Choreographer에게 vsync(vertical synchronization)를 전송하는데, 이때 만약, vsync 타이밍이 16.67ms안에 전송되었다면 UI Thread에게 그리기 예약 작업을 전달한다. (만약 vsync 타이밍이 16.67ms보다 느리다면 Choreographer는 해당 프레임을 삭제한다)

그리기 예약 작업을 받은 UI Thread는 Compose 런타임에게 Canvas API(eg.,drawBehind {...})를 호출함으로써 UI가 표현된다.

frame rendering 3단계를 알아야하는 이유

가장 중요하게 알아야 할 사실은 Composition -> Layout(1. Measurement 2. Placement) -> Draw단계는 단방향 흐름으로, 이미 진행된 렌더링 단계에선 이전 단계로 역행할 수 없다는 것이다. 그러기에 Layout or Placement단계에서 Recomposition을 유발하는 코드(eg., '측정' '배치' '그리기' 수행 단계 때, Composable 상태값 변경 후, 이를 Composable 함수 파라미터로 주입)를 작성한다면, 이는 1개의 frame을 더 반복하게되며, 이는 성능적 저해를 유발한다.

출처 : (안드로이드 공식 홈페이지) Jetpack Compose단계

또한 3단계 중, 가장 큰 비용이 드는 단계는 Composition인데, 이는 생략이 가능하다. 따라서 우리가 하려는 작업이 '너비/높이의 변경' or 'x/y좌표 변경' or '색상의 변경'작업만 존재할 경우, Composition단계를 건너뛰도록 함이 타당하다란 것이다.

아래 게시글은 내가 Layout단계 때 Recomposition을 유발하는 문제를 개선한 블로그 글이다.
👉 Composable함수 상태값 전달에서, Field vs Lambda 주입의 차이

따라서 아래 요소들을 체크해봐야 하며 그에 맞는 코드 작성이 필요하다.

  1. 우리가 하려는 작업이 Composition의 유발 및 새로운 Composable 함수 컴포넌트들을 '생성' or '삭제'해야하는 작업인가?
    👉 Composable 함수들의 신규 표현/삭제 가 이뤄져야 하므로 Composition단계를 유발하는 것이 타당하다.

  2. 우리가 하려는 작업이 Composable 함수들의 높이/너비, x/y좌표를 변경하는 것인가?
    👉 Layout단계 때 상태값을 읽는 함수들(Layout { ... } or Modifier.layout { ... } or offset { ... }등)을 사용하여 Composition단계를 건너뛴다.

  3. 우리가 하려는 작업이 Composable 함수들의 색상(eg., 배경색)을 변경하는 것인가?
    👉 Draw단계 때 상태값을 읽는 함수들(Modifier.drawBehind { ... } or Canvas{ ... }등)을 사용하여 Composition단계를 건너뛴다.

추가
일반적인 Composable함수들은 3단계가 역행 불가한 단방향 흐름이라 했지만, SubcomposeLayout은 이를 어기는 특별한 Custom한 레이아웃이다. 이를 작성한 글이다.
👉 Layout과 SubcomposeLayout

요약

  • 1 phase, Composition: Layout()함수 기반, 'Layout Node Tree'생성
  • 2 phase, Layout: 'Layout Node Tree'내, Composable 함수들의 크기와 위치 계산
  • 3 phase Draw: 그리기 명령을 GPU에 전달 및 GPU buffer가 100% 찼을 때, vsync 신호가 Choreographer로 전달. 그 후, UI 스레드 렌더링 후, Canvas API의 호출로 UI가 그려짐
  • Composition단계를 적절히 건너뛰는 코드를 작성하자.
profile
불가능보다 가능함에 몰입할 수 있는 개발자가 되기 위해 노력합니다.

0개의 댓글