ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Android] Compose 이해하기
    안드로이드 2021. 7. 30. 14:28
    반응형

    Compose란

    Jetpack Compose는 Android를 위한 선언형 UI 도구 Kit이다. 

    앱 UI를 선언형 API를 제공하여 더 쉽게 작성하고 유지관리할 수 있도록 지원한다.

     

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

    지금까지 Android의 뷰 계층구조는 UI 위젯의 트리로 표시할 수 있었다. 앱의 상태가 변경되어 현재 데이터를 변경하려면 findViewById()와 같은 함수로 트리를 탐색하고 setText, addChild, setImageBitmap등의 메소드를 호출하여 노드를 변경했다.

    View는 수동으로 조작하면 오류가 발생할 가능성이 높아진다. 데이터를 여러 위치에서 렌더링한다면 데이터를 표시하는 뷰 중 하나를 업데이트하는 것을 잊기 쉽다. 또 여러 뷰의 상태를 변경하다 보면 예기치 않은 crash가 발생할 가능성도 있다. 일반적으로 업데이트할 View가 많을 수록 유지관리가 복잡해진다. 

     

    그리고 화면  전체를 재생성하는데 시간, 컴퓨팅 성능, 배터리 사용량 측면에서 잠재적으로 비용이 많이 들어간다.

     

    그래서 구글에서는 Compose라는 선언형 UI 프레임워크를 만들어 유지관리를 용이하게 만들고 화면 전체를 재생성하지 않고 특정 시점에 UI의 어떤 부분을 다시 그려야하는지 지능적으로 선택하여 성능을 높였다.

     

    간단한 Composable 함수

    Compose를 사용하면 데이터를 받아 UI요소를 return하는 Composable 함수 집합을 정의하여 사용자 인터페이스를 빌드할 수 있다.

    아래는 Greeting이라는 위젯으로 이름을 받아 인사말 메시지를 표시하는 Text 위젯을 내보낸다.

    • Composable 함수는 함수에 @Composable 어노테이션이 지정되어 있어야 한다. 이렇게 함으로써 이 함수가 데이터를 UI로 변환하기 위한 함수라는 것을 Compose 컴파일러에 알린다.
    • 함수는 데이터를 파라메터로 받고, 그 파라메터를 통해 UI를 구성할 수 있다. 위 예제는 이름을 파라메터로 받아 인삿말을 표시해주는 Text 위젯을 호출함으로써 UI 계층 구조를 내보낸다.
    • 함수는 아무것도 return 하지 않는다. UI를 내보내는 Compose 함수는 UI 위젯을 구성하는 대신 화면 상태를 설명하므로 아무것도 반환할 필요가 없다.
    • 이 함수는 빠르고 변하지 않으며 사이드이펙트 또한 없다. 동일한 인수로 여러번 호출될 때 동일한 방식으로 작동하며, 전역변수 또는 random()호출과 같은 다른 값을 사용하지 않는다. 그리고 속성 또는 전역 변수 수정과 같은 사이드이펙트 없이 UI를 형성한다.

    선언현 패러다임 전환

    많은 명령형 객체 지향 UI 도구 Kit를 사용하여 위젯의 트리를 인스턴스화함으로써 UI를 초기화 한다. 흔히 XML 레이아웃 파일을 확장하여 이 작업을 한다. 각 위젯은 내부 상태를 유지하고 앱 로직이 위젯과 상호작용할 수 있도록 하는 getter, setter 메소드에 노출된다.

     

    Compose는 선언형 접근 방식으로 위젯은 비교적 Stateless 상태이며 getter, setter 함수를 노출하지 않고 동일한 Composable 함수를 파라메터를 다르게 호출하여 UI를 업데이트한다. 이렇게 하면 ViewModel과 같은 아키텍처 패턴에 상태를 쉽게 제공할 수 있다.

     

    최상위 Composable 함수에서 데이터를 제공받아 다른 Composable 함수를 호출함으로써 UI를 형성하고 하위 계층으로 전달한다.

     

    사용자가 UI와 상호작용할때는 onClick과 같은 이벤트를 발생시켜 앱 로직에 전달하여 앱의 상태를 변경해야한다. 상태가 변경되면 Composable 함수는 새 데이터와 함께 다시 호출되어 UI 요소가 다시 그려진다. 이 프로세스를 재구성이라고 한다.

    동적 콘텐츠

    Composable 함수는 XML이 아닌 Kotlin으로 작성되기 때문에 조건문이나 반복문 등에 의해 동적으로 UI를 생성할 수 있다.

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

     

    이 예제는 이름 목록을 받아 반복문을 통해 인사말을 표시하는 UI를 생성한다.

     

    재구성

    Compose는 새 데이터를 사용하여 Composable 함수를 다시 호출하면 함수가 재구성되어 함수에서 내보낸 위젯이 새 데이터로 다시 그려진다. 이 때 모든 위젯이 새로 그려지는 것이 아니라 데이터가 변경된 위젯만 새로 그려진다.

     

    재구성은 데이터가 변경되었을 경우에만 호출하는 프로세스이다. 그리고 변경되지 않은 함수 및 람다는 건너뛰게 함으로써 효율성을 높였다. 그리고 함수의 재구성을 건너뛸 수 있으므로 함수 내에서 공유 객체의 속성을 변경하거나, ViewModel의 요소를 업데이트하거나 공유 환경설정을 업데이트 하는 등 속성의 값이 변경되는 작업들을 하면 사이드이펙트가 발생할 수 있다.

     

    그리고 Composable 함수는 애니메이션이 렌더링될때와 같이 매 프레임마다 자주 다시 실행될 수 있다. 그래서 Composable 함수는 빨라야 한다. SharedPreference에서 값을 읽어오는 것과 같이 비용이 많이들어가는 작업은 백그라운드에서 실행하여 결과 값을 Composable 함수의 파라메터로 전달해야한다.

     

    Compose 프로그래밍 시 알아야할 사항은 아래와 같다.

    • Composable 함수는 순서와 관계없이 실행할 수 있다.
    • Composable 함수는 동시에 실행할 수 있다.
    • 재구성은 최대한 많은 수의 Composable 함수 및 람다를 건너뛴다.
    • 재구성은 낙관적이며 취소될 수 있다.
    • Composable 함수는 애니메이션의 모든 프래임만큼 매우 자주 실행될 수 있다.

    Composable 함수는 순서와 관계없이 실행할 수 있음

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

    위의 소스를 보면 StartScreen, MiddleScreen, EndScreen 이런 순서대로 정의 되어 있다. 하지만 실제로 실행되는 것은 선언된 순서대로 실행되지 않는다. 그 이유는 Compose에는 일부 UI요소가 다른 UI요소보다 우선순위가 높다는 것을 인식하고 그 요소를 먼저 그리기 때문이다. 그리고 순서대로 실행될 줄 알고 StartScreen에서 전역변수 값을 설정하고 MiddleScreen에서 변경된 전역변수 값을 참조한다면 원하는 결과가 나오지 않을 수 있다.

     

    Composable 함수는 동시에 실행할 수 있음

    Compose는 Composable 함수를 동시에 실행하여 재구성을 최적화할 수 있다. 이를 통해 Compose는 다중 코어를 활용하고 화면에 보이지 않는 Composable 함수를 낮은 우선순위로 실행할 수 있다. 이는 Composable 함수가 백그라운드 스레드 풀 내에서 실행될 수 있음을 의미한다. Composable 함수가 ViewModel에서 함수를 호출하면 Compose는 동시에 여러 스레드에서 이 함수를 호출할 수 있다.

     

    그렇기 때문에 Composable 내의 로컬 변수를 사용하여 표시하는 것은 사이드이펙트를 발생시킬 수 있다.

    그 예는 아래의 코드를 보면 알 수 있다.

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

    for문을 돌면서 item을 텍스트로 표시해주고 아이템의 갯수를 표시하는 코드이다. 여기서 Column내에서 items라는 로컬변수를 1씩 증가시키면서 item의 갯수를 구했는데 이럴경우 Column과 Text가 동시에 다른 스레드에서 실행되어 items의 값이 원하는 값이 안나올 것이다. 이 코드는 스레드로부터 안전하지 않은 코드이다.

     

    그렇게 때문에 이 코드는 Compose에서 지원하지 않는 코드이다. 이런 쓰기를 금지함으로써 프레임워크가 Composable 람다를 실행하도록 스레드를 변경할 수 있다.

     

    재구성은 가능한 한 많이 건너뜀

    UI의 일부가 잘못된 경우 Compose는 업데이트해야하는 부분만 재구성하기 위해 최선을 다한다. 

    /**
     * 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.h5)
            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) }))
    }

    위의 예제의 주석을 보면 업데이트를 해야할 컴포넌트만 재구성되는 것을 알 수 있다. header가 변경될 때 상위 요소 중 어느 것도 실행하지 않고 Column 람다로 건너뛸 수 있다. 그리고 Column을 실행할 때 names가 변경되지 않았다면 LazyColumnItems를 건너뛰도록 할 수 있다.

     

    모든 Composable 함수 또는 람다는 사이드이펙트가 발생할만한 것이 없어야한다. 사이드이펙트가 발생할 수 있을 만한 것은 callback에서 trigger 해야한다.

     

    재구성은 낙관적임

    Compose가 Composable의 파라메터가 변경되었을 수 있다고 생각할 때 마다 재구성이 시작된다. 이때 재구성은 낙관적이다. 즉, 파라메터가 다시 변경되기 전에 재구성을 완료할 것으로 예상한다. 재구성이 완료되기 전에 파라메터가 변경된다면 재구성을 취소하고 새로 받은 파라메터로 재구성을 시작한다.

     

    재구성이 취소되면 재구성은 UI 트리를 삭제한다. 표시된 UI에 사이드이펙트가 존재한다면 재구성이 취소되어도 사이드이펙트가 계속 존재하게 된다. 이로 인해 일관되지 않은 앱 상태가 될 수 있다.

     

    낙관적 재구성을 처리하기 위해 모든 Composable 함수 및 람다가 일관되고 사이드이펙트가 없는지 확인해야 한다.

     

    Composable 함수는 매우 자주 실행될 수 있음

    경우에 따라 Composable 함수는 UI 애니메이션의 모든 프레임에서 실행될 수 있다. 그렇기 때문에 Composable 함수에서 기기 저장소에서 읽기와 같은 비용이 많이 들어가는 작업을 실행하면 이 함수로 인해 UI 버벅거림 현상이 발생할 수 있다.

     

    예를 들어 위젯이 기기 설정을 읽으려고 하면 잠재적으로 이 설정을 초당 수백 번 읽을 수 있으며, 이는 앱 성능에 치명적인 영향을 미칠 수 있다.

     

    비용이 많이 들어가는 작업이 필요한 경우에는 Compose 외부에서 백그라운드 스레드에서 실행하고 mutableStateOf 또는 LiveData를 사용하여 결과를 전달받을 수 있다.

    반응형

    댓글

Designed by Tistory.