세상을 바꾸는 개발자

Compose 공식문서정리(3) - Thinking in Compose 본문

안드로이드/Compose

Compose 공식문서정리(3) - Thinking in Compose

헬창코딩 2024. 5. 17. 16:14

선언적 프로그래밍 패러다임

 

지금까지 안드로이드 뷰 계층 구조는 UI 위젯의 트리로 표현할 수 있었습니다. 사용자 상호작용 등으로 인해 앱의 상태가 변경되면 현재 데이터를 표시하기 위해 UI 계층구조를 업데이트해야 합니다. UI를 업데이트하는 가장 일반적인 방법은 findViewById()와 같은 함수를 사용하여 트리를 탐색하고 button.setText(String), container.addChild(View) 또는 img.setImageBitmap(Bitmap) 같은 메서드를 호출하여 노드를 변경하는 것입니다. 이러한 메서드는 위젯의 내부 상태를 변경합니다.

 

뷰를 수동으로 조작하면 오류가 발생할 가능성이 높아집니다. 데이터가 여러 곳에 렌더링되는 경우 데이터를 표시하는 뷰 중 하나를 업데이트하는 것을 잊어버리기 쉽습니다. 또한 두 업데이트가 예기치 않은 방식으로 충돌하는 경우 잘못된 상태가 발생하기 쉽습니다. 예를 들어, 업데이트가 UI에서 방금 제거된 노드의 값을 설정하려고 시도할 수 있습니다. 일반적으로 소프트웨어 유지 관리의 복잡성은 업데이트가 필요한 뷰의 수에 따라 증가합니다.

 

지난 몇 년 동안 업계 전체가 선언적 UI 모델로 전환하기 시작하면서 사용자 인터페이스 구축 및 업데이트와 관련된 엔지니어링이 크게 간소화되었습니다. 이 기술은 전체 화면을 개념적으로 처음부터 다시 생성한 다음 필요한 변경 사항만 적용하는 방식으로 작동합니다. 이 접근 방식은 상태 저장 뷰 계층 구조를 수동으로 업데이트하는 복잡성을 피할 수 있습니다. Compose는 선언적 UI 프레임워크입니다.

 

전체 화면을 재생성할 때 한 가지 문제점은 시간, 컴퓨팅 파워, 배터리 사용량 측면에서 비용이 많이 든다는 점입니다. 이러한 비용을 줄이기 위해 Compose는 특정 시점에 다시 그려야 하는 UI 부분을 지능적으로 선택합니다. 이는 ReComposition에서 설명한 대로 UI 컴포넌트를 디자인하는 방식에 몇 가지 영향을 미칩니다.

 

간단한 컴포저블 기능

 

컴포즈를 사용하면 데이터를 받아 UI 요소를 출력하는 컴포저블 함수 집합을 정의하여 사용자 인터페이스를 구축할 수 있습니다. 간단한 예로 문자열을 받아 인사말 메시지를 표시하는 텍스트 위젯을 출력하는 Greeting 위젯을 들 수 있습니다.

 

 

위 그림은 데이터를 전달받아 이를 사용하여 화면에 텍스트 위젯을 렌더링하는 간단한 컴포저블 함수입니다.

  • @Composable  어노테이션으로 함수에 주석을 달았습니다. 모든 컴포저블 함수에는 이 어노테이션이 있어야 하며, 이 어노테이션은 컴포저 컴파일러에 이 함수가 데이터를 UI로 변환하기 위한 것임을 알려줍니다.
  • 함수는 데이터를 받습니다. 컴포저블 함수는 앱 로직이 UI를 설명할 수 있도록 매개변수를 받아들일 수 있습니다. 이 경우 위젯은 문자열을 받아 이름으로 사용자를 맞이할 수 있습니다.

  • 이 함수는 UI에 텍스트를 표시합니다. 이 함수는 실제로 텍스트 UI 요소를 생성하는 Text() 컴포저블 함수를 호출하여 텍스트를 표시합니다. 컴포저블 함수는 다른 컴포저블 함수를 호출하여 UI 계층구조를 생성합니다.

  • 이 함수는 아무 것도 반환하지 않습니다. UI를 출력하는 컴포짓 함수는 UI 위젯을 구성하는 대신 원하는 화면 상태를 설명하기 때문에 아무 것도 반환할 필요가 없습니다.

  • 이 기능은 빠르고 무력하며 side-effects가 없습니다.
    • 이 함수는 동일한 인수로 여러 번 호출해도 동일한 방식으로 동작하며, 전역 변수나 random() 호출과 같은 다른 값을 사용하지 않습니다.
    • 이 함수는 프로퍼티나 전역 변수를 수정하는 등의 side-effects 없이 UI를 설명합니다.

선언적 패러다임의 전환

많은 필수 객체 지향 UI 툴킷을 사용하면 위젯 트리를 인스턴스화하여 UI를 초기화합니다. 이 작업은 종종 XML 레이아웃 파일을 인플레이트하여 수행합니다. 각 위젯은 자체 내부 상태를 유지하며 앱 로직이 위젯과 상호 작용할 수 있도록 하는 게터 및 세터 메서드를 노출합니다.

 

컴포즈의 선언적 접근 방식에서 위젯은 상대적으로 상태가 없으며 세터나 게터 함수를 노출하지 않습니다. 실제로 위젯은 객체로 노출되지 않습니다. 다른 인수를 사용하여 동일한 컴포저블 함수를 호출하여 UI를 업데이트합니다. 이렇게 하면 앱 아키텍처 가이드에 설명된 대로 뷰모델과 같은 아키텍처 패턴에 상태를 쉽게 제공할 수 있습니다. 그러면 컴포저블은 관찰 가능한 데이터가 업데이트될 때마다 현재 애플리케이션 상태를 UI로 변환하는 작업을 담당합니다.

 

 

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

사용자가 UI와 상호 작용하면 UI는 onClick과 같은 이벤트를 발생시킵니다. 이러한 이벤트는 앱 로직에 알림을 보내면 앱의 상태를 변경할 수 있습니다. 상태가 변경되면 컴포저블 함수가 새 데이터로 다시 호출됩니다. 이렇게 하면 UI 요소가 다시 그려지는데, 이 과정을 recomposition이라고 합니다.

 

 

위 그림은사용자가 UI 요소와 상호 작용하여 이벤트가 트리거되었습니다. 앱 로직이 이벤트에 응답하면 필요한 경우 컴포저블 함수가 새 매개변수를 사용하여 자동으로 다시 호출됩니다.

동적 컨텐츠

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

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

 

이 함수는 이름 목록을 받아 각 사용자에 대한 인사말을 생성합니다. 컴포저블 함수는 상당히 정교할 수 있습니다. if 문을 사용하여 특정 UI 요소를 표시할지 여부를 결정할 수 있습니다. 루프를 사용할 수 있습니다. 헬퍼 함수를 호출할 수 있습니다. 기본 언어의 모든 유연성을 활용할 수 있습니다. 이러한 강력한 성능과 유연성은 Jetpack Compose의 주요 장점 중 하나입니다.

Recomposition(재구성)

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

 

예를 들어 버튼을 표시하는 이 컴포저블 함수를 생각해 보겠습니다:

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

 

버튼을 클릭할 때마다 호출자는 클릭 수 값을 업데이트합니다. Compose는 새 값을 표시하기 위해 Text 함수로 람다를 다시 호출하며, 이 과정을 recomposition이라고 합니다. 값에 의존하지 않는 다른 함수는 재구성되지 않습니다.

 

앞서 설명한 것처럼 전체 UI 트리를 재구성하는 작업은 컴퓨팅 성능과 배터리 수명을 소모하는 계산 비용이 많이 들 수 있습니다. 컴포즈는 이 지능형 recomposition 통해 이 문제를 해결합니다.

 

Recomposition 은 입력이 변경될 때 컴포저블 함수를 다시 호출하는 프로세스입니다. 이는 함수의 입력이 변경될 때 발생합니다. 컴포즈는 새로운 입력에 따라 재작성할 때 변경되었을 수 있는 함수나 람다만 호출하고 나머지는 건너뜁니다. 매개변수가 변경되지 않은 함수나 람다를 모두 건너뛰면 컴포즈는 효율적으로 재구성할 수 있습니다.

 

함수의 재구성을 건너뛸 수 있으므로 컴포저블 함수 실행으로 인한 부작용에 의존해서는 안 됩니다. 그렇게 하면 사용자가 앱에서 이상하고 예측할 수 없는 동작을 경험할 수 있습니다. 부작용이란 앱의 나머지 부분에 표시되는 모든 변경 사항을 말합니다. 예를 들어 이러한 동작은 모두 위험한 부작용입니다:

 

  • 공유 개체의 프로퍼티에 쓰기
  • ViewModel에서 관찰 가능 항목 업데이트하기
  • 공유 환경 설정 업데이트하기
 

컴포저블 함수는 애니메이션이 렌더링될 때와 같이 매 프레임마다 자주 다시 실행될 수 있습니다. 컴포저블 함수는 애니메이션 중 끊김 현상을 방지하려면 속도가 빨라야 합니다. 공유 환경설정에서 읽는 것과 같이 비용이 많이 드는 연산을 수행해야 하는 경우 백그라운드 코루틴에서 수행하고 값 결과를 컴포저블 함수에 매개변수로 전달하세요.

 

예를 들어, 이 코드는 공유 환경설정의 값을 업데이트하는 컴포저블을 생성합니다. 컴포저블은 공유 환경설정 자체에서 읽거나 쓰지 않아야 합니다. 대신 이 코드는 백그라운드 코루틴에서 읽기 및 쓰기를 뷰모델로 이동합니다. 앱 로직은 콜백을 통해 현재 값을 전달하여 업데이트를 트리거합니다.

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

 

 작성 기능을 사용할 때 주의해야 할 여러 가지 사항에 대해 설명합니다:

  • 컴포저블 함수는 어떤 순서로든 실행할 수 있습니다.
  • 컴포저블 함수는 병렬로 실행할 수 있습니다.
  • Recomposition 은 가능한 한 많은 컴포저블 함수와 람다를 건너뜁니다.
  • Recomposition 낙관적이며 취소될 수 있습니다.
  • 컴포저블 함수는 애니메이션의 모든 프레임만큼 자주 실행될 수 있습니다.


컴포저블 함수는 어떤 순서로든 실행할 수 있습니다.

컴포저블 함수의 코드를 보면 코드가 표시된 순서대로 실행된다고 생각할 수 있습니다. 하지만 반드시 그렇지는 않습니다. 컴포저블 함수에 다른 컴포저블 함수에 대한 호출이 포함된 경우 해당 함수는 어떤 순서로든 실행될 수 있습니다. 컴포즈에는 일부 UI 요소가 다른 요소보다 우선순위가 높다는 것을 인식하여 먼저 그리는 옵션이 있습니다.

 

예를 들어 탭 레이아웃에 세 개의 화면을 그리는 다음과 같은 코드가 있다고 가정해 보겠습니다:

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

 

StartScreen, MiddleScreen, EndScreen 호출은 어떤 순서로든 발생할 수 있습니다. 즉, 예를 들어 StartScreen()이 전역 변수를 설정하고(side-effects) MiddleScreen()이 그 변경 사항을 활용하도록 할 수 없습니다. 대신 각 함수는 독립적으로 사용해야 합니다.

컴포저블 함수를 병렬로 실행할 수 있습니다.

컴포즈는 컴포지션 가능한 함수를 병렬로 실행하여 재구성을 최적화할 수 있습니다. 이를 통해 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")
    }
}

 

이 예제에서는 항목이 재구성될 때마다 수정됩니다. 애니메이션의 모든 프레임이 될 수도 있고 목록이 업데이트될 수도 있습니다. 어느 쪽이든 UI에 잘못된 개수가 표시됩니다. 따라서 이와 같은 쓰기는 컴포즈에서 지원되지 않으며, 이러한 쓰기를 금지함으로써 프레임워크가 스레드를 변경하여 컴포즈 가능한 람다를 실행할 수 있도록 합니다.

가능한 한 Recomposition 건너뛰기

UI의 일부가 유효하지 않은 경우, 컴포즈는 업데이트가 필요한 부분만 재구성하기 위해 최선을 다합니다. 즉, 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는 LazyColumn의 항목을 건너뛰도록 선택할 수 있습니다.

 

다시 말하지만, 모든 컴포저블 함수나 람다를 실행할 때는 부작용이 없어야 합니다. 부작용을 수행해야 하는 경우 콜백에서 트리거하세요.

재구성은 낙관적입니다.

Recomposition 은 컴포저블의 파라미터가 변경되었을 수 있다고 Compose가 판단할 때마다 시작됩니다. 재구성은 낙관적이기 때문에 컴포즈는 파라미터가 다시 변경되기 전에 재구성을 완료할 것으로 예상합니다. 재구성이 완료되기 전에 매개변수가 변경되면 컴포즈는 재구성을 취소하고 새 매개변수로 다시 시작할 수 있습니다.

 

재구성을 취소하면 컴포지션은 재구성에서 UI 트리를 삭제합니다. 표시되는 UI에 따라 달라지는 부작용이 있는 경우 컴포지션이 취소되더라도 부작용이 적용됩니다. 이로 인해 앱 상태가 일관되지 않을 수 있습니다.

 

모든 컴포지션 가능한 함수와 람다가 비활성 상태이고 부작용이 없는지 확인하여 낙관적인 재구성을 처리합니다.

컴포저블 함수는 꽤 자주 실행될 수 있습니다.

경우에 따라 UI 애니메이션의 모든 프레임에 대해 컴포저블 함수가 실행될 수 있습니다. 함수가 디바이스 스토리지에서 읽는 것과 같이 비용이 많이 드는 작업을 수행하는 경우, 이 함수로 인해 UI가 끊길 수 있습니다.

 

예를 들어 위젯이 디바이스 설정을 읽으려고 하면 1초에 수백 번씩 해당 설정을 읽게 되어 앱 성능에 치명적인 영향을 미칠 수 있습니다.

 

컴포저블 함수에 데이터가 필요한 경우 데이터에 대한 매개변수를 정의해야 합니다. 그런 다음 비용이 많이 드는 작업을 컴포저블 외부의 다른 스레드로 옮기고 mutableStateOf 또는 LiveData를 사용하여 데이터를 Compose에 전달할 수 있습니다.

 

 

참고 : https://developer.android.com/develop/ui/compose/compiler

Comments