Understanding Jetpack Compose-2

최희창·2023년 2월 25일
0

Jetpack Compose

목록 보기
2/9

Introduction

Compose에 관한 2번째 글이다. 첫 번째 게시물에서는 Compose의 이점, Compose가 해결하는 문제, 일부 디자인 결정의 이유 및 이러한 결정이 앱 개발자에게 어떻게 도움이 되는지에 대해 설명했습니다. 또한 Compose의 mental model, Compose에서 작성하는 코드에 대해 어떻게 생각 해야 하는지, API를 어떻게 형성해야 하는지에 대해 논의 했습니다.

이제 Compose가 내부적으로 어떻게 작동하는지 살펴보겠습니다. 하지만 시작하기전에 Compose를 사용하기 위해 Compose 구현 방법을 이해할 필요는 없다는 점을 강조하고 싶습니다. 다음은 순전히 지적 호기심을 충족하기 위한 것입니다.

What does @Composable mean?

Compose를 본 적이 있다면 여러 코드 예제에서 @Composable 주석을 보았을 것이다. 중요한 점은 Compose가 Annotation processor가 아니라는 점이다. Compose는 Kotlin의 타입 검사 및 코드 생성 단계에서 Kotlin 컴파일러 플러그인의 도움으로 작동합니다. Compose를 사용하기 위해 Annotation processor가 필요하지 않습니다.

이 Annotation은 언어 키워드와 더 유사합니다. 좋은 비유는 Kotlin의 suspend 키워드입니다.

// function declaration
suspend fun MyFun() { ... }

// lambda declaration
val myLambda = suspend { ... }

// function type
fun MyFun(myParam: suspend () -> Unit) {...}

Kotlin의 suspend 키워드는 함수 유형에서 작동합니다. suspend, Lambada 또는 타입인 함수 선언을 가질 수 있습니다.
Compose는 동일한 방식으로 작동합니다. 즉, 함수 유형을 변경할 수 있습니다.

//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
 }

Execution Model

그렇다면 우리가 전달하고 있는 호출 컨텍스트는 무엇이며 왜 그렇게 해야 할까요?

우리는 이 객체를 Composer라고 부릅니다. Composer의 구현에는 Gap Buffer와 밀접하게 관련된 데이터 구조가 포함되어 있습니다. 이 데이터 구조는 일반적으로 텍스트 편집기에서 사용됩니다.

Gap Buffer는 현재 인덱스 또는 커서가 있는 컬렉션을 나타냅니다. Flat array로 메모리에서 구현됩니다. Flat array는 그것이 나타내는 데이터 모음보다 크며 사용되지 않은 공간을 Gap이라고 합니다.

이제 실행 중인 Composable 계층 구조가 이 데이터 구조에 호소하고 여기에 항목을 삽입할 수 있습니다.

계층 구조 실행을 완료했다고 상상해봅시다. 어느 시점에서 우리는 무언가를 재구성할 것입니다. 따라서 커서를 배열의 맨 위로 재설정한 다음 다시 실행을 진행합니다. 실행할 때 데이터를 보고 아무것도 하지 않거나 값을 업데이트할 수 있습니다.

UI 구조가 변경되어 삽입을 원할 수 있습니다. 이 시점에서 우리는 Gap을 현재 위치로 이동합니다.

이제 삽입물을 만들 수 있습니다.

이 데이터 구조에 대해 이해해야 할 중요한 점은 get, move insert, delete와 같은 모든 작업이 Gap 이동을 제외하고 일정한 시간 작업이라는 것입니다. Gap 이동은 O(n)입니다. 우리가 이 데이터 구조를 선택한 이유는 평균적으로 UI가 구조를 많이 변경하지 않을 것이라고 확신하기 때문입니다. 동적 UI가 있는 경우 값 측면에서 변경되지만 구조는 거의 자주 변경되지 않습니다. 구조를 변경하면 일반적으로 큰 덩어리로 변경되므로 이 O(n) 갭 이동을 수행하는 것은 합리적인 절충안입니다.

Counter 예를 봅시다.

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

이것은 우리가 작성할 코드이지만 컴파일러가 수행하는 작업을 살펴보겠습니다.

컴파일러가 Composable 주석을 확인하면 추가 매개변수를 삽입하고 함수 본문에 호출합니다.

먼저 컴파일러는 composer.start 메서드에 대한 호출을 추가하고 컴파일 시간에 생성된 키 정수를 전달합니다.

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

또한 컴파일러는 함수 본문의 모든 composable한 곳에 composer 객체를 전달합니다.

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

컴파일러가 실행되면 다음과 같은 과정을 거친다.
1. Composer.start가 호출되고 그룹 객체를 저장합니다.
2. 그룹 객체 삽입을 기억합니다.
3. mutableStateOf가 반환하는 값인 상태 인스턴스가 저장됩니다.
4. Button은 그룹과 각 매개변수를 저장합니다.
그리고 composer.end에 도착합니다.

데이터 구조는 이제 컴포지션의 모든 객체, 실행 순서의 전체 트리, 사실상 트리의 깊이 우선 순회를 보유합니다.

이젠 모든 그룹 객체가 많은 공간을 차지하는데 왜 존재할까요? 이러한 그룹 객체는 동적 UI에서 발생할 수 있는 이동 및 삽입을 관리하기 위해 존재합니다. 컴파일러는 UI구조를 변경하는 코드가 어떤 모양인지 알고 있으므로 해당 그룹을 조건부로 삽입할 수 있습니다. 대부분의 경우 컴파일러는 많은 그룹들을 테이블에 삽입하지 않으므로 그들을 필요로 하지 않습니다. 다음 조건부 로직을 봅시다.

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

이 Composable에서 getData 함수는 일부 결과를 반환하고 어떤 경우에는 Loading 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()
    }

이 코드가 처음 실행될 때 결과가 null이라고 가정해 봅시다. 그러면 Gap 배열에 그룹이 삽입되고 로딩 화면이 실행됩니다.함수가 두 번째로 실행될 때 if문의 두 번째 분기가 실행되도록 결과가 더 이상 null이 아니라고 가정합니다. 이것이 흥미로워 지는 곳입니다.

composer.start에 대한 호출에는 키가 456인 그룹이 있습니다. 컴파일러는 슬롯 테이블 123의 그룹이 일치하지 않는 것을 확인하므로 이제 UI 구조가 변경되었음을 알게 됩니다.

그런 다음 컴파일러는 Gap을 현재 커서 위치로 이동하고 거게 있던 UI에서 간격을 확장하여 효과적으로 제거합니다.

이 시점에서 코드는 정상적으로 실행되고 새로운 UI(헤더 및 본문)가 삽입됩니다.
이 경우 if문의 오버헤드는 슬롯 테이블의 단일 슬록 항목이었습니다. 이 단일 그룹을 삽입함으로써 우리는 컴파일러가 UI를 실행하는 동안 이를 관리하고 이 캐시와 같은 데이터 구조에 호소할 수 있도록 UI에 임의의 제어 흐름을 갖게 됩니다.

이 개념은 우리가 Positional Memoization이라고 부르는 것이며 Compose가 처음부터 구축되는 개념입니다.

Positional Memoization

일반적으로 전역 메모이제이션은 컴파일러가 해당 함수의 입력을 기반으로 함수의 결과를 캐시한다는 의미입니다. 위치 메모이제이션의 예를 설명하기 위해 계산을 수행하는 Composable 함수를 봅시다.

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

이 함수는 문자열 항목 목록과 쿼리를 받은 다음 목록에서 필터 계산을 수행합니다. 우리는 이 계산을 remember 호출로 감쌀 수 있습니다. remember는 슬롯 테이블에 호소하는 방법을 알고 있습니다. 항목을 살펴보고 목록과 쿼리를 슬롯 테이블에 저장하는 것을 기억하십시오. 그런 다음 필터 계산이 실행되고 기억하여 결과를 다시 전달하기 전에 저장합니다.

함수가 두 번째로 실행될 때 전달되는 새 값을 확인하고 이전 값과 비교합니다. 둘 다 변경되지 않은 경우 필터 작업을 건너뛰고 이전 결과가 반환됩니다. 바로 위치 메모이제이션입니다.

흥미롭게도 이 작업은 정말 저렴했습니다. 컴파일러는 이전 호출을 하나 저장해야 했습니다. 이 계산은 UI 전체에서 발생할 수 있으며 위치별로 저장하기 때문에 해당 위치에 대해서만 저장합니다.

이것은 기억 함수의 서명이며, 임의의 수의 입력과 계산 함수를 취할 수 있습니다.

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

하지만 입력 값이 없는 퇴화 사례가 존재했습니다. 우리가 할 수 있는 것 중의 하나는 의도적으로 API를 잘못 사용하는 것입니다. Math.Random과 같이 의도적으로 불순한 계산을 메모할 수 있습니다.

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

전역 메모이제이션으로 이 작업을 수행하는 경우 의미가 없습니다. 그러나 위치 메모이제이션을 사용하면 결국 새로운 의미를 갖게 됩니다. Composable한 계층에서 앱을 사용할 때마다 새로운 Math.random 값이 반환됩니다. 그러나 Composable이 다시 구성될 때마다 동일한 Math.random 반환 값이 됩니다. 이것은 지속성을 발생시키고 지속성은 상태를 발생시킵니다.

Stroing parameters

Composable 함수의 매개변수가 저장되는 방식을 설명하기 위해 숫자를 가져와 Address Composable을 호출하고 주소를 렌더링 하는 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)
    ...

Compose는 Composable 함수의 매개변수를 슬롯 테이블에 저장합니다. 이 경우 위의 예를 보면 주소 호출에 추가된 Mountain View 및 CA가 기본 텍스트 호출에 다시 저장되므로 이러한 문자열이 두 번 저장됩니다..

컴파일러 수준에서 Composable 함수에 정적 매개변수를 추가하여 이러한 중복성을 제거할 수 있습니다.

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"

이 경우 정적 매개변수는 매개변수가 변경되지 않는다는 것을 런타임이 알고 있는지 여부를 나타내는 비트 필드입니다. 매개변수가 변경되지 않는 것으로 알려진 경우 저장할 필요가 없습니다. 따라서 이 Google예제에서 컴파일러는 매개변수가 변경되지 않는다는 비트 필드를 저장합니다.

그런 다음 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)
}

이 비트 논리는 읽기 어렵고 혼란스럽니다. 그러나 우리가 이것을 이해할 필요는 없습니다. 컴파일러는 이것에 능숙하지만 인간은 그렇지 않습니다.

Google 예에서 중복 정보가 있지만 여기에도 상수가 있음을 알 수 있습니다. 그것들도 저장할 필요가 없다는 것이 밝혀졌습니다. 따라서 전체 계층 구조는 숫자 매개변수에 의해 결정되며 컴파일러가 저장해야 하는 유일한 값 입니다.

이 때문에 우리는 더 나아가 숫자만 변경될 것임을 이해하는 코드를 생성할 수 있습니다. 그런 다음 이 코드는 숫자가 변경되지 않은 경우 함수 본문을 완전히 건너뛰도록 작동할 수 있으며, 마치 함수가 실행된 것처럼 현재 인덱스를 위치로 이동하도록 composer에 지시할 수 있습니다.

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

Composer는 필요한 곳에서 재개하기 위해 실행을 얼마나 빨리 감아야 하는지 알고 있습니다.

Recomposition

재구성이 작동하는 방식을 설명하기 위해 반대 예제로 돌아가 보겠습니다.

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

컴파일러가 이 카운터에 대해 생성하는 코드에는 composer.start 및 composer.end가 있습니다. Counter가 실행될 때마다 런타임은 count.value를 호출할 때 appmodel 인스턴스의 속성을 읽는다는 것을 이해합니다. 런타임에 compose.end를 호출할 때마다 선택적으로 값을 반환합니다.

$composer.end()?.updateScope { nextComposer ->
	Counter(nextComposer)
}

그런 다음 필요한 경우 이 Composable을 다시 시작하는 방법을 런타임에 알려주는 람다를 사용하여 해당 값에 대해 updateScope 메서드를 호출할 수 있습니다. 이는 LiveData가 수신하는 람다와 동일합니다.

Closing thoughts

이러한 세부 사항의 대부분은 단지 구현 세부 사항일 뿐이라는 점을 기억하는 것이 중요합니다. Composable 함수는 표준 kotlin 함수와 동작 및 기능이 다르며 이러한 기능이 구현되는 방식을 이해하는 것이 도움이 될 수 있지만 동작 및 기능은 변경되지 않지만 구현은 변경될 수 있습니다.

profile
heec.choi

0개의 댓글