컴포즈 애니메이션 최적화

조회

이 글은 Restart Scope, inline Composable에 대한 지식이 있을 때 더 쉽게 읽을 수 있다. 위 개념들이 익숙치 않다면 아래 글을 참고하길 바란다. https://ao3921.tistory.com/9

 

나는 애니메이션을 참 좋아한다. 내 생각에 UX를 끌어올리는 가장 쉬운 방법이 애니메이션이라고 생각하기 때문이다. 그렇기에, 이번에 우아한테크코스에서 미션을 수행하다가 장바구니 담기 버튼에다가 애니메이션을 적용해서 UI를 구성했다.

ProductItem 애니메이션

ProductItem애니메이션 플로우를 만들고 애니메이션 중 리컴포지션은 몇 번 일어나는걸까? 하는 의문이 생겼다. 리컴포지션이 적으면 좋을수록 좋다는 사실은 알고 있지만, 애니메이션 중 리컴포지션이 많이 일어난다면 최적화 측면에서 굉장히 별로일 것 같다는 생각이 들었다.

해당 주제에 대해 공부하고 나서 깨달은 사실이지만, 컴포즈에서 애니메이션을 최적화하려면 ProductItem 컴포저블 함수를 적게 실행해야 한다는 사실을 깨달았다.

다시, 처음의 의문을 구체화하여 정리하자면 아래와 같다.

애니메이션을 감싸고 있는 ProductItem 컴포저블 함수는 몇 번 실행될까?


1 프레임은 세 단계로 처리된다

 

한 프레임의 구성단계를 설명하는 영상 일부, 출처 -  https://www.youtube.com/watch?v=SWBN0y0lFNY

 

Compose는 3 phase로 프레임을 구성한다.

  1. Composition - 어떤 위젯이 있을지를 결정한다. 컴포저블 함수 본문이 실행되는 단계가 바로 여기다.
  2. Layout - 그 위젯들이 화면 어디에, 얼마만 한 크기로 놓일지를 측정하고 배치한다.
  3. Draw - 실제 픽셀을 화면에 그린다.

위에서 아래 순으로 실행 비용이 작다. 그렇기에 최대한 리컴포지션, 즉 Composition 단계를 다시 실행하지 않아야 한다.

결국, state를 어느 phase에서 읽느냐가, 그 state가 바뀌었을 때 어느 phase부터 다시 도는지를 결정한다. Composition 단계에서 읽은 state가 바뀌면 세 단계를 전부 다시 돈다. 하지만 Draw 단계에서만 읽은 state가 바뀌면, Composition과 Layout은 건드리지 않고 Draw만 다시 돈다.

phase를 미룰수록 싸다. Composition보다 Layout이, Layout보다 Draw가 싸다. 그래서 잘 만든 애니메이션은 state read를 최대한 뒤쪽 phase로 미룬다.


미루는 도구는 람다다

read 시점을 미루는 트릭은 의외로 단순하다. 소괄호냐 중괄호냐의 차이다.

Modifier.offset(x = animatedDp) 처럼 소괄호로 값을 넘기면, 그 값은 이 코드가 적힌 자리, 즉 Composition phase에서 읽힌다. 애니메이션 값이 매 프레임 바뀌면 매 프레임 Composition부터 다시 돌아야 한다. → 이 상황을 가장 피해야 한다.

반면 Modifier.offset { IntOffset(animatedValue, 0) } 처럼 중괄호 람다로 넘기면, 그 람다는 Layout phase에 가서야 호출된다. 그래서 안에서 읽는 state도 Layout phase에서 읽힌다. Composition은 멀쩡히 그대로 있고, 값만 매 프레임 다시 읽힌다.

같은 일을 하는 코드처럼 보이지만 비용은 완전히 다르다. 괄호 모양 하나가 phase를 결정한다.


그럼 항상 람다를 쓰면 되는가

자연스럽게 드는 질문이다. 애니메이션에서 람다를 사용하는 것이 유리하다면 , Compose는 왜 offset(x.dp) 같은 값 버전 API를 굳이 남겨뒀을까. 답은 람다 버전이 항상 더 좋지는 않기 때문이다.

먼저 람다 자체에도 비용이 있다. 람다 객체 할당, 캡처된 변수 추적, phase마다의 호출들은 공짜가 아니다. 둘째로 람다 버전은 더 장황해서 가독성을 깎는다. 그리고 deferred read(지연된 읽기)의 이득은 "자주 바뀌는 state를 읽을 때만" 나온다.

그렇기에, offset(x.dp) 을 사용하되 x를 바꾸지 않는다면 람다를 사용하지 않는 것이 더 좋다고 할 수 있다. 값이 한 번 정해지고 안 바뀌면 Composition이 한 번도 일어나지 않으니, 람다든 값이든 비용이 같다.

반대로 값이 매 프레임 바뀌면 offset(x.dp) 은 매 프레임 Composition을 무효화하고, 람다 버전은 Layout/Draw로 한정한다. 그래서 상수거나 드물게 바뀌는 값이면 간결한 값 버전(소괄호 ())을, 애니메이션처럼 매 프레임 바뀌는 값이면 람다 버전(중괄호 {})을 쓴다.


이제 코드 두개를 보고 더 자세히 살펴보자.

Ripple

버튼을 누를 때 퍼지는 애니메이션인 ripple은 DrawModifierNode로 구현되어 있는데, 이건 Composition도 Layout도 거치지 않는 노드다.

아래 코드는 radius와 alpha를 표현하기위해 실제 코드와 다름을 알린다.

class RippleNode : DrawModifierNode {
    val radius = Animatable(0f)
    val alpha = Animatable(0f)

    override fun ContentDrawScope.draw() {
        drawContent()
        drawCircle(
            radius = radius.value,   // Draw scope 안에서만 read
            alpha = alpha.value,
        )
    }
}

radius와 alpha는 ContentDrawScope.draw { ... } 람다 안에서만 읽힌다. 위젯 트리는 그대로고, 버튼 크기도 그대로다. 변하는 건 오직 픽셀뿐이다.

phase별 비용:

  • Composition - 0번 (트리 그대로)
  • Layout - 0번 (버튼 크기 그대로)
  • Draw - 매 프레임 (물결만)

ProductItem

글 최상단에서 언급했던 ProductItem 의 애니메이션은 AnimatedContent 로 구현하였는데 내부적으로 아래의 코드로 구현하고 있다.

AnimatedContent(
...
  transitionSpec = {
      (fadeIn(tween(200)) + scaleIn(initialScale = 0.8f)) togetherWith
          (fadeOut(tween(150)) + scaleOut(targetScale = 0.8f))
  },
...
)

위 코드를 조금 더 쉽게 표현하자면 아래와 같다. 내부적으로 fadeIn·scaleIn 같은 트랜지션을 Modifier.graphicsLayer { ... } 람다 안에서 처리하기 때문이다.

Modifier.graphicsLayer {
    alpha = transition.alpha   // Draw phase 람다 안에서 read
    scaleX = transition.scale
    scaleY = transition.scale
}

graphicsLayer의 람다는 Draw phase에서 호출된다. 그래서 alpha와 scale을 매 프레임 다시 읽어도, 그 read는 Draw phase로 위임된다. 상위 Composition은 다시 돌 필요가 없다. 이것이 ProductItem이 딱 한 번만 실행되는 이유다.

AnimatedContent의 phase별 비용을 정리하면:

  • Composition - 1번 (위젯 교체가 일어나는 그 순간 한 번)
  • Layout - 매 프레임 (들어오고 나가는 콘텐츠 크기를 보간해야 하므로)
  • Draw - 매 프레임 (alpha·scale)

Composition은 단 한 번이고, 매 프레임 도는 건 Layout과 Draw뿐이다, alpha와 scale을 바꾸는 건 Draw 페이즈로 충분하지만 AnimatedContent에서 “보간”을 실행하여 Layout 페이즈를 거친다. 해당 개념은 다른 글에서 다루겠다.

같은 컴포넌트, 다른 비용

두 애니메이션을 나란히 놓으면 차이가 분명해진다.

애니메이션 Composition Layout Draw
Ripple (크기 불변, 픽셀만 변함) 0번 0번 매 프레임
AnimatedContent (위젯 교체 + 크기 보간) 1번 매 프레임 매 프레임

공통점은 둘 다 ProductItem 함수 본문은 다시 돌지 않는다는 것이다. 컴포저블 호출은 0회다. 차이점은 ripple이 더 가볍다는 것인데, 이유는 변하는 게 픽셀뿐이라 Layout마저 건너뛰기 때문이다.

여기에서 애니메이션을 만들 때 Layout도 피하고 Draw만 하도록 한다는 점을 유념해야 한다. 위치나 크기가 꼭 변해야 하는 애니메이션이 아니라면, alpha·color·scale처럼 Draw에서 처리 가능한 속성으로 표현하는 게 싸고 빠르다.


이 애니메이션은 Layout까지인가, Draw만인가

이 글을 작성하면서 Layout 단계에서 실행되는지 Draw에서 실행되는지를 애니메이션이 부모 레이아웃이 알아야 하는 위치·크기를 바꾸는가, 아니면 본인의 시각적 모습만 바꾸는가. 로 구분하길 제시하지만 쉽게 와닿지는 않았지만 아래를 보고 더 쉽게 이해할 수 있었다.

  • 위치/크기가 변해 부모가 영향받음 → Layout + Draw animateContentSize(), size { } 애니메이션처럼 측정값(measure)이나 배치 좌표(placement)가 바뀌면 Layout부터 다시 돌고, Draw는 따라서 돈다.
  • 본인의 시각적 모습만 바뀌고 부모는 모름 → Draw graphicsLayer { translationX = ... }는 위치를 시각적으로 옮긴다고 해도 부모 레이아웃은 원래 자리에 있다고 믿는다. scale도 마찬가지로, 키워도 부모는 원래 크기로 인식한다. alpha·rotation·color처럼 외형만 바뀌는 경우도 여기 속한다.

어떻게 검증하나

말은 그럴듯한데, 정말 ProductItem이 다시 안 도는지 어떻게 확인할까. Android Studio의 Layout Inspector의 Recomposition Count를 쓰면 된다.

Layout Inspector

애니메이션이 도는 동안 ProductItem의 카운트가 오르지 않으면 성공이다. 만약 카운트가 오른다면, 어딘가에서 람다 밖에서 state를 읽고 있다는 신호다. 괄호로 넘겨야 할 걸 직접 넘겼거나, Draw에 미룰 수 있는 값을 Composition에서 읽고 있는 것이다.

Layout Inspector의 Count는 Composition phase만 센다.

Layout과 Draw에서 일어나는 변화는 이 카운트에 잡히지 않는다. 그래서 카운트가 오르지 않더라도 Layout과 Draw는 거친다. 카운트를 보고 Composition이 폭주하지 않도록 검증하자


정리

phase를 미룰 수 있으면 미뤄라. Composition보다 Layout이, Layout보다 Draw가 싸다.

이제 다시 처음 의문으로 돌아가보자.

애니메이션을 감싸고 있는 ProductItem 컴포저블 함수는 몇 번 실행될까?

이제 이 의문에 답변할 수 있다. 최초의 Composition 한번을 제외하면 ProductItem 애니메이션 중에는 Composition은 단 한번도 일어나지 않는다.

댓글

홈으로 돌아가기

검색 결과

"search" 검색 결과입니다.