시배's Android

Compose | Compose Guidelines to Optimize Performance 본문

Android/Compose

Compose | Compose Guidelines to Optimize Performance

si8ae 2023. 12. 18. 21:29
 

6 Jetpack Compose Guidelines to Optimize Your App Performance

Since Google announced Jetpack Compose stable 1.0, many companies are getting started to adopt Jetpack Compose into their projects…

proandroiddev.com

1. Aim to Write Stable Classes

Compose에는 전용 runtime이 있으며, 입력이나 상태가 변경될 때 어떤 Composable 함수를 재구성할지 결정합니다.

런타임 성능을 최적화하기 위해 Compose는 읽고 있는 상태가 변경되었는지 유추할 수 있어야 합니다.

기본적으로 아래 세 가지 stability 유형이 있습니다.

  • Unstable : 이 유형은 변경 가능한 데이터를 보유하며 변경 시 Composition에 알리지 않습니다. Compose는 데이터가 변경되지 않았는지 확인할 수 없습니다.
  • Stable : 변경 가능한 데이터를 보유하지만 변경 시 Composition에 알립니다. 이렇게 하면 Composition이 항상 상태 변경을 알 수 있으므로 안정적입니다.
  • Immutable : 불변의 데이터를 보유합니다. 데이터가 변경되지 않으므로 Compose는 이를 안정적인 데이터로 취급할 수 있습니다.

Example of The Real-World Implications

Compose가 stability를 보장할 수 있다면 Composable에 특정 성능 이점을 부여할 수 있는데, 주로 건너뛸 수 있는 것으로 표시할 수 있습니다.

data class InherentlyStableClass(val text: String)

@Composable
fun StableComposable(
    stableClass: InherentlyStableClass
) {
    Text(
        text = stableClass.text
    )
}

해당 코드 블럭을 Compiler report를 통해 분석해볼 수 있습니다.

stable class InherentlyStableClass {
  stable val text: String
  <runtime stability> = Stable
}

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun StableComposable(
  stable stableClass: InherentlyStableClass
)

이 결과는 무엇을 의미할까요?

data class의 모든 매개변수가 안정적으로 표시되어 있으므로 런타임 안정성도 안정적인 것으로 간주된다는 것을 알 수 있습니다.

  • Restartable : 이 Composable이 restartable 범위에 있음을 의미합니다. 즉 이 Composable이 recomposition 할 때마다 부모 scope의 recomposition을 트리거하지 않습니다.
  • Skippable : Composable이 상태로 사용하는 유일한 파라미터가 안정적이므로, Composable은 언제 변경되었는지 여부를 유추할 수 있습니다. 따라서 Compose 런타임은 부모 scope가 재구성되고 Composable이 상태로 사용하는 모든 파라미터가 동일하게 유지될 때 이 Composable의 recomposition을 건너뛸 수 있습니다.
data class InherentlyUnstableClass(var text: String)

@Composable
fun UnstableComposable(
    unstableClass: InherentlyUnstableClass
) {
    Text(
        text = unstableClass.text
    )
}

해당 코드 블럭을 Compiler report를 통해 분석해 봅니다.

unstable class InherentlyUnstableClass {
	stable
	var text: String
	<runtime stability> = Unstable
}

restartable scheme("[androidx.compose.ui.UiComposable]") fun UnstableComposable(
  unstable unstableClass: InherentlyUnstableClass
)

상태 클래스와 Composable은 동일한 작업을 수행하지만 똑같지는 않습니다. 이 예제에서는 불안정한 클래스를 사용하기 때문에 필요할 때 이 Composable을 건너뛸 수 있는 기능을 잃었습니다.

2. Rules for Writing classes

이는 대부분 불변성을 목표로 해야 한다는 뜻입니다.

  • 상태를 가지는 클래스에서는 var을 사용하지 말자.
  • Private properties 역시 stability에 영향을 끼친다.
  • 외부 모듈에 속한 클래스를 사용하여 상태를 형성하지 말자.
  • 컬렉션에서 불변성을 기대하지 말자.
  • Flowunstable하다.
  • Inlined Composable은 restartable, skippable하지 않다.

3. Hoist state properly

State Hoisting은 stateless Composables을 만드는 방법입니다.

  • 필요한 모든 상태는 Composable의 호출자로부터 전달되어야 합니다.
  • 모든 이벤트는 상태의 근원지로부터 위쪽으로 흘러야 합니다.\
@Composable
fun CustomButton(text: String, onClick: () -> Unit) {
    Button(onClick = onClick) {
    Text(text = text)
  }
}

setContent {
  var count by remember {
    mutableStateOf(0)
  }

  CustomButton(text = "Clicked $count times") {
    count++
  }
}

State Hoisting 덕분에 Composable은 잘 작동하고, 단방향 흐름을 따르며, 테스트가 더 쉽습니다.
하지만 더 복잡한 시나리오에서 이 패턴을 쉽게 오용할 수 있기 때문에 완전히 안전하다고 할 수는 없습니다.

4. Don't read the state from a too high scope

조금 더 복잡한 state holder가 있다고 가정해 보겠습니다.

class StateHoldingClass {
  var counter by mutableStateOf(0)
  var whatAreWeCounting by mutableStateOf("Days without having to write XML.")
}

그리고 다음과 같은 Composable 함수에서 사용됩니다.

@Composable
fun CustomButton(count: Int, onClick: () -> Unit) {
  Button(onClick = onClick) {
    Text(text = count.toString())
  }
}

가상의 시나리오에서 이 상태 홀더는 일반적인 관행인 뷰모델 내부에서 호스팅되며 다음과 같은 방식으로 읽습니다:

setContent {
  val viewModel = ViewModel()

  Column {
    Text("This is a cool column I have")
    CustomButton(count = viewModel.stateHoldingClass.counter) {
      viewModel.stateHoldingClass.counter++
    }
  }
}

StateHoldingClass.counter라는 프로퍼티가 CustomButtom 매개변수로 사용되기 때문에 CustomButton만 재구성된다고 생각할 수 있지만 그렇지 않습니다.
이것은 Column 내부에서 읽은 상태로 간주되므로 이제 전체 Column을 재구성해야 합니다. 하지만 여기서 끝나지 않습니다. Column은 인라인 함수이기 때문에 부모 범위의 재구성도 트리거합니다.
다행히도 이 문제를 피할 수 있는 쉬운 방법이 있는데, 바로 상태 읽기를 낮추는 것입니다.

@Composable
fun CustomButton(stateHoldingClass: StateHoldingClass, onClick: () -> Unit) {
  Button(onClick = onClick) {
    Text(text = stateHoldingClass.counter.toString())
  }
}
setContent {
  val viewModel = ViewModel()

  Column {
    Text("This is a cool column I have")
    CustomButton(stateHoldingClass = viewModel.stateHoldingClass) {
      viewModel.stateHoldingClass.counter++
    }
  }
}

이제 상태 읽기가 CustomButton 내부에서 발생하므로 해당 Composable의 내용만 재구성할 것입니다. 이 시나리오에서는 Column과 그 부모 범위 모두 불필요한 재구성을 피할 수 있습니다!

5. Avoid running expensive calculations unnecessarily

@Composable
fun ConcertPerformers(venueName: String, performers: PersistentList<String>) {
  val sortedPerformers = performers.sortedDescending()

  Column {
    Text(text = "The following performers are performing at $venueName tonight:")

    LazyColumn {
      items(items = sortedPerformers) { performer ->
        PerformerItem(performer = performer)
      }
    }
  }
}

이것은 간단하게 작성할 수 있으며, 공연장 이름과 함께 해당 공연장에서 공연하는 공연자를 표시합니다. 또한 독자가 관심 있는 공연자가 공연 중인지 쉽게 찾을 수 있도록 목록을 정렬할 수 있습니다.

하지만 여기에는 한 가지 중요한 결함이 있습니다. 공연장이 변경되더라도 공연자 목록이 그대로 유지되면 목록을 다시 정렬해야 합니다. 이는 잠재적으로 비용이 많이 드는 작업입니다. 다행히도 이 기능은 필요할 때만 실행하는 것이 매우 쉽습니다.

@Composable
fun ConcertPerformers(venueName: String, performers: PersistentList<String>) {
  val sortedPerformers = remember(performers) {
    performers.sortedDescending()
  }

  Column {
    Text(text = "The following performers are performing at $venueName tonight:")

      LazyColumn {
        items(items = sortedPerformers) { performer ->
          PerformerItem(performer = performer)
        }
      }
    }
}

위의 예제에서는 출연자를 키로 사용하여 정렬된 목록을 계산하는 remember를 사용했습니다. 이 함수는 출연자 목록이 변경될 때만 다시 계산하여 불필요한 recompositon을 방지합니다.

원래 State<T> 인스턴스에 액세스할 수 있는 경우 다음 예제에서와 같이 상태를 직접 파생하여 추가적인 이점을 얻을 수 있습니다

@Composable
fun ConcertPerformers(venueName: String, performers: State<PersistentList<String>>) {
  val sortedPerformers = remember {
    derivedStateOf { performers.value.sortedDescending() }
  }

  Column {
    Text(text = "The following performers are performing at $venueName tonight:")

    LazyColumn {
      items(items = sortedPerformers.value) { performer ->
          PerformerItem(performer = performer)
      }
    }
  }
}

여기서 Compose는 불필요한 계산을 건너뛸 뿐만 아니라, 로컬로 읽은 프로퍼티만 변경하고 전체 함수를 새 매개변수로 재구성하지 않으므로 상위 스코프를 재구성하지 않아도 될 만큼 똑똑합니다.

6. Defer reads as long as possible

슬라이딩하여 애니메이션을 적용할 수 있는 작은 Composable을 만들어 보겠습니다.

@Composable
fun SlidingComposable(scrollPosition: Int) {
  val scrollPositionInDp = with(LocalDensity.current) { scrollPosition.toDp() }

  Card(
    modifier = Modifier.offset(scrollPositionInDp),
    backgroundColor = Color.Cyan
  ) {
    Text(
      text = "Hello I slide out"
    )
  }
}
LazyColumn은 ScrollState를 취하지 않으므로 Composables를 다음과 같이 약간 수정해야 합니다.
@Composable
fun ConcertPerformers(
  scrollState: ScrollState,
  venueName: String,
  performers: PersistentList<String>,
  modifier: Modifier = Modifier
) {
  Column(modifier = modifier) {
    Text(
      modifier = Modifier.background(color = Color.LightGray),
      text = "The following performers are performing at $venueName tonight:"
    )

    Column(
      Modifier
        .weight(1f)
        .verticalScroll(scrollState)
    ) {
        for (item in performers) {
          PerformerItem(performer = item)
        }
      }
    }
}

@Composable
fun PerformerItem(performer: String) {
  Card(
    modifier = Modifier
      .padding(vertical = 10.dp)
      .background(
        color = Color.LightGray,
      )
      .wrapContentHeight()
      .fillMaxWidth()
    ) {
      Text(
        modifier = Modifier.padding(10.dp),
        text = performer
      )
   }
}
setContent {
  val scrollState = rememberScrollState()

  Column(
    Modifier.fillMaxSize()
  ) {
    SlidingComposable(scrollPosition = scrollState.value)

    ConcertPerformers(
      modifier = Modifier.weight(1f),
      scrollState = scrollState,
      venueName = viewModel.venueName,
      performers = viewModel.concertPerformers.value
    )
  }
}

이렇게 하면 이전과 동일한 문제가 발생합니다. 상태를 높게 읽고 setContent 내부의 모든 것을 재구성해야 합니다. 이전과 같은 방식으로 전체 ScrollState를 매개변수로 전달하여 문제를 해결하고 싶을 수도 있지만, 매우 편리한 다른 방법이 있습니다.

람다를 도입하여 상태 읽기를 지연시킬 수 있습니다.

@Composable
fun SlidingComposable(scrollPositionProvider: () -> Int) {
  val scrollPositionInDp = with(LocalDensity.current) { scrollPositionProvider().toDp() }

  Card(
    modifier = Modifier.offset(scrollPositionInDp),
    backgroundColor = Color.Cyan
  ) {
    Text(
      text = "Hello I slide out"
    )
  }
}

람다는 변경되지 않으며 호출이 발생할 때 결과만 변경됩니다. 이는 곧 상태 읽기는 이제 SlidingComposable 내부에서 발생하며 부모 재구성을 유발하지 않습니다.

@Composable
fun SlidingComposable(scrollPositionProvider: () -> Int) {

  Card(
    modifier = Modifier.offset {
      IntOffset(x = scrollPositionProvider(), 0)
    },
      backgroundColor = Color.Cyan
  ) {
    Text(
      text = "Hello I slide out"
    )
  }
}
Modifier.offset(offset: Density.() -> IntOffset)는 레이아웃 단계에서 호출되므로 이 함수를 사용하면 컴포지션 단계를 완전히 건너뛸 수 있으며, 결과적으로 상당한 성능 이점을 얻을 수 있습니다