이 글은 medium.com에 있는 compose 내부 동작에 관련된 두 번째 posting인 https://medium.com/androiddevelopers/under-the-hood-of-jetpack-compose-part-2-of-2-37b2c20c6cdd 의 내용을 번역 및 의역하였습니다.
첫 번째 포스팅은 Compose 용어의 의미부터, 기존 방식과 선언형 방식, kotlin domain으로 UI 요소가 넘어오면서의 이점을 설명합니다. 따라서 첫 번째 포스팅을 읽으면 Compose가 나오게 된 배경이나 당위성에 대한 이유를 좀 더 명확하게 이해할 수 있습니다. (개인적으로는 Android Developer의 공식 페이지에서 왜 Compose를 써야 하는지에 대한 내용보다 더 와닿았습니다. [2])
하지만 이는 기술적인 부분들에 대한 이해보다는 당위성과 장점에 대한 부분이므로 따로 번역하여 포스팅하지는 않겠습니다. 시간 되실 때 읽어보시기 바랍니다. [3]
What does @Composable mean?
compose 코드를 작성할 때 @Compose annotation을 붙임으로써 Composer에게 "이건 네가 처리(관리) 해야 할 부분이야."라고 알려 줍니다. 실제 안쪽을 들여다보면 @Compose를 처리하는 annotation processor가 따로 있는 게 아닙니다. compose는 Kotlin type 체크 및 코드 생성 단계에서 kotlin compiler plugin의 도움을 받아 처리합니다.
따라서 annoation의 형태이긴 하지만 @Composable은 suspend처럼 function type에 가깝습니다. 예를 들어 suspend는 아래처럼 세 가지 타입으로 표현해서 사용할 수 있습니다.
// function declaration
suspend fun MyFun() { … }
// lambda declaration
val myLambda = suspend { … }
// function type
fun MyFun(myParam: suspend () -> Unit) { … }
동일하게 @Composable 역시 아래와 같이 사용합니다.
// function declaration
@Composable fun MyFun() { … }
// lambda declaration
val myLambda = @Composable { … }
// function type
fun MyFun(myParam: @Composable () -> Unit) { … }
suspend function은 일반 함수를 호출할 수 있지만 일반 함수는 suspend function을 호출할 수 없습니다. 이는 suspend 함수의 경우 suspend를 처리하는 context가 필요하기 때문인데, composable function 역시 유사합니다.
즉 composable function은 일반 함수를 호출할 수 있지만 일반 함수에서 composable 함수를 호출할 수 없습니다. compose에서도 내부적으로 composable를 실행하는 obejct가 필요하기 때문인데, 이를 Composer라고 부릅니다.
정리하면 suspend function에는 (coroutine) context가 필요하고, composable function에는 Composer가 필요합니다.
Execution model
Composer가 사용하는 메모리 구조는 일반적으로 text editor에서 사용하는 Gap buffer와 유사합니다.
Gap buffer는 index나 cursor를 갖는 구조로 flat array로 구성됩니다. 이 flat array는 여기에 담는 data보다 큰 사이즈를 가지며 남는 공간은 Gap 이 됩니다.
실제로 composable hierarchy가 수행되면 flat array에 대응되어 해당 정보가 채워집니다.
Hierarchy에 대한 수행이 다 끝난 후, recomposing 이 발생하는 순간, cursor는 메모리 구조의 맨 위로 다시 reset 되며, 다시 실행됩니다. 이때 데이터를 확인하여 변경이 없는 경우 아무것도 하지 않거나, 변경이 필요한 경우 데이터를 업데이트합니다.
만약 UI의 구조가 변경되어 데이터를 array에 넣어야 한다면 위 그림처럼 변경되어야 되는 지점으로 cursor를 이동시킵니다. 그리고 cursor가 위치된 해당 부분으로 Gap을 이동시킵니다.
그럼 새로운 데이터를 넣을 gap이 생기기 때문에 이 공간에 다시 필요한 데이터를 채워 나갑니다. 여기서 중요한 점은 Gap을 이동시키는 연산이 O(n)인 것만 제외하면, 그 외 데이터의 get, move, insert, delete의 연산은 BigO가 상수값이므로 매우 빠릅니다.
이 메모리 구조가 Compose에 선택된 이유는 주로 데이터(값)의 변경이 주로 발생되며, 이는 UI 구조를 자주 바꾸는 작업이 아니므로, UI 구조가 많이 바뀌지 않을 거라는 가정을 기반으로 하고 있습니다. 만약 화면의 특정 위치에서 if문으로 visibility를 조정하여 composable을 보이거나, 사라지는 작업을 빈번하게 사용한다면 약간은 불리하겠죠? 하지만 대체적으로 변경된 data를 UI로 표기하는 작업이 대부분 이기 때문에 해당 메모리 구조가 compose에서 사용하기에는 부족함이 없어 보입니다.
실제로 단순한 counter composable을 통해서 메모리에 어떤 값들이 어떻게 채워지는지 확인해 보겠습니다.
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(
text="Count: $count",
onPress={ count += 1 }
)
}
위 코드를 컴파일하면 Compiler는 compile time에 생성된 integer key값을 갖는 composert.start()와 Compose 함수가 종료되는 부분에 composer.end() 함수를 추가합니다.
fun Counter($composer: Composer) {
$composer.start(123)
var count by remember { mutableStateOf(0) }
Button(
text="Count: $count",
onPress={ count += 1 }
)
$composer.end()
}
그리고 컴파일러는 composer객체를 함수 안에서 호출하는 모든 composable function에 전달합니다.
fun Counter($composer: Composer) {
$composer.start(123)
var count by remember($composer) { mutableStateOf(0) }
Button(
$composer,
text="Count: $count",
onPress={ count += 1 },
)
$composer.end()
}
이 composable function이 수행되면 composer는 아래와 같이 동작합니다.
- composer.start가 호출되고 group object를 저장
- remember도 group object를 저장
- mutableStateOf가 return 하는 상태 값 역시 저장
- Button composable function도 group 객체를 저장하고 자기가 가지고 있는 param도 순서대로 저장
위와 같은 형태로 저장되면, 데이터 구조를 통해서 저장된 모든 객체 + 전체 tree의 실행 순서를 알 수 있고, tree를 효과적으로 탐색할 수 있습니다.
사실 이렇게 저장된 group들은 공간을 많이 차지합니다. 이 group들은 dynamic UI에서 이동과 추가를 관리하기 위해 존재해야 하는데, 실제 컴파일러는 UI structure를 변경할 것 같은 코드들을 알고 있기 때문에 조건적으로 이런 그룹들을 추가합니다. 정리하면 대부분의 이런 group들은 컴파일러에서 필요로 하지 않기 때문에 많은 group들이 실제 이 메모리 구조에 추가되지는 않습니다.
추가적인 예제로 실제 메모리의 구성 부분을 다시 한번 확인해 보겠습니다.
@Composable
fun App() {
val result = getData()
if (result == null) {
Loading(...)
} else {
Header(result)
Body(result)
}
}
위 예제는 getData()의 결과에 따라 Loading 화면을 보여줄지 Header와 Body 내용을 보여줄지가 결정됩니다. 따라서 compiler는 if문의 분기에 따라 각각 다른 key값을 아래와 같이 할당합니다.
fun App($composer: Composer) {
val result = getData()
if (result == null) {
$composer.start(123)
Loading(...)
$composer.end()
} else {
$composer.start(456)
Header(result)
Body(result)
$composer.end()
}
}
만약 if문에 의해서 Loading 화면이 보여야 한다면 gap array에 해당 group을 넣습니다.
반대로 값이 반환되어 else 구문을 탄다면 코드에서 key가 456인 composer.start()를 호출해야 합니다. 하지만 이전에 메모리 slot에 들어있는 key는 123이었기 때문에 compilier는 두 값이 다른 것을 파악하여, UI tructure가 변경되었음을 인지합니다.
그럼 compiler는 현재 cursor 위치로 gap을 이동시키고 거기 있던 UI 전체까지 gap을 확장시켜 효과적으로 기존 것들을 제거합니다. 따라서 새로운 UI인 header와 body가 메모리 slot에 새로이 추가됩니다.
이 코드의 경우에 if문에 의해서 발생되는 추가적인 overhead는 시작점에 (하나의 slot)에 대한 진입 정도입니다. 이렇게 group 한 개를 넣음으로써 complier는 암시적으로 if문의 흐름 제어를 가능하도록 만듭니다. 이런 컨셉으로 Compose가 구동되며, 여기서는 이런 방식을 Positional Memoization이라 부릅니다.
Positional Memoization
보통 Compose는 compiler가 함수의 input에 의하여 반환되는 결과를 cache 하는 global memoization을 가지고 있습니다.
@Composable
fun App(items: List<String>, query: String) {
val results = items.filter { it.matches(query) }
// ...
}
여기서 composable function은 string list인 items와 string으로 된 query라는 두 개의 param을 받고 함수 내부에서 filter연산을 수행합니다. 이 연산을 remember로 묶는다면 remember의 메모리 slot 할당 방식에 따라 items와 query를 slot에 저장합니다. 그리고 연산을 진행한 후에 결과를 전달하기 전에 결과 역시 slot에 저장합니다. 즉 input값과 그에 해당하는 결과를 slot에 저장합니다.
여기서 positional memoization이란, 이 function을 두 번째 호출할 때 remember에 의해서 저장되어 있는 input 값을 보고 새로 인입된 값과 비교하여, 어떤 값도 변경되지 않았다면 filter 연산을 다시 하지 않고 이전에 저장해 두었던 결과를 반환하는 방식을 말합니다.
compiler는 이전 수행에 값들을 한 번은 저장해야 하지만 대신 연산 자체는 매우 가벼워(cheap) 집니다. 또한 이 연산은 함수가 재활용됨에 따라 전체 UI에 걸쳐서 발생할 수 있지만 이전 연산들에 대한 정보는 해당 위치에 대응되어 저장되므로 여러 곳에서 호출되더라도 각각의 호출 위치가 다르므로 문제가 되지 않습니다.
아래의 remember 함수의 정의입니다. vararg Any?로 되어 있기 때문에 여러 개의 input값을 받을 수 있습니다.
@Composable
fun <T> remember(vararg inputs: Any?, calculation: () -> T): T
여기서 만약 위 정책가 위반되게 remember에 매번 변경되는 값을 저장한다면 오동작을 유발할 수 있습니다.
@Composable fun App() {
val x = remember { Math.random() }
// ...
}
즉 위 예제처럼 random함수를 넣어 놓는다면 composable이 생성될 때 (lifecycle상 Enter 될 때) 계산된 값이 composable function이 Hierarchy에서 제거될 때 (lifecycle 상 Leave)까지 유지됩니다. 즉 recompose 단계에서는 값이 유지되기 때문에 호출시마다 값이 변경되는 (imdempotent 하지 않은) 형태로 사용되지 않도록 주의해야 합니다.
References
[1] https://medium.com/androiddevelopers/under-the-hood-of-jetpack-compose-part-2-of-2-37b2c20c6cdd
[2] https://developer.android.com/jetpack/compose/why-adopt
[3] https://medium.com/androiddevelopers/understanding-jetpack-compose-part-1-of-2-ca316fe39050
'개발이야기 > Android' 카테고리의 다른 글
[Compose] Button Selector in Android Jetpack (0) | 2022.02.28 |
---|---|
Telephony ID 종류들 (dual SIM, eSIM 단말에서의 구분) (0) | 2022.01.12 |
[Compose] Understanding compose lifecycle and recomposing with case study (0) | 2021.11.16 |
[Compose] Google Summit 2021 - Deep dive into Jetpack Compose layouts (0) | 2021.11.15 |
[Compose] 11. State holder (0) | 2021.10.11 |