Jetpack Compose 최고의 성능을 위한 Best Practice

컴포즈 성능 최적화 하기

Ji Sungbin
성빈랜드
12 min readJul 15, 2022

--

Photo by Dušan veverkolog on Unsplash

Jetpack Compose 는 아주 많은 리컴포지션과 여러 상태의 추적을 받고 있기 때문에 성능 최적화가 중요합니다. 제가 이번에 소개할 성능 최적화는 크게 4가지로 분류됩니다.

  1. 리컴포지션 범위 줄이기
  2. 리컴포지션 없애기
  3. 불필요한 상태 추적 제거
  4. AOT 컴파일 활성화

차례대로 알아보겠습니다.

리컴포지션 범위 줄이기

State 를 최대한 늦게 읽기

아래와 코드와 같이 number state 를 setContent 상단에서 읽어서 text 로 계산하고 있고, Button 에서 onClick 으로 number++ 연산을 해주고 있습니다.

만약 버튼이 눌러서 number 값이 증가된다면 리컴포지션이 어느 컴포저블에 진행될까요?

  1. setContent content + Button + Text
  2. Button + Text
  3. Text

정답은 1번으로 setContent content, Button, Text 모두 리컴포지션이 진행됩니다. number 를 setContent 최상단에서 읽고 있기에 number 의 값이 변경되면 가장 먼저 영향을 받는 범위인 setContent 자체가 리컴포지션이 진행됩니다.

number 를 사용하고 있는 Text 만 리컴포지션을 진행하기 위해선 number 를 Text 에서만 읽어야 합니다.

이렇게 하면 number 가 변경되면 Text 만 리컴포지션이 진행됩니다. 이는 도넛홀 건너뛰기의 일부로 더 자세한 내용과 작동 원리에 대해선 “Jetpack Compose 리컴포지션 최적화: 도넛홀 건너뛰기” 를 참고해 주세요.

안정성 시스템

다음은 안정성 입니다. 이 내용은 컴포즈 안정성 시스템의 이해가 필요합니다. 안정성 시스템에 미숙하신 분은 “Jetpack Compose 리컴포지션 최적화: 안정성 시스템” 글을 먼저 읽어주세요.

간단히 Text 를 래핑하는 TextWrapper 컴포저블이 있습니다. 만약 number 값이 변경된다면 리컴포지션이 어느 컴포저블에 진행될까요?

number 을 읽는 최소한의 컴포저블인 TextWrapper 와(TextWrapper 안에 있는 Text 도 포함) 이에 영향을 받는 setContent 까지 모두 리컴포지션 됩니다. 코드를 아래와 같이 변경해 보겠습니다.

TextWrapper 에서 text 를 람다로 받게 TextWithLambda 로 변경하였습니다. 이 코드에서는 number 의 변경이 일어나면 TextWithLambda 안에 있는 Text 만 리컴포지션이 진행됩니다.

text 를 String 에서 람다로만 바꿨을 뿐인데 왜 이런 큰 차이가 생기는 걸까요? 정답은 안정성에 있습니다. String 은 값이 바뀔 때 마다 새로운 인스턴스가 만들어져서 TextWrapper 인자 값이 달라지기 때문에 매번 TextWrapper 전체를 리컴포지션을 진행합니다. 하지만 람다의 경우에는 람다 내부 값이 달라져도 Function0<String> 으로 래핑돼 있기 때문에 매번 같은 인스턴스를 유지합니다(즉, 람다 인스턴스 자체는 불변합니다). 따라서 TextWithLambda 컴포저블은 항상 리컴포지션을 건너뛰게 되고, 안에서 람다가 invoke 되고 있는 Text 만 리컴포지션이 진행됩니다.

이렇게 자주 바뀌는 값을 람다로 전달하기만 해도 많은 리컴포지션 생략이 가능합니다.

리컴포지션 없애기

다음으로 리컴포지션을 없애는 방법을 다뤄보겠습니다.

movableContentOf

간단하게 column 과 row 를 전환하는 컴포저블이 있습니다.

이는 예상 하셨겠지만 column/row 전환 할 때마다 리컴포지션이 진행됩니다. 이렇게 위치가 변경될 수 있는 컴포저블을 movableContentOf 로 감싸주면 위치 이동에는 리컴포지션이 되지 않고 기존에 컴포지션된 정보를 재사용하게 됩니다.

movableContentOf 는 컴포즈 1.2.0-alpha03 버전에 처음 등장했습니다. movableContentWithReceiverOf 를 이용하여 receiver 있는 content 도 받을 수 있습니다.

이는 LookaheadLayout 과 사용하면 최고의 효과를 볼 수 있습니다. LookaheadLayout 은 아래 글을 참고해 주세요.

@ReadOnlyComposable

어노테이션 이름에서부터 알 수 있듯이 값을 읽기만 하는 컴포저블에 사용하면 해당 컴포저블에 대한 그룹이 생성되지 않고 슬릇 테이블에서 공간을 차지하지 않기 때문에 리컴포지션을 비롯해 불필요한 관련 로직이 생성되지 않습니다.

따라서 성능 최적화를 기대할 수 있고, 머터리얼 테마 변수들과 리소스를 가져오는 컴포저블에 주로 적용돼 있습니다.

@NonRestartableComposable

이번에는 NonRestartableComposable 이며 이번에도 이름에서 알 수 있듯이 자체적으로 리컴포지션 될 수 없는 컴포저블에 사용됩니다. 좀 더 명확하게 하기 위해 주로 사용되고 있는 코드를 보겠습니다.

SideEffect handler 들은 컴포저블 호출을 허용하지 않기 때문에 무조건 컴포저블이 아닌 일반 함수만을 사용함으로 “리컴포지션” 이라는 개념 자체가 성립하지 않습니다. 또한 Image 는 내부에서 다른 Image 로 델리게이트 하고 있으므로 Image 컴포저블 자체로는 리컴포지션이 진행될 조건을 가지고 있지 않습니다.

일반 컴포저블들은 RestartableGroup 으로 감싸져 자체의 리컴포지션 스코프를 할당받지만, @NonRestartableComposable 이 붙은 컴포저블은 ReplaceableGroup 으로 감싸져서 자체의 리컴포지션 스코프를 갖지 않습니다.

따라서 자체적으로 리컴포지션을 수행하는 불필요한 로직이 사라져서 성능 최적화를 기대할 수 있습니다.

[08. 26. 업데이트] @NonRestartableComposable 에 대해 이해가 쉽지 않은거 같아 내용을 추가합니다.

@NonRestartableComposable 의 공식 설명을 보면 다음과 같습니다.

This annotation can be applied to Composable functions in order to prevent code from being generated which allow this function’s execution to be skipped or restarted. This may be desirable for small functions which just directly call another composable function and have very little machinery in them directly, and are unlikely to be invalidated themselves.

이 주석은 이 함수의 실행을 건너뛰거나 다시 시작할 수 있도록 하는 코드가 생성되는 것을 방지하기 위해 구성 가능한 함수에 적용할 수 있습니다. 이것은 다른 구성 가능한 함수를 직접 호출하고 그 안에 직접적으로 기계가 거의 없으며 자체적으로 무효화될 가능성이 없는 작은 함수에 대해 바람직할 수 있습니다.

@NonRestartableComposable 을 이해하기 위해선 위 설명에서 강조돼 있는 “자체적으로 무효화될 가능성이 없는” 이 문장을 이해해야 합니다. 우리는 도넛홀 건너뛰기에서 봤듯이 모든 컴포저블은 자체적으로 RecomposeScope 을 형성한다는 것을 알고 있고, 컴포즈는 RecomposeScope 에 invalidation 이 요청되면 해당 RecomposeScope 에 있는 (리컴포지션 돼야 하는) 모든 컴포저블을 재호출함으로써 리컴포지션을 수행합니다. 추가 정보로 이를 응용해서 State 의 변경 없이 리컴포지션을 수행할 수도 있습니다.

생각보다 유용하게 쓰일 수 있는 트릭입니다

이제 내부에서 직접적으로 State 를 사용하는 컴포저블과 다른 컴포저블을 바로 델리게이트하는 컴포저블을 보겠습니다.

간단하게 버튼을 누르면 숫자를 1 올려서 보여주는 Counter 컴포저블과 이를 바로 델리게이트 하고 있는 DelegateOtherComposable 이 있습니다. DelegateOtherComposable 을 사용하여 아래와 같이 보여주고 있습니다.

이 코드로 생성된 화면에서 버튼이 눌리면 도넛홀 건너뛰기에 의해 Text 하나만 리컴포지션이 진행됩니다. 즉, 컴포저블 자체로 State 를 바로 사용하고 있지 않은 DelegateOtherComposable 컴포저블과, Counter 안에 있는 Button 컴포저블은 항상 리컴포지션을 건너뜁니다. 다시 말해서 이 2개의 컴포저블은 @NonRestartableComposable 어노테이션을 붙이는 것이 모범 사례 입니다.

하지만 Button 컴포저블은 캡처하는 값이 꼭 안정적일 것이라고 가정할 수 없기에 리컴포지션이 충분히 될 수 있어서 @NonRestartableComposable 이 사용되지 않았습니다. 따라서 위 코드는 아래와 같이 변경돼야 합니다.

2번째 줄에 @NonRestartableComposable 이 추가됐습니다

이렇듯 @NonRestartableComposable 은 자체적으로 리컴포지션될 가능성이 없는 컴포저블에 사용할 수 있습니다.

불필요한 상태 추적 제거

컴포즈에서 불필요한 상태 추적을 없애는것으로도 성능 최적화를 기대할 수 있습니다.

@file:NoLiveLiteral

컴포즈 컴파일러가 활성화 되면 모든 리터럴들을 State 로 추출하여 추척하며 hot-reload 를 구현합니다. 이러한 과정을 LiveLiteral 이라고 합니다. LiveLiteral 은 컴포저블에 영향을 받는 파일 외에 모든 파일에 적용되는 사항이므로 모든 리터럴들이 추적 받느라 엄청난 성능 손실을 야기합니다.

이를 방지하기 위해선 hot-reload 가 필요 없는 파일에 대해 @file:NoLiveLiteral 를 사용해 LiveLiteral 를 비활성화 해야 합니다. 릴리즈 모드에서는 기본적으로 모든 파일에 대해 LiveLiteral 이 비활성화 됨으로 이 방법은 디버그 모드에서만 유효합니다.

LiveLiteral 에 대한 자세한 정보는 “Flutter에서만 되던 hot-reload, Jetpack Compose는 어떻게 구현했을까?” 를 참고해 주세요.

staticCompositionLocalOf

컴포즈에서 CompositionLocal 은 정말 많이 사용되고, compositionLocalOf 를 이용해 직접 CompositionLocal 을 만들수도 있습니다. 이때, 만약 자주 바뀌지 않거나 아예 바뀌지 않는 값을 provide 한다면 staticCompositionLocalOf 을 사용해 CompositionLocal 을 만들어야 합니다.

compositionLocalOf 는 제공받는 값을 추적하여 값이 변경되는 경우에는 해당 값을 사용중인 컴포저블만 리컴포지션을 진행합니다. 반면에 staticCompositionLocalOf 는 제공받는 값을 추적하기 않기에 만약 값이 변경된 경우에는 해당 값이 제공된 컴포저블 트리 자체를 리컴포지션을 진행합니다. 즉, staticCompositionLocalOf 를 올바른 방법으로 사용하면 값을 추적하는 오버헤드를 없앰으로써 최적화 할 수 있습니다.

staticCompositionLocalOf 는 설명한대로 Context, ClipboardManager, Density, FocusManager 와 같이 제공된 이후로 자주 변경되지 않거나 아예 변경되지 않는 값들에 대해 사용되고 있습니다.

AOT 컴파일 활성화

컴포즈는 안드로이드에 내장된 기능이 아니기 때문에 AOT 컴파일이 적용되지 않습니다. 따라서 앱 실행시에 컴포즈 코드를 읽어오느라 앱 퍼포먼스가 초기엔 떨어질 수 있습니다. 이를 방지하기 위해 컴포즈에 Baseline profiles 을 적용할 수 있습니다. 이미 기본적으로 컴포즈 라이브러리들에는 Baseline profiles 이 적용돼 있지만 직접 정의하면 더 나은 결과를 얻을수도 있습니다.

Baseline profiles 은 공식 문서에서 이렇게 설명하고 있습니다.

Baseline profiles 은 머신 코드에 대한 중요한 경로를 사전 컴파일하기 위해 설치 중에 Android 런타임(ART)에서 사용하는 APK에 포함된 클래스 및 메서드 목록입니다.

Baseline profiles 에 대한 자세한 설명은 이 글의 주제와는 벗어남으로 자세한 설명은 공식 문서를 확인해 주세요.

공식 문서의 Create Baseline Profiles 세션을 따라 직접 Baseline profiles 를 정의한 후와 전을 비교해 보았습니다.

저의 경우에는 RunnerBe 프로젝트로 측정해 보았고, 앱 실행 후 첫 화면이 보이기 까지 max 시간이 약 1초가량 줄어든걸 확인할 수 있습니다.

Baseline profiles 적용 전과 후의 코드 변화는 #171 에서 확인하실 수 있습니다.

끝!

이번 글은 그동안 제가 써오던 컴포즈 성능 향상 비법들을 공유해 드렸습니다. 여러분들도 본인만의 성능 향상 비법이 있으신가요? 그러면 댓글로 공유해 주세요! 읽어주셔서 감사합니다.

안드로이드 개발자 분들을 위한 카카오톡 오픈 채팅방을 운영하고 있습니다.

--

--

Ji Sungbin
성빈랜드

Experience Engineers for us. I love development that creates references.