시배's Android

Compose Docs | Thinking in Compose 본문

Android/Compose Docs

Compose Docs | Thinking in Compose

si8ae 2023. 7. 31. 17:51
 

Compose 이해  |  Jetpack Compose  |  Android Developers

Compose 이해 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Jetpack Compose는 Android를 위한 현대적인 선언형 UI 도구 키트입니다. Compose는 프런트엔드 뷰를 명령

developer.android.com

The declarative programming paradigm

과거에는 안드로이드 뷰 계층 구조를 UI 위젯의 트리로 표현했습니다. 앱의 상태가 사용자 상호작용과 같은 이유로 변경될 때, UI 계층 구조를 업데이트하여 현재 데이터를 표시해야 했습니다. UI를 업데이트하는 가장 일반적인 방법은 findViewById()와 같은 함수를 사용하여 트리를 순회하고, button.setText(String), container.addChild(View), 또는 img.setImageBitmap(Bitmap)과 같은 메서드를 호출하여 노드를 변경하는 것이었습니다. 이러한 메서드들은 위젯의 내부 상태를 변경합니다.

 

수동으로 뷰를 조작하는 것은 오류가 발생할 확률을 높입니다. 데이터가 여러 곳에 렌더링되는 경우, 해당 데이터를 보여주는 뷰 중 하나를 업데이트하는 것을 잊기 쉽습니다. 또한 두 개의 업데이트가 예상치 못한 방식으로 충돌하여 잘못된 상태를 생성하기 쉽습니다. 예를 들어, 뷰에서 방금 제거된 노드의 값을 설정하려고 할 수 있습니다. 일반적으로 뷰를 업데이트해야 하는 수가 증가할수록 소프트웨어 유지보수의 복잡성이 증가합니다.

 

지난 몇 년간 전체 산업이 선언적 UI 모델로 전환해왔으며, 이는 사용자 인터페이스를 구축하고 업데이트하는 데 관련된 엔지니어링을 크게 단순화합니다. 이 기술은 개념적으로 전체 화면을 처음부터 다시 생성한 다음 필요한 변경 사항만 적용하는 방식으로 작동합니다. 이러한 접근 방식은 상태를 가지는 뷰 계층을 수동으로 업데이트하는 복잡성을 피하는 데 도움이 됩니다. 젯팩 컴포즈는 선언적 UI 프레임워크입니다.

 

전체 화면을 재생성하는 것은 시간, 컴퓨팅 파워 배터리 사용량 측면에서 비용이 비싸다는 가지 문제가 있습니다. 비용을 줄이기 위해 Compose 어떤 부분의 UI 어느 시점에 다시 그려야 할지를 지능적으로 선택합니다. 이는 UI 컴포넌트를 설계하는 방식에 일부 영향을 미칩니다. 이에 대해서는 "Recomposition(재구성)"에서 논의되었습니다.

The declarative paradigm shift

많은 명령형 객체지향 UI 툴킷에서는 위젯 트리를 인스턴스화하여 UI를 초기화합니다. 이를 일반적으로 XML 레이아웃 파일을 인플레이트하여 수행합니다. 각 위젯은 자체 내부 상태를 유지하며, 앱 로직이 위젯과 상호작용할 수 있도록 getter와 setter 메서드를 노출합니다.

젯팩 컴포즈의 선언적 접근 방식에서는 위젯은 비교적 상태를 가지지 않으며, setter 또는 getter 함수를 노출하지 않습니다. 사실, 위젯은 객체로 노출되지 않습니다. UI 업데이트하는 방법은 다른 인자를 사용하여 동일한 조합 가능한 함수를 호출함으로써 이루어집니다. 이렇게 하면 ViewModel 같은 아키텍처 패턴에 상태를 제공하기 쉬워지며, 가이드에서 설명한 바와 같이, 관찰 가능한 데이터가 업데이트될 때마다 컴포저블(composable) 현재 애플리케이션 상태를 UI 변환하는 역할을 담당합니다.

앱 로직은 데이터를 최상위 컴포저블(composable) 함수에 제공합니다. 이 함수는 해당 데이터를 사용하여 다른 컴포저블을 호출하여 UI를 설명하며, 이러한 컴포저블에 적절한 데이터를 전달하고 계층 구조를 따라 내려갑니다.

사용자가 UI 상호작용할 , UI onClick 같은 이벤트를 발생시킵니다. 이러한 이벤트는 로직을 알릴 필요가 있으며, 그러면 앱의 상태를 변경할 있습니다. 상태가 변경되면, 새로운 데이터를 사용하여 컴포저블 함수가 다시 호출됩니다. 이로 인해 UI 요소가 다시 그려지게 되는데, 프로세스를 재구성(recomposition)이라고 합니다.

 

사용자가 UI 요소와 상호작용하여 이벤트가 발생했습니다. 로직이 이벤트에 대응하고, 필요한 경우 매개변수를 가지고 컴포저블 함수들이 자동으로 다시 호출됩니다.

Dynamic content

컴포저블 함수는 XML 대신 Kotlin으로 작성되기 때문에, 다른 Kotlin 코드와 마찬가지로 동적으로 사용할 있습니다. 예를 들어, 사용자 목록을 인사하는 UI 구성하고자 한다고 가정해보겠습니다:

@Composable
fun Greeting(names: List<String>) {
    for (name in names) {
        Text("Hello $name")
    }
}

함수는 이름 목록을 입력받고 사용자에 대한 인사말을 생성합니다. 컴포저블 함수는 매우 복잡하게 작성할 있습니다. 특정 UI 요소를 표시할지 여부를 결정하기 위해 if 문을 사용할 있습니다. 반복문을 사용할 수도 있고, 도우미 함수를 호출할 수도 있습니다. 이러한 작업들은 기본 언어의 모든 유연성을 활용할 있습니다. 이러한 강력함과 유연성은 젯팩 컴포즈의 주요 장점 하나입니다.

Recomposition

명령형 UI 모델에서 위젯을 변경하려면, 위젯의 내부 상태를 변경하기 위해 위젯에 세터(setter)를 호출합니다. 그러나 Compose에서는 새 데이터를 사용하여 컴포저블 함수를 다시 호출합니다. 이렇게 하면 함수가 재구성되며, 필요한 경우 함수가 방출하는 위젯들이 새 데이터와 함께 다시 그려집니다. Compose 프레임워크는 변경된 컴포넌트만 지능적으로 재구성할 수 있습니다.

예를 들어, 다음과 같은 버튼을 표시하는 컴포저블 함수를 고려해보세요:

@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("I've been clicked $clicks times")
    }
}

버튼이 클릭될 때마다 호출자는 clicks의 값을 업데이트합니다. Compose는 Text 함수와 함께 해당 람다를 다시 호출하여 새 값이 표시되도록 합니다. 이 과정을 재구성(recomposition)이라고 합니다. 값에 의존하지 않는 다른 함수들은 재구성되지 않습니다.

위에서 논의한 바와 같이, 전체 UI 트리를 재구성하는 것은 컴퓨팅 비용이 많이 들며, 컴퓨팅 파워와 배터리 수명을 소모합니다. Compose는 이러한 문제를 지능적인 재구성으로 해결합니다.

재구성은 입력이 변경되면 컴포저블 함수를 다시 호출하는 과정을 의미합니다. 이는 함수의 입력이 변경될 때 발생합니다. Compose는 새로운 입력을 기반으로 재구성할 때, 변경된 함수 또는 람다만 호출하고 나머지는 스킵합니다. 변경되지 않은 매개변수를 가진 모든 함수 또는 람다를 건너뛰는 것으로 인해 Compose는 효율적으로 재구성할 수 있습니다.

컴포저블 함수를 실행하는 동안 부작용에 의존해서는 안 됩니다. 함수의 재구성이 건너뛰어질 수 있기 때문에, 부작용에 의존하면 사용자가 앱에서 이상하고 예측할 수 없는 동작을 경험할 수 있습니다. 부작용은 앱의 나머지 부분에서 볼 수 있는 모든 변경을 의미합니다. 예를 들어, 다음과 같은 동작들은 모두 위험한 부작용입니다:

- 공유 객체의 속성에 쓰기
- ViewModel에서 관찰 가능한 업데이트
- SharedPreferences 업데이트

컴포저블 함수는 애니메이션 렌더링과 같이 매 프레임마다 재실행될 수 있습니다. 따라서 컴포저블 함수는 애니메이션 중간에 간격이 없도록 빠르게 실행되어야 합니다. SharedPreferences와 같은 비용이 많이 드는 작업이 필요한 경우에는 백그라운드 코루틴에서 이를 수행하고 결과 값을 매개변수로 컴포저블 함수에 전달하는 것이 좋습니다.

예를 들어, 다음 코드는 SharedPreferences에서 값을 업데이트하는 컴포저블을 생성합니다. 이 컴포저블 자체에서는 공유 프리퍼런스를 읽거나 쓰지 않습니다. 대신, 이 코드는 읽기와 쓰기를 ViewModel에서 백그라운드 코루틴으로 처리합니다. 앱 로직은 현재 값을 콜백과 함께 전달하여 업데이트를 트리거합니다.

@Composable
fun SharedPrefsToggle(
    text: String,
    value: Boolean,
    onValueChanged: (Boolean) -> Unit
) {
    Row {
        Text(text)
        Checkbox(checked = value, onCheckedChange = onValueChanged)
    }
}

이 문서에서는 Compose를 사용할 때 주의해야 할 사항들에 대해 다룹니다:

- 컴포저블 함수는 어떤 순서로든 실행될 수 있습니다.
- 컴포저블 함수는 병렬로 실행될 수 있습니다.
- 재구성은 가능한 많은 컴포저블 함수와 람다를 건너뜁니다.
- 재구성은 낙관적이며 취소될 수 있습니다.
- 컴포저블 함수는 애니메이션의 각 프레임처럼 상당히 자주 실행될 수 있습니다.

다음 섹션에서는 재구성을 지원하기 위해 컴포저블 함수를 어떻게 구성하는지에 대해 다룰 것입니다. 모든 경우에 대해, 가장 좋은 방법은 컴포저블 함수를 빠르고 멱등성을 갖추며 부작용이 없도록 유지하는 것입니다.

Composable functions can execute in any order

컴포저블 함수의 코드를 보면, 코드가 나타나는 순서대로 실행된다고 가정할 수 있습니다. 그러나 이것은 반드시 사실이 아닙니다. 만약 컴포저블 함수가 다른 컴포저블 함수를 호출하는 경우, 이러한 함수들은 어떤 순서로든 실행될 수 있습니다. Compose는 일부 UI 요소가 다른 요소보다 높은 우선순위를 가지는 것을 인식하고, 이러한 요소를 먼저 그릴 수 있습니다.

@Composable
fun ButtonRow() {
    MyFancyNavigation {
        StartScreen()
        MiddleScreen()
        EndScreen()
    }
}

StartScreen(), MiddleScreen(), EndScreen()과 같은 호출은 어떤 순서로든 발생할 수 있습니다. 이로 인해 StartScreen()이 전역 변수(부작용)를 설정하고 MiddleScreen()이 해당 변경 사항을 활용하는 것과 같이 동작할 수 없습니다. 대신, 각 함수는 자체적으로 완전한 형태를 가져야 합니다.

Composable functions can run in parallel

Compose는 컴포저블 함수들을 병렬로 실행하여 재구성을 최적화할 수 있습니다. 이를 통해 Compose는 다중 코어를 활용하고, 화면에 나타나지 않은 컴포저블 함수들을 낮은 우선순위로 실행할 수 있습니다.

이 최적화는 컴포저블 함수가 백그라운드 스레드 풀에서 실행될 수 있음을 의미합니다. 만약 컴포저블 함수가 ViewModel에서 함수를 호출한다면, Compose는 여러 스레드에서 동시에 해당 함수를 호출할 수 있습니다.

앱이 올바르게 동작하도록 보장하기 위해서, 모든 컴포저블 함수는 부작용이 없어야 합니다. 대신에, 항상 UI 스레드에서 실행되는 onClick과 같은 콜백에서 부작용을 트리거하는 것이 좋습니다.

컴포저블 함수가 호출될 때, 호출은 호출자와 다른 스레드에서 발생할 수 있습니다. 이는 컴포저블 람다에서 변수를 수정하는 코드를 피해야 하는 이유입니다. 이러한 코드는 스레드 안전하지 않을 뿐만 아니라, 컴포저블 람다의 불허된 부작용입니다.

아래는 리스트와 해당 개수를 표시하는 컴포저블 예시입니다.

@Composable
fun ListComposable(myList: List<String>) {
    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
            }
        }
        Text("Count: ${myList.size}")
    }
}

코드는 부작용이 없으며, 입력된 리스트를 UI 변환합니다. 이는 작은 목록을 표시하는데 좋은 코드입니다. 그러나 만약 함수가 로컬 변수에 쓰기 작업을 한다면, 코드는 스레드 안전하지 않거나 정확하지 않을 있습니다.

@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")
    }
}

예시에서는 items 재구성마다 수정되고 있습니다. 이는 애니메이션의 프레임마다 또는 리스트가 업데이트될 때마다 발생할 있습니다. 어떤 경우에도 UI 올바른 개수를 표시하지 않을 것입니다. 이로 인해 이와 같은 쓰기 작업은 Compose에서 지원되지 않습니다. 이러한 쓰기 작업을 금지함으로써, 프레임워크가 스레드를 변경하여 컴포저블 람다를 실행할 있도록 허용합니다.

Recomposition skips as much as possible

일부 UI의 변경이 필요할 때, Compose는 필요한 부분만 재구성하기 위해 최선을 다합니다. 이는 단일 버튼의 컴포저블을 다시 실행하지 않고, UI 트리에서 그 위나 아래에 있는 컴포저블을 실행하지 않을 수도 있다는 것을 의미합니다.

컴포저블 함수와 람다는 개별적으로 재구성될 있습니다. 다음은 리스트를 렌더링할 일부 요소를 건너뛸 있는 재구성의 예시입니다:

/**
 * Display a list of names the user can click with a header
 */
@Composable
fun NamePicker(
    header: String,
    names: List<String>,
    onNameClicked: (String) -> Unit
) {
    Column {
        // this will recompose when [header] changes, but not when [names] changes
        Text(header, style = MaterialTheme.typography.bodyLarge)
        Divider()

        // LazyColumn is the Compose version of a RecyclerView.
        // The lambda passed to items() is similar to a RecyclerView.ViewHolder.
        LazyColumn {
            items(names) { name ->
                // When an item's [name] updates, the adapter for that item
                // will recompose. This will not recompose when [header] changes
                NamePickerItem(name, onNameClicked)
            }
        }
    }
}

/**
 * Display a single name the user can click.
 */
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
    Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}

 

각각의 이 스코프들은 재구성 동안 실행되는 유일한 요소가 될 수 있습니다. Compose는 헤더가 변경될 때 Column 람다를 실행하지 않고, 그 부모들 중 아무 것도 실행하지 않을 수도 있습니다. 또한 Column을 실행할 때, Compose는 names가 변경되지 않았다면 LazyColumn의 아이템들을 건너뛰도록 선택할 수 있습니다.

다시 말하지만, 모든 컴포저블 함수나 람다는 부작용이 없어야 합니다. 부작용을 수행해야 때는 콜백에서 트리거하는 것이 좋습니다.

Recomposition is optimistic

재구성은 Compose가 컴포저블의 매개변수가 변경될 수 있다고 판단할 때마다 시작됩니다. 재구성은 낙관적(optimistic)입니다. 즉, Compose는 매개변수가 다시 변경되기 전에 재구성을 완료할 것으로 예상합니다. 하지만 재구성이 완료되기 전에 매개변수가 변경된다면, Compose는 재구성을 취소하고 새 매개변수로 다시 시작할 수 있습니다.

재구성이 취소되면, Compose는 재구성 중 생성된 UI 트리를 폐기합니다. 만약 UI가 표시되어야만 하는 부작용이 있다면, 재구성이 취소되더라도 해당 부작용은 적용될 수 있습니다. 이로 인해 일관성 없는 앱 상태가 발생할 수 있습니다.

낙관적 재구성을 처리하기 위해 모든 컴포저블 함수와 람다가 멱등성과 부작용이 없도록 보장해야 합니다.