본문 바로가기

안드로이드

Compose - Modifier.Node ( composed 상위호환 )

반응형

Compose 1.3 버전에서
Modifier.Node라는 개념이 추가되었다.

1. Modifier.Node 란?

https://www.youtube.com/watch?v=BjGX2RftXsU

Modifier.Node 는 구글의 Compose Modifiers deep dive 영상에서 소개되었는데,
기존의 Modifier에서 성능상에 이슈가 되는 부분을 해결하기 위해 추가된 개념이다.

 

영상에서 지적하는 기존 설계에서 문제점은 3가지가 있다.

1. materialize

Modifier는 한 번 선언하여 여러 곳에서 사용할 수 있다.

하지만 실제로 하나의 Modifier 만 존재한다면,

여러 곳에서 사용할 경우 clickable 같은 상태가 존재하는 Modifier는 관리가 불가능하다.

 

그래서 각 Layout 별로 자기가 사용할 Modifier를 새롭게 생성하는 과정을 거치는데, 이걸 materialize 라고 부른다.

 

materialize 는 Layout 에 주어진 모든 Modifier를 돌면서 상태가 없는 Modifier의 경우 그냥 가져오고, 상태가 있는 composed Modifier의 경우 새롭게 생성하는 과정을 거친 후에 가져오게 된다.

그렇기에 뎁스가 길고 상태가 많을수록 materialize 과정은 오래 걸리게 된다.

2. remember / state

clickable과 같은 Modifier의 내부를 확인해보면 많은 remember와 state를 사용하고 있다.

이런 거처럼 상태를 관리해야 하는 Modifier 가 굉장히 많고, 이러한 요소는 자연스럽게 1번 문제점인 materialize 과정을 오래 걸리게 만든다.

3. deep depth & big tree

영상에서 소개하는 big tree

영상에서는 Modifier 2~3 개 만으로도 위에 이미지보다 더 많은 Modifier의 연속이라고 한다.

 

clickable 함수를 타고 들어가 보면 실제로 엄청난 양의 Modifier를 호출하고 조합하고 있다.

2. Modifier.Node 사용해보기

위의 문제점을 어떻게 해결했는지 보기 전에 코드를 먼저 보겠다.

 

사용법이 아직 이렇다 할 가이드는 따로 찾지 못했고, github에 검색하니 몇 개의 샘플이 나왔다.
아래 코드는 구글이 만들어 놓은 샘플 코드이다.

// google sample https://github.com/androidx/androidx/blob/9a1e41820fa898a8b1990ba31423e26dda66a17f/compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/DrawModifierSample.kt#L137

class CircleNode(var color: Color) : DrawModifierNode, Modifier.Node() {
    override fun ContentDrawScope.draw() {
        drawCircle(color)
    }
}

커스텀 Modifier.Node 를 만드는 방법이다.

 

DrawModifierNode 처럼 역할에 따라 몇 가지의 Node 인터페이스가 준비되어있다.

 

커스텀한 Node는 Modifier.Node 를 필수로 상속받아야만 한다.

이걸 상속받아야만 현재 Node Tree에서 내가 쓸 Node를 받아올 수 있다.
( 상속 안 받으면 동작 못 시킨다. )

 

기존에 있던 DrawModifier, OnRemeasuredModifier등 Modifier 인터페이스들과 동일하다.
역할과 이름은 크게 달라지지 않고, 거의 1대 1 매칭이 되도록 만들어져 있다.

ex ) DrawModifier <-> DrawModifierNode

 

상속받은 후 내부 구현은 기존의 방법과 달라지는 부분은 없다.

fun Modifier.circle(color: Color) = this then modifierElementOf(
    key = color,
    create = { CircleNode(color) },
    update = { it.color = color },
    definitions = {
        name = "circle"
        properties["color"] = color
    }
)

이렇게 만든 Modifier는 modifierElementOf 를 통해서 연결한다.

 

modifierElementOf 에는 4가지 인자가 있다.

 

하나씩 설명하자면

key 는 해당 Node를 변경시키기 위한 트리거 역할을 한다.

위의 예제 코드에선 color 가 변경될 때만 update 함수가 동작한다.

key는 단일 인자만 가능하다.
( Pair 나 Triple 등을 이용하면 여러 개의 키를 지정할 수 있다. )

 

create 는 Node를 생성하기 위해서 사용한다. Node는 여기서만 딱 한 번만 생성된다.

update 는 key 가 바뀔 경우에 동작하며 Node를 업데이트하기 위해서 사용한다.

definitions 는 디버그용 inspector값이다.

3. 문제는 어떻게 해결됐나?

위에서 설명한 코드를 통해서 Modifier.Node는 기존의 문제들을 해결하였다.

 

1번째 materialize 문제는 create 를 통해 해결했다.

create를 통해서 생성하게 만들면서 materialize 에서 composed Modifier를 찾아가면 생성해줬던 과정을 필요 없게 만들었다.

 

2번째 remember / state 문제는 keyupdate 를 통해 해결했다.

key를 통해서 Modifier에서 state를 가지지 않고도 변화에 대해 감지할 수 있게 만들고,

update를 통해서 변경되어야 할 값들을 관리할 수 있게 만들었다.

또한, Modifier.Node 가 변수를 가질 수 있게 만들어 remember 없이도 Node 가 살아있는 한 값을 유지할 수 있도록 하였다.

 

3번의 문제인 big tree는 materialize 가 없어지면서 자연스럽게 해결된 것으로 보인다.
composed Modifier를 찾기 위한 과정이 없어졌으므로, 각 Modifier를 분리해서 관리할 필요가 없고,
하나의 큰 Node로 묶어서 관리하는 게 가능해졌다. ( ClickableNode, PaddingNode 등 큰 덩어리로 )

update를 통해서 각 Node를 별도로 업데이트할 수도 있다.
이런 과정을 통해 Node로 구성된 small tree 구조가 되었다.

 

그리고 이러한 과정을 개발자는 아무런 수정 없이 적용할 수 있도록 하위 호환성을 먼저 준비하였다.

물론 아직 1.3.0 에서는 materialize 코드가 그대로 있고, state / remember 도 그대로 있다.
구글이 추후 개선을 위해 시작하였으니 곧 대부분 변경이 일어날 것 같다.

4. composed를 대체할 수 있다?

if you're using the composed API, consider using Modifier.Node instead once it is available.

 

영상에서는 Modifier.composed 대신에 사용해볼 것을 얘기한다.

 

위의 예제 코드처럼 state, remember 가 필요 없기 때문에 대부분 대체가 가능하다.
대신 LocalDensity와 같은 CompositionLocal 값들을 사용하는 경우는 별도의 처리방법을 제공하지 않아서 composed를 사용할 수밖에 없다.

5. 다른 예제

Modifier.Node를 사용한 다른 코드이다.

( Modifier에서 Modifier.Node로 변경한 것이다 (Diff) )

@OptIn(ExperimentalComposeUiApi::class)
fun Modifier.gooeyBackground(color: Color, shape: Shape, solidShape: Boolean = true) =
    this.composed {
        val intensity = LocalGooeyIntensity.current.intensity
        val layoutDirection = LocalLayoutDirection.current
        val density = LocalDensity.current

        modifierElementOf(params = Pair(color, shape), create = {
            GooeyModifierNode(
                shape, layoutDirection, density, color, intensity, solidShape
            )
        }, update = {
            it.gooeyColor = color
        }, definitions = {
            name = "gooeyBackgroundNode"
            properties["color"] = color
            properties["shape"] = shape
            properties["solidShape"] = solidShape
        })
    }

@OptIn(ExperimentalComposeUiApi::class)
class GooeyModifierNode(
    private val shape: Shape,
    private val layoutDirection: LayoutDirection,
    private val density: Density,
    var gooeyColor: Color,
    intensity: Float,
    solidShape: Boolean = false
) : DrawModifierNode, LayoutAwareModifierNode, Modifier.Node() {
    private var path = Path()
    private var blurPaint = createBlurPaint(
        intensity, if (solidShape) BlurMaskFilter.Blur.SOLID else BlurMaskFilter.Blur.NORMAL
    )

    override fun onRemeasured(size: IntSize) {
        this.path = Path().apply {
            val size = Size(width = size.width.toFloat(), height = size.height.toFloat())
            addOutline(shape.createOutline(size, layoutDirection, density))
        }
    }

    override fun ContentDrawScope.draw() {
        drawIntoCanvas { canvas ->
            canvas.drawPath(path, blurPaint.apply {
                color = gooeyColor
            })
            drawContent()
        }
    }
}
 

GitHub - D000L/Gooey: "Gooey-Effect" for android-compose

"Gooey-Effect" for android-compose. Contribute to D000L/Gooey development by creating an account on GitHub.

github.com

반응형