Compose Camp 2022 요약 - 2

6 분 소요

1편에 이은 2편이다.

Advanced State and Side Effect

LaunchedEffect를 사용하며 콜백 블럭 내부에서 어떤 기억된 값을 사용할 때는 rememberUpdatedState이 필요할 수 있다. 특히 아래 코드와 같은 상황이다. LaunchedEffect에서 딜레이가 있는 작업이 있을 때 onTimeout 매개변수가 변경되는 경우 단순 remember를 이용한다면 딜레이가 끝나고나서 이전의 값을 이용하게 된다. 이는 rememberUpdatedState를 이용하여 항상 최신의 값을 참조할 수 있도록 문제를 해결할 수 있다.

@Composable
fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        // This will always refer to the latest onTimeout function that
        // LandingScreen was recomposed with
        val currentOnTimeout by rememberUpdatedState(onTimeout)

        // Create an effect that matches the lifecycle of LandingScreen.
        // If LandingScreen recomposes or onTimeout changes,
        // the delay shouldn't start again.
        LaunchedEffect(true) {
            delay(SplashWaitTime)
            currentOnTimeout()
        }

        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

상태 홀더

이 부분은 신선하게 다가왔다. ViewModel에서 이용하는 UiState 말고 정말 UI 로직만 담은 UiState 홀더를 구현한다. 예를 들어 LazyListState와 같은 요소는 상태 홀더에 담아 관리하고 그 외의 비지니스 로직 혹은 UI 요소가 아닌 요소는 ViewModel에서 관리하는 것이다. 상태 홀더를 만드는 방법은 아래와 같다.

  1. 상태를 나타내는 별도 클래스를 따로 만든다.
    • 내부에는 mutableStateOfby를 이용하여 값을 외부에서 수정할 수 있도록 한다.
  2. Saver object 객체를 만들어 remeberSaveable를 사용할 수 있도록 한다.
  3. 상태를 끌어올린다.(매개변수로 두면 됨)
  4. 값 변경을 구독하고 싶은 경우 아래와 같이 snapshotFlow를 이용한다.
val currentOnDestinationChanged by rememberUpdatedState(
        newValue = onToDestinationChanged
)
LaunchedEffect(editableUserInputState){
    snapshotFlow{editableUserInputState.text}
        .filter{!editableUserInputState.isHint}
        .collect{
            currentOnDestinationChanged(editableUserInputState.text)
        }
}

ViewModel과 상태 홀더를 동시에 사용하는 예제는 아래와 같다.

class ExampleState(
    val lazyListState: LazyListState,
    private val resources: Resources,
    private val expandedItems: List<Item> = emptyList()
) {
    fun isExpandedItem(item: Item): Boolean = TODO()
    /* ... */
}

@Composable
fun rememberExampleState(/* ... */): ExampleState { TODO() }

@Composable
fun ExampleScreen(viewModel: ExampleViewModel = viewModel()) {

    val uiState = viewModel.uiState
    val exampleState = rememberExampleState()

    LazyColumn(state = exampleState.lazyListState) {
        items(uiState.dataToDisplayOnScreen) { item ->
            if (exampleState.isExpandedItem(item)) {
                /* ... */
            }
            /* ... */
        }
    }
}

Navigation

아래와 같이 인터페이스를 정의하여 네비게이션 객체들을 관리하면 용이하다.

/**
 * Contract for information needed on every Rally navigation destination
 */
interface RallyDestination {
    val icon: ImageVector
    val route: String
}

object SingleAccount : RallyDestination {
    // Added for simplicity, this icon will not in fact be used, as SingleAccount isn't
    // part of the RallyTabRow selection
    override val icon = Icons.Filled.Money
    override val route = "single_account"
    const val accountTypeArg = "account_type"
    val routeWithArgs = "${route}/{${accountTypeArg}}"
    val arguments = listOf(
        navArgument(accountTypeArg) { type = NavType.StringType },
    )
}

외부 앱에서 딥링크를 통해 특정 컴포저블을 바로 보일 수 있다.

Performance best practices

Configuration

  • reelase 빌드는 minifyEnable R8 설정을 true로 설정해라.

Something to remember

  • composable 함수는 매우 자주 실행될 수 있다는 것을 항상 염두해두어라.
  • 계산 비용이 큰 함수를 실행하는 경우 결과 값을 remember 등을 이용하여 기억해라.

LazyList Key

  • LazyList item에 키를 지정해라. 키가 동일하지 않을 때만 리컴포징 하기 때문이다.

Deriving change

  • LazyList는 매 프레임마다 listState를 업데이트 하기 때문에 아래의 경우처럼 코드를 작성하는 경우 너무 많은 리컴포지션이 발생한다.
val listState = rememberLazListState()
LazyColumn (state = listState) {
    // ...
}
val showButton = listState.firstVisibleItemInde× > 0 // <----- Here
AnimatedVisibility (visible = showButton) {
    ScrollToTopButton()
}

이는 아래와 같이 derivedStateOf를 이용하여 결과 값이 다른 경우만 리컴포지션을 발생시 킬 수 있다.

val listState = rememberLazListState()
LazyColumn (state = listState) {
    // ...
}
val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemInde× > 0
    }
}
AnimatedVisibility (visible = showButton) {
    ScrollToTopButton()
}

Procrastination

아래의 코드는 매 프레임만다 컴포지션을 발생한다. 이유를 알기 위해서는 컴포지션 과정을 이해해야 한다.

val color by animateColorBetween (Color.Cyan, Color.Magenta)
Box(Modifier.fillMaxSize().background(color))

컴포지션은 아래의 세 과정으로 이루어진다.

  1. Composition
    1. 컴포저블 함수가 실행됨
    2. 콘텐츠가 어떻게 구성되어있는지 생성 및 업데이트하고 다음 페이즈(Layout)에서 무엇을 할지 알려줌
  2. Layout
    1. UI 요소를 측정하고 어디에 배치할지 결정한다.(단일 패스로 동작함)
  3. Draw
    1. canvas를 이용하여 그린다.

데이터가 변경되지 않으면 하나 혹은 그 이상의 단계가 생략 가능하다. 아래의 코드는 composition, layout 과정을 생략하여 훨씬 성능이 좋다. drawBehind 함수로 전달되는 람다는 draw 단계에서 실행되기 때문이다.

val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
    Modifier
        .fillMaxSize ()
        .drawBehind {
            drawRect(color)
        }
)

Running backwards

아래의 코드는 루프 중간에 계속해서 balance를 갱신한다. 즉, 쓰기가 일어나고나서 읽기가 일어난다. 때문에 계속해서 리컴포지션이 발생하여 나쁜 코드이다.

var balance by remember { mutableState0f(0) }

balance = 0

for (transaction in transactions) {
    Row {
        balance += transaction
        Text ("Transaction: $transaction Balance: $balance")
    }
}

이는 아래와 같이 개선할 수 있다. 쓰기를 한 번만 진행하는 것이다. 물론 아래의 계산은 viewModel과 같은 곳에서 처리할 수도 있다.

val balances = remember(transactions) {
    transactions. runningReduce { a, b -> a + b }
}
for ((transaction, balance) in transactions.zip(balances))
    Text ("Transaction: $transaction Balance: $balance")

요약하자면 컴포저블 함수 내에서 이미 읽어진 관찰 값을 다시 쓰는 일은 없도록 해야한다는 것이다.

Covering your bases

앱 첫 실행 후 잠시 몇 초동안 버벅거림을 느낀적이 있나? 이는 어떻게 해결해야 하나?

baseline Profile을 이용하면 앱을 설치할 때 precompile한다. 시작 속도 개선이 상당히 된다.

Testing

Isolate

테스트하려는 부분만 고립하여 띄우고 테스트를하자. 예를 들어 앱바를 테스트하기 위해 엑티비티를 띄우기 보다 setContent를 활용하여 앱 바만 띄워서 테스트하자.

Test debugging

semantics tree를 이용하여 테스트를 디버깅할 수 있다. composeTestRule.onRoot().printToLog("currentLabelExists")를 이용하여 아래와 같이 어떤 속성이 존재하는 지 디버깅이 가능하다. #3에서 text 속성은 존재하지 않다는 것을 알 수 있다. 이 상황에서 onNodeWithText를 사용하면 테스트에 실패한다.

...com.example.compose.rally D/currentLabelExists: printToLog:
    Printing with useUnmergedTree = 'false'
    Node #1 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
     |-Node #2 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
       [SelectableGroup]
       MergeDescendants = 'true'
        |-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
        | Role = 'Tab'
        | Selected = 'false'
        | StateDescription = 'Not selected'
        | ContentDescription = 'Overview'
        | Actions = [OnClick]
        | MergeDescendants = 'true'
        | ClearAndSetSemantics = 'true'
        |-Node #6 at (l=189.0, t=105.0, r=468.0, b=168.0)px
        | Role = 'Tab'
        | Selected = 'true'
        | StateDescription = 'Selected'
        | ContentDescription = 'Accounts'
        | Actions = [OnClick]
        | MergeDescendants = 'true'
        | ClearAndSetSemantics = 'true'
        |-Node #11 at (l=552.0, t=105.0, r=615.0, b=168.0)px
          Role = 'Tab'
          Selected = 'false'
          StateDescription = 'Not selected'
          ContentDescription = 'Bills'
          Actions = [OnClick]
          MergeDescendants = 'true'
          ClearAndSetSemantics = 'true'

이러한 상황에서는 composeTestRule.onRoot(useUnmergedTree = true).printToLog("currentLabelExists")와 같이 useUnmergedTree = true를 사용하면 Button 내의 존재하는 Text의 속성이 병합되지 않아 앞서 설명한 상황을 해결할 수 있다.

즉, content descriptionAccount인 노드가 부모인 자식을 matcher를 통해 찾을 수 있다. 이는 아래와 같이 개선 된 테스트 코드를 작성할 수 있다.

composeTestRule
    .onNode(
        hasText(RallyScreen.Accounts.name.uppercase()) and
        hasParent(
            hasContentDescription(RallyScreen.Accounts.name)
        ),
        useUnmergedTree = true
    )
    .assertExists()

Synchronization

UI테스트는 다음과 같은 과정으로 진행된다. 먼저 앱이 idle 상태일 때까지 대기한다. 그 다음 해당 semantics tree를 쿼리한다. 동기화 없이는 요소가 보이기 전에 쿼리를 하거나 의미없이 대기할 뿐이다.

단순히 finishedListener를 이용하여 애니메이션을 무한 반복 한다면 UI는 앞서 말한 동기화 문제가 발생한다. 이는 Infinite animations라는 API를 이용하면 된다.

Infinite animations are a special case that Compose tests understand so they’re not going to keep the test busy.

Accessbility

  • 사용자가 UI 요소와 안정적으로 상호작용하기 위해서는 요소의 크기가 최소한 48dp 이상이어야 한다.
  • onClickLabel을 이용하여 클릭 이벤트가 어떤 동작을 하는지 구체화하자.
  • semanticscustomActions로 목록을 탐색하지 않고 음성 안내 지원의 맞춤 작업 팝업을 사용할 수 있도록 하자.
  • 이미지 요소에 contentDescription을 제공하자.
  • 제목을 지정하여 시각 장애가 있는 사용자에게 원하는 섹션을 빠르게 찾을 수 있도록 하자. -> Modifier.semantics { heading() }
  • 요소를 병합하여 관련된 요소들을 묶어서 탐색할 수 있도록 하자. mergeDescendants = true
  • 스위치나 체크박스는 라벨과 묶어 컨텍스트를 이해하기 쉽게하자.
Modifier
    .toggleable(
        value = selected,
        onValueChange ={onToggle()},
        role = Role.Checkbox
    )
  • 추가로 스위치가 무엇에 대해 켜진건지 상태를 설명하자.
Modifier
    .semantics{
        stateDescription = if (selected) {
            stateSubscribed
        } else {
            stateNotSubscribed
        }
    }

댓글남기기