본문 바로가기
Android/Compose 숨참고 Deep dive

Compose 내부 구조

by DONXUX 2023. 3. 30.

이 글은 2020.08.29에 작성된 Compose 세부구현에 관한 문서를 바탕으로 정리한 글이므로, 포스팅 작성 시기의 세부구현과는 다를 수 있음을 알려드립니다.

원문 : https://medium.com/androiddevelopers/under-the-hood-of-jetpack-compose-part-2-of-2-37b2c20c6cdd

 

Under the hood of Jetpack Compose — part 2 of 2

Under the hood of Compose

medium.com

 

@Composable

Compose를 사용해보셨다면, 많은 코드에서 @Composable 어노테이션을 보셨을겁니다. 하지만 Compose는 어노테이션 프로세서가 아닙니다. Compose는 Kotlin 타입 체킹과 코드 생성과정에서 Kotlin 컴파일러 플러그인의 도움을 받아 작동합니다. 

 

자세한 내용은 아래 글을 참고해주세요.

https://ducorner.tistory.com/8

 

Compose 컴파일러 Part 1 : A Kotlin compiler plugin

Compose 컴파일러는 Kotlin 컴파일러 플러그인입니다. Compose를 사용해보신 분이라면 Kotlin 함수에 @Composable 어노테이션을 붙인 함수는 Composable 함수로 변환되는 것은 알고 계실겁니다. 보통, 코틀린

ducorner.tistory.com

 

suspend 키워드를 봅시다.

// function declaration
suspend fun MyFun() { … }
 
// lambda declaration
val myLambda = suspend { … }
 
// function type
fun MyFun(myParam: suspend () -> Unit) { … }

 

Kotlin suspend 키워드는 함수 선언뿐만 아니라 람다나 타입에서도 사용할 수 있습니다. Composable도 마찬가지입니다.

// function declaration
@Composable fun MyFun() { … }
 
// lambda declaration
val myLambda = @Composable { … }
 
// function type
fun MyFun(myParam: @Composable () -> Unit) { … }

 

여기서 중요한 점은 함수 타입에 @Composable 어노테이션을 붙이면 해당 함수 타입이 변경된다는 것입니다. 즉, 어노테이션이 없는 함수 타입은 어노테이션이 붙은 타입과 호환되지 않습니다.

 

또한, suspend 함수는 호출 컨텍스트가 요구됩니다. 즉, suspend 함수는 오로지 다른 suspend 함수에서만 호출할 수 있다는 것입니다.

fun Example(a: () -> Unit, b: suspend () -> Unit) {
   a() // allowed
   b() // NOT allowed
}
 
suspend 
fun Example(a: () -> Unit, b: suspend () -> Unit) {
   a() // allowed
   b() // allowed
}

 

Composable도 마찬가지입니다. 이는 모든 호출에서 스레드로 전달해야하는 어떠한 호출 컨텍스트 객체가 있기 때문입니다.

fun Example(a: () -> Unit, b: @Composable () -> Unit) {
   a() // allowed
   b() // NOT allowed
}
 
@Composable 
fun Example(a: () -> Unit, b: @Composable () -> Unit) {
   a() // allowed
   b() // allowed
}

 

Composable는 어떻게 작동하는가

그렇다면 위에서 언급한 Composeable의 호출 컨텍스트의 정체가 무엇이며, 왜 그것을 전달해야할까요? 우선 Composable의 호출 컨텍스트의 정체는 Composer라는 녀석입니다. Composer 내에는 Slot table이라는 객체가 존재합니다. 이는 Composition 데이터들이 저장되는 배열인데, 구조는 Gap buffer와 유사합니다, 이는 텍스트 에디터에서 많이 사용하는 자료구조입니다.

 

Gap buffer에 대한 내용을 참고하시려면 아래 게시물을 참고해주세요.

https://ducorner.tistory.com/12

 

Gap buffer

텍스트 편집기를 구현하려고합니다. 글자들을 배열에 나열했다고 상상해봅시다. 편집기에서 글을 쓰면서 글자 삽입, 삭제가 빈번하게 일어납니다. 일반적으로는 삽입 및 삭제가 일어날 때마다

ducorner.tistory.com

 

아래 그림은 Composer의 Composition에 대한 정보들이 저장되는 Slot table입니다. 현재 Slot table은 모두 비워져있습니다. 즉, 전체가 Gap이라고 볼 수 있습니다.

 

실행중인 Composable 계층은 이 자료구조에 접근하여 내용을 삽입할 수 있습니다.

 

계층 실행이 끝났다고 해봅시다. 여기서 중간에 데이터 변경으로 인해 Recomposition이 일어나면 어떻게 될까요? 우선, 커서를 배열의 맨 위로 재설정한 다음 다시 실행을 수행합니다. 그리고 다시 데이터를 확인하고 아무것도 하지 않거나 값을 업데이트를 합니다.

 

그럼 중간에 UI가 하나 추가된다면 어떻게될까요? 이 경우에는 커서를 변경되어야하는 위치로 옮긴 뒤 갭이 해당 위치로 이동합니다.

 

그리고 갭에 삽입합니다.

 

Gap buffer를 사용하는 이유

Compose는 왜 이런 자료구조를 선택했을까요? 우선 Gap buffer에서 Gap의 이동이 이루어지면 O(n)으로 수행되고, 그 외에는 모두 O(1)에 수행된다는 사실을 알고 계셔야합니다. Gap의 이동이 이루어지는 순간은 언제일까요? 현재 커서 이외의 지역에서 UI나 다른 데이터의 삽입이 이루어질 때 Gap의 이동이 이루어집니다. 이는 UI 구조의 변화가 이루어질 때 Gap 이동이 이루어질 가능성이 매우 높습니다.  

Compose 팀은 UI 구조 자체가 많이 변경되지 않을 것이고, 데이터의 변경으로 UI가 변경되는 경우가 많을 것이라고 가정했습니다. 그리고 데이터 변경으로 UI가 변경되는 연산은 빠른시간에 연산될 수있는 전략을 취하기 위해 Gap buffer가 선택되었습니다.

 

버튼을 누르면 숫자가 카운팅되는 Compose 예제를 실제로 컴파일 해봅시다.

@Composable
fun Counter() {
 var count by remember { mutableStateOf(0) }
 Button(
   text="Count: $count",
   onPress={ count += 1 }
 )
}

 

컴파일러는 다음과 같이 컴파일합니다. 컴파일러는 컴파일 타임에 정수 키를 생성하고 함수 위와 밑에 각각 $composer.start(), $composer.end()를 호출하고 생성한 정수 키를 전달합니다. 또한 함수 내 Composable에도 $composer를 전달합니다.

fun Counter($composer: Composer) {
 $composer.start(123)
 var count by remember { mutableStateOf(0) }
 Button(
   $composer,
   text="Count: $count",
   onPress={ count += 1 }
 )
 $composer.end()
}

 

Composable 함수가 실행되면 Composer는 다음 작업을 수행합니다.

  • Composer.start가 호출되어 그룹 객체를 저장합니다.
  • remember를 그룹 객체에 삽입합니다.
  • mutableStateOf의 리턴값인, 상태 인스턴스가 저장됩니다.
  • 버튼도 그룹 객체를 저장하고, 각 매개변수를 저장합니다.

그리고 composer.end()에 도달합니다.

 

 

Slot table에는 요소들이 계층 트리의 DFS 순서로 채워지게됩니다.

 

이제 Slot table에 많은 그룹 객체들이 저장되었습니다. 그들이 존재하는 이유는 무엇일까요? 이러한 그룹 객체는 동적 UI에서 발생할 수 있는 이동 및 삽입을 관리하기 위해 존재합니다. 하지만 컴파일러는 UI 구조를 변경하는 코드가 어떤지 알고 있기 때문에, 조건부로 그룹을 삽입할 수 있습니다. 다음 코드를 봅시다.

@Composable fun App() {
 val result = getData()
 if (result == null) {
   Loading(...)
 } else {
   Header(result)
   Body(result)
 }
}

 

이 Composable에서는 getData 함수가 결과를 반환하고, 이 결과에 따라 한 경우에는 로딩 Composable을 렌더링하고, 다른 경우에는 헤더와 본문을 렌더링합니다. 컴파일러는 if문의 각 분기에 대해 별도의 키를 삽입합니다.

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()
 }
}

 

이 코드가 처음에 실행될 때 result가 null이라고 가정해봅시다. 이는 Gap buffer에 그룹을 삽입하고 로딩 화면일 실행합니다.

 

함수가 두 번째로 실행될 때는, result가 null이 아니므로, if문의 두 번째 분기가 실행됩니다.

컴파일러는 Slot table의 그룹이 123과 일치하지 않는 것을 보고, UI 구조가 변경되었다는 것을 알게됩니다.

컴파일러는 그룹 키가 123인 Slot table과 매치되는 그룹을 보지 못하므로, 현재 커서 위치로 Gap을 이동시키고 해당 UI를 건너뛰면서 Gap을 확장합니다. 이 과정에서 UI는 삭제됩니다.

이 시점에서, Header와 Body가 삽입됩니다.

 

이 경우 if문의 오버헤드는 Slot table에 단일한 항목만 추가되는 것입니다. 이 단일한 그룹을 삽입함으로써 UI의 흐름을 제어할 수 있습니다. 즉, 컴파일러는 이를 관리하고 실행 중 UI를 캐시와 유사한 데이터 구조로 활용할 수 있습니다.

 

이러한 방식을 Positional Memoization이라고 합니다.

 

Positional memoization

다음 코드를 봅시다.

@Composable
fun App(items: List<String>, query: String) {
 val results = items.filter { it.matches(query) }
 // ...
}

 

이 함수는 string 리스트인 items와 query를 받고, 그 리스트를 필터링합니다. 이 연산을 remember로 감싼다면 remember의 Slot table 할당 방식에 따라 items와 query를 Slot table에 저장합니다. 그리고 연산을 수행한 후에 결과를 전달하기 전에 결과 역시 Slot table에 저장합니다.

 

여기서 함수가 두 번째로 호출되었다고 해봅시다. remember에 의해서 저장되어있는 input 값을 보고 새로 전달받은 값과 비교합니다. 여기서 어떤 값도 변경되지 않았다면 본문을 수행하지 않고 이전에 저장해둔 결과를 반환합니다. 이것이 Positional memoization입니다.

 

물론, 컴파일러는 이전 값들을 저장해야하지만, 연산이 매우 가벼워진다는 이점이 생깁니다. 

 

아래는 remember 함수입니다. 

@Composable
fun <T> remember(vararg inputs: Any?, calculation: () -> T): T

 

Math.random()을 remember로 감싸면 어떻게 될까요? Math.random()은 호출될 때마다 매번 새로운 값을 가져옵니다. 즉, Recomposition이 일어날 때마다 항상 새로운 값을 받게되어 본문이 실행될 것입니다. 메모이제이션의 이점이 무의미해집니다. 이러한 형태의 코드를 작성하지 않도록 주의해야합니다.

@Composable fun App() {
 val x = remember { Math.random() }
 // ...
}

 

파라미터 저장

Composable 함수의 파라미터가 어떻게 저장되는지를 설명하기 위해, 숫자를 받고, Address를 호출하고 출력하는 Google Composable를 살펴봅시다.

@Composable fun Google(number: Int) {
 Address(
   number=number,
   street="Amphitheatre Pkwy",
   city="Mountain View",
   state="CA"
   zip="94043"
 )
}
 
@Composable fun Address(
 number: Int,
 street: String,
 city: String,
 state: String,
 zip: String
) {
 Text("$number $street")
 Text(city)
 Text(", ")
 Text(state)
 Text(" ")
 Text(zip)
}

Compose는 Slot table에 Composable 함수의 파라미터를 저장합니다. 위 예제를 보면 몇 가지 중복되는 지점을 파악할 수 있습니다. "Mountain View"와 "CA"는 텍스트 호출에 다시 저장되므로 두 번 저장됩니다.

 

여기서 저희가 주목해야할 점은 static 파라미터입니다. 컴파일러는 Composable 함수에 static 파라미터를 추가하여 이 중복을 제거합니다.

fun Google(
 $composer: Composer,
 $static: Int,
 number: Int
) {
 Address(
   $composer,
   0b11110 or ($static and 0b1),
   number=number,
   street="Amphitheatre Pkwy",
   city="Mountain View",
   state="CA"
   zip="94043"
 )
}

static은 런타임에 파라미터의 변경여부에 대한 정보가 담긴 비트 필드입니다. 만약 파라미터가 바뀌지 않는걸 알고 있다면, 굳이 저장할 필요가 없습니다. 그래서 위 Google 예제에서는 컴파일러가 static 비트 필드를 통해 파라미터 중 어떤 것도 바뀌지 않는다는 정보를 전달합니다.

 

그런 다음, Address에서 컴파일러는 같은 일을 하고 그것을 텍스트로 전달합니다.

fun Address(
  $composer: Composer,
  $static: Int,
  number: Int, street: String, 
  city: String, state: String, zip: String
) {
  Text($composer, ($static and 0b11) and (($static and 0b10) shr 1), "$number $street")
  Text($composer, ($static and 0b100) shr 2, city)
  Text($composer, 0b1, ", ")
  Text($composer, ($static and 0b1000) shr 3, state)
  Text($composer, 0b1, " ")
  Text($composer, ($static and 0b10000) shr 4, zip)
}

이 비트 논리를 굳이 이해할 필요는 없습니다.

 

상수도 있는데, 이것들도 저장할 필요가 없습니다. 따라서 전체 계층 구조는 number 파라미터에 의해 결정되며 컴파일러가 저장해야 하는 유일한 값입니다.

 

이 때문에, 우리는 더 나아가 number가 바뀌어질 유일한 값이라는 것을 이해하는 코드를 생성할 수 있습니다. 이 코드는 number가 변경되지 않은 경우 함수의 본문을 완전히 건너뛸 수 있도록 작동할 수 있으며, Compose에게 현재 인덱스를 함수가 실행된 것처럼 있는 곳으로 이동하도록 지시할 수 있습니다.

fun Google(
 $composer: Composer,
 number: Int
) {
 if (number == $composer.next()) {
   Address(
     $composer,
     number=number,
     street="Amphitheatre Pkwy",
     city="Mountain View",
     state="CA"
     zip="94043"
   )
 } else {
   $composer.skip()
 }
}

 

Reference

https://medium.com/androiddevelopers/under-the-hood-of-jetpack-compose-part-2-of-2-37b2c20c6cdd

https://en.wikipedia.org/wiki/Gap_buffer