Google IO 2022 - Compose Performance : Defer reads as long as possible

이태훈·2022년 5월 17일
0
post-thumbnail

안녕하세요. 오늘은 Google IO 2022에서 다룬 Compose Performance에서 Defer reads as long as possible에 대해 포스팅하겠습니다.

공식 가이드Google IO 2022 해당 영상을 참고하시면 됩니다.

먼저 이 영상을 보기 전에 Jetpack Compose에서 뷰를 그리는 단계가 총 세 단계로 나뉘어져 있는 것을 알고 계셔야 합니다.

이 그림을 염두해주시고 이어지는 내용을 보시면 되겠습니다.

Layout Phase Optimization

먼저 공식 가이드에 나오는 샘플을 보겠습니다.

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack, scroll.value)
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scroll: Int) {
    // ...
    val offset = with(LocalDensity.current) { scroll.toDp() }

    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
        // ...
    }
}

scrollState에 대한 scroll 값을 받아 파라미터로 넘겨주는 JetSnack의 코드였습니다.

이 코드를 기반으로 Layout Inspector에서 테스트해보겠습니다.

Layout Inspector에서 Recomposition Count 기능을 사용하기 위해 Android Studio Dolphin | 2021.3.1 Beta 1를 사용했습니다.

갤럭시폰에서 Layout Inspector를 사용할 경우 composable trees를 보여주지 못 하는 경우가 있는데, 이 경우에는 개발자 옵션에서 Enable view attribute inspection을 활성화 해주시면 됩니다.

아래와 같이 코드를 짜고 Layout Inspector를 사용해봤습니다.

class MainActivity : ComponentActivity() {
	override fun onCreate(savedInstanceState: Bundle?) {
		super.onCreate(savedInstanceState)
		setContent {
			SnackDetail()
		}
	}
}

@Composable
fun SnackDetail() {
	Column(modifier = Modifier.fillMaxSize()) {
		val scroll = rememberScrollState()

		Title(scroll.value)
		Content(scroll)
	}
}

@Composable
fun Title(scroll: Int) {
	val offset = with(LocalDensity.current) { scroll.toDp() }

	Column(modifier = Modifier
		.offset(y = offset)
	) {
		Text("test")
	}
}

@Composable
fun Content(scroll: ScrollState) {
	
	fun randomColor() = Color(Random.nextInt(256), Random.nextInt(256), Random.nextInt(256))
	
	Column(modifier = Modifier.fillMaxSize().verticalScroll(scroll)) {
		repeat(10) {
			Box(modifier = Modifier
				.fillMaxWidth()
				.height(300.dp)
				.background(randomColor())
			)
		}
	}
}

Recomposition이 SnackDetail과 Title Composable 둘 다 일어나는 것을 볼 수 있습니다.

하지만, 여기서 scroll 상태를 그냥 읽어들이는 용도로만 쓴다면 recompose 되는 요소들을 줄일 수 있습니다.

위의 코드에서 Title 함수 부분만 변경해주었습니다.

@Composable
fun SnackDetail() {
	Column(modifier = Modifier.fillMaxSize()) {
		val scroll = rememberScrollState()

		Title { scroll.value }
		Content(scroll)
	}
}

@Composable
fun Title(scrollProvider: () -> Int) {
	val offset = with(LocalDensity.current) { scrollProvider().toDp() }

	Column(modifier = Modifier.offset(y = offset)) {
		Text("test")
	}
}

Lambda를 넘겨줌으로써 Scroll State에 대한 값을 Title이 가지고 있도록 했습니다.

그래서 Scroll State가 변경이 돼 Recomposition이 일어날 스코프를 감지했을 때, state의 value가 가장 가까이 위치한 Composable 함수. 즉, Title에서부터 Recomposition이 발생하게 됩니다.

Layout Inspector를 통해 확인해보겠습니다.

다음으로 최종본입니다.

@Composable
fun Title(scrollProvider: () -> Int) {
	Column(modifier = Modifier
		.offset { IntOffset(x = 0, y = scrollProvider()) }
	) {
		Text("test")
	}
}

이 코드는 Compose에서 Frame의 단계 중 Composition의 단계가 아닌 Layout 단계에서 scrollProvider의 값을 읽습니다.

그래서 Composition을 건너뛰고 Layout 단계부터 시작할 수 있게 합니다. 여기서 Defer reads as long as possible의 개념이 나옵니다.

State를 최대한 뒤의 단계에서 값을 읽어서 최대한 많은 단계를 건너뛰어 퍼포먼스를 증가시켜야 합니다.

따라서, 위에 예제에서는 Modifier.offset을 파라미터로 값을 넣어줘서 Composition 단계에서 State의 값을 읽는 것이 아닌 Lambda로 넣어줘서 값을 Layout 단계에서 읽어서 그 단계부터 진행이 됩니다.

Layout Inspector를 통해 확인해보겠습니다.

Recomposition Count가 증가하지 않는 것을 볼 수 있습니다.

실제로 Modifier.offset(x: Dp, y: Dp)와 Modifier.offset(offset: Density.() -> IntOffset) 을 보도록 하겠습니다.

@Stable
fun Modifier.offset(x: Dp = 0.dp, y: Dp = 0.dp) = this.then(
    OffsetModifier(
        x = x,
        y = y,
        rtlAware = true,
        inspectorInfo = debugInspectorInfo {
            name = "offset"
            properties["x"] = x
            properties["y"] = y
        }
    )
)


fun Modifier.offset(offset: Density.() -> IntOffset) = this.then(
    OffsetPxModifier(
        offset = offset,
        rtlAware = true,
        inspectorInfo = debugInspectorInfo {
            name = "offset"
            properties["offset"] = offset
        }
    )
)

private class OffsetPxModifier(
    val offset: Density.() -> IntOffset,
    val rtlAware: Boolean,
    inspectorInfo: InspectorInfo.() -> Unit
) : LayoutModifier, InspectorValueInfo(inspectorInfo) {
    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        val placeable = measurable.measure(constraints)
        return layout(placeable.width, placeable.height) {
            val offsetValue = offset()
            if (rtlAware) {
                placeable.placeRelativeWithLayer(offsetValue.x, offsetValue.y)
            } else {
                placeable.placeWithLayer(offsetValue.x, offsetValue.y)
            }
        }
    }
    
    ...
}

Modifier.offset(x: Dp, y: Dp)은 Modifier의 함수가 실행될 때 값을 읽기 때문에 Recomposition을 일으키는 반면에 Modifier.offset(offset: Density.() -> IntOffset)은 MeasureScope.mesaure 단계에서 offset 람다를 실행시켜 값을 읽어 Layout 단계에서 읽는 것을 볼 수 있습니다.

Drawing Phase Optimization

앞서 말씀드린대로 Compose의 단계는 총 세 단계가 있습니다. Composition, Layout, Drawing

위에서 Layout 단계에서부터 값을 읽어 퍼포먼스를 증가시키는 예제를 보여드렸으니 Drawing 단계에서부터 값을 읽어 퍼포먼스를 증가시키는 예제를 보여드리겠습니다.

먼저, 색이 계속 변화하는 View를 보여드리겠습니다.

@Composable
fun AnimatedBox() {
	val infiniteTransition = rememberInfiniteTransition()
	val color by infiniteTransition.animateColor(
		initialValue = Color.Cyan,
		targetValue = Color.Magenta,
		animationSpec = infiniteRepeatable(
			animation = tween(1000, easing = LinearEasing),
			repeatMode = RepeatMode.Reverse
		)
	)
	Box(Modifier.fillMaxSize().background(color))
}

이 코드를 Layout Inspector를 돌려보면 다음과 같습니다.

깔라가 바뀔 때마다 Recomposition이 일어나는 것을 볼 수 있습니다.

이 코드를 Draw Phase에서 값을 읽어 Recomposition이 일어나지 않도록 해보겠습니다.

Box(Modifier.fillMaxSize().drawBehind { drawRect(color) })

이 코드의 Layout Inspector 결과는 다음과 같습니다.

이전처럼 깔라가 바뀔 때마다 Recomposition이 일어나지 않는 것을 볼 수 있습니다.

fun Modifier.background(
    color: Color,
    shape: Shape = RectangleShape
) = this.then(
    Background(
        color = color,
        shape = shape,
        inspectorInfo = debugInspectorInfo {
            name = "background"
            value = color
            properties["color"] = color
            properties["shape"] = shape
        }
    )
)

private class Background constructor(
    private val color: Color? = null,
    private val brush: Brush? = null,
    private val alpha: Float = 1.0f,
    private val shape: Shape,
    inspectorInfo: InspectorInfo.() -> Unit
) : DrawModifier, InspectorValueInfo(inspectorInfo) {

	...

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

    private fun ContentDrawScope.drawRect() {
        color?.let { drawRect(color = it) }
        brush?.let { drawRect(brush = it, alpha = alpha) }
    }
    
    ...
}


fun Modifier.drawBehind(
    onDraw: DrawScope.() -> Unit
) = this.then(
    DrawBackgroundModifier(
        onDraw = onDraw,
        inspectorInfo = debugInspectorInfo {
            name = "drawBehind"
            properties["onDraw"] = onDraw
        }
    )
)

private class DrawBackgroundModifier(
    val onDraw: DrawScope.() -> Unit,
    inspectorInfo: InspectorInfo.() -> Unit
) : DrawModifier, InspectorValueInfo(inspectorInfo) {

    override fun ContentDrawScope.draw() {
        onDraw()
        drawContent()
    }

	...
}

두 함수 모두 draw 단계에서 그려주지만 Modifier.background는 Modifier 함수 실행할 때 값을 읽어 Composition 단계에서부터 진행이 되고 Modifier.drawBehind는 draw가 실행될 때 람다가 실행되기 때문에 Draw 단계에서부터 값을 읽어 진행이 되는 것을 볼 수 있습니다.

profile
https://www.linkedin.com/in/%ED%83%9C%ED%9B%88-%EC%9D%B4-7b9563237

0개의 댓글