Android dev summit 2022 - Compose Modifiers deep dive

이태훈·2022년 11월 4일
0
post-thumbnail

안녕하세요, 안드로이드 데브 서밋 2022에서 진행된 세션중에 Compose Modifiers deep dive 세션을 한번 살펴보겠습니다.

개요

Modifier에 관해 1.3.0-beta01 버전부터 Node Refactor라는 이름의 업데이트가 있었습니다. 이 Node refactor는 Modifier Architecture의 전환점이 됩니다.

우선, 현재 Modifier가 어떻게 디자인 되었는지에 대해 알아보겠습니다.

초기 디자인 형태

// Hypothetical Implementation
interface NotComposeUiNode {
	var onClick: () -> Unit
   	var onLongClick: () -> Unit
    var onPointerEvent: suspend (PointerEvent) -> Unit
    var backgroundColor: Color?
    var background: Brush?
    var paddingStart: Dp
    var paddingTop: Dp
    var paddingEnd: Dp
    var paddingBottom: Dp
    var align: Alignment
    var arrangement: Arrangement
    var borderWidth: Dp
    var borderColor: Color
    var borderShape: Shape
    var alpha: Float
    var onFocusChanged: (FocusState) -> Unit
    var focusable: Boolean
    ...

먼저, Ui 툴킷을 처음부터 개발하고 Modifier에 대한 아무 개념이 안 잡혀있다고 생각해봅시다. UI를 핸들링 할 수 있는 Node를 설계한다고 가정했을 때, 여러 이벤트나 속성들을 처리할 수 있어야 합니다.

하지만, 너무 많은 경우들이 존재하고 대부분의 Node들은 거의 모든 것을 사용하지 않습니다. 또한, 우리는 기본적인 노드 유형이 모든 단일 노드에 대해 너무 많은 비용을 주고 싶지 않았습니다.

// Hypothetical Implementation
@Composable fun App() {
	Padding(10.dp) {
    	BackgroundColor(Color.Blue) {
        	Clickable({ ... }) {
            	Content()
            }
        }
    }
}

그래서 각 동작들을 tree 자체의 node로 규정해봤습니다. 이 사진의 경우 패딩, 배경 색상, 클릭 이벤트들이 자체적으로 구성되며 tree에 추가되었습니다.

하지만, 이 경우에는 람다 중첩과 같은 여러 syntactic overhead가 있습니다. 가장 큰 문제는 각 Composable 함수에 content 람다 함수를 가지고 있는 것입니다.

// Hypothetical Implementation
@Composable fun App() {
	Padding(10.dp) {
    	BackgroundColor(Color.Blue) {
        	Clickable({ ... }) {
            	Content()
                Content()
            }
        }
    }
}

또한, child composable에 단일 composable이 아닌 여러개의 composable이 일어났을 경우에도 문제가 발생했습니다.

이러한 것들은 layout 정책만 신경쓰는 Row, Column과 같은 layout composable을 컴포즈 초기에 사용했습니다.

하지만 이와 같은 정책은 문제가 발생했을 때 사용자들이 많은 혼란을 겪곤 했습니다.

Modifier.composed

따라서, 우리는 결국 모든 Composable이 여러 child를 허용해야하고 layout 정책을 필수로 가져야한다고 결론을 내렸습니다. 또한, 다른 동작들은 매개변수를 통해 넘겨지는 것으로 결정했습니다.

그리고 이 방법의 좋은 점 중 하나는 임시 변수를 통해 Modifier를 공유할 수 있다는 것입니다. 하지만 이 방법은 보기보다 엄청 복잡하게 동작합니다.

clickable Modifier의 경우를 생각해보면 clickable에는 상태를 저장합니다. (touch up, touch down, delay, ripple effect, ...)

따라서, clickable을 여러 레이아웃에 쓸 때는 각 레이아웃별로 상태가 존재해야 합니다. 그리고 이 경우를 해결하기 위해 Modifier.composed api를 고안했습니다.

Modifier.composed는 Composable Lambda Factory를 정의할 수 있게 하며 Modifier 자체를 반환하는 함수입니다. 따라서, Composable Lambda이기 때문에 생성하는 Modifier의 상태를 관리할 수 있습니다.

이 함수의 특별한 점은 바로 레이아웃별로 호출된다는 점입니다. 따라서 우리가 필요로 하는 것처럼 상태가 레이아웃 별로 존재하게 됩니다.

우리는 materialize라는 내부 함수를 통해 그것을 가능케 합니다.
이 materialize 함수는 파라미터로 Modifier를 취하며 그 중 일부는 composed modifier일 수 있습니다. 그런 다음 의미상 동일한 Modifier를 반환합니다.

그런 다음 모든 Compose Node 적용 전에 materialize를 호출합니다.

clickable modifier의 구현체는 위와 같을 수 있습니다. 이 경우에는 어떤 상태를 메모리에 저장하고 그 상태는 node의 lifecycle을 탑니다.

이렇게 문제가 해결된 것 같았지만, composed api에는 몇 가지 성능 문제이 존재합니다.

composed api performance issue

composition 과정에서 유효한 키 포인트는 skippable입니다. 하지만, 반환 값이 있는 함수는 skip하지 못합니다.

위의 경우 Modifier.clickable이 반환값을 가지기 때문에 Modifier.composed도 반환값을 가지게 됩니다. 따라서, Modifier.composed를 사용한 모든 Modifier는 결과적으로 skip되지 않습니다.

그리고 Modifier.composed를 호출하는 함수는 그 자체로 composable 할 수 없기 때문에 사용하는 람다를 캐시할 수 없습니다.

그리고 이 말은 곧 반환된 Modifier와 그 이전의 Modifier와 비교할 수 없다는 말이 됩니다. Recomposition 중에 어떤게 변경되었는지 판단해야 하는데 비교할 수 없기 때문에 이 점은 꽤나 큰 문제입니다.

그래서, 이 경우 모든 Modifier가 바뀌지 않았음에도 불구하고 바뀐 것처럼 인식합니다.

따라서, 모든 recomposition 동안 다른 Modifier 보다 더 많은 remember api 호출이 일어나게 되고 더 많은 비용이 듭니다.

그러나 이러한 문제에도 불구하고 composed api는 몇 가지 특수한 경우에만 사용하면 된다고 생각했습니다. 하지만, 그렇지 않아 개선점을 찾게 됩니다.

Modifiers in practice

간단한 Modifier chain 예를 보겠습니다.

이 Modifier chain은 Composition 단계에서 처음 생성됩니다. 이 4개의 Modifier 중에 clickable은 ComposedModifier가 됩니다. 그리고 이 Modifier는 최종적으로 Layout에 전달됩니다.

이 중 clickabl은 사진과 같이 전개될 것이고, 각 Composition에서 구축된 상태(사진에서 초록색 박스)들이 해당하는 Modifier들 안에 내재되어 있습니다. 그리고 이러한 많은 것들에 대해 materialize를 호출하게 됩니다.


이러한 과정을 거쳐 구체화된 Modifier를 set-Modifier를 호출하여 Modifier를 Layout에 적용합니다.

여기서 padding의 값이 변경이 됐다고 가정해봅시다.
Recomposition이 일어날 것이고 Recomposition이 일어나는 동안 Modifier를 재구성하게 됩니다. 이 과정에서 똑같이 materialize를 호출하고 Modifier를 전개합니다. 이 시점에서 이전에 remember 된 상태들은 새로운 Modifier에서 재사용됩니다.

여기서 핵심은 Modifier 중 많은 부분이 상태를 가져야 한다는 것입니다. 그리고 그 상태는 결국 적용된 Layout Node에 따라 결정된다는 것입니다.

그리고 적어도 최근까지는 Modifier.composed api를 제외하고는 이를 가능하게하는 api가 없었습니다.

Modifier.Node

여기까지 살펴본 문제들을 해결하기 위해 새롭게 추상 클래스 Modifier.Node가 추가되었습니다.

앞서 살펴본 예제와 똑같은 예제로 살펴보겠습니다. 여기서 중요한 점은 clickable이 더이상 ComposedModifier가 아니란 것입니다.

대신 이 4가지 모두 해당하는 Modifier.Node 클래스의 인스턴스를 관리하는 방법을 알고있는 가볍고 불변적인 Modifier.Element입니다.

이렇게 진행함으로써 clickable은 composed api를 사용하지 않게 되며 그에 따라 materialize 함수를 호출할 필요가 없습니다.

바로 Layout에 적용하기만 하면 됩니다.

Recomposition이 일어났을 경우에도 엄청 간단해집니다. 해당 Modifier들은 불변적이기 때문에 쉽게 비교가 가능합니다.

Conclusion

이와 같은 Refactor를 통해 얻을 수 있는 이점은 다음과 같습니다.

  • Less Allocations
    구체화 하는 동안 Modifier chain을 재구성할 필요가 없고 chain 자체가 더 짧으며 재구성할 때마다 레이아웃에 엔티티를 할당하지 않습니다.
  • Less Composition
    상태를 remember하거나 변경된 매개변수를 proxy하기 위해 snapshot state를 사용할 필요가 없으므로 Modifier와 같이 overhead가 줄어듭니다.
  • Smaller Tree
    clickable과 같은 Modifier는 16개의 노드를 생성하곤 했습니다. 이제 이 대신 단일 노드만 생성하게 됩니다.

이러한 이점으로 우리는

  • Better Performance
  • Custom Modifiers
  • Backwards Comptabile

과 같은 이점을 누릴 수 있게됩니다.

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

0개의 댓글