본문으로 바로가기
반응형

 

Photo by Unsplash

이미 앞에서 compose에 대한 기본 구조 및 예제에 대해 설명했습니다. 이제, Compose의 중요한 concept에 대한 선언형 (Declarative) UI에 대해서 설명합니다. 이 포스팅을 예제보다 먼저 쓰지 않은 이유는 Compose의 개념과 사상에 대해서 먼저 기술하는 경우 기술문서를 읽다가 지치기 십상이기 때문입니다. 

이 글은 Android developer 공식 사이트에서 제공하는 문서를 기반으로 의역, 번역 하였습니다.[1]

선언형 프로그래밍의 paradigm

기존의 안드로이드에서는 유저의 상호작용이나 개발자의 의도로 특정 Data가 변경되는 경우 직접 UI widget에 접근하여 데이터를 변경했습니다. 즉 TextView.setText, container.addChild(View) 등 tree 구조로 구성된 UI widget에서 findViewById()를 통해서 해당 위젯의 객체를 찾아서 직접 UI를 수정했습니다. 물론 NPE를 피하고, 중간 과정을 간소화하기 위해 ViewBinding이나, DataBinding이 추가되었지만 기본적인 컨셉은 동일합니다.

물론 지금까지도 복잡한 UI를 잘 개발해 왔지만, 수동으로 작업하는 UI widget의 양이 늘어날수록 이런 UI들을 관리하는 코드의 복잡도는 올라갑니다. 예를 들어 데이터 하나가 여러 곳에서 UI로 표시되는 경우 이를 업데이트 할 때 빼먹기 쉽고, 예기치 않은 방식으로 여러곳에서 UI의 상태를 업데이트 하려고 할때 충돌이 발생할 수 있습니다.

Compose는 선언형 UI framework으로 이런 사용자 인터페이스 및 widget의 업데이트에 대한 관리가 간소화해졌습니다. 즉, 상태가 변화하는 UI widget을 직접 업데이트하지 않고, 개념적으로는 화면 전체를 처음부터 다시 그립니다. 다만 이럴 경우 성능 이슈가 발생하기 때문에 실제로는 전체 화면 중에 변경이 발생했다고 인지되는 필요한 부분만 업데이트합니다. 따라서 Compose compiler가 plugin 형태로 추가되며, 이 compiler는 어떤 시점에 어떤 부분을 다시 그려야 하는지를 판단하여 필요한 곳을 부분적으로 업데이트 합니다. 따라서 성능 부분에서 이슈가 될 수 있는 화면 전체를 재생성하는데 필요한 CPU, Battery 비용을 감소시킵니다.

다만 재목에서 언급했듯이 paradigm이 변했기에, 전체 재생성에 대한 비용 증가 이슈 이외에도 개발자가 이해하고 넘어야 할 산들이 많습니다. compose의 compiler가 똑똑할지언정, 이를 사용하는 개발자가 똑똑하게 구성해 주지 않는다면, 기존 방법보다 나을 것이 없습니다. 여기서는 기본적인 선언형 UI의 이해와 compose의 compiler를 똑똑하게 만들어 주기 위한 이해를 돕기 위한 부분들에 대해서 다룹니다.

Composable Function의 기본 구성

Compose의 함수의 구성을 보면 아래와 같습니다.

@Composable
fun Greeting(name: String) {
    Text(text = "Hello $name!")
}

1. @Composable annotation을 붙여 이 함수가 데이터를 화면으로 표현하기 위한 함수라는 걸 Compose compiler에게 알립니다.

2. 함수명은 대문자로 시작하며, param을 받을 수 있습니다.

3.  Text라는 Composable 함수를 호출하여 실제 UI 구조에 해당 데이터를 UI로 변경하여 내보내며, Composable함수는 composalbe 함수를 호출할 수 있습니다. (호출 depth에 따라 UI 계층구조가 만들어집니다.)

4. Composable function은 return값이 없습니다. (Data를 UI로 변경하는 함수이므로 함수의 실행결과는 UI입니다.)

5. Composable 함수는 빠르고, 동일한 param으로 호출 시 항상 동일한 결과가 나오며(idempotent)이며 side-effect에서 자유로워야 합니다. 

  • idempotent라는 말은 함수 내부에서 random() 같은 함수를 호출하지 않고, 호출할 때마다 동일한 UI가 표기되어야 함을 의미합니다.
  • 또한 side-effect이란 단어는 추후 따로 분리하여 포스팅하겠지만 Compose에서 관리하는 State가 아닌 Application의 상태를 변경하는걸 의미합니다.
  • 이는 recompose의 동작 방식 때문에 위와 같은 제약이 발생하며, recompose에 대한 내용은 아래에서 좀 더 자세히 설명합니다.

선언형 Paradigm으로의 변환

기존에는 object-oriented UI 형태로 tree구조의 widget을 initialize 하여 사용했습니다. 안드로이드에서는 XML을 inflating 하여 객체로 만들어 사용하고, 이 widget들은 내부에 각각의 상태를 가집니다. 그렇기 때문에 이 상태에 접근할 수 있는 setter나 getter를 제공하고 (위에 언급했던 setText()처럼) 앱에서 로직 변경에 따라 이 함수들을 호출하여 직접 widget의 상태를 변경합니다.

Compose의 선언형 방식에서 widget은 상대적으로 변하지 않는 상태를 가지며, 객체로 노출되지도 않습니다. 따라서 이를 접근하기 위한 getter/setter 역시 존재하지 않습니다. 기존에는 widget을 객체로 받아와서 직접 상태를 바꿨다면, Compose에서는 UI를 변경하기 위해선 동일한 composable function을 다른 param으로 호출하는 방법밖에는 없습니다.

따라서 MVVM에서 ViewModel을 이용하면 변경되는 데이터를 Observable을 만들어 데이터가 변경될 때마다 compose 함수를 호출하도록 구조를 만들기가 용이합니다. 이때 변수값은 compose의 param으로 전달하여 Composer가 Data를 UI로 변경하도록 만듭니다.

출처: https://developer.android.com/images/jetpack/compose/mmodel-flow-data.png

데이터는 최상위에 존재하는 Composable로 전달합니다. Composable을 함수는 최상위부터 시작하여 여러 Composable 함수를 호출하여 단계별 view의 구조가 만들어지며, 해당 데이터를 필요로 하는 composable function까지 전달되어 Data가 UI로 변환됩니다.

만약 Click event 같은 사용자의 interaction이 발생하면 이 이벤트를 app의 로직 쪽으로 전달되도록 만들며, 이 전달로 인하여 data의 상태가 변하도록 만듭니다. 상태가 변경되면 다시 composable function이 변경되면서 UI 구성 요소가 다시 그려지는데 이러한 과정을 recomposition이라고 합니다.

좀 더 코드에 가깝게 설명하면, ViewModel에 있는 data를 livedata로 만들고 상태가 변경될때 마다 observing 하도록 코드를 만듭니다. 물론 이 observing 블럭 안에는 compose 함수를 호출 하도록 하고, 변경된 데이터를 composable 함수의 param으로 넘겨주면서 다시 호출합니다. 이 compose 함수는 변경된 데이터를 받는 param 이외에 필요에 따라 상태를 변화시키는 lambda 형태의 param을 하나 더 가질수도 있습니다. 실제 코드 샘플은 resomposition에서 좀더 자세히 다룹니다.

Dynamic content

Compose는 XML 대신 Kotlin 코드로 작성됩니다. 따라서 Kotlin에서 사용하는 기본적인 문법들을 통해서 dynamic 하게 UI를 만들 수 있습니다.

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

예를 들면 names list를 전달받아 composable function 내부에서 반복문으로 Text composable function을 여러 번 호출하면, 여러 개의 이름이 출력됩니다. 물론 if문을 이용해서 보여줄 composble function을 분기할 수도 있으며, kotlin에서 제공하는 문법을 이용하여 유연하게 코드로 UI를 구성할 수 있다는 장점을 같습니다.

Recomposition

기존 UI model에서는 widget을 변경하기 위해서는 setter를 통해서 widget에 접근하고 내부의 상태를 직접 변경했습니다. composable에서는 새로운 데이터를 param으로 넘겨주고 다시 composabe function을 호출하여 화면을 다시 그리도록 유도합니다. 이렇게 다시 그려지는걸 recomposition이라고 하며, recomposition은 compiler에 의해 새로운 데이터로 인하여 변경되어야 하는 composable만 재 호출하여 화면을 변경합니다.

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

위 샘플 코드는 변경할 data와 이벤트들 전달할 lambda로 param을 구성하는 전형적인 composable의 함수 형태입니다. 매번 버튼이 클릭될 때마다 onClick lambda가 호출되고, 실제 click 내용은 이 lambda를 전달한 calller에서 처리합니다. 이 lambda 내부에서 clicks 변수를 변경하게 되면 이 변수의 변경으로 인하여 Text composable은 recompose 됩니다.

clicks와 관계없는 composable function들은 재구성하지 않기 때문에 전체 UI tree를 재구성하게 될때 우려되는 CPU, Battery 이슈는 발생하지 않습니다. 반대로 얘기하면 composable function은 자신이 가지고 있는 param이 변경되면 recompose의 대상이 됩니다.

Composable 함수를 구성할 때 side-effects으로 일컬어지는 작업을 포함하면 안 됩니다. 여기서 말하는 side-effects의 예는 아래와 같습니다.

  • 공유 객체의 속성에 값 쓰기
  • ViewModel의 observable 항목을 직접 update
  • sharedPreference의 값을 update 하는 것처럼 오래 걸릴 수 있는 작업을 수행.

한 예로 애니메이션을 rendering 하는 경우 composable function은 매 frame마다 재호출 되어 수행될 수 있습니다. 따라서 composable 함수는 빨라야 하기 때문에 sharedPreference의 값을 직접 읽거나, 쓰는 작업을 직접 해서는 안되며, 변경해야 하는 값을 함수의 prarm 만들어 넘겨서 (lambda가 되겠죠?) 외부에서 background coroutine으로 처리하도록 해야 합니다. 물론 외부로 옮겨진 작업은 다른 비동기 형태로 처리해도 상관은 없습니다.

실제로 checkbox의 값을 sharedPreference에 저장하는 Composable은 아래와 같이 구성해야 합니다.

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

Composable은 그저 sharedPreference의 값을 받아서(value) Checkbox의 값을 그리고, 만약 check값이 변경되면 변경된 값을 lambda를 통해서 caller function에 던져 줍니다. 이 lambda의 실제 구현은 ViewModel에서 Background coroutine 형태로 처리되도록 해야 합니다. backgroud coroutine에서는 값이 sharedPreference에 저장하고 value값을 변경하여 state를 갖는 (LiveData 같은) 변수를 업데이트하여 다시 composable 함수가 recompose 되도록 유도합니다. ViewModel과 compose를 연결하는 부분은 추후에 따로 다룹니다.

Composable function을 만들 때 고려해야 할 점은 아래 세 가지입니다.

  • 빨라야 한다.
  • 멱 등원(idempotent) 이어야 한다
  • side-effects과 연관되어서는 안 된다.

그럼 실제로 이 원칙들을 지키기 위해 어떻게 구현해야 하는지 알아봅니다.

Composable function들은 순서대로 실행되지 않을 수 있다.

아래와 같이 composable function 안에 다른 composable function을 호출하도록 코드를 구성했다고 가정합니다.

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

기본적인 이해로는 StartScreen() -> MiddleScreen() -> EndScreen() composable function은 순차적으로 호출되어야 합니다. Compose는 UI 구성요소에 우선순위를 부여할 수 있으며, 해당 우선순위에 따라 화면에 표시합니다. 따라서 반드시 일반 코드처럼 순차적으로 호출된다고 가정하여 코드를 구성하면 안 됩니다.

만약 StartScreenn() 함수에서 전역 변수의 값을 변경하고, MiddleScreen()에서 해당 값에 따라서 동작을 바꾸는듯한 코드를 작성하면 오작동일 발생할 수 있습니다. 사실 전역변수의 값을 변경하는 것 자체가 side-effet을 만드는 작업이라 composable function에서는 해서는 안 되는 동작입니다. 각각의 함수는 서로 독립적이어야 합니다.

여기서는 해서는 안된다만 언급하고 실제로 이런 동작이 필요한 경우에 대한 처리는 부분은 다음 포스팅(상태 관리)에서 다룹니다.

Composable functions 은 병렬로 실행될 수 있다.

Compose는 재구성을 빠르게 하기 위해 (최적화하기 위해) composable function을 병렬로 호출할 수 있습니다. 이는 composable function이 multiple core를 이용하여, background thread pool에서 호출될 수 있음을 의미하며, 우선순위가 낮은 (현재 화면에 보이지 않거나, 바뀌지 않은) composable function은 실행하지 않을 수 있습니다.

따라서 composable function이 내부에서 ViewModel에 있는 함수를 호출하도록 명시해 놓는다면, 해당 함수는 recomposition이 발생함에 따라 동시에 여러 thread에서 호출될 수 있으므로 Thread-safe 하지 않습니다. 이를 방지하기 위해서 이런 동작들은 항상 UI thread에서 호출하는 onClick 같은 callback에 넣어서 처리해야 합니다. 다르게 돌려서 어렵게? 얘기하면 composable function은 onClick 같은 callback을 이용하여 side-effect을 발생시키지 않도록 주의해야 합니다.

유사하게 composable function과 이를 호출한 caller function은 서로 다른 thread에서 동작할 수 있습니다. 따라서 composable function 안에서는 변화가 가능한 변수가 선언되면 그 값은 thread-safe 하지 않기 때문에 변수를 선언하지 않아야 합니다. 

예를 들어 아래와 같은 코드는 side-effects가 없는 코드입니다.  -> side-effect free

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

넘겨받은 myList 변수를 반복문을 통해 Text로 전부 그려 냅니다.

하지만 composable block 안에 아래와 같은 변수를 지정하면 side-effects이 됩니다.

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

 item 함수는 반복문을 통해 계속 증가하나, LisWithBugRow, Column은 모두 composable function으로 다른 thread에서 수행될 수 있습니다. 또한 ListWithBug가 recomposing 되면 items는 초기화되므로 값이 유지되지 못하고 엉뚱한 값이 나오게 됩니다.

개발자 입장에서는 thread-safe하지 않는 변수가 생기므로 이런 식의 코드 구성을 하지 않아야 하며, 반대로 Framework 입장에서 보면, 이런 코드가 없어야만 composable lambda block들을 여러 thread가 호출하여 처리할 수 있습니다.

Recomposition은 변경된 것만 재실행되고 나머지는 skip 됨.

특정 Data가 변경되어 recomposition이 필요한 경우 Composable의 상하 관계에(구조) 따른 순차적인 호출이 발생하지 않고, 변경이 필요한 composable만 호출합니다. 즉 특정 버튼의 문구가 변경되었다면 그 버튼을 감싸고 있는 무수히 많은 composable function이나 composable lambda는 전부 skip 되고 해당 버튼만 딱! 다시 호출됩니다.

/**
 * Display a list of names the user can click with a header
 */
@Composable
fun NamePicker(
    header: String,
    names: List<String>,
    onNameClicked: (String) -> Unit
) {
    Column {
        // [header]가 변경됐을때 호출됨, [names] 변경시에는 호출 안됨
        Text(header, style = MaterialTheme.typography.h5)
        Divider()

        //기존 XML 구조에서 LazColumn은 RecyclerView 역할, items는 ViewHolder 역할임.
        LazyColumn {
            items(names) { name ->
                // [name]이 변경되면 호출됨.
                // [header]가 변경되면 여기는 호출되지 않음.
                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) }))
}

여기서 names의 값 하나가 변경될 경우 전체가 다시 그려지는지, 변경된 하나만 그려지는지는 추후 Composable의 LifeCycle 부분에서 논의합니다. 미리 귀띔해 두면, 개발자가 코드를 어떻게 구성하느냐에 따라서 전자가 될 수도 있고 후자가 될수도 있습니다. (개발자는 후자 쪽으로 동작하도록 코드를 구성해야겠죠?)

Recomposition is optimistic

Optimistic이란 단어를 말 그대로 번역하기에 애매하여 제목에 단어 그대로를 가져왔습니다.  제목을 의역해서 번역하면 다음과 같습니다.

Recomposition is optimistic: recomposition은 param이 변경되었다고 인지하면 바로 발생되며, param이 또다시 바뀌기 전에 해당 composable의 recomposition은 끝난다. 만약 param이 다시 바뀌기 전에 recomposition이 다시 시작된다면 이전 recomposition은 취소되고 새 param으로 재시작된다.

위와 같은 의미의 optimistic을 유지하려면 composable fucntion이나 composiable lambda는 idempotent 하고, side-effects가 없어야 합니다. (side-effects가 있다면 recomposition이 cancel 된다 하더라도 오동작은 그대로 발생합니다.)

Composable은 빈번하게 호출 가능하므로 가벼워야 합니다.

만약 animation을 그리는 composable이라면 매 frame마다 호출되면서 recomposition이 발생합니다. 만약 여기에 기기의 세팅 정보를 읽는다던가, 네트워크를 통해 데이터를 받아오는 듯한 IO작업들을 직접 수행해서는 안됩니다.

그럼 기기에 가해지는 부담도 커지고 UI도 버벅거리게 됩니다. 따라서 무거운 비즈니스 동작들은 외부로 빼내어서 background로 처리하고 처리 결과는 mutablerStateOfLiveData로 composable function의 param으로 전달해야 합니다. 그럼 Compose는 data가 변경되었다고 인지하고 recomposition을 진행합니다.

마지막 잡담.

이로서 Compose의 시작 개념이 되는 포스팅을 마칩니다.

실제로 이 글은 References에 명시된 android developer의 공식문서를 기술한 내용입니다. 하지만 이 문서를 번역본을 읽게 되면 '부작용', '낙관적', '멱등원', '구성 가능한 함수', '구성가능한 람다'등 맞는 번역이지만 이해하기 어려움에 맞닥 드리게 됩니다. [1]

물론 영어 원문으로 읽어야 이해가 확실하나, 원문의 문장으로도 불분명한 표현이나 갸우뚱했던 부분들에 대해서 좀 더 자세히 풀어내려고 노력했습니다.

하지만 아직 갈길이 머네요. 다음 포스팅에는 compose의 상태 관리에 대해서 정리하도록 하겠습니다.

References

[1] https://developer.android.com/jetpack/compose/mental-model

반응형