동기
Jetpack Compose 를 사용해서 UI 를 개발하다 보면, Modifier 의 확장 함수를 구현하여야 하는 경우가 더러 있습니다. 불필요하게 길어지는 Modifier Chaining 을 방지하기 위해서나, 자주 사용하게 되는 Modifier Chaining 을 함수화 하여 사용하기 위해서 입니다.
별도의 지식 없이 Modifier 확장 함수를 구현하면 IDE 에서 이런 저런 주의를 주며 자동 코드 편집을 지원해 주는데요. 별 생각 없이 기능을 이용하고 코드를 그대로 두기 보다, 자세히 학습하여 사용하는 편이 추후에 발생할 가능성이 있는 문제를 해결하는 데에 큰 도움이 되리라 생각하여 학습하게 되었습니다.
Modifier 와 확장 함수
Jetpack Compose 로 UI 를 구현할 때에 가장 중요한 것은, 개인적인 생각이지만 뭐니 뭐니 해도 Modifier 입니다. Modifier 는 이미 그 자체로 매우 다양한 기능을 손쉽게 구현할 수 있도록 만들어져 있습니다.
더 좋은 건, Kotlin 의 확장 함수 기능을 통해서 여러 상황에 좋은 가독성의 코드를 작성할 수 있다는 점입니다.
동기에서도 기술했지만, Modifier 의 확장 함수를 구현하려고 하면 IDE 에서 코드 편집 툴팁을 보여줍니다. 간단한 예로 확인해 봅니다. 예시 케이스는 Modifier 내부에 remember { } 를 사용하여 하나의 정수를 저장하고, 해당 Composable 을 터치했을 때 저장된 정수에 1을 더해주는 확장 함수입니다.
@Composable
fun Modifier.clickToIncrease(index: Int): Modifier {
var number by remember { mutableIntStateOf(0) }
return this
.then(Modifier.clickable { number++ })
.also {
println("$index $number")
}
}
이와 같이 코드를 작성하면, IDE 에서 코드 편집 툴팁을 표시하며, 이는 아래와 같습니다.
Modifier 는 Composable 에 사용되기 때문에, 자연스럽게(?) @Composable Annotation 을 작성해주었더니, 위와 같이 @Composable 을 제거하고 composed { } 메서드로 변경하라는 툴팁이 나옵니다.
일단, 그대로 두고 해당 Modifier 를 5개의 Composable 에 각각 적용해보고, 0번과 1번을 터치하면, 콘솔에 다음과 같이 결과가 표시됩니다.
// Console
0 0
1 0
2 0
3 0
4 0
0 1
1 0
2 0
3 0
4 0
0 1
1 1
2 0
3 0
4 0
하나의 Composable 에서 해당 기능이 동작, 내부의 state 가 변경되면서 Recomposition 이 발생합니다. 이에 따라 Composition Tree 의 Recomposition 이 발생하면서 다른 Composable 들 역시 Recomposition 의 영향을 받아 터치하지도 않은 Composable 에서도 Modifier 가 재호출되며 println() 메서드가 동작하게 됩니다.
이러한 경우를 방지하기 위해 만들어진 것이 composed { } 입니다.
composed { }
fun Modifier.clickToIncrease(index: Int): Modifier = composed {
var number by remember { mutableIntStateOf(0) }
this.then(Modifier.clickable { number++ })
.also {
println("$index $number")
}
}
composed { } 를 적용하면 이와 같이 코드를 작성하게 됩니다. 그리고 이전과 같이, 0번과 1번 Composable 을 터치하면, 콘솔에 다음과 같이 출력됩니다.
0 0
1 0
2 0
3 0
4 0
0 1
1 1
@Composable Annotation 을 작성하여 구현한 확장 함수와는 다르게, 최초 1회 호출 이후 터치한 Composable 에 대해서만 println() 메서드가 호출됩니다. 즉, 각 Modifier 가 독립적으로 존재하고 있습니다.
복잡해 보이지만, 사실 별 것 없습니다. composed { } 는 메서드 호출의 주체가 되는 Modifier 와 인자로 넘겨주는 Modifier (인자 블럭에 작성하는 Modifier)를 합쳐줍니다. 하지만, 단순히 합치는 것이라면 Modifier.then() 과 다를 바가 없겠죠. ComposedModifier 타입의 객체와 합칩니다.
fun Modifier.composed(
inspectorInfo: InspectorInfo.() -> Unit = NoInspectorInfo,
factory: @Composable Modifier.() -> Modifier
): Modifier = this.then(ComposedModifier(inspectorInfo, factory))
하지만 웃기게도, composed { } 역시 내부적으로 Modifier.then() 을 사용하고 있습니다.
Modifier.then() 을 사용하는 것은 같지만, 단순히 Modifier.then() 을 사용하는 것과 결과가 다른 이유는 ComposedModifier 의 스펙에 있습니다.
private open class ComposedModifier(
inspectorInfo: InspectorInfo.() -> Unit,
val factory: @Composable Modifier.() -> Modifier
) : Modifier.Element, InspectorValueInfo(inspectorInfo)
ComposedModifier 는 Modifier.Element 를 상속하고 있는데, 이는 Modifier Chain 에서 각 요소를 식별하고 관리할 수 있도록 합니다. Modifier.Element 를 상속하면 hashCode() 와 equals() 메서드를 재정의하게 되는데, 이를 통해 Modifier 인스턴스 간 동등성을 비교하여 Modifier 요소를 식별하게 됩니다. 이후
Composition Tree 에 Recomposition 이 발생하더라도, 해당 Modifier 에 변경 사항이 없다면 Recomposition 을 건너뛰게 됩니다.
간단하게 정리하자면, composed { } 는 then() 을 통해 두 개 이상의 Modifier 를 합쳐 새로운 Modifier 를 생성합니다. 내부적으로는 Modifier.Element 를 상속하는 ComposedModifier 를 통해서 해당 Modifier 가 독립적으로 식별 및 관리될 수 있도록 합니다. 이를 통해 UI 변경 사항을 추적하는 과정에서의 최적화가 이루어 질 수 있게 되는 것입니다.
Modifier.Node
Modifier.Node 는 커스텀 Modifier 를 만드는 가장 효율적인 방식입니다.
맞춤 수정자 만들기 | Jetpack Compose | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. 맞춤 수정자 만들기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Compose는 일반적인 동작을 위한 여
developer.android.com
해당 문서에도 나와있듯이, composed {} 는 성능 상의 이슈로 더 이상 사용이 권고되지 않습니다.
Materialize 라는 과정이 있는데, 동일한 Modifier 를 여러 Composable 에 적용하고자 할 때,
Composition Tree 에 있는 Layout 들에 각각의 구체화된 Modifier 를 생성하여 적용하는 과정입니다.
즉, 앞서 설명했던 원리를 통해 생성된 Modifier 들이 적용되는 단계입니다.
해당 Modifier 들은 Composition Tree 에 종속되기 때문에, 일반적인 Composable 메서드와 같이
Recompsition 이 발생했을 때, 내부 상태를 관리하기 위해 remember 를 사용해야 합니다. 이 때,
Modifier 의 상태를 저장하기 위해 수많은 remember 가 사용되면서, Materialize() 시 이를 처리하기 위한 큰 오버헤드가 발생합니다.
ComposedModifier 가 Modifier.Element 를 상속할 때 구현한 equals() 메서드에 의해 동등성 비교가 이루어지는데, equals() 메서드의 리턴값이 false 인 경우, 당연하게도 Recomposition 이 발생하게 됩니다. 내부적으로 Modifier, remember 를 아주 많이 사용하는 Modifier 가 Recomposition 된다는 건데, 심지어 여기서 Materialize 과정까지 거친다고 생각하면 정말 큰 오버헤드가 발생할 것임은 자명합니다.
이러한 문제를 해결하기 위해 등장한 것이 Modifier.Node 입니다.
Modifier.Node 는 Modifier Chain 에서 각 Modifier 요소를 나타내는 노드입니다. Modifier Chain 과 같이, Node 역시 NodeChain 이라는 이름의 LinkedList 로 관리됩니다. 각 Modifier 인스턴스는 이 노드의 인스턴스와 대응되며, 그 구조를 형성하게 됩니다.
Modifier.Node 는 Composition Tree 의 영향이 미치지 않도록 관리되기 때문에, Recomposition 에서 자유롭다는 특징이 있습니다. Composition Tree 에 Recomposition 이 발생하면, 실제 Modifier 와 그 Modifier 에 대응되는 노드를 equals(), hashCode() 등으로 비교, 변경 사항이 있는 경우, UI 가 업데이트 되는 방식입니다.
예제
class OnPointerEventNode(
var callback: (Int) -> Unit,
val count: MutableIntState,
) : PointerInputModifierNode, Modifier.Node() {
override fun onPointerEvent(
pointerEvent: PointerEvent,
pass: PointerEventPass,
bounds: IntSize,
) {
if (pointerEvent.type == PointerEventType.Press && pass == PointerEventPass.Initial) {
count.intValue += 1
callback(count.intValue)
}
}
override fun onCancelPointerInput() = Unit
}
이전의 예시를 composed {} 가 아닌 Modifier.Node 를 사용해 재구현했습니다. Composable 메서드를 사용하지 않기 때문에, 클릭 이벤트를 구현할 때에도 조금 더 로우 레벨의 코드를 사용하도록 되어 있습니다. 포인터 이벤트가 발생하면 count 의 값에 1을 더해주고 callback 을 실행해줍니다.
해당 클래스가, 제가 구현하고자 하는 기능의 핵심 로직입니다.
data class PointerInputElement(
val index: Int,
val callback: (Int) -> Unit,
val count: MutableIntState,
) :
ModifierNodeElement<OnPointerEventNode>() {
override fun create() = OnPointerEventNode(
callback = callback,
count = count
)
override fun update(node: OnPointerEventNode) {
node.callback = callback
}
override fun InspectorInfo.inspectableProperties() {
name = "onPointerEvent"
properties["callback"] = callback
properties["count"] = count.intValue
}
}
제가 구현하고자 하는 것은 결국 Modifier 의 속성(Element)이기 때문에 ModifierNodeElement 를 상속하여야 하며, 타입 파라미터에는 OnPointerEventNode 를 사용했습니다. 이 외에도 디자인을 위한 클래스 등이 존재하니, 구현하고자 하는 기능에 맞게 적절히 취사선택하여 사용하면 되겠습니다.
insepectableProperties() 메서드에서는 LayoutInspector 를 통해 UI 디버깅을 수행할 때에 표현되는 타이틀을 정의할 수 있습니다.
fun Modifier.clickToIncrease(
index: Int,
callback: (Int) -> Unit,
) = this then PointerInputElement(
index = index,
callback = callback,
count = mutableIntStateOf(0)
)
구현한 PointerInputElement 를 Modifier 에 추가하는 확장 함수입니다. 확실히 별도의 기능을 담당하는 모듈을 만들고 이를 추가하는 뉘앙스가 강합니다.
repeat(5) {
Box(
modifier = Modifier
.size(100.dp)
.background(Color.Red)
.clickToIncrease(
index = it,
callback = { count ->
println("인덱스 $it, count $count")
}
)
)
}
구현이 완료되었고, 결과는 다음과 같습니다.
인덱스에 따라 별도로 카운트가 올라갑니다.
inspectableProperties() 메서드에 작성한 것과 같이, callback 에 대한 코드 위치와 count 가 표시됩니다.
평소에 굉장히 어렵고 난해하다 생각했던 부분인데, 이번 기회에 확실하게 탐구하고 예제까지 작성해 볼 수 있어서 좋았습니다. 이를 활용해서 더 나은 Jetpack Compose 코드를 작성하게 될 수 있을 것 같아 기쁘기도 하고요.
'Android > Tech' 카테고리의 다른 글
UiEffect 를 위한 Channel<T> 사용법 (0) | 2025.01.15 |
---|---|
안드로이드의 MVI 와 Reducer (0) | 2025.01.15 |
LazyList 과 RecyclerView 의 메커니즘 알아보기 (0) | 2025.01.13 |
[Jetpack Compose] CompositionLocal 로 이벤트 처리하기 (0) | 2024.02.29 |
[Jetpack Compose] 불필요한 Recomposition 을 줄여 앱 퍼포먼스 개선하기 (1) | 2024.02.25 |