본문 바로가기

Compose

[ComposeInternals] 컴포즈 UI (Compose UI) - (2) Measure(측정)

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

 

 

Compose UI에서의 측정 (Measuring in Compose UI)

실제 측정이 어떻게 이뤄지는가

 

모든 LayoutNode는 Owner를 통해 재측정을 요청할 수 있다

요청 시 Owner(뷰)는 "dirty"로 표시(invalidate)되며 노드는 재측정 및 재배치할 노드 목록에 추가된다

다음 그리기 시점에 AndroidComposeView의 dispatchDraw 함수가 호출된다

AndroidComposeView는 재측정 및 재배치할 노드 목록을 순회하고 해당 작업 수행

 

재측정 및 재배치가 예정된 노드에 적용되는 3가지 단계

  • 노드가 재측정이 필요한지 확인 후 재측정 수행
  • 측정 후, 재배치가 필요한지 확인 후 재배치 수행
  • 모든 노드에 대해 연기된 측정 요청이 있는지 확인하고 이 노드들의 재측정 예약 (1단계로 다시)

각 노드 측정시 외부 LayoutNodeWrapper에 위임

 

Wrapper를 통해 측정 과정이 어떻게 이루어지는지 알 수 있다

internal fun insertAt(index: Int, instance: LayoutNode) {
    ...
    // 노드가 삽입될 때 노드의 외부 래퍼가 현재 노드(새로운 부모)의 내부 래퍼에 의해 감싸진다
    instance.outerLayoutNodeWrapper.wrappedBy = innerLayoutNodeWrapper
    ...
}

 

각 LayoutNode는 외부와 내부의 LayoutNodeWrapper를 가지고 있다

 

외부 래퍼는 현재 노드의 측정과 그리기를 담당

내부 래퍼는 자식들에 대해 동일한 작업 수행

하지만 노드에는 측정에 영향을 줄 수 있는 modifier가 적용될 수 있기에 불완전하다

Modifier는 상태가 없기에 상태를 유지하기 위해 래퍼가 필요하다

LayoutNode는 내부/외부 래퍼 뿐만 아니라 modifier 각각에 대한 래퍼를 가진다

래퍼는 연쇄적으로 연결되어 있고 순서대로 적용된다

 

래퍼가 서로 연결되어 있는 방식

  • 부모 LayoutNode는 해당 노드의 measurePolicy를 사용해 모든 자식의 외부 래퍼를 측정
  • 각 자식의 외부 래퍼는 체인의 첫 번째 modifier를 wrap
  • 해당 modifier는 다음 modifier wrap
  • modifier 수많큼 순차적으로 wrap
  • 마지막 modifier는 내부 래퍼를 wrap
  • 내부 래퍼는 현재 노드의 measurePolicy를 사용해 각 자식의 외부 래퍼 측정

 

노드를 측정하는 동안, 측정 람다(MeasurePolicy) 내 모든 변경 가능한 상태 읽기가 기록된다

측정 정책은 외부에서 전달되며 Compose State에 자유롭게 의존할 수 있다

측정 후, 이전 크기와 현재 측정된 크기를 비교해 변경될 경우 부모에게 재측정 요청

 

 

측정 정책 (Measuring policies)

@UiComposable
@Composable
inline fun Layout(
    content: @Composable @UiComposable () -> Unit,
    modifier: Modifier = Modifier,
    measurePolicy: MeasurePolicy	// 측정 정책
) {
    ...
    ReusableComposeNode<ComposeUiNode, Applier<Any>>(
        factory = ComposeUiNode.Constructor,
        update = {
            // 부모가 아닌 노드에 직접 설정된 측정 정책을 사용
            set(measurePolicy, SetMeasurePolicy)
            ...
        },
        skippableUpdate = materializerOf(modifier),
        content = content
    )
}

 

예시 1) Spacer Composable

@Composable
@NonRestartableComposable
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) {}
        }
    }
}

 

예시 2) Box Composable

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

@PublishedApi
@Composable
internal fun rememberBoxMeasurePolicy(
    alignment: Alignment,
    propagateMinConstraints: Boolean
): MeasurePolicy = if (alignment == Alignment.TopStart && !propagateMinConstraints) {
    DefaultBoxMeasurePolicy
} else {
    remember(alignment, propagateMinConstraints) {
        BoxMeasurePolicy(alignment, propagateMinConstraints)
    }
}

 

BoxMeasurePolicy

private data class BoxMeasurePolicy(
    private val alignment: Alignment,
    private val propagateMinConstraints: Boolean
) : MeasurePolicy {
    // 측정 정책 구현체
    override fun MeasureScope.measure(
        measurables: List<Measurable>,
        constraints: Constraints
    ): MeasureResult {
        // Box에 자식이 없는 경우 부모나 modifier에 의해 부여된 최소 너비와 높이에 맞추어 크기 조정
        if (measurables.isEmpty()) {
            return layout(
                constraints.minWidth,
                constraints.minHeight
            ) {}
        }
        
        // 자식에게 전파되도록(true)이면 부여된 제약 조건 적용하고
        // 아닐 경우 최소 너비, 높이 제한을 제거하고 자식들이 이에 대해 결정할 수 있도록
        val contentConstraints = if (propagateMinConstraints) {
            constraints
        } else {
            constraints.copy(minWidth = 0, minHeight = 0)
        }
        
        /* 자식이 하나인지 아닌지 체크 */
        if (measurables.size == 1) {
            val measurable = measurables[0]
            ...
            // 부모 크기에 맞추도록 설정되지 않는 경우 (Modifier.fillMaxSize()),
            // 감싸고 있는 컨텐츠에 맞게 크기 조정
            if (!measurable.matchesParentSize) {
                placeable = measurable.measure(contentConstraints)
                boxWidth = max(constraints.minWidth, placeable.width)
                boxHeight = max(constraints.minHeight, placeable.height)
            }
            // Box가 부모 크기와 일치하도록 설정되면,
            // 너비와 높이가 상위 제약 조건에 의해 적용된 최소너비, 높이와 동일한 값으로 설정
            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)
        
        // 부모 크기에 맞추지 않도록 설정된 모든 자식을 측정하는 것으로 시작
        var hasMatchParentSizeChildren = false
        var boxWidth = constraints.minWidth
        var boxHeight = constraints.minHeight
        
        // 모든 자식들 순회
        // 자식들이 부여된 최소 제약 조건을 초과할 경우 Box를 이에 맞게 조정하거나, 최소 제약 조건을 크기로 설정
        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
            }
        }

        // 부모 크기와 일치하는 모든 자식들이 계산된 제약 조건을 사용해 측정된다
        if (hasMatchParentSizeChildren) {
            // The infinity check is needed for default intrinsic measurements.
            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 목록에 추가
                    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)
            }
        }
    }
}

 

 

고유 크기 측정 (Intrinsic measurements)

MeasurePolicy는 레이아웃의 고유 크기를 계산하는 함수를 포함한다

고유 크기란, 제약 조건이 없을 때 레이아웃의 추정 크기를 의미

측정(measure) 단계 이전에 자식의 추정 크기를 알아야 할 때 유용하다

 

MeasurePolicy

@Stable
@JvmDefaultWithCompatibility
fun interface MeasurePolicy {
    fun MeasureScope.measure(
        measurables: List<Measurable>,
        constraints: Constraints
    ): MeasureResult

    /* 반대 치수를 제공해 계산 */
    
    // 주어진 높이에 대한 minInstrinsicWidth
    // 특정 높이에 대해 레이아웃이 가질 수 있는 최소 너비 제공
    fun IntrinsicMeasureScope.minIntrinsicWidth(
        measurables: List<IntrinsicMeasurable>,
        height: Int
    ): Int {
        ...
        return layoutResult.width
    }

    // 주어진 너비에 대한 minInstrinsicHeight
    // 특정 너비에 대해 레이아웃이 가질 수 있는 최소 높이 제공
    fun IntrinsicMeasureScope.minIntrinsicHeight(
        measurables: List<IntrinsicMeasurable>,
        width: Int
    ): Int {
        ...
        return layoutResult.height
    }

    // 주어진 높이에 대한 maxIntrinsicWidth
    // 더 늘려도 최소 고유 높이가 감소하지 않는 최소 너비를 제공
    fun IntrinsicMeasureScope.maxIntrinsicWidth(
        measurables: List<IntrinsicMeasurable>,
        height: Int
    ): Int {
        ...
        return layoutResult.width
    }

    // 주어진 너비에 대한 maxInstrinsicHeight
    // 더 늘려도 최소 본질적 너비가 감소하지 않는 최소 높이를 제공
    fun IntrinsicMeasureScope.maxIntrinsicHeight(
        measurables: List<IntrinsicMeasurable>,
        width: Int
    ): Int {
        ...
        return layoutResult.height
    }
}

 

예시) Modifier.width(intrinsicSize: IntrinsicSize)

IntrinsicSize.Max일 경우 최소 고유 너비를 최대 고유 너비와 일치시키기 위해 덮어쓰고, 들어오는 maxHeight 제약 조건에 대해 가능한 최대 고유 너비와 일치하도록 컨텐츠 제약 조건을 고정한다

@Stable
fun Modifier.width(intrinsicSize: IntrinsicSize) = this then IntrinsicWidthElement(
    width = intrinsicSize,
    enforceIncoming = true,
    inspectorInfo = debugInspectorInfo {
        name = "width"
        properties["intrinsicSize"] = intrinsicSize
    }
)

enum class IntrinsicSize { Min, Max }

private class IntrinsicWidthElement(
    val width: IntrinsicSize,
    val enforceIncoming: Boolean,
    val inspectorInfo: InspectorInfo.() -> Unit
) : ModifierNodeElement<IntrinsicWidthNode>() {

    override fun create() = IntrinsicWidthNode(width, enforceIncoming)
    ...
}

private class IntrinsicWidthNode(
    var width: IntrinsicSize,
    override var enforceIncoming: Boolean
) : IntrinsicSizeModifier() {

    // IntrinsicSize 타입에 따라 분기
    override fun MeasureScope.calculateContentConstraints(
        measurable: Measurable,
        constraints: Constraints
    ): Constraints {
        var measuredWidth = if (width == IntrinsicSize.Min) {
            measurable.minIntrinsicWidth(constraints.maxHeight)
        } else {
            measurable.maxIntrinsicWidth(constraints.maxHeight)
        }
        if (measuredWidth < 0) { measuredWidth = 0 }
        return Constraints.fixedWidth(measuredWidth)
    }

    override fun IntrinsicMeasureScope.minIntrinsicWidth(
        measurable: IntrinsicMeasurable,
        height: Int
    ) = if (width == IntrinsicSize.Min) measurable.minIntrinsicWidth(height) else
        measurable.maxIntrinsicWidth(height)

    override fun IntrinsicMeasureScope.maxIntrinsicWidth(
        measurable: IntrinsicMeasurable,
        height: Int
    ) = if (width == IntrinsicSize.Min) measurable.minIntrinsicWidth(height) else
        measurable.maxIntrinsicWidth(height)
}

 

그렇다면, 사용자 관점에서 modifier를 사용할 때 UI는 어떻게 보이는 것일까

 

예시) DropdownMenuContent를 통해 알아보자

Column의 너비를 모든 자식의 최대 고유 너비와 일치하도록 설정

@Composable
fun DropdownMenuContent(modifier: Modifier = Modifier) {
    ...
    Column(
        modifier = modifier
            .padding(vertical = DropdownMenuVerticlaPadding)
            .width(IntrinsicSize.Max)
            .verticalScroll(rememberScrollState()),
        content = content
    ) { ... }
}