movableContentOf: Compose는 하위 트리를 상태를 잃지 않고 어떻게 옮기는가
movableContentOf: Compose는 하위 트리를 상태를 잃지 않고 어떻게 옮기는가
Compose 개발자라면, remember가 리컴포지션은 견디지만 위치가 바뀌는 것은 견디지 못한다는 사실을 일찌감치 배웁니다. 컴포저블을 if의 한 분기에서 다른 분기로, 혹은 Row에서 Column으로 옮기면, 기억해 두었던 상태가 초기값으로 되돌아갑니다. 런타임이 보기엔 새 컴포저블이 새 자리에 나타났고 옛것은 버려진 것이기 때문입니다. movableContentOf가 이를 바꿔 놓습니다. 하위 트리(subtree)를 옮길 수 있는 것으로 표시해, 같은 콘텐츠가 다른 호출 지점에 나타날 때 그 기억된 값과 이펙트, 레이아웃 노드가 버려지고 다시 만들어지는 대신 함께 따라 움직이게 합니다. 창 크기에 맞춰 콘텐츠의 자리를 옮기는 적응형 레이아웃이 바로 이것이 중요해지는 지점입니다. 겉으로 드러난 API는 함수 하나지만, 그 아래에서 런타임은 자료 구조의 한 위치에서 슬롯 한 무리를 물리적으로 들어 올려 다른 곳에 도로 이어 붙입니다.
이 글에서는 movableContentOf의 내부 구조를 깊이 있게 파고듭니다. 이동 가능한 하위 트리가 어떻게 안정적인 정체성 아래 등록되는지, 컴포저가 슬롯 테이블에서 그 그룹을 어떻게 표시하는지, 콘텐츠가 한 호출 지점을 떠나 다른 호출 지점에 도착했음을 런타임이 어떻게 감지하는지, 떠난 하위 트리가 어떻게 자체 슬롯 테이블로 추출되어 보관되는지, 떠남이 정체성을 통해 도착과 어떻게 짝지어지는지, 보관된 슬롯과 레이아웃 노드가 remember를 다시 실행하지 않고 어떻게 새 위치로 이식되는지, 그리고 이 모든 것이 key() 및 컴파일러가 그것을 위해 만들어 내는 이동 가능 그룹과 어떻게 다른지를 차례대로 살펴봅니다.
근본적인 문제: 컴포저블을 옮기면 상태가 초기화된다
상태가 remember에 담긴 카운터에서 출발해 보겠습니다.
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) { Text("Clicked $count") }
}
이제 플래그 값에 따라 그 카운터를 두 레이아웃 중 하나에 놓아 보겠습니다.
@Composable
fun MovableDemo(inColumn: Boolean) {
if (inColumn) {
Column { Counter() }
} else {
Row { Counter() }
}
}
버튼을 몇 번 누른 뒤 inColumn을 뒤집어 보세요. 카운트가 0으로 초기화됩니다. 이유는 위치 기반 정체성에 있습니다. Compose는 컴포저블을 객체 참조로 추적하지 않습니다. 슬롯 테이블(slot table)이라 부르는 평평한 구조에서 그 호출이 어디에 떨어지는지로 추적합니다. inColumn이 뒤집히면 Column 아래의 Counter() 호출은 사라지고, Row 아래의 Counter() 호출이 다른 위치에 나타납니다. 런타임은 이 둘이 논리적으로 같은 위젯임을 알지 못합니다. 그래서 첫 번째를 폐기하는데, 그러면서 그 remember가 잊히고, 두 번째는 처음부터 새로 만듭니다. 모든 mutableStateOf, 모든 DisposableEffect, 모든 레이아웃 노드가 허물어지고 다시 만들어집니다.
여기서는 카운터를 key()로 감싸도 소용없고, 상태를 끌어올려도 소용없습니다. 끌어올리기는 문제를 옮겨 놓을 뿐이기 때문입니다. 상태를 쥔 무언가가 이제 함께 움직여야 합니다. 정작 우리가 원하는 것은, 특정 하위 트리가 시간에 따라 여러 자리에 나타날 수 있는 하나의 정체성이며, 그럴 때 그 슬롯이 다시 만들어지는 대신 그대로 옮겨져야 한다고 런타임에 알려 줄 방법입니다. 바로 그것을 movableContentOf가 제공하는데, 그 방식을 이해하려면 먼저 슬롯이 실제로 무엇인지 알아야 합니다.
슬롯 테이블: 컴포지션 상태가 실제로 사는 곳
Compose는 컴포지션이 돌아가는 동안의 상태를 슬롯 테이블이라는 구조에 저장합니다. 기본 런타임에서는 이 구조를 갭 버퍼(gap buffer)가 뒷받침하는데, 갭 버퍼는 커서 근처의 삽입과 삭제를 값싸게 만들어 주는, 움직일 수 있는 빈 영역을 가진 평평한 배열입니다. 개념적으로 슬롯 테이블은 평탄화된 트리입니다. 각 컴포저블 호출은 그룹을 하나 열고, 그 그룹은 그 호출이 만들어 낸 모든 것을 담습니다. remember에 넘긴 값, RememberObserver 이펙트의 홀더 객체, 레이아웃 노드에 대한 참조, 그리고 그 호출이 부른 컴포저블들의 중첩된 자식 그룹을 말입니다. 이 배열 안에서 그룹이 놓인 위치가 곧 그 정체성입니다. 이것이 앞 절에서 말한 근본 원인입니다. Counter가 Column이 아니라 Row 아래에 떨어지면 다른 위치를 차지하므로, 런타임은 이를 다른 그룹으로 취급합니다.
그룹의 두 가지 특징이 movable content를 가능하게 합니다. 첫째는 앵커(anchor)로, 배열이 편집돼도 살아남는, 그룹에 대한 안정적인 손잡이입니다. 런타임은 슬롯들이 그룹 주위에서 이리저리 밀려나도 그 그룹이 어디에 있는지 앵커로 추적합니다. 둘째는 마크 비트(mark bit)입니다. 그룹에는 플래그를 붙일 수 있고, 그 조상들은 '마크 포함(contains mark)'이라는 별도의 플래그를 받아 스캔이 플래그 붙은 자손을 빠르게 찾을 수 있으며, 삭제 경로는 바로 이 비트를 훑습니다. 평범한 컴포저블은 이 비트를 결코 세우지 않습니다. movableContentOf가 이 비트에 기대는, 여러분이 직접 호출하는 주된 API이며, 그 마크가 이 메커니즘 전체를 끌어당기는 실입니다. 서브컴포지션도 이 비트를 세웁니다. SubcomposeLayout과 다이얼로그, 팝업이 rememberCompositionContext를 통해 만드는 컴포지션 컨텍스트 그룹도 같은 마크를 지니는데, 뒤에서 삭제 스캔이 두 경우를 모두 처리하는 이유가 바로 이것입니다.
사실 트리에는 컴포저 구현이 두 개 있습니다. 갭 버퍼 컴포저가 기본이고, 같은 movable content 설계를 가진 연결 리스트 슬롯 테이블 변형이 기능 플래그(feature flag) 뒤에 존재합니다. 아래의 모든 내용은 기본인 갭 버퍼 경로를 따라갑니다. 두 변형이 함께 상속하는 기반 타입이 SlotStorage인데, 곧 만나게 될 참조와 상태 클래스들이 slotStorage를 들고 있다가 필요할 때 구체 테이블로 다운캐스트하는 것도 그래서입니다.
등록: 람다가 아니라 안정적인 정체성
다음이 그 해법이자 이 API의 핵심으로, 정석대로 쓴 모습입니다.
@Composable
fun MovableDemo(inColumn: Boolean) {
val content = remember {
movableContentOf {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) { Text("Clicked $count") }
}
}
if (inColumn) Column { content() } else Row { content() }
}
이제 inColumn을 뒤집어도 카운트가 살아남습니다. 왜 그런지 이해하려면, movableContentOf가 무엇을 반환하는지 살펴보겠습니다.
@RememberInComposition
public fun movableContentOf(content: @Composable () -> Unit): @Composable () -> Unit {
val movableContent = MovableContent<Nothing?>({ content() })
return { currentComposer.insertMovableContent(movableContent, null) }
}
이 함수는 MovableContent 홀더 객체를 하나 만들고, 그 안에 여러분의 람다를 감싼 뒤, 새 람다를 반환합니다. 반환된 그 람다가 호출 지점에서 실행될 때마다, 같은 홀더를 넘기며 currentComposer.insertMovableContent(movableContent, null)을 호출합니다. 그 홀더가 곧 정체성입니다.
@InternalComposeApi
public class MovableContent<P>(public val content: @Composable (parameter: P) -> Unit)
홀더가 존재하는 것은, 런타임이 람다 자신의 정체성에 기대지 않아도 되게 하기 위해서입니다. Compose 컴파일러는 똑같은 람다들을 싱글톤으로 합쳐 버릴 수 있고, 디버그 빌드와 릴리스 빌드 사이에서 달라지는 정체성은 짝짓기를 믿을 수 없게 만듭니다. 전용 홀더 객체는 movableContentOf 호출마다 안정적이고 고유합니다. 이 함수가 @RememberInComposition을 달고 있고 사용 예제가 이를 remember로 감싸는 것도 그래서입니다. movable content를 리컴포지션마다 다시 만들면 매번 새 홀더를 찍어 내게 되고, 그러면 떠남과 도착을 더 이상 맞출 수 없어 상태가 초기화됩니다. 홀더를 끌어올리는 것은 문체 취향이 아닙니다. 정체성을 살려 두는 방법입니다.
호출 지점에서 표시된 그룹 내보내기
반환된 람다가 실행되면, 컴포저는 invokeMovableContentLambda로 흐름을 넘깁니다. 이 함수가 가장 먼저 하는 일은, 고정된 키 아래에 그룹을 열고 하위 트리의 정체성 해시를 고정하는 것입니다.
private fun invokeMovableContentLambda(content, locals, parameter, force: Boolean) {
startMovableGroup(movableContentKey, content)
updateSlot(parameter)
val savedCompositeKeyHash = compositeKeyHashCode
compositeKeyHashCode = CompositeKeyHashCode(movableContentKey)
if (inserting) writer.markGroup()
// ...
}