Jetpack Compose의 Compose 이해하기
본 글은 정확하지 않을 수 있습니다. 참고용으로만 봐주시면 감사하겠습니다.
compose의 이해에 잘 설명이 되어 있지만 몇몇 용어에 대한 어려움이 있었기에 따로 정리를 하였습니다.
해당 문서를 먼저 읽고 오시는 것을 권유드립니다.
Compose 란
Compose는 쉽게 말하면 선언형 UI 프레임워크를 말한다.
지금까지는 앱의 상태가 변경될 경우 UI를 업데이트하기 위해 뷰를 수동으로 조작해 주었다. 하지만 이 방법은 잘못된 조작법으로 인한 많은 문제를 야기시킬 수 있다.
선언형 UI가 등장하고 나서 위와 같은 문제점을 해결할 수 있었다.
이 기법은 처음부터 화면 전체를 개념적으로 재생성한 후 필요한 변경사항만 적용하는 재호출하는 방식으로 작동한다. 즉 UI가 변경될 경우 해당 부분의 구성 가능한 함수가 재호출 된다.
따라서 수동으로 뷰를 업데이트 하지 않아도 된다.
하지만 화면 전체를 재생성하는데 많은 비용이 든다. 따라서 전체를 재생성하기보다, 지능적으로 필요 부분만 재구성한다
구성 가능한 함수
구성 가능한 함수는 쉽게 말해서, @Composable 어노테이션이 붙은 함수를 말한다. ex) Text(), Button()
좀 더 특징을 알아보자면,
- @Composable 어노테이션을 붙여서 해당 함수가 데이터를 UI로 변환하기 위한 함수라는 것을 Compose 컴파일러에 알린다.
- 매개변수를 통해 데이터를 받는다. 해당 데이터를 통해 앱 로직이 UI를 형성할 수 있다.
- 구성 가능한 함수는 다른 구성 가능한 함수를 호출하여 UI 계층 구조를 내보냅니다.
- 아무것도 반환(return)하지 않는다.
- 이 함수는 빠르고 멱등원이며 부작용(side effect)이 없다.
멱등원 : 즉 여러 번 호출해도 항상 동일한 방식으로 작동한다.
부작용이 없다 : 구성 가능한 함수 외부에 있는 상태를 변경시키지 않는다.
부작용 예시(side effect)
사실 부작용 관련해서도 다양한 문서가 있기에 추후 따로 정리할 예정이다.
해당 예시는 이해를 돕기 위한 예시이며, 정확하지 않을 수 있다.
// 부작용이 있는 함수
var totalSum = 0
fun addToTotalSum(value: Int) {
totalSum += value // 함수 외부에 있는 상태 변경시킨다.
} // 따라서 양방향 의존이 발생한다.
재구성
재구성은 위젯(UI)이 변경될 때 관련된 구성 가능한 함수를 다시 호출하는 프로세스이다.
@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
Button(onClick = onClick) {
Text("I've been clicked $clicks times")
}
}
위 코드에서 버튼을 클릭하면 clicks값이 변경된다고 과정을 하면 Text의 값이 새로 업데이트가 된다.
따라서 Text함수는 재호출이 된다.
전체를 재호출할 경우 컴퓨팅 비용이 많이 들기 때문에 Compose는 이 지능적 재구성을 통해 이 문제를 해결한다.
따라서 Text함수만 재호출이 된다.
Compose에서 프로그래밍할 때 알아야 할 여러 가지 사항
1. 구성 가능한 함수는 순서와 관계없이 실행할 수 있음
@Composable
fun ButtonRow() {
MyFancyNavigation {
StartScreen()
MiddleScreen() // 순서가 없음
EndScreen()
}
}
MiddleScreen()가 StartScreen()보다 먼저 실행될 수 있기 때문에 StartScreen()에서 뭔가 설정을 해서 MiddleScreen()에서 활용할 수 없음을 의미하기도 한다.
2. 구성 가능한 함수는 동시에 실행할 수 있음
구성 가능한 함수를 동시에 실행하여 재구성을 최적화할 수 있다.
동시에 실행될 수 있다는 것은 즉 여러 스레드에서 하나의 구성 가능한 함수를 동시에 호출할 수 있다는 의미가 된다. 따라서 [구성 가능한 람다 내부에서 변수를 수정하는 코드 = 부작용(side effect)]는 피해야 한다.
이러한 코드는 스레드 안전하지 않을 뿐만 아니라, 구성 가능한 람다에서 허용되지 않는 부작용으로 간주된다.
@Composable
@Deprecated("Example with bug")
fun ListWithBug(myList: List<String>) {
var items = 0
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Column {
for (item in myList) {
Text("Item: $item")
items++ // Avoid! Side-effect of the column recomposing.
}
}
Text("Count: $items")
}
}
// 여러 스레드에도 실행
val thread1 = thread { ListWithBug(myList) }
val thread2 = thread { ListWithBug(myList) }
동시에 ListWithBug함수가 실행될 경우 items값이 각각의 함수에서 ++되기 때문에 ui화면에 잘못된 count값이 나올 수 있다.
3. 재구성은 가능한 한 많이 건너뜀
Compose는 업데이트해야 하는 부분만 재구성하기 위해 최선을 다 한다. 따라서 변경사항이 없는 컴포저블을 건너뛴다.
@Composable
fun NamePicker(
header: String,
names: List<String>,
onNameClicked: (String) -> Unit
) {
Column {
// 이것은 [header]가 변경될 때 재구성되지만 [names]이 변경될 때는 재구성되지 않습니다.
Text(header, style = MaterialTheme.typography.h5)
Divider()
LazyColumn {
items(names) { name ->
// 항목의 [names]이 업데이트되면 해당 항목의 어댑터를 다시 구성합니다.
// [header]가 변경되면 재구성되지 않습니다.
NamePickerItem(name, onNameClicked)
}
}
}
}
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}
여기까지는 쉽게 이해할 수 있었지만 문서에 나와있는 아래 문장은 이해하기 어려웠다.
"모든 구성 가능한 함수 또는 람다를 실행하는 작업에는 부작용이 없어야 합니다. 부작용을 실행해야 할 때는 콜백에서 부작용을 트리거해야 합니다."
먼저 현식적으로 외부 상태를 변경하거나 환경과 상호작용해야 하는 상황이 발생할 수 있다. 이럴 경우에는 콜백을 활용하는 것을 권장한다.
그 이유로를 ChatGPT에 물어보았다. (정확하지 않을 수 있지만 일단은 아래와 같은 이유로 이해하였다.)
1. 코드의 명확성
콜백을 사용하여 부작용을 트리거함으로써, 해당 부작용이 발생하는 시점과 의도를 명확하게 표현할 수 있습니다. 코드를 읽는 사람들에게도 코드의 의도를 이해하기 쉽게 해 준다.
2. 의존성 분리: 콜백을 사용하여 부작용을 트리거함으로써, 부작용을 실행하는 코드를 구성 가능한 함수나 람다 외부로 분리할 수 있습니다. 이는 코드의 모듈화와 재사용성을 높이는 데 도움을 줍니다.
3. 테스트 용이성: 콜백을 사용하여 부작용을 트리거함으로써, 테스트 코드에서 부작용을 목업 또는 대역으로 대체할 수 있습니다. 이는 테스트의 일관성과 격리성을 유지하는 데 도움을 줍니다.
// 예시 코드
fun fetchDataFromServer(callback: (data: String) -> Unit) {
// 서버에서 데이터 가져오는 작업
// ...
// 데이터 가져오기 완료 후 콜백 호출
val data = "Data from server"
callback(data)
}
// 콜백을 사용하여 데이터 가져오기 부작용 트리거
fetchDataFromServer { data ->
// 데이터를 사용하는 코드
// ...
}
위 코드에서는 데이터 가져오기 작업과 데이터 사용 코드가 명확히 분리되어 있으며, 콜백을 통해 데이터를 전달하는 방식으로 부작용을 명시적으로 표현하고 있다.
4. 재구성은 낙관적임
Compose가 컴포저블의 매개변수가 변경되었을 수 있다고 생각할 때마다 재구성이 시작된다.
재구성이 완료되기 전에 매개변수가 변경되면 Compose는 재구성을 취소하고 새 매개변수를 사용하여 재구성을 다시 시작할 수 있습니다.
재구성이 취소되면 Compose는 재구성에서 UI 트리를 삭제합니다. 표시되는 UI에 종속되는 부작용이 있다면 재구성이 취소된 경우에도 부작용이 적용됩니다. 이로 인해 일관되지 않은 앱 상태가 발생할 수 있습니다.
따라서 모든 구성 가능한 함수 및 람다가 멱등원이고 부작용이 없는지 확인해야 한다.
5. 구성 가능한 함수는 매우 자주 실행될 수 있음
경우에 따라 구성 가능한 함수는 UI 애니메이션의 모든 프레임에서 실행될 수 있습니다. 따라서 읽기와 같이 비용이 큰 작업을 실행하면 버벅거릴 수 있다.
구성 가능한 함수에 데이터가 필요하다면 데이터의 매개변수를 정의해야 합니다. 그런 다음, 비용이 많이 드는 작업을 구성 외부의 다른 스레드로 이동하고 mutableStateOf 또는 LiveData를 사용하여 Compose에 데이터를 전달할 수 있습니다.
결론
1. Compose는 선언형 UI 프레임워크를 말한다.
2. 선언형 UI는 위젯이 변경될 때 구성 가능한 함수를 재호출 한다.
3. 구성 가능한 함수는 @Composable이 붙은 함수를 말하며 Jetpack Compose에서는 Text()나 Button()과 같은 함수를 말한다.
4. 부작용(side effect)은 구성 가능한 함수 외부에 있는 상태를 변경시키는 것을 말한다.
5. 재구성은 위젯(UI)이 변경될 때 관련된 구성 가능한 함수를 다시 호출하는 프로세스이다.
6. compose에서 프로그래밍할 때 알아야 할 5가지 사항이 있다.
- 구성 가능한 함수는 순서와 관계없이 실행할 수 있습니다.
- 구성 가능한 함수는 동시에 실행할 수 있습니다.
- 재구성은 최대한 많은 수의 구성 가능한 함수 및 람다를 건너뜁니다.
- 재구성은 낙관적이며 취소될 수 있습니다.
- 구성 가능한 함수는 애니메이션의 모든 프레임에서와 같은 빈도로 매우 자주 실행될 수 있습니다.
결론적으로 위와 같은 5가지 사항에 맞는 알맞은 코드를 작성하기 위해서는 멱등원이며 부작용(side effect)이 없는 구성 가능한 함수를 작성해야 한다.