https://www.jetpackcompose.app/articles/donut-hole-skipping-in-jetpack-compose

 

What is “donut-hole skipping” in Jetpack Compose?

🍩 Learn how Jetpack Compose is able to be smart during recompositions!

www.jetpackcompose.app

해당 글은 해당 article을 번역한 글입니다.

 

 

Recomposition

Recomposition은 상태가 변경될 때 Composable 함수를 재호출하는 프로세스이다. Compose가 새 상태를 기반으로 재구성할 때 변경되었을 수 있는 함수 또는 람다만 호출하고 나머지는 건너 뛴다. 상태가 변경되지 않는 모든 함수 또는 람다를 건너뛰면 Compose가 효과적으로 재구성될 수 있다. 

high level에서 input이나 @Composable 함수의 상태가 변경될 때마다 최신 변경 사항이 반영되도록 함수를 다시 호출하는 것은 중요하다. 이 동작은 Jetpack Composer가 작동하는 방식에 매우 중요하며 이 반응적 특성이 프레임워크의 일급 시민이기 때문에 Jetpack Compose를 매우 강력하게 만드는 요소이다. 이것을 지나치게 단순화한다면 고전적인 Android View 시스템에 익숙한 사람이라면 누구나 invalidate()의 최신 상태가 View화면에 표시되도록 하는 데 사용된 메서드를 기억할 것이다.

 

이것은 중요한 뉘앙스와 함께 효과적으로 Recomposition이 담당하는 것이다. 스마트 최적화를 사용하여 가능하면 중복 작업을 피할 수 있으므로 이전 UI 툴킷보다 훨씬 더 똑똑하다. 이는 또한 자동으로 발생하기 때문에 함수를 호출할 필요가 없다. 그런 의미에서 Recomposition 작업의 몇 가지 예를 살펴보고 이 게시물의 시작 부분에서 언급한 최적화로 이어질 수 있길 바란다.

 

Example1

@Composable
fun MyComponent() {
    var counter by remember { mutableStateOf(0) }
    CustomText(
        text = "Counter: $counter",
        modifier = Modifier
            .clickable {
                counter++
            },
    )
}

@Composable
fun CustomText(
    text: String,
    modifier: Modifier,
) {
    Text(
        text = text,
        modifier = modifier.padding(32.dp),
        style = TextStyle(
            fontSize = 20.sp,
            textDecoration = TextDecoration.Underline,
            fontFamily = FontFamily.Monospace
        )
    )
}

컴포저블 함수 MyComponent를 생성하였다. 이 함수는 값을 유지하기 위해 count 프로퍼티를 선언하고 state 객체를 초기화하고 있다. 이 counter는 Text 컴포저블에 의해 랜더링 되고, text를 탭할 때마다 counter가 증가한다. 우리가 주목해야 할 부분은 이 함수의 어느 부분이 다시 호출되는 지이다. 이것을 더 조사하기 위해 log문을 사용할 것이다. 우리는 리컴포지션이 일어날 때만 이러한 로그 문을 트리거 하길 원한다. 이것은 성공적인 리컴포지션이 발생할 때마다 다시 호출되는 컴포저블 함수인 SideEffect의 완벽한 사용 사례이다. 여러군데에서 재사용이 필요하므로 함수를 작성해보자

 

class Ref(var value: Int)

// Note the inline function below which ensures that this function is essentially
// copied at the call site to ensure that its logging only recompositions from the
// original call site.
@Composable
inline fun LogCompositions(tag: String, msg: String) {
    if (BuildConfig.DEBUG) {
        val ref = remember { Ref(0) }
        SideEffect { ref.value++ }
        Log.d(tag, "Compositions: $msg ${ref.value}")
    }
}

 

@Composable
fun MyComponent() {
    val counter by remember { mutableStateOf(0) }

+   LogCompositions("JetpackCompose.app", "MyComposable function")

    CustomText(
        text = "Counter: $counter",
        modifier = Modifier
            .clickable {
                counter++
            },
    )
}

@Composable
fun CustomText(
    text: String,
    modifier: Modifier = Modifier,
) {
+   LogCompositions("JetpackCompose.app", "CustomText function")

    Text(
        text = text,
        modifier = modifier.padding(32.dp),
        style = TextStyle(
            fontSize = 20.sp,
            textDecoration = TextDecoration.Underline,
            fontFamily = FontFamily.Monospace
        )
    )
}

이 예제를 실행하면 카운터 값이 변경될 때마아 My Component와 Custom Text가 모두 리컴포지션 된다는 걸 알 수 있다. 이것을 염두에 두고 다른 예제를 통해 두 행동을 비교해보도록 하자

 

Example2

@Composable
fun MyComponent() {
    val counter by remember { mutableStateOf(0) }

    LogCompositions("JetpackCompose.app", "MyComposable function")

   Button(onClick = { counter++ }) {
       LogCompositions("JetpackCompose.app", "Button")
        CustomText(
            text = "Counter: $counter")
	}
}

약간의 차이를 두고 이전 예제를 재사용하고 있다. 클릭 로직을 처리하기 위해 Button 컴포저블을 도입했고 CustomText 함수를 Button 함수의 범위 안으로 이동시켰다. 또한 Button의 람다가 실행 여부를 체크하기 위해 Button 함수의 범위 안에 로그를 추가하였다. 예제를 실행해보면 로그은 다음과 같다

 

여기에 흥미로운 부분이 있다. MyComponent의 body는 Button 컴포저블의 람다와 CustomText 컴포저블과 함께 첫 컴포지션 동안에 실행된다. 그러나 이후 모든 리컴포지션은 MyComponent의 body를 완전히 건너뛰고 Button의 body와 CustomText 컴포저블에서만 발생한다.

 

 

 

Recomposition Scope

 

컴포즈가 재구성을 최적화할 수 있는 방법을 이해하려면 예제에서 사용하는 컴포저블 함수의 범위를 고려하는 것이 중요하다. 컴포즈는 이러한 컴포저블 범위를 내부적으로 추적하고 보다 효율적인 재구성을 위해 구성 컴포저블 기능을 더 작은 단위로 나눈다. 그런 다음 변경될 수 있는 값을 읽고 있는 범위만 리컴포지션하기 위해 최선을 다한다. 이에 대해 이해하기 위해 아래 두 가지 그림을 통해 살펴보도록 하자

 

리컴포지션 범위가 있는 예제1

첫 번째 예제에서 몇 개의 람다 범위, 즉 MyComponent함수의 범위와 CustomText함수의 범위가 있음을 알 수 있다. 또한 CustomText는 MyComponent 함수의 람다 범위 안에 있다. counter 값이 변화할 때, 두 범위가 모두 다시 재호출되고 있음을 확인할 수 있으며 그 이유는 다음과 같다

 

  • Custom Text : counter 값을 포함하여 텍스트 매개변수가 변경되었기 때문에 리컴포지션 된다.
  • MyComponent: 람다 범위가 counter state 객체를 캡처하고 리컴포지션 최적화를 시작하는 데 더 작은 람다 범위를 사용할 수 없기 때문에 리컴포지션 된다.

여기서 "더 작은 람다 범위를 사용할 수 없다"라고 말한 것의 의미는 무엇을까? 다음 예제를 통해 살펴보도록 하자

 

리컴포지션 범위가 있는 예제2

이 예제를 보면, 앞에서 설명했다시피 counter 값이 업데이트 될 때 MyComponent를 모두 건너 뛰고 Button의 body(람다)와 CustomText만 다시 호출된다는 것을 알았다.

  • MyComponent 범위 안에서 counter 초기화가 있더라도 적어도 이 부모 범위에서 직접적으로 해당 값을 읽지 않는다.
  • Button 범위는 counter 값을 읽고 CustomText 컴포저블의 매개변수로 전달한다. 

Compose Runtime은 counter를 읽고 있는 더 작은 범위(Button 범위)를 찾을 수 있기 때문에 MyComponent의 범위 호출을 건너뛰고 Button 범위(값을 읽는 곳)와 CustomText 범위(매개변수로 변경)를 호출한다. 사실, 이는 또한 Button 컴포저블 호출하는 것도 건너뛴다. 즉, 범위(람다)에 대한 호출이지, Button 컴포저블 자체의 호출이 아니라는 의미이다.

 

 

 

도넛이 이 모든 것과 무슨 관련이 있을까?

 

컴포저블 함수는 내부적으로 더 작은 도넛으로 구성된 도넛으로 구성되어 있다고 생각할 수 있다. 이것은 Compose 팀이 리컴포지션과 관련된 최적화를 설명하는 데 사용해온 은유이다. 컴포저블 함수 자체는 도넛을 나타낼 수 있지만 범위는 도넛의 구멍이다. 가능할 때마다 컴포즈 런타임은 변경된 값을 읽지 않는 경우 "도넛" 실행을 건너뛰고(매개변수도 변경되지 않았다고 가정) "도넛 구멍"만 실행한다. 이 시각으로 예제2의 컴포저블을 시각화하고 재구성되는 방법을 살펴보도록 하자. 체크 무늬가 있는 것은 모두 재구성되었음을 나타낸다.

 

이러한 컴포저블 함수들이 컴포즈되기 전의 상태
Counter = 0 첫번째 컴포즈, 모든 컴포저블 함수 및 해당 범위(람다)가 실행되고 컴포즈됨
Counter = 1 Button Scope과 CustomText&CustomText Scope만 리컴포지션됨. MyComponent, MyComponent Scope 및 Button은 리컴포지션을 건너뜀

 

Example3

 

Leland Richardson (Jetpack Compose 개발을 주도하는 개발자 중 한 명)은 도넛 구멍 건너뛰기에 대한 컨텍스트를 추가했다. 이전 두 가지 예를 기반으로 또 다른 예를 살펴봄으로써 Leland가 아래 글에서 말하려고 하는 내용을 이해해보도록 하자

"Donut hole"이라는 용어는 "donut hole caching"이라는 용어에서 따온 것이다. 이것은 웹에서 배운 방식으로  작은 부분(예: 날짜 또는 사용자 이름)을 제외하고 대부분의 웹 페이지를 캐시할 수 있다. "donut hole skipping"의 일부분은 컴포저블(예: Button)에 전달되는 새 람다가 이를 제외한 나머지 부분을 다시 컴파일하지 않고도 리컴포지션할 수 있다는 것을 의미한다. 하지만 람다가 리컴포지션 범위라는 사실은 이 작업을 수행하는데 '필요'하지만 '충분'하지는 않다. 

즉, 컴포저블 람다는 '특별'하다

람다가 상태 객체(state objects)이고 호출(invoke)이 읽기라면 이것이 바로 그 결과라는 놀라운 깨달음을 얻는다.

@Composable
fun MyComponent() {
    val counter by remember { mutableStateOf(0) }

    LogCompositions("JetpackCompose.app", "MyComposable function")

   val readingCounter = counter
   CustomButton(onClick = { counter++ }) {
        LogCompositions("JetpackCompose.app", "CustomButton scope")
        CustomText(
            text = "Counter: $counter",
            modifier = Modifier
                .clickable {
                    counter++
                },
        )
    }
}

이전의 예제를 약간 수정했다. 우선 Button을 CustomButton 컴포저블로 교체했고, 두번째로 MyComponent 함수의 최상위 범위에서 counter의 값을 읽는다. CustomButton 함수 구현도 살펴보자

 

@Composable
fun CustomButton(
    onClick: () -> Unit,
    content: @Composable () -> Unit
) {
    LogCompositions("JetpackCompose.app", "CustomButton function")
    Button(onClick = onClick, modifier = Modifier.padding(16.dp)) {
        LogCompositions("JetpackCompose.app", "Button function")
        content()
    }
}

CustomButton 아래에 있는 Button 컴포저블을 호출하기만 하는 매우 간단한 컴포저블 함수다. 이것을 별도의 함수로 만든 이유는 함수에 일부 로그를 추가하여 이전 예제에서와 같이 모니터링하고 통찰력을 얻을 수 있도록 하기 위함이다. 

 

예제를 돌려보면 예상대로 모든 함수가 첫 번째 컴포지션 중에 호출되었음을 알 수 있다. 그러나 counter값이 변경될 때마다 CustomButton 함수와 Button 함수를 모두 건너 뛴다. 이전 예제에서 Compose는 변경 가능한 상태 객체를 읽지 않거나 입력이 변경되지 않는 컴포저블 함수를 건너뛰는 것이 현명하다고 배웠다. 이 예는 동일한 관찰을 반복한다. 그러나 Jetpack Compose의 동작에는 강조할 가치가 있는 고유한 것이 있으며 이 예제의 로그만 보면 놓쳤을 것이다. 이를 시각화하기 위해 예제3의 도넛 다이어그램을 살펴보자

 

함수가 컴포지션 되기 전의 상태
Counter = 0 첫 번째 컴포지션, 모든 컴포저블 및 해당 범위(람다)가 실행되고 컴포지션 됨

 

Counter = 1 CustomButton을 제외한 모든 항목이 리컴포지션됨

 

Compose를 특별하게 만드는 것은 부모(MyComponent)와 자식(Custom Text)를 리컴포지션하면서 CustomButton의 리컴포지션을 건너뛸 수 있다는 사실이다. 이것은 독특하다. 왜냐하면 다른 대부분의 선언적 시스템에서는 리컴포지션해야 하는 가장 작은 하위 트리를 식별하고, 해당 하위 트리 내의 노드를 건너 뛰지 않고 전체를 다시 호출하기 때문이다.

 

즉, Compose의 최적화가 "donut-hole-skipping"라고 하는 이유는 리컴포지션아 필요 없는 하위 트리 노드를 건너뛸 수 있기 때문이다.

'안드로이드 > UI' 카테고리의 다른 글

Compose 생명주기  (0) 2022.07.31
Compose 상태관리  (0) 2022.07.14
Compose란 무엇인가  (0) 2022.07.14
복사했습니다!