Composable 함수는 Jetpack Compose에서 가장 기본이 되는 요소이며, Composable 트리 구조를 작성하는 데 사용됩니다.
Compose Runtime은 Composable 함수를 메모리에서 큰 트리의 일원인 하나의 노드로서 이해하고 나타낼 수 있습니다.
가령, 아래와 같은 코드를 보면
@Composable
fun NamePlate(name: String) {
}
@Composable 어노테이션을 사용함으로써, 컴파일러는 이 함수를 본질적으로 데이터를 하나의 노드로 변환하여 Composable 트리에 기재할 수 있습니다.
즉, 우리가 Composable 함수를 @Composable (Input) -> Unit과 같은 형태로 보면 입력값은 데이터가 되고, 출력은 함수의 반환 값이 아니라 트리에 요소를 삽입하기 위해 기재된 일련의 동작이라고 볼 수 있습니다.
이러한 동작은 일반적으로 Compose 관용어로 방출이라고 알려져있습니다.
Composable 함수는 실행될 때 함수의 구현 정보를 방출하며 이는 Composition 과정 중에 발생합니다.
Composable 함수의 속성은 대부분 Compose Compiler에 의해 활성화 됩니다. Kotlin 컴파일러 플러그인이며 일반적인 컴파일러 실행단계 중에 동작합니다. Kotlin 컴파일러가 액세스할 수 있는 모든 정보에 액세스할 수 있습니다.
이로써 Composable 함수의 중간 표현인 IR을 가로채고 변환하여 원본 소스의 모든 Composable 함수에 추가적인 정보를 부여할 수 있습니다.
각 Composable 함수에 추가된 요소 중 하나는 함수의 매개변수 목록의 끝에 새롭게 추가된 Composer입니다.
이 매개변수는 암묵적이며 개발자는 이에 대해 알아야 할 필요가 없습니다. Composer 매개변수의 인스턴스는 런타임에 주입되며,
모든 하위 Composable 호출로 전달되므로 트리의 모든 수준에서 접근할 수 있습니다.
앞서 봤던 예제의 코드는 아래처럼 변환됩니다.
@Composable
fun NamePlate(name: String, lastName: String) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = name)
Text(text = lastName)
}
}
fun NamePlate(name: String, lastName: String, $composer: Composer<*>) {
...
Column(modifier = Modifier.padding(16.dp), $composer) {
Text(text = name, $composer)
Text(text = lastName, $composer)
}
...
}
Composer는 트리 내의 모든 Composable 호출로 전달되며, Composable 함수는 오로지 다른 Composable 함수에서만 호출될 수 있습니다.
이 호출 컨텍스트 (Calling Context)는 필수적이며, 이는 트리가 오직 Composable 함수로 구성되도록 보장하고, Composer가 트리를 따라 하향 전달되도록 합니다.
Composer는 개발자가 작성하는 Composable 코드와 Compose Runtime 간의 중재자 역할을 합니다.
Composable 함수는 트리에 대한 변경 사항을 전달하고, 런타임 시에 트리의 형태를 빌드하거나 업데이트하기 위해 Composer를 사용합니다.
Composable 함수는 생성하는 노드 트리에 대해 멱등성을 가져야 합니다. 동일한 입력 매개변수 를 사용하여 Composable 함수를 여러 번 다시 실행하더라도 동일한 트리가 생성되어야 합니다.
Jetpack Compose Runtime은 recomposition과 같은 작업을 위해 이러한 멱등성이 제공하는 가정에 의존합니다.
Recomposition의 과정은 트리를 아래로 순회하면서 어떤 노드를 재구성 해야 하는지 확인합니다. 이 과정에서 입력값이 변경된 노드만 recomposition을 수행하고 나머지는 생략합니다.
특정 노드를 생략하는 것은 해당 노드를 대표하는 Composable 함수가 멱등성의 성질을 가질 때만 가능합니다.
그 이유는 런타임은 동일한 입력값을 제공할 경우 동일한 결과를 생성한다고 가정할 수 있기 때문입니다.
동일한 입력값에 대한 결과는 이미 메모리에 적재되어 있으므로 Compose는 다시 실행할 필요가 없고, 결과적으로 생략할 수 있습니다.
사이드 이펙트(side effect)6는 호출된 함수의 제어를 벗어나서 발생할 수 있는 예상치 못한 모든 동작을 의미합니다.
이러한 사이드 이펙트는 모호함의 근원이라고 볼 수 있습니다.
Compose Runtime은 Composable 함수가 예측 가능하도록(결정론적인) 기대하기 때문에 사이드 이펙트가 포함된 Composable 함 수는 예측이 어려워지고, 결과적으로 Compose에게 좋지 않습니다.
Composable 함수가 사이드 이펙트를 실행한다면 매 함수 호출 시마다 새로운 프로그램 상태를 생성할 수 있으므로, Compose Runtime에게 필수적인 멱등성을 따르지 않게 됩니다.
아래 예시를 통해 설명을 해보겠습니다.
@Composable
fun EventsFeed(networkService: EventsNetworkService) {
val events = networkService.loadAllEvents()
LazyColumn {
items(events) { event ->
Text(text = event.name)
}
}
}
위의 코드는 본적으로 Compose Runtime에 의해 짧은 시간 내에 여러 번 다시 실행될 수 있으며, 이로 인해 네트워크 요청이 여러 번 수행되어 우리의 제어를 벗어날 수 있습니다.
더 최악의 상황은 이러한 사이드 이펙트가 아무 조건 없이 다른 스레드에서 실행될 수 있다는 것입니다.
Compose Runtime은 Composable 함수에 대한 실행 전략을 선택할 권한을 보유하고 있습니다.
이는 하드웨어의 멀티 코어의 이점을 활용하기 위해 recomposition을 다른 스레드로 이전시킬 수 있거나, 필요성이나 우선순위에 따라 임의의 순서로 실행할 수 있습니다.
(예시로, 화면에 보이지 않는 Composable은 낮은 우선순위로 할당될 수 있습니다).
일반적인 사이드 이펙트 유의사항은 우리가 Composable 함수를 다른 Composable 함수의 결과에 의존하도록 만들고, 순서에 어떠한 관계를 부여하는 것입니다. 아래의 예제 코드를 보겠습니다.
@Composable
fun MainScreen() {
Header()
ProfileDetail()
EventList()
}
이 코드에서, Header, ProfileDetail, EventList는 Composable 함수는 Compose Compiler에 의해 어떤 순서로든, 심지어 병렬로도 실행될 수 있습니다.
따라서 특정 실행 순서를 가정하는 로직을 작성해서는 안 됩니다. 가령, Header에서 외부 변수를 업데이트하고 ProfileDetail에서 그 외부 변수를 읽어 들이는 경우입니다.
일반적으로 말하자면, 사이드 이펙트는 Composable 함수에서 이상적이지 않습니다.
우리는 모든 Composable 함수를 stateless(무상태, 상태를 보존하지 않음)하게 만들려고 노력해야 합니다.
그래서 Composable 함수는 모든 입력값은 매개변수로서 받고, 결과를 생성하기 위해 주어진 입력값 만을 사용합니다.
이것은 Composable을 더 어리석어 보일 정도로 단순하고, 높은 재사용성을 갖게 합니다.
그러나, stateful(상태유지, 상태를 보유하는) 프로그램을 개발하기 위해서는 사이드 이펙트가 필요하므로, 특정 단계에서는 우리가 사이드 이펙트를 실행해야만 합니다.
이러한 이유로, Jetpack Compose는 Composable 함수에서 안전하고 통제된 환경 내에서 이펙트를 호출할 수 있는 이펙트 핸들러와 같은 메커니즘을 제공합니다.
Composable 함수는 Recomposition 때문에 단 한번만 호출되는 표준 함수들과는 다릅니다.
Composable 함수는 Recomposition으로 여러 번 재시작될 수 있기 때문에 Compose Runtime은 함수가 재실행될 수 있도록 해당 함수들에 대한 참조를 유지합니다.
Composable 4와 5는 입력값이 변경되면 재실행됩니다.
Composable 함수들은 UI를 구축하거나 반환하지 않습니다.
Composable은 단순히 인메모리 구 조를 구축 및 업데이트하기 위한 데이터를 방출할 뿐입니다.
이 매커니즘이 Composable을 더욱 빠르게 만들며, 런타임이 아무 문제없이 해당 함수를 여러번 실행할 수 있도록 합니다.
이러한 과정은 애니메이션의 각 프레임 만큼이나 자주 발생하기도 합니다.
key 라는 명시적으로 id를 설정할 수 있는 api 제공위치 기억법은 함수 메모이제이션의 한 형태입니다. 함수 메모이제이션은 함수가 입력값에 기반하여 결과를 캐싱하는 기법입니다.
그래서 동일한 입력값에 대해 함수가 호출될 때마다 다시 계산할 필요 없이 캐싱했던 결과를 반환하기만 하면 됩니다.
Compose의 경우는 추가적인 요소가 고려됩니다. Composable 함 수는 소스 코드 내 호출 위치에 대한 불변의 정보를 가지고 있습니다.
Compose Runtime은 동일한 함수가 동일한 매개변수 값으로 다른 위치에서 호출될 때, 동일한 Composable 부모 트리 내에서 고유한 다른 ID를 생성합니다.
@Composable
fun MyComposable() {
Text("Hello") // id 1
Text("Hello") // id 2
Text("Hello") // id 3
}
인 메모리 트리는 해당 함수들을 세 개의 다른 인스턴스로 저장하고, 각각은 고유한 정체성을 가지게 됩니다.
Composable의 정체성은 recomposition을 거쳐도 유지되므로, Compose Runtime이 이 구조를 활용하여 Composable이 이전에 호출되었는지 여부를 파악하고 가능한 경우 생략할 수 있습니다.
하지만, 반복문에서 생성된 Composable의 경우 다른 방식으로 동작합니다.
@Composable
fun TalksScreen(talks: List<Talk>) {
Column {
for (talk in talks) {
Talk(talk)
}
}
}
이 경우, Talk(talk)는 매번 같은 위치에서 호출되지만, 각각의 talk 요소는 리스트 내에서 다른 항목으로 치부되고, 결과적으로 트리에서는 서로 다른 노드로 구성됩니다. 이와 같은 경우에 Compose Runtime은 고유한 ID를 생성하고 여전히 서로 다른 Composable 함수를 구별할 수 있도록 호출 순서에 의존합니다.
이러한 방식이 리스트 끝에 새로운 요소를 추가할 때는 잘 동작하는데, 그 이유는 리스트 내의 기존 Composable 함수들이 여전히 같은 위치에 있기 때문입니다. 하지만, 우리가 리스트 상단이나 중간에 요소를 추가한다면 어떻게 될까요? Compose Runtime은 요소 삽입이 발생하는 지점 아래의 모든 Talk Composable 함수에 대해서 recomposition을 발생시키게 됩니다. 이유인 즉, Composable 함수들의 위치가 변경되었기 때문인데, 심지어 해당 함수들의 입력값이 변경되지 않았더라도 해당합니 다. 이런 방식은 업데이트가 생략되었어야 할 Composable 함수들에게 맹목적으로 recomposition 이 발생했기 때문에 매우 비효율적입니다(특히 리스트 사이즈가 긴 경우).
이 문제를 해결하기 위해서 Compose는 key라는 Composable 함수를 제공하는데, 우리는 이 함수를 이용하여 Composable 함수에게 명시적인 키 값을 지정할 수 있습니다.
@Composable
fun TalksScreen(talks: List<Talk>) {
Column {
for (talk in talks) {
key(talk.id) {
Talk(talk)
}
}
}
}
각 Talk Composable에 대한 키 값으로 talk id(고유한)를 사용하고 있으며, 이것은 Compose Runtime이 Composable 함수의 위치에 상관없이 리스트에 속한 모든 항목의 정체성을 유지하도록 합니다.
Composable 함수의 범위보다 더 세밀하게 인메모리 구조에 의존해야 할 필요가 있습니다. 예를 들어, Composable 함수 내에서 발생하는 비용이 큰 계산 결과를 캐싱하고 싶다고 가정해 보겠습니다. 이와 관련하여 Compose Runtime은 remember 함수를 제공합니다.
@Composable
fun FilteredImage(path: String) {
val filters = remember { computeFilters(path) }
...
}
여기에서 우리는 이미지 필터라는 연산 결과를 사전 계산하고 캐싱하기 위해 remember를 사용합니다. 캐싱된 값을 검색하기 위한 키 값은 소스 코드의 호출 위치와 함수의 입력값에 기반으로 합니다. remember는 트리의 상태를 유지하는 인메모리 구조에 서 값을 메모리에 읽고 쓰는 역할을 수행하는 Composable 함수입니다.
Compose에서 무언가가 메모리에 기록될 때, 메모이제이션을 호출하는 Composable의 컨텍스트 내에서만 수행됩니다. 위의 예시 코드에서는 FilteredImage가 방금 이야기한 컨텍스트가 될 것입니다. 실제로 Compose는 인메모리 구조로 이동하여, 해당 메모리에서 Composable의 정보가 저장된 일종의 슬롯 범위 내에서 유효한 값을 탐색하게 됩니다.
val textComposable: @Composable (String) -> Unit = {
Text(text = it)
}
@Composable
fun NamePlate(name: String, lastName: String) {
Column {
Text(text = name)
textComposable(lastName)
}
}
Kotlin에서 일반적인 람다를 선언하는 것처럼 Composable 람다를 선언할 수 있습니다.
inline fun Box(
...,
content: @Composable BoxScope.() -> Unit
) {
// ...
Layout(
content = { BoxScopeInstance.content() },
measurePolicy = measurePolicy,
modifier = modifier
)
}
또한, Composable 함수는 @Composable Scope.() ‐> A와 같은 형태의 타입을 가질 수 있는데, 이는 특정 Composable로만 정보 범위를 지정하는 데 자주 사용됩니다.