안드로이드 개발을 하면서 Compose를 사용하지 않는 것은 이제는 많이 뒤져치는 것 같습니다. 그렇기에 Compose를 제대로 공부해보고 있습니다.
다만 누가 "Compose에 대해서 이해하고 있느냐?" 라고 묻는다면 "그건 선언적 UI야" 라고밖에 말할 수 없을것 같습니다.
그렇기에 아래의 게시글을 보면서 Compose에 대해 정리해보는 시간을 가지려 합니다.
Jetpack Compose는 무엇인가요?
Jetpack Compose는 안드로이드의 UI를 쉽게 디자인하고 빌드하기 위한 라이브러리입니다. Compose를 통해 정교한 UI를 빠르고 효율적으로 만들 수 있습니다.
그것 뿐일까요?? Compose는 여러 어려움을 더 해결했습니다.
관심사의 분리(Separation of Concerns)
관심사의 분리가 실제로 실천되고 있는지를 이해하려면 "결합(Coupling)"과 "응집(Cohesion)" 관점에서 생각하는 것이 도움이 될 수 있습니다.
코드를 작성할 때, 우리는 여러 단위로 구성된 모듈을 생성합니다. 결합은 서로 다른 모듈 간의 단위 간의 의존성을 나타내며, 한 모듈의 일부가 다른 모듈의 일부에 어떻게 영향을 미치는지를 반영합니다. 응집은 모듈 내의 단위 간의 관계를 나타내며, 모듈 내의 단위가 얼마나 잘 그룹화되어 있는지를 나타냅니다.
관심사의 분리는 우리의 코드에서 관련된 코드를 Grouping 하는 것입니다. 그럼 지금의 안드로이드는 어떻게 관심사를 분리하고 있을까요?? 바로 ViewModel과 XML layout을 사용합니다.
ViewModel은 레이아웃에 데이터를 제공합니다. 이 과정에서 수많은 의존성이 생기게 되고 ViewModel과 XML layout간에 결합이 생기게 됩니다.
다만 대부분의 앱은 실행 중에 UI를 동적으로 표시하고 진화시킵니다. 만약 요소가 실행중에 뷰 계층에서 나가게 되면 종속성이 망가지면서 NulReferenceExceptions 같은 오류가 발생하게 될 수 있습니다.
ViewModel과 XML layout은 서로 다른 언어로 정의되고, 이러한 언어의 차이 때문에 ViewModel과 XML layout이 서로 밀접한 관련이 있더라도 억지로 분리된 라인이 발생하게 됩니다.
흠... 그럼 UI를 Kotlin으로 정의해 언어를 통일한다면 어떨까요?
같은 언어를 사용하는 것만으로도 암시적이었던 것들이 명시적으로 변하게 됩니다. 또한 코드를 리펙터링해서 결합도를 줄이고 응집도를 높일 수 있는 위치로 코드를 옮길 수 있게 됩니다.
이런 이유로 인해 Compose는 탄생하였습니다.
UI와의 분리
다만 이렇게 같은 언어를 사용한다면, UI 로직과 비즈니스 로직들이 섞일 것입니다. 프로그래밍에서 UI 로직은 사실 어디엔가 분명 존재합니다. 프레임워크는 이런 사실을 변경할 수는 없습니다.
그럼 프레임워크는 뭘 해야 할까요?? 바로 분리를 쉽게할 수있게 도와주는 도구를 제공하는 것입니다. 이러한 도구가 바로 composable 함수입니다.
Compose는 다음과 같은 여러 가지 현대 앱 개발의 어려움을 해결했습니다.
1. 관심사 분리: Compose는 composable 함수를 사용하여 UI 로직을 개별 함수 내에 캡슐화할 수 있으므로 관심사를 명확하게 분리할 수 있습니다. 이러한 분리는 코드를 관리하고 유지보수하기 쉽게 만들며 결합(Coupling)을 줄이고 응집(Cohesion)을 높일 수 있습니다.
2. 동적 UI 진화: Compose는 동적이고 진화하는 사용자 인터페이스를 빌드하기에 적합합니다. composable 함수를 사용하여 데이터 변경이나 사용자 상호 작용에 대응하는 UI 구성 요소를 생성할 수 있으므로 UI 요구 사항의 변화를 처리하기가 더 쉬워집니다.
3. 불변 데이터: Compose는 UI 구성 요소에 불변 데이터 사용을 장려합니다. 이로써 composable 함수가 사용하는 데이터가 일관성을 유지하고 예기치 않은 부작용을 발생시키지 않도록 보장됩니다.
4. 성능 최적화: Compose는 가상화된 UI 접근 방식을 사용하여 UI의 가시적인 부분만 렌더링하여 앱의 효율성과 응답성을 향상시킵니다.
5. 반응형 UI: Compose는 반응형 프로그래밍 패러다임과 잘 통합되어 개발자가 쉽게 반응형 및 데이터 주도형 UI를 구축할 수 있습니다.
6.단순화된 테스팅: composable 함수는 본질적으로 테스트 가능하므로 UI 구성 요소에 대한 단위 테스트를 작성하고 그 동작을 검증하는 것이 더 쉬워집니다.
Compose의 구현 방식
Compose의 구현 방식을 이해하는 것은 필수적이지 않다고 말합니다. 그렇기에 크게 관심 없으신 분들은 여기서 읽는 것을 그만두셔도 됩니다.
Compose의 3단계
Compose는 상태(State)를 UI로 변환합니다. 이게 어떤 원리로 이뤄질까요?
Compose는 크게 3 단계로 진행됩니다.
Composition 단계
Composition 단계에서는 composable 함수를 실행하고 UI를 내보내 UI 트리를 만듭니다.
아래와 같은 composable 함수를 실행하면 오른쪽과 같은 트리를 생성할 수 있습니다.
Compose를 보신적이 있다면 아래의 예제와 같은 `@Composable` Annotation을 보신 적이 있으실 것입니다.
이는 일반적인 Annotation과는 다릅니다. 이는 Kotlin의 `suspend` 키워드와 유사합니다.
Kotlin의 `suspend` 키워드는 함수 유형에서 작동합니다. 즉, suspend로 표시된 함수 선언, 람다 또는 유형을 가질 수 있습니다. Compose도 동일한 방식으로 작동합니다. Compose는 함수 유형을 변경할 수 있습니다. 여기서 중요한 점은 함수 유형에 `@Composable` Annotation을 설정하면, 함수의 유형을 변경한다는 것입니다.
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
}
Composer
그렇다면 여기서 전달하고 있는 이 호출 컨텍스트란 무엇이며, 이를 사용하는 이유가 뭘까요??
이 객체를 Compose에서는 `Composer`라고 부릅니다.
Composer의 구현에는 Gap Buffer와 관련된 Slot Table의 메모리 구조가 사용되었습니다.
Gap Buffer
Gap Buffer는 현재의 인덱스 또는 Cursor를 가진 컬렉션을 나타냅니다. 이것은 메모리에서 평면 배열로 구현됩니다.
왼쪽 그림을 보겠습니다. 이 평면 배열은 나타내는 `Gap` 이라는 사용되지 않는 공간을 가지고 있어 데이터의 컬렉션 보다 큽니다.
오른쪽 그림을 보겠습니다. 이제 실행중인 Composable 계층은 이 데이터 구조에 엑세스하고 그 안에 항목을 삽입할 수 있습니다.
Composable 계층의 실행이 완료되었다고 생각해 봅시다. 어느 시점에서 우리는 무언가를 다시 구성해야 할 것입니다. 그래서 배열의 맨 위에 커서를 재설정하고 다시 실행합니다. 왼쪽 그림에 해당합니다.
UI의 구조가 변경되었고 새롭게 삽입을 하려고 결정할 수도 있습니다. 이때는 Gap을 커서에 해당하는 위치로 이동하고 이제 삽입을 할 수 있습니다. 오른쪽 그림에 해당합니다.
이러한 Slot Table 데이터 구조에 이해해야 할 중요한 점은 Gap을 이동하느라 O(N)의 시간이 걸린다는 점입니다. 그럼에도 불구하고 Slot Table 데이터 구조를 선택한 이유는 평균적으로 UI의 구조는 크게 변경되지 않을 것이라 판단했기 때문입니다.
예시
아래의 카운터 예시를 보겠습니다.
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(
text="Count: $count",
onPress={ count += 1 }
)
}
컴파일러가 `@Composable` Annotation을 보면, 함수 본문에 추가 매개변수와 호출을 삽입합니다.
그 다음 컴파일러가 하는 일은 `composer.start()` 메서드를 호출하고, 이를 컴파일 시 생성된 Key와 함께 전달하는 것입니다.
fun Counter($composer: Composer) {
$composer.start(123)
var count by remember { mutableStateOf(0) }
Button(
text="Count: $count",
onPress={ count += 1 }
)
$composer.end()
}
그리고 매개변수로 받은 Composer 객체를 본문 내의 모든 Composable 호출에 전달합니다.
fun Counter($composer: Composer) {
$composer.start(123)
var count by remember($composer) { mutableStateOf(0) }
Button(
$composer,
text="Count: $count",
onPress={ count += 1 },
)
$composer.end()
}
이제 Composer가 실행되면서 아래와 같은 작업을 합니다.
- Composer.start가 호출되고 그룹 객체를 저장합니다.
- remember가 그룹 객체를 삽입합니다.
- mutableStateOf가 반환하는 값, 상태 인스턴스,가 저장됩니다.
- Button은 그룹을 저장하고, 그 후에 각 매개변수를 저장합니다.
- 마지막으로 composer.end에 도달합니다.
이제 Slot Table은 Composition의 모든 객체를 보유하며, 실행 순서대로 전체 트리를 보유하고 있습니다.
이러한 작업은 발생할 수 있는 이동 및 삽입에 대처하기 위함입니다.
컴파일러는 어떤 코드가 UI 구조를 변경하는지 미리 알고 있습니다. 이러한 경우에 Slot Table에 이러한 그룹을 조건부로 삽입합니다. (대부분의 경우에는 UI 구조를 변경하지 않습니다.)
아래의 예시를 보겠습니다.
@Composable fun App() {
val result = getData()
if (result == null) {
Loading(...)
} else {
Header(result)
Body(result)
}
}
`getData()` 함수는 결과를 반환하고 이 결과에 따라 다른 UI 구조를 가지게 됩니다.
컴파일러는 이 코드를 아래와 같이 바꿉니다.
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이라고 가정해 보겠습니다. 그렇다면 Group(123)을 Gap 배열에 넣게 됩니다.
결과가 null이 아니라고 가정해 보겠습니다. 해당 블록에는 키가 456인 그룹이 있습니다. 그렇기에 컴파일러는 Slot Table의 기존의 그룹과 일치하지 않다는 것을 알게되기에 UI 구조가 변경되었다는 것을 알게됩니다.
컴파일러는 Gab을 현재 커서 위치로 이동시키고 기존의 있던 UI를 효과적으로 제거하는 방식으로 Gab을 확장합니다.
이러한 개념을 `Positional memorization(위치 기억)`이라고 부르며, Compose는 이러한 개념을 기반으로 구축되어 있습니다.
Positional memorization
보통, 우리는 함수의 입력을 기반으로 함수의 결과를 캐시하는 전역 메모화(global memoization)를 사용합니다. Positional Memoization의 예를 설명하기 위해 다음과 같은 composable 함수를 살펴보겠습니다.
이 함수는 목록에 대한 필터링 계산을 수행합니다.
@Composable
fun App(items: List<String>, query: String) {
val results = items.filter { it.matches(query) }
// ...
}
이러한 계산을 remember 호출로 래핑할 수 있습니다.
@Composable
fun <T> remember(vararg inputs: Any?, calculation: () -> T): T
remember는 Slot Table에 엑세스 하는 방법을 알고 있습니다. remember는 항목들을 살펴보고 목록과 쿼리를 Slot Table에 저장합니다. 필터 계산이 실행되고 remember는 결과를 저장한 다음 반환합니다.
함수가 두 번째로 실행될 때, remember는 전달되는 새 값들을 보고 이전 값과 비교합니다. 값이 변경되지 않았다면 필터 작업을 건나뛰로 이전 결과를 반환합니다. 이것이 Positional Memoization입니다.
Layout 단계
Layout 단계에서는 이 트리를 작동하고 UI의 각 부분을 측정하여 화면에 배치합니다. 각, 노드가 너비와 높이를 경정하고 x, y 좌표를 알아냅니다.
Drawing 단계
Drawing 단계에서는 UI 트리가 작동하고 모든 요소를 랜더링합니다.
'Android > Compose' 카테고리의 다른 글
Compose의 Remember, RememberSaverable 정복하기 (1) | 2024.03.06 |
---|---|
Compose의 버전 관리: BOM!! (0) | 2024.02.19 |
내가 Compose로 만든 앱의 화면이 버벅거린다면? (1) | 2024.01.14 |
Compose의 Layout 단계 (0) | 2023.10.24 |
Compose에서의 State(상태) (1) | 2023.10.05 |