티스토리 뷰

Android/Compose

Compose UI(2) - Measuring, Modifier

어리둥절범고래 2025. 11. 2. 14:06
반응형

Compose UI의 측정은 LayoutNode 트리를 화면에 렌더링하기 위해 각 UI 요소의 크기와 위치를 계산하는 과정입니다. 이 측정은 Modifier, LayoutNodeWrapper, MeasurePolicy, Constraints, Intrinsic Measurements 등 다양한 컴포넌트의 상호작용을 통해 수행됩니다.

 

 

Owner를 통한 재측정 요청

 

LayoutNode의 구조적 변화(자식 추가, 제거, 이동 등)에 따라 크기나 위치 계산이 다시 필요할 때 Owner를 통해 재측정을 요청합니다.

  • 재측정 트리거: LayoutNode는 자식의 추가(attach), 제거(detach), 이동 등 구조 변경 시 requestRemeasure()를 통해 자신과 부모에 재측정을 요청합니다.
  • 요청 경로: 재측정 요청은 Owner에게 전달되며, Android에서는 AndroidComposeView가 이를 구현합니다.
  • 무효화 처리: 요청된 노드는 dirty 상태로 표시되고, 이후 재측정/재배치 대상으로 스케줄됩니다.
  • 실제 적용 시점: 다음 프레임의 dispatchDraw() 호출 시, 예약된 노드를 순회하며 재측정, 재배치, 연기된 요청 처리 순으로 실행됩니다.
  • 측정 위임 구조: 측정은 LayoutNodeWrapper 체인을 따라 진행되며, Modifier들이 순서대로 적용됩니다.
  • 측정 결과 전파: 측정 결과 크기가 변경되면 부모 노드 또는 루트 노드의 requestLayout()을 통해 Android View 시스템까지 반영됩니다.
/**
 * 이 LayoutNode의 [Owner]를 설정합니다.  
 * 이 LayoutNode는 이미 attach(연결)되어 있어서는 안 됩니다.  
 * 전달된 [owner]는 이 노드의 [parent]의 [owner]와 동일해야 합니다.
 */
internal fun attach(owner: Owner) {
    checkPrecondition(this.owner == null) {
        "Cannot attach $this as it already is attached.  Tree: " + debugTreeToString()
    }
    checkPrecondition(_foldedParent == null || _foldedParent?.owner == owner) {
        "Attaching to a different owner($owner) than the parent's owner(${parent?.owner})." +
                " This tree: " + debugTreeToString() +
                " Parent tree: " + _foldedParent?.debugTreeToString()
    }
    val parent = this.parent
    if (parent == null) {
        // 루트 노드인 경우, 부모가 없으므로 항상 배치된 상태로 간주됩니다.
        // (루트 노드는 부모에 의해 명시적으로 배치되지 않기 때문)
        measurePassDelegate.isPlaced = true
        lookaheadPassDelegate?.let { it.isPlaced = true }
    }

    // 첫 번째 비가상(virtual이 아닌) 부모의 내부 코디네이터(innerCoordinator)를 사용합니다.
    outerCoordinator.wrappedBy = parent?.innerCoordinator

    this.owner = owner
    this.depth = (parent?.depth ?: -1) + 1

    pendingModifier?.let { applyModifier(it) }
    pendingModifier = null

    if (nodes.has(Nodes.Semantics)) {
        invalidateSemantics()
    }

    // Owner에 이 LayoutNode가 연결되었음을 알립니다.
    owner.onAttach(this)

    // attach 시 Lookahead root 갱신
    // 중첩된 경우 항상 가장 가까운 Lookahead root를 사용합니다.
    if (isVirtualLookaheadRoot) {
        lookaheadRoot = this
    } else {
        // 현재 노드가 virtual lookahead root가 아닌 한,
        // 부모의 lookahead root를 우선적으로 사용합니다.
        lookaheadRoot = _foldedParent?.lookaheadRoot ?: lookaheadRoot
        if (lookaheadRoot == null && nodes.has(Nodes.ApproachMeasure)) {
            // MovableContent 내에 intermediateLayout이 이동된 경우 발생할 수 있음
            lookaheadRoot = this
        }
    }

    if (!isDeactivated) {
        nodes.markAsAttached()
    }

    // 모든 자식 노드에 대해 attach 수행
    _foldedChildren.forEach { child ->
        child.attach(owner)
    }

    if (!isDeactivated) {
        nodes.runAttachLifecycle()
    }

    // 측정 무효화 (재측정이 필요함을 표시)
    invalidateMeasurements()
    parent?.invalidateMeasurements()

    // 내부 및 외부 코디네이터에 attach 이벤트 전달
    forEachCoordinatorIncludingInner { it.onLayoutNodeAttach() }

    // attach 시 등록된 콜백 호출
    onAttach?.invoke(owner)

    // 부모 데이터 갱신 (modifier chain의 parent 관련 데이터 업데이트)
    layoutDelegate.updateParentData()

    if (!isDeactivated) {
        invalidateFocusOnAttach()
    }
}

 

 

측정 정책(MeasurePolicy)

  • MeasurePolicy는 LayoutNode의 자식들을 측정하고 배치하는 로직을 정의
  • 최소/최대 너비와 높이로 구성된 Constraints를 기반으로 노드의 크기를 결정
  • LayoutNode에 할당된 측정 정책에서 읽은 상태 값이 변경되면 자동으로 재측정이 발생해 UI가 반응적으로 갱신
  • 측정은 Modifier 래퍼 체인을 따라 위임되며, 마지막에 측정 정책이 자식을 측정
// [MeasurePolicy]는 [Layout]의 측정 및 배치 동작을 정의합니다.
fun interface MeasurePolicy {

    // 실제 측정 및 배치를 정의하는 함수입니다.
    fun MeasureScope.measure(
        measurables: List<Measurable>,
        constraints: Constraints
    ): MeasureResult

    //...
}
interface LayoutInfo {
    fun getModifierInfo(): List<ModifierInfo>
    val width: Int
    val height: Int
    val coordinates: LayoutCoordinates
    val isPlaced: Boolean
    val parentInfo: LayoutInfo?
    val density: Density
    val layoutDirection: LayoutDirection
    val viewConfiguration: ViewConfiguration
    val isAttached: Boolean
    val semanticsId: Int
    val isDeactivated: Boolean get() = false
}

class ModifierInfo(
    val modifier: Modifier,
    val coordinates: LayoutCoordinates,
    val extra: Any? = null
) {
    override fun toString(): String {
        return "ModifierInfo($modifier, $coordinates, $extra)"
    }
}

예시로 Box를 살펴보면 다음과 같습니다.

// 1
@Composable
inline fun Box(
    modifier: Modifier = Modifier,
    contentAlignment: Alignment = Alignment.TopStart,
    propagateMinConstraints: Boolean = false,
    content: @Composable BoxScope.() -> Unit
) {
    val measurePolicy = maybeCachedBoxMeasurePolicy(contentAlignment, propagateMinConstraints)
    Layout(
        content = { BoxScopeInstance.content() },
        measurePolicy = measurePolicy,
        modifier = modifier
    )
}

// 2
internal fun maybeCachedBoxMeasurePolicy(
    alignment: Alignment,
    propagateMinConstraints: Boolean
): MeasurePolicy {
    val cache = if (propagateMinConstraints) cache1 else cache2
    return cache[alignment] ?: BoxMeasurePolicy(alignment, propagateMinConstraints)
}

// 3
private data class BoxMeasurePolicy(
    private val alignment: Alignment,
    private val propagateMinConstraints: Boolean
) : MeasurePolicy {
    override fun MeasureScope.measure(
        measurables: List<Measurable>,
        constraints: Constraints
    ): MeasureResult {
    
        // 빈 Box인 경우
        if (measurables.isEmpty()) {
            return layout(
                constraints.minWidth,
                constraints.minHeight
            ) {}
        }

        val contentConstraints = if (propagateMinConstraints) {
            constraints
        } else {
            constraints.copy(minWidth = 0, minHeight = 0)
        }

        // 단일 자식인 경우
        if (measurables.size == 1) {
            val measurable = measurables[0]
            val boxWidth: Int
            val boxHeight: Int
            val placeable: Placeable
            if (!measurable.matchesParentSize) {
                placeable = measurable.measure(contentConstraints)
                boxWidth = max(constraints.minWidth, placeable.width)
                boxHeight = max(constraints.minHeight, placeable.height)
            } else {
                boxWidth = constraints.minWidth
                boxHeight = constraints.minHeight
                placeable = measurable.measure(
                    Constraints.fixed(constraints.minWidth, constraints.minHeight)
                )
            }
            // 리턴해버림
            return layout(boxWidth, boxHeight) {
                placeInBox(placeable, measurable, layoutDirection, boxWidth, boxHeight, alignment)
            }
        }
        
        
        // 다중 자식인 경우
        val placeables = arrayOfNulls<Placeable>(measurables.size)
        // First measure non match parent size children to get the size of the Box.
        var hasMatchParentSizeChildren = false
        var boxWidth = constraints.minWidth
        var boxHeight = constraints.minHeight
        // 먼저 matchParent가 아닌 자식들부터 측정
        measurables.fastForEachIndexed { index, measurable ->
            if (!measurable.matchesParentSize) {
                val placeable = measurable.measure(contentConstraints)
                placeables[index] = placeable
                boxWidth = max(boxWidth, placeable.width)
                boxHeight = max(boxHeight, placeable.height)
            } else {
                hasMatchParentSizeChildren = true
            }
        }

        // 그 다음 matchParent인 자식들 측정
        if (hasMatchParentSizeChildren) {
            val matchParentSizeConstraints = Constraints(
                minWidth = if (boxWidth != Constraints.Infinity) boxWidth else 0,
                minHeight = if (boxHeight != Constraints.Infinity) boxHeight else 0,
                maxWidth = boxWidth,
                maxHeight = boxHeight
            )
            measurables.fastForEachIndexed { index, measurable ->
                if (measurable.matchesParentSize) {
                    placeables[index] = measurable.measure(matchParentSizeConstraints)
                }
            }
        }

        return layout(boxWidth, boxHeight) {
            placeables.forEachIndexed { index, placeable ->
                placeable as Placeable
                val measurable = measurables[index]
                placeInBox(placeable, measurable, layoutDirection, boxWidth, boxHeight, alignment)
            }
        }
    }
}

 

 

제약 조건(Constraints)


측정 정책은 노드와 자식 노드의 크기를 결정하기 위해 제약 조건을 필수적으로 사용합니다.

  • 제약 조건은 너비와 높이에 대한 최소값(min)최대값(max)으로 구성된 픽셀 단위의 범위이며, 측정된 레이아웃(자식)은 이 범위 내에 맞아야 합니다.
  • 측정 정책은 이 제약 조건을 사용하여 레이아웃의 너비와 높이를 결정합니다.
@Immutable
@JvmInline
value class Constraints(val value: Long) {

    val minWidth: Int get() = ...
    val maxWidth: Int get() = ...
    val minHeight: Int get() = ...
    val maxHeight: Int get() = ...

    val hasBoundedWidth: Boolean get() = ...
    val hasBoundedHeight: Boolean get() = ...
    val hasFixedWidth: Boolean get() = ...
    val hasFixedHeight: Boolean get() = ...
    val isZero: Boolean get() = ...

    fun copy(
        minWidth: Int = this.minWidth,
        maxWidth: Int = this.maxWidth,
        minHeight: Int = this.minHeight,
        maxHeight: Int = this.maxHeight
    ): Constraints = createConstraints(minWidth, maxWidth, minHeight, maxHeight)

    companion object {
        const val Infinity = Int.MAX_VALUE

        fun fixed(width: Int, height: Int): Constraints = 
            createConstraints(width, width, height, height)

        fun fixedWidth(width: Int): Constraints = 
            createConstraints(width, width, 0, Infinity)

        fun fixedHeight(height: Int): Constraints = 
            createConstraints(0, Infinity, height, height)

        fun fitPrioritizingWidth(minWidth: Int, maxWidth: Int, minHeight: Int, maxHeight: Int): Constraints = ...
        fun fitPrioritizingHeight(minWidth: Int, maxWidth: Int, minHeight: Int, maxHeight: Int): Constraints = ...
    }
}

internal fun createConstraints(
    minWidth: Int,
    maxWidth: Int,
    minHeight: Int,
    maxHeight: Int
): Constraints {
    val heightVal = if (maxHeight == Infinity) minHeight else maxHeight
    val heightBits = bitsNeedForSizeUnchecked(heightVal)

    val widthVal = if (maxWidth == Infinity) minWidth else maxWidth
    val widthBits = bitsNeedForSizeUnchecked(widthVal)

    if (widthBits + heightBits > 31) {
        invalidConstraint(widthVal, heightVal)
    }

    // Same as if (maxWidth == Infinity) 0 else maxWidth + 1 but branchless
    // in DEX and saves 2 instructions on aarch64. Relies on integer overflow
    // since Infinity == Int.MAX_VALUE
    var maxWidthValue = maxWidth + 1
    maxWidthValue = maxWidthValue and (maxWidthValue shr 31).inv()

    var maxHeightValue = maxHeight + 1
    maxHeightValue = maxHeightValue and (maxHeightValue shr 31).inv()

    val focus = when (widthBits) {
        MinNonFocusBits -> MinFocusHeight
        MinFocusBits -> MinFocusWidth
        MaxNonFocusBits -> MaxFocusHeight
        MaxFocusBits -> MaxFocusWidth
        else -> 0x00 // can't happen, widthBits is computed from bitsNeedForSizeUnchecked()
    }

    val minHeightOffset = minHeightOffsets(indexToBitOffset(focus))
    val maxHeightOffset = minHeightOffset + 31

    val value = focus.toLong() or
        (minWidth.toLong() shl 2) or
        (maxWidthValue.toLong() shl 33) or
        (minHeight.toLong() shl minHeightOffset) or
        (maxHeightValue.toLong() shl maxHeightOffset)
    return Constraints(value)
}
fun Spacer(modifier: Modifier) {
    Layout(measurePolicy = SpacerMeasurePolicy, modifier = modifier)
}

private object SpacerMeasurePolicy : MeasurePolicy {

    override fun MeasureScope.measure(
        measurables: List<Measurable>,
        constraints: Constraints
    ): MeasureResult {
        return with(constraints) {
            val width = if (hasFixedWidth) maxWidth else 0
            val height = if (hasFixedHeight) maxHeight else 0
            layout(width, height) {}
        }
    }
}
@Composable
fun Box(modifier: Modifier) {
    Layout(measurePolicy = EmptyBoxMeasurePolicy, modifier = modifier)
}

internal val EmptyBoxMeasurePolicy = MeasurePolicy { _, constraints ->
    layout(constraints.minWidth, constraints.minHeight) {}
}



LayoutNodeWrapper 체인

 

(현재는 NodeCoordinator로 이름이 변경됨)

 

LayoutNodeWrapper 체인은 Compose UI에서 측정(measuring)과 그리기(drawing) 로직을 순차적으로 적용하고 관리하는 핵심 구조입니다. 이는 특히 Modifier가 UI 요소의 크기나 레이아웃에 영향을 미칠 때 중요한 역할을 합니다.

 

LayoutNodeWrapper 체인의 구조 및 구성 요소

  • LayoutNode는 여러 개의 LayoutNodeWrapper로 이루어진 체인을 갖고 있으며, 이 체인은 측정과 그리기 로직을 순차적으로 적용하는 데 사용됩니다. 이 체인은 세 가지 종류의 래퍼로 구성되어 있으며, Modifier들이 정의된 순서대로 정확하게 측정과 그리기가 이뤄지도록 보장합니다.
  • 외부 래퍼(Outer): 현재 노드의 측정과 그리기를 담당
  • Modifier 래퍼: LayoutNode에 적용된 Modifier 하나하나를 감싸며 상태를 유지하고 관련 기능(draw, hit test 등)을 담당
  • 내부 래퍼(Inner): 현재 노드의 자식 노드들을 측정하고 그리는 데 사용

측정 프로세스와 체인 내 역할 분담

  • 측정은 LayoutNode에 재측정 요청이 들어오면서 시작되며, 외부 래퍼에서 최초로 측정이 호출됩니다.
  • 부모 LayoutNode는 자식의 외부 래퍼를 측정할 때 자신의 MeasurePolicy를 사용하여 제약조건(Constraints)을 전달합니다.
  • 이 과정에서 Modifier 체인을 따라 외부에서 내부로 측정 호출이 순차적으로 위임되며, 각 Modifier 래퍼는 자식에게 전달할 제약조건을 수정하거나 레이아웃 동작을 추가할 수 있습니다.
  • 체인의 마지막에 도달하면 내부 래퍼가 자식 노드들의 외부 래퍼를 재측정하여 자식들의 레이아웃을 완성합니다.
  • 측정 결과에 따라 현재 노드의 크기가 바뀌면, 부모 노드에게 재측정 또는 재배치를 요청하게 됩니다.

체인의 연결 및 LayoutNode 삽입

  • 새로운 LayoutNode가 Compose 트리에 삽입될 때, UiApplier는 LayoutNode.insertAt()을 호출하여 해당 노드를 부모와 연결합니다.
  • 이 과정에서 새 노드의 외부 래퍼는 부모 노드의 내부 래퍼에 의해 감싸지도록 설정되며, 이를 통해 체인이 자연스럽게 연결됩니다.
  • 이 구조는 부모에서 자식으로 연결된 Modifier 및 측정 체인을 형성하며, 측정과 그리기 로직이 계층적으로 일관되게 흐를 수 있도록 합니다.
// LayoutNode.kt
internal fun insertAt(index: Int, instance: LayoutNode) {
        instance._foldedParent = this
        _foldedChildren.add(index, instance)
        onZSortedChildrenInvalidated()

        if (instance.isVirtual) {
            virtualChildrenCount++
        }
        invalidateUnfoldedVirtualChildren()

        val owner = this.owner
        if (owner != null) {
            instance.attach(owner)
        }

        if (instance.layoutDelegate.childrenAccessingCoordinatesDuringPlacement > 0) {
            layoutDelegate.childrenAccessingCoordinatesDuringPlacement++
        }
    }
internal abstract class NodeCoordinator(
    override val layoutNode: LayoutNode
) : LookaheadCapablePlaceable(), Measurable, LayoutCoordinates, OwnerScope {

    internal var wrapped: NodeCoordinator? = null
    internal var wrappedBy: NodeCoordinator? = null
    abstract val tail: Modifier.Node

    override val layoutDirection get() = layoutNode.layoutDirection
    override val density get() = layoutNode.density.density
    override val fontScale get() = layoutNode.density.fontScale
    override val coordinates get() = this
    override val parent get() = wrappedBy
    override val alignmentLinesOwner get() = layoutNode.layoutDelegate.alignmentLinesOwner
    override val child get() = wrapped
    override val isAttached get() = tail.isAttached
    override val hasMeasureResult get() = _measureResult != null

    private var released = false
    private var _measureResult: MeasureResult? = null
    override var measureResult: MeasureResult
        get() = _measureResult ?: error("Unmeasured")
        internal set(value) {
            _measureResult = value
            measuredSize = IntSize(value.width, value.height)
        }

    final override val size: IntSize get() = measuredSize
    override var position: IntOffset = IntOffset.Zero
        protected set
    var zIndex: Float = 0f
        protected set

    var layer: OwnedLayer? = null
        private set
    private var explicitLayer: GraphicsLayer? = null
    protected var layerBlock: (GraphicsLayerScope.() -> Unit)? = null

    open fun placeAt(position: IntOffset, zIndex: Float, layerBlock: (GraphicsLayerScope.() -> Unit)?) {
        placeSelf(position, zIndex, layerBlock)
    }

    private fun placeSelf(position: IntOffset, zIndex: Float, layerBlock: (GraphicsLayerScope.() -> Unit)?) {
        if (this.position != position) {
            this.position = position
            layoutNode.owner?.onLayoutChange(layoutNode)
        }
        this.zIndex = zIndex
        this.layerBlock = layerBlock
    }

    fun draw(canvas: Canvas, graphicsLayer: GraphicsLayer?) {
        val x = position.x.toFloat()
        val y = position.y.toFloat()
        canvas.translate(x, y)
        wrapped?.draw(canvas, graphicsLayer)
        canvas.translate(-x, -y)
    }

    open fun onPlaced() {
        layoutNode.owner?.onLayoutChange(layoutNode)
    }

    open fun invalidateLayer() {
        layer?.invalidate() ?: wrappedBy?.invalidateLayer()
    }

    fun onRelease() {
        released = true
        invalidateLayer()
        layoutNode.requestRelayout()
    }
}

 

 

 

Modifier 체인 모델링

 

Modifier 체인 모델링은 Compose UI에서 Composable에 기능(동작, 스타일 등)을 추가하는 불변 객체들의 연결 리스트 구조이며, 측정과 그리기 과정에 직접적으로 관여하는 구성 요소입니다.

  • Modifier는 자신을 감싸는 방식으로 연결되는 연결 리스트(linked list) 형태로 구성되며, 각 Modifier는 다음 Modifier를 감싼 구조를 형성합니다.
  • then() 함수로 두 Modifier가 결합되면 CombinedModifier 객체가 생성되며, 이는 outer(현재 Modifier)와 inner(다음 Modifier)를 참조하여 전체 체인을 구성합니다.
// Modifier.kt
infix fun then(other: Modifier): Modifier =
    if (other === Modifier) this else CombinedModifier(this, other)
  • 여러 Modifier가 연결될 경우, 중첩 구조로 표현됩니다.
// Modifier.kt
class CombinedModifier(
    private val outer: Modifier,
    private val inner: Modifier
) : Modifier
  • Modifier는 명시적으로 then()을 사용하거나, Modifier.padding().background()와 같은 방식으로 암묵적으로 연결됩니다. 내부적으로는 모두 then()을 호출하여 결합합니다.
  • Modifier는 불변(immutable) 객체이기 때문에 체인이 결합될 때마다 새로운 CombinedModifier 인스턴스가 생성됩니다.
  • Modifier 체인은 LayoutNode의 측정(measuring)과 그리기(drawing) 과정에서 중요한 역할을 하며, Modifier에 해당하는 래퍼들이 순서대로 적용됩니다.
  • 측정 과정은 LayoutNode의 외부 래퍼에서 LayoutNode.measure()가 호출되면서 시작되어 Modifier 체인을 따라 각 Modifier가 순서대로 개입하며 진행됩니다. 각 Modifier는 자신만의 측정 로직을 통해 자식에게 전달할 Constraints를 조정하거나 새로운 레이아웃 동작을 추가할 수 있습니다.
  • 그리기 단계에서는 LayoutNode.draw()에서 시작되며, outerCoordinator.draw(canvas) 호출이 Modifier 체인을 따라 전달되며, 각 Modifier는 순서대로 시각적 효과를 적용합니다. DrawModifier들은 배경, 테두리, 그림자 등 다양한 그래픽 효과를 그리기 순서에 따라 렌더링하며, 각 Modifier는 drawContent()를 통해 자식 콘텐츠를 이어서 그릴 수 있습니다.
  • Modifier는 foldIn (head→tail), foldOut (tail→head)  메서드를 통해 Modifier 체인 내의 Modifier들을 순차적으로 순회하며 값을 누적하거나 처리할 수 있는 기능을 제공합니다.
// 이랬던 애가
Modifier
    .padding(8.dp)
    .background(Color.Red)
    .border(2.dp, Color.Black)

-------------------------------------------------------------

@Stable
fun Modifier.padding(all: Dp) = this then PaddingElement(
    start = all,
    top = all,
    end = all,
    bottom = all,
    rtlAware = true,
    inspectorInfo = {
        name = "padding"
        value = all
    }
)

@Stable
fun Modifier.background(
    brush: Brush,
    shape: Shape = RectangleShape,
    @FloatRange(from = 0.0, to = 1.0)
    alpha: Float = 1.0f
) = this.then(
    BackgroundElement(
        brush = brush,
        alpha = alpha,
        shape = shape,
        inspectorInfo = debugInspectorInfo {
            name = "background"
            properties["alpha"] = alpha
            properties["brush"] = brush
            properties["shape"] = shape
        }
    )
)

@Stable
fun Modifier.border(width: Dp, brush: Brush, shape: Shape) =
    this then BorderModifierNodeElement(width, brush, shape)
    
-------------------------------------------------------------

// 이렇게 된다!
Modifier
    .then(PaddingModifier)
    .then(BackgroundModifier)
    .then(BorderModifier)
interface Modifier {
    fun <R> foldIn(initial: R, operation: (R, Element) -> R): R
    fun <R> foldOut(initial: R, operation: (Element, R) -> R): R
    fun any(predicate: (Element) -> Boolean): Boolean
    fun all(predicate: (Element) -> Boolean): Boolean
    infix fun then(other: Modifier): Modifier =
        if (other === Modifier) this else CombinedModifier(this, other)

    interface Element : Modifier {
        override fun <R> foldIn(initial: R, operation: (R, Element) -> R): R =
            operation(initial, this)
        override fun <R> foldOut(initial: R, operation: (Element, R) -> R): R =
            operation(this, initial)
        override fun any(predicate: (Element) -> Boolean): Boolean = predicate(this)
        override fun all(predicate: (Element) -> Boolean): Boolean = predicate(this)
    }

    companion object : Modifier {
        override fun <R> foldIn(initial: R, operation: (R, Element) -> R): R = initial
        override fun <R> foldOut(initial: R, operation: (Element, R) -> R): R = initial
        override fun any(predicate: (Element) -> Boolean): Boolean = false
        override fun all(predicate: (Element) -> Boolean): Boolean = true
        override infix fun then(other: Modifier): Modifier = other
        override fun toString() = "Modifier"
    }
}
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(): String = "[" + foldIn("") { acc, element ->
        if (acc.isEmpty()) element.toString() else "$acc, $element"
    } + "]"
}

 

 

 

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

 

그리기 레이어(RenderNodeLayer 및 ViewLayer)는 Compose UI가 LayoutNode 트리안드로이드 캔버스에 효율적으로 렌더링하고 하드웨어 가속을 활용할 수 있도록 지원합니다.

  • 그리기 레이어는 LayoutNode의 렌더링 명령을 플랫폼에 전달하는 핵심 수단이며, 그리기 시작은 루트 LayoutNode의 외부 래퍼에서 이루어짐
  • AndroidComposeView는 dispatchDraw 이전에 모든 LayoutNode의 그리기 레이어를 무효화하여 다시 그려야 함을 표시
  • LayoutNodeWrapper 체인을 따라 그리기 레이어가 있으면 해당 레이어에 그리기를 지시하며, Modifier 및 자식 노드도 순차적으로 그려짐
  • RenderNodeLayer: 기본 그리기 레이어로 RenderNode를 기반으로 하드웨어 가속을 적극 활용하며, 효율적인 재사용이 가능
  • ViewLayer: RenderNode 사용이 불가능할 때 사용하는 대체 방식으로 ViewGroup.drawChild()를 통해 그리기를 처리
  • 두 레이어 타입 모두 직접 또는 간접적으로 RenderNode에 의존하여 하드웨어 가속을 지원
  • 어떤 레이어를 사용할지는 Owner가 결정하며, AndroidComposeView는 지원 시 RenderNodeLayer를 우선 사용
  • 루트 LayoutNode의 그리기 레이어는 Compose 상태를 관찰하며, 변경 시 자동으로 무효화되어 다시 그려짐
internal class RenderNodeLayer(
    val ownerView: AndroidComposeView,
    drawBlock: (canvas: Canvas, parentLayer: GraphicsLayer?) -> Unit,
    invalidateParentLayer: () -> Unit,
) : OwnedLayer, GraphicLayerInfo {

    private var drawBlock: ((canvas: Canvas, parentLayer: GraphicsLayer?) -> Unit)? = drawBlock
    private var invalidateParentLayer: (() -> Unit)? = invalidateParentLayer
    private var isDirty = false
    private var transformOrigin: TransformOrigin = TransformOrigin.Center

    private val renderNode = RenderNodeApi29(ownerView).apply {
        setHasOverlappingRendering(true)
        clipToBounds = false
    }

    private val matrixCache = LayerMatrixCache { rn, matrix -> rn.getMatrix(matrix) }
    private val canvasHolder = CanvasHolder()

    override fun updateLayerProperties(scope: ReusableGraphicsLayerScope) {
        renderNode.scaleX = scope.scaleX
        renderNode.scaleY = scope.scaleY
        renderNode.alpha = scope.alpha
        renderNode.translationX = scope.translationX
        renderNode.translationY = scope.translationY
        renderNode.rotationZ = scope.rotationZ
        renderNode.elevation = scope.shadowElevation
        renderNode.clipToBounds = scope.clip
        renderNode.pivotX = transformOrigin.pivotFractionX * renderNode.width
        renderNode.pivotY = transformOrigin.pivotFractionY * renderNode.height
        matrixCache.invalidate()
        triggerRepaint()
    }

    override fun resize(size: IntSize) {
        renderNode.setPosition(
            renderNode.left,
            renderNode.top,
            renderNode.left + size.width,
            renderNode.top + size.height
        )
        renderNode.pivotX = transformOrigin.pivotFractionX * size.width
        renderNode.pivotY = transformOrigin.pivotFractionY * size.height
        invalidate()
    }

    override fun move(position: IntOffset) {
        renderNode.offsetLeftAndRight(position.x - renderNode.left)
        renderNode.offsetTopAndBottom(position.y - renderNode.top)
        triggerRepaint()
        matrixCache.invalidate()
    }

    override fun invalidate() {
        if (!isDirty) {
            ownerView.invalidate()
            isDirty = true
        }
    }

    private fun triggerRepaint() {
        ownerView.invalidate()
    }

    override fun drawLayer(canvas: Canvas, parentLayer: GraphicsLayer?) {
        updateDisplayList()
        renderNode.drawInto(canvas.nativeCanvas)
    }

    override fun updateDisplayList() {
        drawBlock?.let { renderNode.record(canvasHolder, null) { it(it, null) } }
        isDirty = false
    }

    override fun destroy() {
        drawBlock = null
        invalidateParentLayer = null
        isDirty = false
    }

    override fun reuseLayer(
        drawBlock: (canvas: Canvas, parentLayer: GraphicsLayer?) -> Unit,
        invalidateParentLayer: () -> Unit
    ) {
        matrixCache.reset()
        isDirty = false
        this.drawBlock = drawBlock
        this.invalidateParentLayer = invalidateParentLayer
    }

    override fun transform(matrix: Matrix) {
        matrix.timesAssign(matrixCache.calculateMatrix(renderNode))
    }

    override fun inverseTransform(matrix: Matrix) {
        matrixCache.calculateInverseMatrix(renderNode)?.let {
            matrix.timesAssign(it)
        }
    }
}

 

 

 

Jetpack Compose에서의 Semantics (의미론)

 

Semantics 트리는 Compose UI를 접근성 서비스나 테스팅 도구가 이해할 수 있도록 설명하는 별도의 트리 구조입니다.

  • UI 트리와 별개로 존재하는 의미론적 트리로, 노드는 label, description, 상태 정보 등 메타데이터를 가집니다.
  • 접근성 및 UI 테스트에 필수 요소입니다.
  • Owner (AndroidComposeView)가 Semantics 트리와 Android 접근성 시스템을 연결합니다.
  • setContent 시 기본 Semantics Modifier가 적용되어 트리 루트가 구성됩니다.
  • Semantics가 바뀌면 Owner가 접근성 이벤트를 트리거합니다.
  • 트리 구조나 속성이 바뀌면 시스템에 접근성 이벤트로 알림을 보냅니다.
  • Android SDK의 AccessibilityDelegateCompat를 통해 전달됩니다.
  • Modifier.semantics { } 로 의미를 지정할 수 있습니다.
  • 내부적으로 고유 ID를 기억하고, composed Modifier로 구성됩니다.
  • Compose는 병합된(merged) Semantics 트리와 병합되지 않은(unmerged) 트리를 유지합니다.
@Composable
fun Icon(
    painter: Painter,
    contentDescription: String?,
    modifier: Modifier = Modifier,
    tint: Color = LocalContentColor.current
) {
    val colorFilter =
        remember(tint) { if (tint == Color.Unspecified) null else ColorFilter.tint(tint) }
        
    // Compose Semantics
    val semantics =
        if (contentDescription != null) {
            Modifier.semantics {
                this.contentDescription = contentDescription
                this.role = Role.Image
            }
        } else {
            Modifier
        }
    Box(
        modifier
            .toolingGraphicsLayer()
            .defaultSizeFor(painter)
            .paint(painter, colorFilter = colorFilter, contentScale = ContentScale.Fit)
            .then(semantics)
    )
}
/**
 * 사용자 인터페이스 요소의 유형입니다. 접근성 서비스는 이를 사용하여 요소를 설명하거나
 * 사용자 정의를 수행할 수 있습니다. 대부분의 역할은 이 요소의 semantics 속성을 통해
 * 자동으로 해결할 수 있습니다. 그러나 미묘한 차이가 있는 일부 요소는 정확한 역할 지정이 필요합니다.
 * 정확한 역할이 나열되지 않은 경우, [SemanticsPropertyReceiver.role]은 설정하지 않아야 하며,
 * 프레임워크가 자동으로 역할을 결정합니다.
 */
@Immutable
@JvmInline
value class Role private constructor(@Suppress("unused") private val value: Int) {
    companion object {
        // 아래 7가지만 지원하는 듯 하다.
        val Button = Role(0)
        val Checkbox = Role(1)
        val Switch = Role(2)
        val RadioButton = Role(3)
        val Tab = Role(4)
        val Image = Role(5)
        val DropdownList = Role(6)
    }

    override fun toString() = when (this) {
        Button -> "Button"
        Checkbox -> "Checkbox"
        Switch -> "Switch"
        RadioButton -> "RadioButton"
        Tab -> "Tab"
        Image -> "Image"
        DropdownList -> "DropdownList"
        else -> "Unknown"
    }
}
반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2026/01   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
글 보관함