[요약] Google I/O 2022 Performance best practice for Jetpack Compose

seong-hwan Kim
shDev
Published in
7 min readMay 24, 2022

Configuration

컴포즈로 작성된 앱을 테스트할 때는 R8 optimization가 활성화된 릴리즈 빌드로 테스트 하는것이 중요하다.

디버그 모드로 실행 시 안드로이드 시스템은 디버그 성능 향상을 위해 여러 가지 최적화를 수행하지 않는다. 앱에서 성능 이슈를 발견했다면 가장 먼저 릴리즈 모드에서도 동일한 이슈가 발생하는지 확인해야 한다.

Gotchas

Something to remember

아래 코드의 문제점은 무엇일까?

위의 코드는 ContactList의 리컴포지션이 발생할 때 마다 항상 contacts가 다시 정렬된다.

컴포저블이 매우 빈번하게 리컴포지션될 수 있다는 것을 항상 기억해라.

remember {}: Use to only run expensive operations once

contacts와 sortComparator를 key로 remeber를 호출하면, 리컴포지션이 발생할 때 마다 리스트가 재정렬되지 않고 contacts 또는 sortComparator가 변경됐을 때만 재정렬된다.

이외에도 정렬 로직을 뷰모델이나 데이터 소스로 이동시켜서 컴포지션 스코프 밖으로 보낼 수도 있다. 컴포저블 state의 변경이 필요하다면 가능한 한 최소한의 오버헤드를 가져야 한다.

A key piece

LazyList Key: Define a key on your LazyList items

LazyList 아이템에 key를 명시하면, 리스트의 변경이 발생했을 때 효과적으로 리컴포지션을 수행할 수 있다.

key를 명시하지 않는다면 기본적으로 아이템의 position을 key로 사용하는데, 이러한 방식은 리스트의 순서가 변경되거나 아이템의 삭제, 추가가 발생했을 때 영향을 받는 모든 아이템이 리컴포지션되기 때문에 성능적으로 좋지 않다.

Deriving change

앱에서 리스트의 최 상단으로 이동하는 버튼이 추가되었다고 가정하다. 버튼은 리스트가 아래로 스크롤됐을때만 보여져야 한다.

이를 구현하기 위해 lazy list state의 firstVisibleItemIndex를 사용하여 버튼 가시성을 결정하는 boolean flag를 사용할 수도 있다.

하지만 이러한 방식에는 문제가 있다.

LazyList는 스크롤 중 모든 프레임 마다 list state를 업데이트 하기 때문에 list state를 단순히 참조하는 것은 수많은 리컴포지션을 불필요하게 발생시킨다. 위의 유스케이스에서는 firstVisibleItemIndex가 0을 기점으로 변화하는지만 필요하다.

derivedStateOf {}: Use to buffer the rate of change

derivedStateOf를 사용하면 빈번하게 업데이트되는 state에 대해 값이 변경됐을 때만 리컴포지션이 발생하도록 제한할 수 있다.

derivedStateOf는 이처럼 빠르게 변화하는 값을 boolean condition으로 변환할 때 유용하게 사용할 수 있다.

다만 모든 경우에 dericedStateOf를 사용하지 않도록 주의해라.

위의 예시에서 contactCount는 contacts의 아이템이 변경될 때 마다 함께 변경되어야하기 때문에 값을 따로 저장할 필요가 없다. 따라서 이 유스케이스에서 derivedStateOf를 사용하는 것은 불필요한 중복이다.

Procrastination

컴포저블의 백그라운드 컬러가 변경되는 케이스를 생각해보자.

컴포즈에서는 아주 쉬운 방식으로 컬러 변경 애니메이션을 구현할 수 있다.

여기에서 약간의 최적화를 추가할 수 있다.

위의 코드에서는 애니메이션이 적용되었기 때문에 모든 프레임마다 리컴포지션이 수행된다. 이게 왜 문제가 되는지 인지하기 위해서는 먼저 컴포즈의 동작 방식을 이해할 필요가 있다.

컴포즈는 세 가지 단계를 거치며 동작한다.

  1. Composition: 컴포저블 함수가 실행되며 애플리케이션의 content가 생성, 업데이트가 이루어진다.
  2. Layout: Composition 단계에서 결정된 content에 대해 measure 및 placement가 수행되어 컴포저블이 스크린에서 어디에 위치할 지 결정된다.
  3. Draw: 이전의 단계인 Layout에서 결정된 위치에 맞게 실제로 캔버스에 컴포저블을 그린다.

컴포저블의 상태가 변경되는 모든 프레임마다 위의 세 단계가 수행된다. 만약 데이터가 변경되지 않았다면 위의 과정에서 하나 이상의 단계가 생략될 수 있다.

다시 위의 코드를 보면 다른 데이터의 변경 없이 컬러만 변경되고 있기 때문에 전체 과정을 반복하는것 보다 Draw 과정만 반복하는것이 더욱 효과적이다.

Reading state: Defer reading state until you need it.

컴포즈에서는 실제로 값이 필요할 때 까지 state를 reading을 지연시키는 것이 중요한 컨셉이다. state 참조를 지연시키면 재실행되는 함수의 수를 줄일 수 있다.

drawBehind에 전달하는 함수는 Compose의 draw phase에서 호출된다. 따라서 애니메이션 중 color의 상태가 변경되더라도 draw 단계만 다시 실행된다.

state를 파라미터로 전달하고 function instance 내에서 참조하는것 또한 컴포즈 단계를 생략하기 좋을 뿐만 아니라, state가 변경됐을 때 재실행되는 코드의 양을 줄이는 방법이 된다.

Running backwards

이 절에서는 컴포즈를 사용하며 반드시 작성을 피해야하는 코드를 보여준다.

위의 코드를 빌드하고 프로파일링하면 메인 스레드가 바쁘게 동작하는것을 확인할 수 있다. 모든 프레임마다 컴포지션이 반복되며 중지되지 않는다.

balance를 업데이트하는 코드에 의해 위와 같은 문제가 발생한다. 컴포즈는 값을 읽은 후에는 컴포지션이 완료되기 전 까지 값이 변경되지 않는다고 가정하지만, 위의 코드는 이 가정을 위반한다.

Backwards write: Writing to state you have already read

Backwards write는 모든 프레임마다 리컴포지션을 발생시킨다.

위의 코드는 아래와 같이 개선할 수 있다.

위의 코드는 transactions가 변경됐을때만 리컴포지션이 발생한다. balances를 계산하는 코드를 viewModel로 이동할 수 있다면 더욱 좋다.

Covering your bases

앱을 실행했을 때 처음 몇 초 동안 끊김이 발생하다가 다시 부드럽게 동작하는것을 확인한다면, 가장 먼저 릴리드 빌드 모드에서 R8 최적화가 활성화되었는지 확인해보자. 만약 제대로 구성된 경우에도 같은 문제가 발생한다면 JIT 컴파일링 때문이다.

Baseline Profiles: Spped up startup and hot paths

이하 생략.

--

--