본문 바로가기

Compose

[ComposeInternals] 컴포즈 UI (Compose UI) - (4) Modifier

Jetpack Compose Internals를 참고하여 작성하였습니다

 

 

Modifier는 Compose UI에서 굉장히 중요한 부분이다

 

 

Modifier 체인 모델링 (Modeling modifier chains)

Modifier 인터페이스는 UI Composable을 장식하거나 동작을 추가하는 불변 요소의 컬렉션을 모델링

 

3가지 기능을 제공하는 추상화

  1. then (수정자의 모든 유형을 체인으로 결합)
  2. foldIn, foldOut (체인을 통과하면서 값을 누적)
  3. any, all (체인 내 any, all 조건과 일치하는지 확인하는 연산 제공)

 

Modifier.kt

@Stable
@JvmDefaultWithCompatibility
interface Modifier {
    /**
     * initial 값으로 시작해서 각 요소에 operation 함수를 적용
     * 부모(head)에서 자식(tail)으로 이동하면서 값을 축적
     */
    fun <R> foldIn(initial: R, operation: (R, Element) -> R): R
    
    /**
     * initial 값으로 시작해서 각 요소에 operation 함수를 적용
     * 자식(tail)에서 부모(head)로 이동하면서 값을 축적
     */
    fun <R> foldOut(initial: R, operation: (Element, R) -> R): R
    
    // 체인 내의 어떤 Element라도 predicate 함수가 true를 반환하면 true 반환
    fun any(predicate: (Element) -> Boolean): Boolean
    
    // 체인 내의 모든 Element가 predicate 함수에서 true를 반환하면 true 반환
    fun all(predicate: (Element) -> Boolean): Boolean
    
    /**
     * 현재 Modifier와 다른 Modifier를 연결
     * 두 Modifier를 순서대로 적용한 새로운 Modifier 반환
     */
    infix fun then(other: Modifier): Modifier =
        if (other === Modifier) this else CombinedModifier(this, other)
        
    ...
}

 

예시) Chaining modifiers

/**
 * 모든 chaining은 Modifier를 반환하도록 되어있다
 * 명시적으로 then이 아닌 경우, 확장 함수를 통해 연결
 */ 
Box(
    modifier = Modifier.then(indentModifier)
        .fillMaxWidth()
        .height(targetThickness)
        .background(color = Color.Black)
)

 

예시) 확장 함수를 통해 선언된 modifier

@Stable
fun Modifier.padding(
    horizontal: Dp = 0.dp,
    vertical: Dp = 0.dp
) = this then PaddingElement(
    start = horizontal,
    top = vertical,
    end = horizontal,
    bottom = vertical,
    rtlAware = true,
    inspectorInfo = {
        name = "padding"
        properties["horizontal"] = horizontal
        properties["vertical"] = vertical
    }
)

 

then이 호출 시 CombinedModifier 리턴

 

CombinedModifier 클래스

/**
 * Modifier 체인의 Node
 * 최소 2개의 element 포함
 * 현재 modifier(outer)와 다음 modifier(inner)로의 포인터
 */
class CombinedModifier(
    internal val outer: Modifier,
    internal val inner: Modifier
) : Modifier {
    override fun <R> foldIn(initial: R, operation: (R, Modifier.Element) -> R): R =
        inner.foldIn(outer.foldIn(initial, operation), operation)

    override fun <R> foldOut(initial: R, operation: (Modifier.Element, R) -> R): R =
        outer.foldOut(inner.foldOut(initial, operation), operation)

    override fun any(predicate: (Modifier.Element) -> Boolean): Boolean =
        outer.any(predicate) || inner.any(predicate)

    override fun all(predicate: (Modifier.Element) -> Boolean): Boolean =
        outer.all(predicate) && inner.all(predicate)

    override fun equals(other: Any?): Boolean =
        other is CombinedModifier && outer == other.outer && inner == other.inner

    override fun hashCode(): Int = outer.hashCode() + 31 * inner.hashCode()

    override fun toString() = "[" + foldIn("") { acc, element ->
        if (acc.isEmpty()) element.toString() else "$acc, $element"
    } + "]"
}

 

 

LayoutNode에 Modifier 설정 (Setting modifiers to the LayoutNode)

Layout 선언 시 update 람다에서 구체화된 modifier 체인이 LayoutNode에 설정된다

 

Layout.kt

@Composable
@UiComposable
inline fun Layout(
    modifier: Modifier = Modifier,
    measurePolicy: MeasurePolicy
) {
    val compositeKeyHash = currentCompositeKeyHash
    val materialized = currentComposer.materialize(modifier)
    val localMap = currentComposer.currentCompositionLocalMap
    ReusableComposeNode<ComposeUiNode, Applier<Any>>(
        factory = ComposeUiNode.Constructor,
        update = {
            set(measurePolicy, SetMeasurePolicy)
            set(localMap, SetResolvedCompositionLocals)
            set(materialized, SetModifier)
            @OptIn(ExperimentalComposeUiApi::class)
            set(compositeKeyHash, SetCompositeKeyHash)
        },
    )
}

 

update 람다는 factory 람다를 통해 생성되고 바로 호출되며 LayoutNode의 상태를 초기화 혹은 업데이트 한다

 

update 람다에서 구체화된 modifier가 설정된다

// modifier는 단일이거나 여러 modifier의 chain이다
val materialized = currentComposer.materialize(modifier)

set(materialized, SetModifier)

val SetModifier: ComposeUiNode.(Modifier) -> Unit = { this.modifier = it }

 

표준 modifier인 경우 수정 없이 반환하고 composed modifier인 경우 LayoutNode에 할당되기 전에 Composable factory 함수를 통해 구성되어야 한다

fun Composer.materialize(modifier: Modifier): Modifier {
    // ComposedModifier가 아닌 경우 그대로 반환
    if (modifier.all { it !is ComposedModifier }) {
        return modifier
    }

    startReplaceableGroup(0x48ae8da7)
    
    // ComposedModifier인 경우
    val result = modifier.foldIn<Modifier>(Modifier) { acc, element ->
        acc.then(
            if (element is ComposedModifier) {
                // factory 람다 먼저 실행
                @Suppress("UNCHECKED_CAST")
                val factory = element.factory as Modifier.(Composer, Int) -> Modifier
                val composedMod = factory(Modifier, this, 0)
                materialize(composedMod)
            } else {
                element
            }
        )
    }

    endReplaceableGroup()
    return result
}

 

Composed modifier
상태를 가진(stateful) 특별한 modifier
modifier 구현을 위해 Composition이 필요할 때 사용된다
(modifier 로직에서 remember 사용, CompositionLocal에서 값을 읽는 경우 등)

 

Composed Modifier들은 이들이 수정하는 각 요소에 대해 구성(compose)된다

 

ComposedModifier.kt

private open class ComposedModifier(
    inspectorInfo: InspectorInfo.() -> Unit,
    val factory: @Composable Modifier.() -> Modifier
) : Modifier.Element, InspectorValueInfo(inspectorInfo)

 

factory Composable 람다를 먼저 실행하는 이유

LayoutNode들이 composed modifier를 처리하는 방법을 알지 못하기 때문에 일반 modifier로 변환 후 할당해야 하기 때문

 

Custom modifier 관련 공식 문서에서는 Modifier.composed 확장 함수를 성능 상 이슈로 추천하지 않는다고 한다
하지만 Composition이 필요할 때 여전히 composed 확장 함수는 필요하다

 

 

LayoutNode가 새로운 modifier를 받아들이는 방법 (How LayoutNode ingests new modifiers)

LayoutNode에 modifier가 설정될 때 Modifier의 처리 과정

 

  • Modifier 캐싱
    • 이미 설정된 모든 modifier들은 캐시에 저장된다
    • 새로 설정되는 modifier 체인에 재사용될 수 있는 modifier가 있는지 식별
  • 캐시 초기화 및 재사용 확인
    • 먼저 모든 캐시된 modifier를 재사용할 수 없도록 초기화
    • foldIn 함수를 사용해 새 modifier 체인을 머리부터 순회
    • 각 modifier에 대해 캐시에 동일한 modifier가 있는지 확인하고 있다면 재사용 가능하다고 표시
    • 재사용 가능한 modifier의 모든 부모 modifier도 재사용 가능으로 표시
  • LayoutNodeWrapper 재구성
    • LayoutNode는 자신의 외부 LayoutNodeWrapper를 재구성
    • 이후 modifier들을 순서대로 감싼다
    • 이런 래핑 구조 때문에 새 modifier 체인을 설정할 때는 외부 wrapper부터 재구축

 

 

  • LayoutNodeWrapper 재구축 과정
    • foldOut 함수를 사용해 modifier 체인을 꼬리에서 머리로 순회
    • 초기값으로 innerLayoutNodeWrapper(가장 안쪽 래퍼)로 시작
    • 각 modifier에 대해 캐시를 확인해 재사용 여부 확인 후
      • 재사용 가능 시 해당 래퍼를 체인에 추가하고 캐시에서 제거
      • 재사용 불가능 시 새로운 LayoutNodeWrapper를 생성하고 체인에 추가
    • 각 modifier 성격에 따라 적절한 LayoutNodeWrapper 타입 선택
    • fold 연산 중 체인의 머리(outerLayoutNodeWrapper)에 도달하면 부모 노드의 내부 래퍼로 할당
    • 캐시에 남아있는 모든 modifier를 분리
    • 모든 새로운 래퍼에 attach 함수 호출
    • 필요한 경우 부모 노드의 재측정 요청

 

노드 트리 그리기 (Drawing the node tree)

그리기 과정에서도 LayoutNodeWrapper 체인을 따라 현재 노드가 먼저 그려지고, modifier들, 자식들 순서로 그려진다

안드로이드 시스템에서 그리기는 측정과 배치 단계 이후에 이루어진다

 

Compose에서의 그리기 과정

 

  • 그리기 시작 시 조건
    • LayoutNode가 재측정 요청 시 dirty로 표시
    • 다음 그리기 단계에서 Owner(ex. AndroidComposeView)가 dirty 노드들을 재측정, 재배치 후 그리기 진행
    • Owner는 그리기 전 모든 LayoutNode의 그리기 레이어를 무효화
  • 그리기 실행 순서
    • 루트 LayoutNode에서 시작 -> root.draw(canvas)를 통해
    • 외부 래퍼(outerLayoutNodeWrapper)부터 내부로 진행
    • 현재 노드 -> modifier들 순서대로 -> 자식들 순으로 그려진다
  • LayoutNodeWrapper의 그리기 처리 순서
    • 래퍼에 그리기 레이어가 있으면 해당 레이어에 그리기 지시 (LayoutNode, Modifier 등의 노드를 그림)
    • 그리기 레이어가 없지만 그리기 modifier가 있으면 모드 그린다
    • 둘 다 없으면 체인의 다음 래퍼에 draw 호출
  • 2가지 그리기 레이어 유형
    • RenderNodeLayer
      • RenderNode를 그리기 위한 것
      • Compose UI 노드를 렌더링하는 기본 방법
      • 하드웨어 가속 그리기 지원
    • ViewLayer
      • View 기반 구현
      • RenderNode를 직접 사용할 수 없을 경우 대체 수단으로 사용
  • 레이어 업데이트 과정
    • 모든 래퍼가 그려지면 dispatchDraw 함수는 dirty 플래그가 지정된 레이어들의 표시 목록을 업데이트
    • 레이어는 무효화될 때 dirty로 표시된다
    • 루트 노드는 자식 노드의 상태 변경을 관찰해 필요시 그리기 레이어를 무효화