[Android] View #2 Compose UI

6 분 소요

Android View #1에 이어서 Compose Layout은 Activity에서 어떻게 연결되고, 어떻게 생성되고, 사라지기 까지의 lifecycle에 대해서 공부한내용을 정리해보고자 한다.

What’s Difference


Jetpack Compose는 선언형 UI 패러다임으로 kotlin 코드로 작성하여 UI를 만들수 있는 프레임워크 이다.

기존의 명령형 UI 패러다임으로 작성했던 Android View는 여러가지 문제점이 있었다.

Overdraw

Android View #1 에서 보았듯이, 기존의 방법은 layoutResource를 inflate 과정을 통해 메모리상에 tree 구조로 뷰객체를 만들어 둔다.

ViewGroup이 많아질수록 tree의 구조는 깊어지고, 이것이 rendering 과정에서 덮어쓰는 형태로 그려지다보니 성능상 문제가 심각했다.

그것의 대안으로 만들어진 것이 constraintLayout 이고 우리는 이 viewgroup 을 통해 view tree의 깊이를 평탄화 하여 UI element들을 생성할수 있었다.

이와 달리 Compose Layout은 UI tree를 gap Buffer자료구조로 메모리에 1차원배열 구조로 저장하기 때문에 이러한 문제들을 해결하였다.

Measure Pass

View의 lifecycle과정에서 보았듯이, onMeasure()는 한번이상 호출될수 있다는 것을 알았다.

하지만 이것이 자식 view를 측정한후 다시 부모view를 측정하고 그에따라 또다시 자식View를 측정해야 하는 알고리즘으로 작성되기 때문에 성능상 좋지않았고 그에따라 transition animation을 만들기가 까다로웠다.

Jetpack Compose는 layout composable을 생성할 때 single pass 로 그려지기 때문에 한번의 측정만으로 UI tree를 모두 측정한다. 또한 2번이상 측정할 경우 예외를 발생시킨다.

또한, onMeasure 이후 onLayout이 호출되는 순서가 엄격하지 않아 measure 되지 않은 view가 layout되는 미묘한 버그들도 발생할 수 있었지만 Compose Layout은 kotlin-dsl scope로 측정된 것만 배치 한다는 개선점도 있다.

명령형 과 선언형

선언형이라는 것은 명령형과 달리 “어떻게” 가 아니라 “무엇을” 에 집중하는 프로그래밍 방법이다.

또한 선언형 패러다임의 일종인 함수형 패러다임에 따라 기본적으로 순수함수의 원칙에 따라 함수의 입력값이 달라지면, 함수의 출력값이 달라지는 것만 허용하며, 그이외의 side effect를 허용하지 않는 함수를 말한다.

Deep Dive into Compose Layout를 보면, 도입부에서 state를 통해 UI를 만든다고 얘기한다.

Jetpack Compose에서는 함수의 입력 매개변수 뿐만아니라 내부에 mutableStateOf api로 생성된 state객체의 변화를 감지하고, state로 UI를 생성 뿐만아니라 재구성(recomposition)을 발생시킴으로써 UI를 update한다.

이러한점들 뿐만아니라 kotlin 코드로 UI를 생성할수 있다는 장점과 높은 생산성이 있는 Compose를 최근에 Google에서 밀고있다.

도입은 이정도로 마무리하고, 마찬가지로 compose layout의 시작점인 ComponentActivity.setContent() 메소드의 구현체부터 살펴보겠다.

ComponentActivity.setContent()


public fun ComponentActivity.setContent(
    parent: CompositionContext? = null,
    content: @Composable () -> Unit
) {
    val existingComposeView = window.decorView
        .findViewById<ViewGroup>(android.R.id.content)
        .getChildAt(0) as? ComposeView

    if (existingComposeView != null) with(existingComposeView) {
        setParentCompositionContext(parent)
        setContent(content)
    } else ComposeView(this).apply {
        setParentCompositionContext(parent) // null로 설정, root view가 최상위 부모이기 때문
        setContent(content) // content composable 설정 -> decorView에 addview()하는것과 같은 역할
        setOwners() 
        setContentView(this, DefaultActivityContentLayoutParams)
    }
}

먼저 가장눈에 보이는 부분은 mContentParent에 있는 view tree의 최상위 view를 가져와서 ComposeView 타입으로 캐스팅한다.

만약, null이 아니면 이미 존재하기 때문에 현재의 content를 교체하고 compositionContext를 설정한다.

compositionContext는 두개의 composable을 연결해주는데 관여하는 요소로 부모composable이 자식composable에게 이 context를 넘겨주어 두 composable 간 데이터전달이나 invalidate(무효화 = recomposition 발생)를 수행하도록 할수 있다.

setContent() 에서 생성되는 composeView가 root view가 되므로 여기서는 parent가 null임을 알수있다.

만약, null이면 ComposeView 인스턴스를 만들어 추가적으로 setOwners() 와 앞서 다룬 ComponentActivity.setContentView()를 추가적으로 수행한다.

setOwners() 메소드를 살펴보면,

private fun ComponentActivity.setOwners() {
    val decorView = window.decorView
    if (decorView.findViewTreeLifecycleOwner() == null) {
        decorView.setViewTreeLifecycleOwner(this)
    }
    if (decorView.findViewTreeViewModelStoreOwner() == null) {
        decorView.setViewTreeViewModelStoreOwner(this)
    }
    if (decorView.findViewTreeSavedStateRegistryOwner() == null) {
        decorView.setViewTreeSavedStateRegistryOwner(this)
    }
}

decorView에 viewTreeLifecycleOwner, viewTreeViewModelStoreOwner 와 viewTreeSavedStateRegistryOwner 가 null 이면 설정해주고 있다.

viewTreeLifecycleOwner의 경우 view 자체의 라이프사이클을 구독하여, 변화에 따라 특정 이벤트를 수행해주기 위해 사용하며,

viewTreeViewModelsStoreOwner는 이름에서 알수있듯이 viewModel의 인스턴스 생성에 관여하고, viewTreeSavedStateRegistryOwner 역시 view 내에서의 savedState api에 관여한다.

이 변수들은 모두 lifecycle에 대한 이해가 필요하고, 이들을 사용하여 사용자 관점에서 제공하는 비즈니스에 따라 state와 data를 어떻게 관리하고 보여줄 수 있는가 에 대한 이되는것 같다.

그래서 이3가지 요소를 알고있다는 것과 사용할 수 있다는 관점은 안드로이드 개발에 있어서 매우 중요하다고 생각한다.

여기서는 생성되는 rootView 인 ComposeView 인스턴스가 결국 안드로이드 View의 커스텀뷰 형태이기 때문에 view tree의 lifecycle에 따라 동작시켜주기 위해 해당 요소들을 설정해주고 있다.

마지막으로 ComponentActivity.setContentView() 를 호출해줌으로써 앞서 설명한 동작을 수행해준다.

눈에보이는 메소드들은 이게 다인것 같지만, 실제로 다른 레퍼런스들을 참조했을 때 compositionLocal의 초기화작업이나 recomposition을 위해 view tree의 lifecycle에 따라 invalidate를 수행하거나 requestLayout()이 동작한다는 점을 어디선가 수행해주고 있다는 점을 기억해두자.

Compose UI가 만들어지는 과정


Deep Dive into Compose Layout를 보면 compose 의 layout 과정을 다음 3가지로 구분한다고 설명한다.

  1. Composition
  2. Layout
  3. Draw

Composition

Composition 단계에서는 ComponentActivity.setContent() 에서 decorView에 rootView인 ComposeView() 인스턴스를 만들고 여기에 composable들을 UI tree로 만들어 연결하는 단계이다.

우리는 코드상에서 UI구조를 다음과 같이 만들었다고 가정하면,

composable_tree_1

만들어진 composable들은 모두 Layout() 이라는 composable 함수를 호출하고 있다.

composable_tree_2

그 내부구조를 살펴보면,

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

요약해두자면, 해당 composable은 ReusableComposeNode<> 라는 composable을 호출하는데, 이는 compose kotlin compiler 의 타겟이 되어 composable 함수를 관리가능한 factory 인자에 따라 LayoutNode로 만들고 이를 방출(UI tree 생성) 하는 과정을 수행하며

또한, Applier라는 인자를 통해 구체화(UI를 실제로 그리는 작업)하는 방법을 전달하는 역할을 수행한다.

더 자세한 내용은 성빈랜드_compose UI를 그리기까지의 여정에 있으니 잊어먹으면 참고하자.

Layout

Layout 단계는 measure 와 place 단계로 구성된다. 이는 View의 measure 과 layout과 비슷하지만 앞서 설명했듯이 view의 여러 문제들을 개선하는 형태의 알고리즘으로 작성되었다.

measure 단계에서는 각 layout node의 자식들의 크기를 측정한뒤, 자신의 크기를 측정하게 된다. 이 측정 단계는 반드시 1번만 수행되며, 2번이상 수행될 시에 예외를 던진다.

composable_layout

그림처럼 top-down 으로 방출된 UI tree를 순회하면서 leaf node인 자식노드의 크기가 측정되면 그 부모의 크기를 결정하고 배치 lamda를 결과로 방출하게 된다.

이후 place 단계에서 방출된 배치 lamda를 일괄적으로 top-down 으로 순회하면서 배치를 실행한다.

Draw

마지막으로 draw단계는 앞서 설명한 구체화 단계로써 canvas 객체로 그리기 작업을 수행하여 UI를 화면에 보여주는 단계이다.

이부분의 internal 구조를 설명하는 reference를 찾긴 어려웠고, custom drawing을 하기위한 방법은 여기서 찾을수 잇었다.

이를 알기전에 Modifier에 대해서 알아야 한다.

Modifier


Modifier는 수정자 라고 번역되며, 모든 composable들을 layout node로 만들기 위해 사용되는 Layout() composable에도 modifier 파라미터를 받고 있는것을 볼수있듯이 매우 중요한 부분이다.

Modifier는 위에서의 layout단계의 측정과 배치에 constraints 인자로써 관여할수 있는 LayoutModifier가 있으며, 그외에도 drawModifier, focusEventModifier 등이 구현체로써 존재한다.

이들의 이름에서 알수 있듯이 Modifier는 다음과 같은 역할을 수행한다. 여기 참고

  • composable의 size, layout, draw, shape 변경
  • tags와 같은 정보 추가
  • 사용자 입력 처리
  • 클릭, 스크롤, 드래그, 확대/축소 가능하게 만드는 높은 수준의 상호작용

이를 통해 layoutModifier로 측정과 배치에 관여하거나, drawModifier로 그리기와 , focusEventModifier로 사용자 상호작용을 커스텀하게 처리할 수 있다.

layoutModifier

위 그림을 살펴보면, modifier에 chaining한 요소들을 순서대로 measure하여 그것을 Box composable의 MeasurePolicy에있는 constraints로 넘겨줌으로써 측정과 배치에 관여하고 있으며

modifier.background()의 내부구조를 따라가면,

@Stable
fun Modifier.background(
    color: Color,
    shape: Shape = RectangleShape
): Modifier {
    val alpha = 1.0f // for solid colors
    return this.then(
        BackgroundElement(
            color = color,
            shape = shape,
            alpha = alpha,
            inspectorInfo = debugInspectorInfo {
                name = "background"
                value = color
                properties["color"] = color
                properties["shape"] = shape
            }
        )
    )
}

this.then()으로 chaining 된 modifier들을 합쳐주고 있고 BackgroundElement()를 따라가면,

private class BackgroundElement(
    private val color: Color = Color.Unspecified,
    private val brush: Brush? = null,
    private val alpha: Float,
    private val shape: Shape,
    private val inspectorInfo: InspectorInfo.() -> Unit
) : ModifierNodeElement<BackgroundNode>() {
    override fun create(): BackgroundNode {
        return BackgroundNode(
            color,
            brush,
            alpha,
            shape
        )
    }

ModifierNodeElement.create로 BackGroundNode 인스턴스를 리턴하고 있는데

private class BackgroundNode(
    var color: Color,
    var brush: Brush?,
    var alpha: Float,
    var shape: Shape,
) : DrawModifierNode, Modifier.Node() {

    // naive cache outline calculation if size is the same
    private var lastSize: Size? = null
    private var lastLayoutDirection: LayoutDirection? = null
    private var lastOutline: Outline? = null
    private var lastShape: Shape? = null

    override fun ContentDrawScope.draw() {
        if (shape === RectangleShape) {
            // shortcut to avoid Outline calculation and allocation
            drawRect()
        } else {
            drawOutline()
        }
        drawContent()
    }

    private fun ContentDrawScope.drawRect() {
        '''
    }

    private fun ContentDrawScope.drawOutline() {
        '''
				
				
		@JvmDefaultWithCompatibility
interface ContentDrawScope : DrawScope {
    /**
     * Causes child drawing operations to run during the `onPaint` lambda.
     */
    fun drawContent()
}
}

결국 내부적으로 DrawModifierNode를 구현하고 있고(위에서 보았듯이) 내부적으로 ContentDrawScope. draw(), drawRect(), drawOutline()을 구현하고 있고,

또한 ContentDrawScope은 DrawScope을 구현하고 있는것으로 보아 DrawScope 인터페이스를 이용하여 그리기작업에 대한 커스터마이징을 할수 있는걸 확인할수 있다.

마찬가지로 fillmaxSize(), wrapContentSize()도 쭉 따라가면 layoutModifierNode로 만들어졌음을 확인할수 있다.

태그:

카테고리:

업데이트:

댓글남기기