본문 바로가기

Android/Trouble Shoot

[Jetpack Compose] SubcomposeLayout 으로 UI 배치 문제 해결하기

Unsplash, Didssph.

동기

확실히 사이드 프로젝트를 하니, 다양한 문제에 부딪히고 이를 해결할 수 있는 기회가 자꾸 생겨서 좋습니다. 이번에 맞닥뜨린 문제 역시 진행 중인 사이드 프로젝트에서 발생했고, 별 고민 없이 해결할 수 있을 줄 알았으나, 또 다른 기술에 대한 이해가 있어야 우아하게 해결할 수 있는 문제였습니다.

 

HorizontalPager 가 하나 있고, 각 페이지는 텍스트와 이미지가 포함된 Column 이 존재합니다. HorizontalPager 의 바로 아래에는 HorizontalPager 의 Indicator 가 있고요. 

 

각 페이지에서 보여져야 하는 이미지는 비율이 각기 다르고, 그 위에 표시되는 텍스트가 차지하는 영역도 모두 다릅니다. 단순 고정 크기 Column 에 이미지의 weight() 를 설정하여 해결할 수는 없습니다. 기기 대응의 문제가 있기 때문입니다.

 

즉, '페이지의 전체 높이는 가변적인데 Indicator 의 위치를 고정해야한다'는 조건에 의해 발생한 문제입니다.

 

오늘은 이를 해결하기 위해 SubcomposeLayout 이라는 이름의 Composable 에 대해 학습하고 적용하며 얻은 결과를 나눕니다.


SubcomposeLayout

Jetpack Compose 는 선언형 UI 로 Composition-Layout-Drawing 의 명확한 3단계 파이프라인을 통해 UI 를 렌더링합니다. 이 과정은 단방향으로 흐르며 역행하지 않습니다. 그러므로, 상위 Composable 을 Composition 하는 데에 아직 측정되지 않은 하위 Composable 의 Layout 정보에 의존해야 하는 경우를 처리하기가 다소 난감해지기도 하죠.

 

SubcomposeLayout  상위 Composable Layout(measure) 단계 이후에 하위 Composable 의 Composition 을 먼저 수행할 수 있도록 설계된 특수한 Layout 입니다. 전통적인 Compose 의 UI 렌더링 프로세스와의 차이를 도식화하면 다음과 같습니다.

 

동일한 프로세스는 동일한 색상으로 구분하였습니다. 대두되는 차이는 Layout Composable Composition 이후, 하위 Composable 이 Composition 되는 순서입니다. 

 

Layout(measure) 는 하위 Composable 들의 크기 정보를 확정하여 Placeable 객체를 획득하는 단계이고, Layout(size-placement) 는 이를 실제 사이즈로 지정된 위치에 배치하는 단계입니다.

 

전통적인 Compose 의 렌더링 프로세스는 Layout Composable 의 Composition 이후, 하위 Composable 의 크기 정보를 Layout Composable 이 파악하고 나서 이후 과정이 진행됩니다. 이러한 과정은 기존의 Layout Composable(Box, Column, Row)의 구조를 떠올리면 쉽게 이해가 가능합니다. (정해진 순서대로 나열, 그에 따라 Layout Composable 의 크기가 결정) 

 

그러나, SubcomposeLayout LayoutComposable 의 Layout(measure)  먼저 진행하여, 조건에 따라 어떤 Composable 을 언제 어떻게 배치할지를 개발자로 하여금 런타임에 설정할 수 있도록 합니다. 이 과정에서 실제로 어떤 하위 Composable 을 어떻게 배치할지 결정한 뒤, LayoutComposable 이 이에 대한 크기 정보를 파악하고 나서 과정이 진행됩니다. 그 결과, 조건에 따라 원하는 Composable 을 원하는 조건으로 지연 생성할 수 있게 됩니다.

 

전통적 Compose 의 렌더링 프로세스를 벗어나 자율적으로 렌더링 프로세스를 구축하는 과정은 다음과 같습니다.

/*
선언
*/

/**
 * The inner state containing all the information about active slots and their compositions. It is
 * stored inside LayoutNode object as in fact we need to keep 1-1 mapping between this state and the
 * node: when we compose a slot we first create a virtual LayoutNode child to this node and then
 * save the extra information inside this state. Keeping this state inside LayoutNode also helps us
 * to retain the pool of reusable slots even when a new SubcomposeLayoutState is applied to
 * SubcomposeLayout and even when the SubcomposeLayout's LayoutNode is reused via the
 * ReusableComposeNode mechanism.
 */
@OptIn(ExperimentalComposeUiApi::class)
internal class LayoutNodeSubcompositionsState(
    private val root: LayoutNode,
    slotReusePolicy: SubcomposeSlotReusePolicy,
) : ComposeNodeLifecycleCallback {
    ...
    
    /** The inner state associated with [androidx.compose.ui.layout.SubcomposeLayout]. */
    internal var subcompositionsState: LayoutNodeSubcompositionsState? = null

    ...
}

/*
할당
*/
internal val setRoot: LayoutNode.(SubcomposeLayoutState) -> Unit = {
    _state =
        subcompositionsState
            ?: LayoutNodeSubcompositionsState(this, slotReusePolicy).also {
                subcompositionsState = it
            }
    state.makeSureStateIsConsistent()
    state.slotReusePolicy = slotReusePolicy
}

 

SubcomposeLayout 이 Layout 될 때, Compose Tree 에 LayoutNode 가 추가되는데, 해당 Node Subcomposition 을 목적으로한 State 가 미리 null 로 선언되어 있습니다. 이는 SubcomposLayout 이 생성될 때 할당됩니다. LayoutNodeSubcompositionsState 의 subcompose() 메서드가 SubcomposeLayout 의 핵심입니다.

private fun subcompose(node: LayoutNode, nodeState: NodeState, pausable: Boolean) {
    ...
    Snapshot.withoutReadObservation {
        ignoreRemeasureRequests {
            ...
            val composition =
                if (existing == null || existing.isDisposed) {
                    if (pausable) {
                        createPausableSubcomposition(node, parentComposition)
                    } else {
                        createSubcomposition(node, parentComposition)
                    }
                } else {
                    existing
                }
            nodeState.composition = composition
            ...
        }
    }
}

 

Snapshot.withoutReadObservation() 메서드를 통해 람다 내부에서 값을 선언하고 이를 참조하는 과정이 Recomposition 이 발생하지 않도록 상태 관찰 등록을 배제합니다. Composition 의 Layout(measure) 단계에서 실행되는 코드인데, 외부 상태가 지속적으로 변경된다고 해서 내부 Subcomposition 이 끊임없이 Recomposition 된다면 UI 가 응답하지 않는 상황이 될테니까요.

 

이후 ignoreRemeasuresRequestes() 메서드를 통해 람다 내부에서 발생하는 크기 변화에 의해 상위 Composable 이 하위 Composable 을 재측정하지 않도록 방어합니다. 이유는 간단한데, Subcomposition 단계에서 크기 변경이 있다고 SubcomposeLayout 에게 다시 하위를 측정해달라는 요청이 보내진다면, 이에 따라 무한 재귀 호출이 발생할 수 있기 때문입니다.

 

발생할 수 있는 문제를 사전에 방지하고, 이후 Composition 객체를 생성합니다. 이 때, Composition 은 무거운 작업이므로, 기존에 만들어 둔 객체가 있는 경우에는 기존 객체를 재사용합니다. 즉, 성능 최적화가 적용된 부분입니다.

 

재사용, 또는 모든 작업이 끝난 뒤의 메모리 누수 방지를 위해 Composition 객체를 변수에 할당하고, setContent() 메서드를 실행하여 전체 Composition 내에서 새로운 Subcomposition 을 진행합니다.

 

즉, 전통적인 Compose 렌더링 프로세스를 뜯어서 작업을 수행하는 것이 아니라, 필요한 작업을 위해 다양한 플래그를 사용하여 업데이트를 강제로 불가능하게 만들어 둔 뒤 사이에 끼워넣는다고 보는 편이 맞는 것 같습니다. 전체 Composition 의 영향권 안에 모든 Composable 이 속해있지만, Subcomposition 내에서 발생한 영향은 외부로 전파되지 않습니다. 


적용

@Composable
private fun OnBoardingPageContent(page: OnBoardingPage) {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text(
            text = stringResource(page.titleStringId),
            style = Head1_B,
            textAlign = TextAlign.Center,
            color = ColorTextDefault
        )
        Text(
            modifier = Modifier.padding(top = 8.dp),
            text = stringResource(page.bodyStringId),
            style = Body1_R,
            textAlign = TextAlign.Center,
            color = ColorTextSubtle
        )
        Image(
            modifier = Modifier.fillMaxWidth(),
            painter = painterResource(page.imageId),
            contentScale = ContentScale.FillWidth,
            contentDescription = "image_on_boarding"
        )
    }
}

 

HorizontalPager 의 내용물로 사용할 Composable 입니다. 페이지는 총 3페이지입니다.

 

영상을 자세히 보시면, HorizontalPager 하단에 표시되는 Indicator 가 페이지 높이에 따라 조금씩 움직입니다. 이는 내부 텍스트의 줄바꿈, 원본 이미지의 비율 차이 등에 의해 발생합니다.

 

높이가 가장 높은 페이지의 높이로 HorizontalPager 를 구성하면 하단의 Indicator 가 움직이지 않게 되겠습니다. 만약 페이지 내용물이 들썩거리면 페이지 내용의 Column 에 하단 선정렬을 이용하면 됩니다.

@Composable
private fun FixedHeightPager(
    pageCount: Int,
    pageContent: @Composable (Int) -> Unit,
    pagerState: PagerState
) {
    SubcomposeLayout(modifier = Modifier.fillMaxWidth()) { constraints ->
        val measuredHeights = (0 until pageCount).map { index ->
            subcompose(index) { pageContent(index) }.first().measure(constraints).height
        }
        val maxHeight = measuredHeights.maxOrNull() ?: 0

        val pagerPlaceable = subcompose("pager") {
            HorizontalPager(
                modifier = Modifier.height(maxHeight.toDp()),
                state = pagerState
            ) { pageIndex ->
                pageContent(pageIndex)
            }
        }.first().measure(constraints)

        layout(pagerPlaceable.width, pagerPlaceable.height) {
            pagerPlaceable.placeRelative(0, 0)
        }
    }
}

 

subcompose() 메서드를 이용해 각 페이지를 임시 Composition, Layout(measure) 하여 Measurable 을 생성하고, 이를 measure() 하여 Placeable 객체로 만든 뒤, 각 Placeable 의 높이를 구하면서 가장 큰 값을 획득합니다. 그 값을 활용하여 HorizontalPager 의 높이를 사전에 설정하여 Placeable 객체로 만든 뒤 이를 배치합니다. layout 의 파라미터로 넘기는 값들은, 배치하고자 하는 Placeable 의 너비, 높이입니다.

 

HorizontalPager 의 높이가 전체 페이지 중 가장 긴 페이지를 기준으로 고정되어, HorizontalPager 하단의 Indicator 위치 역시 잘 고정된 모습입니다.


이렇게만 보면 활용도가 꽤 높은 Composable 로 보입니다. onLayoutRectChanged() 메서드나 onGloballyPositioned() 메서드 등을 통해 사전에 임의의 길이를 확보하고, 이를 활용하여 UI 를 초기화하는 등에 사용이 가능하겠습니다.

 

다만 중요한 것이 있는데, 불필요한 Recomposition 을 회피하는게 Jetpack Compose 의 최우선 과제인 이유는 Composition 자체가 고비용의 작업이기 때문입니다. 하물며 기존에 존재하던 Composition 과정이 아닌, 내부에 새로이 생성하는 별도의 Composition 작업이기 때문에, 이 역시 비용이 꽤 되리라 생각합니다. 그래서인지 해당 Composable 의 주석에는 이를 사용해야할만 한 케이스들에 대해 작성되어 있습니다.

Possible use cases:
* You need to know the constraints passed by the parent during the composition and can't solve
  your use case with just custom [Layout] or [LayoutModifier]. See
  [androidx.compose.foundation.layout.BoxWithConstraints].
* You want to use the size of one child during the composition of the second child.
* You want to compose your items lazily based on the available size. For example you have a list
  of 100 items and instead of composing all of them you only compose the ones which are currently
  visible(say 5 of them) and compose next items when the component is scrolled.

 

 

그래도 이를 적재적소에 잘 활용한다면, 다양한 문제를 더 편하게 해결할 수 있을 것 같습니다.

긴 글 읽어주셔서 감사합니다.