본문 바로가기

Android/Trouble Shoot

[Jetpack Compose] onGloballyPositioned 를 onLayoutRectChanged 로 대체하기

Unsplash, ChutterSnap.

동기

Google I/O '25 내용 중 가장 관심이 갔던 파트는 바로 Jetpack Compose 의 Visibility 파트입니다.

Composable 이 화면에 표시되는 과정 전반에 걸쳐 적용할 수 있는 다양한 API 들이 소개되었습니다. 이 중 

onLayoutRectChanged() 메서드는 기존에 존재하던 onGloballyPositioned() 메서드의 성능을 개선한 메서드로 소개가 되었는데요. onGloballyPositioned() 메서드를 종종 사용했던 기억이 나, 어떠한 원리로 이를 대체할 수 있는지에 대해 알아보았습니다.


onLayoutRectChanged() 는 효율적인가?

Android 공식 문서를 살펴보면, onGloballyPositioned() 메서드보다 onLayoutRectChanged() 메서드가 보다 효율적임을 이야기하고 있습니다. 이유는 간단한데, 애초에 onLayoutRectChanged() 메서드가onGloballyPositioned() 메서드의 문제를 보완하는 방식으로 설계되었기 때문입니다. 즉, 기존 방식이 고도화되었다기 보다, 기존 방식과 다른 방식으로 설계되었다는 것입니다.

Android 공식 문서

 

이는 당연히 'onLayoutRectChanged() 메서드가 효율적이다' 로 받아들여질 수 있지만, 반대로 'onGloballyPositioned() 메서드 사용시 비효율이 발생할 여지가 있다' 로도 해석이 가능합니다. onGloballyPositioned() 메서드의 비효율을 onLayoutRectChanged() 메서드는 어떻게 해결하고 있는지 알기 위해 두 메서드의 내부 동작 방식을 살펴봅니다. 먼저, onGloballyPositioned() 입니다.

internal fun dispatchOnPositionedCallbacks() {
    if (layoutState != Idle || layoutPending || measurePending || isDeactivated) {
        return // it hasn't yet been properly positioned, so don't make a call
    }
    if (!isPlaced) {
        return // it hasn't been placed, so don't make a call
    }
    nodes.headToTail(Nodes.GlobalPositionAware) {
        it.onGloballyPositioned(it.requireCoordinator(Nodes.GlobalPositionAware))
    }
}

 

Composition 에 의해 Node 가 Positioned 되면, GlobalPositionAwareModifierNode onGloballyPositioned 메서드가 호출됩니다. 이는 headToTail() 이라는 메서드 명칭만 봐도 알 수 있듯, 재귀 호출을 통해 각 노드를 순회합니다. 그리고 전달된 와 같은 Node 에게 동일 명령을 수행할 수 있도록 전달합니다.

// TRAVERSAL
internal inline fun <reified T> firstFromHead(type: NodeKind<T>, block: (T) -> Boolean): T? {
    headToTail(type) { if (block(it)) return it }
    return null
}
internal inline fun <reified T> headToTail(type: NodeKind<T>, block: (T) -> Unit) {
    headToTail(type.mask) { it.dispatchForKind(type, block) }
}
internal inline fun headToTail(mask: Int, block: (Modifier.Node) -> Unit) {
    if (aggregateChildKindSet and mask == 0) return
    headToTail {
        if (it.kindSet and mask != 0) {
            block(it)
        }
        if (it.aggregateChildKindSet and mask == 0) return
    }
}

 

headToTail() 메서드에 의해 연관된 모든 Node 를 순회, 동일한 타입의 Node(GlobalPositionAwareModifierNode)block 람다가 넘겨지면서 실행되면, onGloballyPositioned() 메서드가 모두 실행되는 구조입니다. 

 

'재귀 구조 자체가 비효율적이냐'고 묻는다면, 그건 당연히 아닙니다. 연결된 모든 Node 에 접근하여 변경 사항이 있는지 확인하고, 변경 사항이 있으면 이에 대한 액션을 취할 수 있도록 하는 구조이니까요. 그러나, 이전의 상태를 캐싱하지 않기 때문에 변화가 있었는지를 알 수 없으므로, 어쩔 수 없이 모든 onGloballyPositioned() 람다를 호출하도록 구현되어 있습니다.

 

onLayoutRectChanged() 는 이러한 오버 헤드 문제를 근본적으로 해결하였습니다.(구조가 조금 더 복잡해지긴 했지만) 필연적으로 오버 헤드가 발생하는 재귀 구조에서 벗어나, 선형 자료 구조를 채택한 모습입니다.

아래는 Composable 의 위치나 크기가 변경될 때 내부에서 호출되는 onLayoutPositionChanged() 메서드 중 일부입니다.

if (parent != null) {
    rects.moveBasedOnParentOffset(
        value = semanticsId,
        parentId = parent.semanticsId,
        offsetFromParentX = offsetFromParent.x,
        offsetFromParentY = offsetFromParent.y,
        width = width,
        height = height,
    )
} else {
    // moving the root.
    // when parent is null offsetFromParent is just the outer coordinator
    // offset.
    rects.move(
        value = semanticsId,
        l = offsetFromParent.x,
        t = offsetFromParent.y,
        r = offsetFromParent.x + width,
        b = offsetFromParent.y + height,
    )
}
invalidate()

 

rects 는 RectList 라는 캐싱 목적의 선형 자료구조이며, Composable 이 차지하는 영역에 대한 정보를 담고 있습니다. move 메서드는 callback 람다가 실행될 수 있도록 RectList 내 캐싱된 데이터들의 값을 업데이트하는 메서드이고요. RectList 에 저장된 값이 변경되었다는 것은 화면 배치가 변경되었다는 것을 의미하므로,

onLayoutRectChanged() 람다가 실행될 수 있도록 invalidate() 해줍니다.

 

RectManager 에서 관리하는 RectList 에 작성된 주석

 

요약하자면, onLayoutRectChanged() 메서드는 IntId 와 Rect 의 정보를 저장하는 단순한 자료 구조를 활용하여, 연관된 Composable 이 차지하는 사각형 영역 데이터를 캐싱하는 방식을 채택한 겁니다. Layout 정보 파악을 위해 모든 Node 를 순회하는 것이 아니라, 주요 정보만 Array 에 담아두고 인덱스로 접근하는 방식으로, 구조 자체의 복잡도는 상승할 수 있지만 확실한 성능 개선이 있었다고 볼 수 있겠습니다. 삽입은 당연히 O(1) 로 가능하고, 조회 역시 SemanticId 가 있기 때문에 이를 활용하여 O(1) 로 가능합니다. 선형 구조의 지역성 덕분에 캐싱 효율도 높습니다.


캐싱의 이점

재귀 방식과 선형 자료 구조 활용 방식의 또 다른 차이는 '이전 상태를 알고 있다'는 부분입니다. 이를 활용해 이전과 상태가 달라지지 않았다면, 별도의 callback 실행을 명령하지 않을 수 있습니다. 

이게 의미가 있는 이유는, Node 를 재귀 순회 하든 선형 자료 구조에 캐싱을 하든 상위 Composable 에 Layout 이 발생하면 하위 Composable 도 Layout 되기 때문입니다. 이 때, 이전 상태와 현재 상태가 같다면 callback 실행을 건너뛰어버리는 겁니다.  

 

이를 테스트할 수 있는 예시는 아래와 같습니다.

var offsetXState by remember { mutableIntStateOf(0) }
Column(modifier = Modifier.fillMaxSize()) {
    Box(
        modifier = Modifier
            .padding(start = offsetXState.dp)
            .size(100.dp)
            .background(Color.Black)
            .clickable {
                offsetXState++
            }
    )
    Box(
        modifier = Modifier
            .size(100.dp)
            .background(Color.Black)
            .onGloballyPositioned() {
                println("onGloballyPositioned")
            }
    )
}

 

아주 간단한 예시입니다. Column 내부에 두 개의 Composable 을 배치하고, 버튼 역할을 하는 Composable 을 클릭하면 해당 Composable 의 padding 값을 수정합니다. offset 을 이용하면 화면에 그려지는 것은 달라지지만 레이아웃 자체에는 변화가 없으므로, 이와 같이 구현합니다.

 

내용은 간단한데요. 첫 번째 Composable 을 클릭하면 1.dp 이동하는 게 전부입니다. 첫 번째 Composable 이 이동하면서 전체 Column 이 Recomposition 됩니다. 두 번째 Composable 은 이동하지 않았지만, 상위 Composable 이 Recomposition 되면서 Layout 단계에 onGloballyPositioned() 메서드 람다가 실행됩니다.

 

 

앞서 기술한대로, onGloballyPositioned() 메서드는 onLayoutRectChanged() 메서드와 다르게 이전 상태를 갖고 있지 않으므로(현재 상태만 알 수 있으므로), 두 번째 Composable 의 상태 변화와 무관하게 람다가 실행됩니다.

 

아래는 onGloballyPositioned() 메서드를 onLayoutRectChanged() 메서드로 변경한 뒤, 똑같이 테스트를 진행했습니다.

 

이번에는 첫 번째 Composable 의 상태 변화에 따라 Column Recomposition 이 발생해도, 두 번째 Composable 은 상태 변화가 없기 때문에 별도로 람다가 실행되지 않습니다. 즉 불필요한 상황에서 람다를 실행하지 않으므로, 추가적으로 발생할 수 있는 불필요한 계산을 사전에 회피할 수 있습니다.


높은 사용성

onLayoutRectChanged() 메서드는 자체적으로 throttle debounce 를 지원해, 스크롤링과 같이 미세한 변화가 연속적으로 발생하는 작업에서 발생할 수 있는 불필요한 오버헤드를 줄일 수 있도록 구현되어 있습니다. onGloballyPositioned() 메서드를 사용해서 이러한 상황에 대응하기 위해 derivedStateOf 를 추가로 활용하여 최적화할 수 있었지만,  onGloballyPositioned() 에서 발생하는 근본적인 오버헤드로 인해 시스템 부하가 적지 않았기 때문에 찝찝한 부분이 있었습니다. onLayoutRectChanged() 를 통해 이러한 상황에서 유연하게 대응이 가능해진거죠.

 

안드로이드 공식 블로그에서는 onLayoutRectChanged() 메서드가 onGloballyPositioned() 를 사용했던 대부분의 유즈 케이스를 대신할 수 있다고 말합니다. LazyList 에서의 활용에 대한 이야기는 덤이고요. 

https://android-developers.googleblog.com/2025/04/whats-new-in-jetpack-compose-april-25.html


결론

onLayoutRectChanged() 메서드의 등장으로, onGloballyPositioned() 메서드를 사용하는 대부분의 상황에서의 성능 개선을 경험할 수 있게 되었습니다. 

onGloballyPositioned() 를 사용해야만 하는 상황이 뭐가 있을까 생각해보았을때, '지속적으로 특정 Composable 의 위치를 추적해야만 하는 상황' 이 떠올랐는데, 사실 이마저도 onLayoutRectChanged() 메서드에 기본적으로 적용되어 있는 debounce 의 값을 0L 로 설정해주면 커버가 가능하죠.

지금 당장은 생각나지 않으니, 앞으로 개발하면서 만약 onGloballyPositioned() 메서드를 사용해야만 하는 상황을 맞닥뜨리게 되면 그때 업데이트 하도록 하겠습니다.

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