본문으로 바로가기
반응형

Photo by Unsplash

Compose 란?

Jetpack의 compose는 UI를 좀더 간편하게 개발 할 수 있는 toolkit으로 Kotlin으로 개발할 수 있고 reactive programing과 결합된 개발 모델 입니다. 여기서 reactive의 의미는 데이터가 변경되면 framework에서 해당 데이터와 연관된 Composable 함수들을 다시 호출하여 view를 업데이드 해주는 작업을 말합니다.

무엇보다도 compose는 여러 함수들을 호출하여 데이터를 구조화된 UI로 만들수 있는 선언형 UI라는 특징을 가집니다.

이는 일반 함수 처럼 보이는 함수에 @Composable annotation을 붙임으로 쉽게 Compose용 함수로 지정할수 있으며 Compose는 시간이 경과함에 따라 변경되거나 유지하기 위해 이런 함수들을 특별하게 관리 합니다.

따라서 annotation을 붙임으로써 UI를 구성하는 코드를 작은 단위의 함수들로 만들어 서로 호출관계를 가지면서 UI를 구성하도록 합니다. (Compose 함수는 다른 Compose 함수를 호출 할 수 있습니다.)

참고로 Composable function은 줄여서 composable 이라고 부르기도 합니다.

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

 

Project의 기본 구성

Compose를 사용하기 위해서는 Sample이 어떻게 초기값을 생성하고 정의하는지를 참고하는게 좋습니다.

먼저 Android studio에서 (Arctic Fox 버전) File -> New -> New Project-> Empty Compose Activity를 선택하여 프로젝트를 하나 생성 합니다.

Gradle쪽 먼저 보면 아래와 같습니다.

android {
   ...
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
        useIR = true
    }
    buildFeatures {
        compose true
    }
    composeOptions {
        kotlinCompilerExtensionVersion compose_version
        kotlinCompilerVersion '1.5.30'
    }
}

dependencies {
     ...
    implementation "androidx.compose.ui:ui:$compose_version"
    implementation "androidx.compose.material:material:$compose_version"
    implementation "androidx.compose.ui:ui-tooling:$compose_version"
    ...
    implementation 'androidx.activity:activity-compose:1.3.1'
    ...
    androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
}

Compose 관련 dependency가 추가 되었고, 저는 $compose_version을 1.0.3 으로 사용했으나, 항상 최신 버전을 유지해 주시면 됩니다.

Main Activity를 시작하기에 앞서 기본 생성된 부분중에 "ui.theme"라는 package를 보면 아래와 같이 구성된 파일들이 존재 합니다.

먼저 Color.kt는 우리가 이전에 사용했던 colors.xml을 대체하는 듯한 코드들이 보입니다.

xml이나 코드에서 불러쓸때 @colors/...R.colors 형태로 사용했으나 compose에서는 XML로 만든 layout에서는 사용할 일이 없으니 위 변수처럼 컬러값을 지정해서 사용합니다. (사용법은 MainActivity를 구성하는 부분에서 확인해 보겠습니다.)

Shape.kt 파일의 구성은 아래와 같습니다.

이건 보통 배경을 round 처리하기 위해서 <shape... 형태로 따로 xml로 만들어 썼던 부분을 대체 해 줍니다. (진작 이렇게 만들어 주지..) 아마도 이 코드는 xml 생성을 정말 많이 줄여줄듯 하네요.

Typography.kt에는 글자의 style에 대한 값이 정의 됩니다.

기존 styles.xml에서 정의되던 부분중에 Text 관련 부분만 옴겨진것 처럼 보입니다.

마지막으로 Theme.kt 입니다.

App은 일단 theme을 가져야 합니다.(App에서 기본적으로 사용할 theme값을 지정하는데 여기서 합니다.) 따라서 프로젝트 생성과 함께 Theme으로 사용할 Composable 함수가 생성됩니다. (제가 생성해 놓은 package 이름을 따서 'composeTest1Theme' 이란 테마 함수가 하나 자동 생성되었습니다.)

시스템의 dark / light theme 따라서 사용할 기본 색상들이 선택되고, 앞에서 정의했던 color와 typo, shape 값들을 지정해 줍니다. 이제 이런것들을 어떻게 호출하는 보도록 하지요.

Activity에서 Compose의 사용

생성된 Activity의 onCreate를 보면 setContent부분이 교체된것을 알수 있습니다.

기존에는 R.layout.xxx를 넣어 화면을 구성하는 기본 xml을 지정해 줬지만 이번에는 depth별로 Theme -> Surface -> Text를 표현하는 함수를 넣었습니다. 물론 이들은 전부 @Composable이 붙은 함수들 입니다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ComposeTest1Theme {
                // A surface container using the 'background' color from the theme
                Surface(color = MaterialTheme.colors.background) {
                    Greeting("Android")
                }
            }
        }
    }
}

setContext 안에는 자동 생성된 Theme와 그 안에 TextView를 구성하는 Greeting 함수로 구성되어 있습니다. 여기서는 "composable의 시작은 이렇게 한다"라고 알고 가면 됩니다. 또한 눈썰미가 있는분은 이미 찾으셨겠지만 함수의 이름이 대문자 입니다. 앞으로는 @Composable을 붙이는 함수에 대해서는 대문자로 시작하도록 합니다. (일종의 룰인듯 하네요.)

Theme의 구성에 대한 내용은 추후 Theme에서 더 자세히 알아 봅니다.

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

Greeting 함수의 내부는 text를 그리는 Text() 함수로 구성됩니다. Text 함수는 composable libaray에 정의된 text를 그리는 함수이며, 함수 시작이 대문자 이니 @Composable 함수라고 유추해 볼수 있습니다.

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    ComposeTest1Theme {
        Greeting("Android")
    }
}

기본 화면 구성의 마지막으로 @Preview를 붙이면 Android studio에서 UI를 바로 확인해 볼수 있습니다.

이때 주의할 점은 @Preview annotation을 갖는 함수는 인자를 가질수 없습니다. 따라서 setContent에서 사용해던 함수를 그대로 복사해서 넣어야 하는데, 하나의 Compable 시작 함수를 만들고 그 함수를 setContent와 Preview에 넣어 놓으면, 복사할 필요 없이 쉽게 확인해 볼수 있겠네요~

선언형 UI 

만약 TextView에 배경색을 입히고 싶다면 Surface를 쓰면 됩니다. Surface는 배경색 위에 그려집니다.

import androidx.compose.ui.graphics.Color

@Composable
fun Greeting(name: String) {
    Surface(color = Color.Yellow) {
        Text(text = "Hello $name!")
    }
}

그리고 미리보기에서 상단의 refresh 버튼을 눌러 간단하게 노란색이 적용된 UI를 확인할 수 있습니다.

Modifier

대부분의 Compose UI 요소들은 Kotlin object인 modifier를 optional한 param으로 입력 받습니다. 이 Modifier를 이용하여 어떻게 배치할지, 어떻게 그릴지, 또는 부모의 layout 안에서 어떻게 행동 할지를 결정 할 수 있습니다.

Modifier는 변수에 담아서 재사용 할 수 도 있고, then 함수를 이용하여 다른 Modifier와 연결하거나, factory 확장 함수를 이용하여 여러개를 연결시킬 수도 있습니다.

간단하게 Text에 padding을 넣으려면 아래와 같이 Modifier를 사용합니다.

import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun Greeting(name: String) {
    Surface(color = Color.Yellow) {
        Text(text = "Hello $name!", modifier = Modifier.padding(10.dp))
    }
}

Compose의 재사용

기존에 일반 함수를 재사용하는것 처럼 Composable 함수역시 재사용이 가능합니다. 따라서 하나의 긴 Composable 함수로 UI를 나타내기 보단 재활용 할수 있도록 각각의 역할을 하는 Component로 UI를 분리하여 libraray처럼 만들어 앱 내에서 재사용하여 코드를 효율화 시킬 수 있습니다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyApp()
        }
    }
}

@Composable
fun MyApp() {
    ComposeTest1Theme {
        Surface(color = Color.Yellow) {
            Greeting("Android")
        }
    }
}

@Composable
fun Greeting(name: String) {
    Text(text = "Hello $name!", modifier = Modifier.padding(10.dp))
}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    ComposeTest1Theme {
        Greeting("Android")
    }
}

setContent에 있던 로직들을 MyApp()이라는 Composable 함수를 만들어 extract 했습니다. 또한 Greeting 함수도 공통적인 속성만 포함하도록 Surface 부분은 MyApp()쪽으로 이동 시킵니다.

또한 모든 Composable 함수들이 Activity 외부에 선언되어 top-level function이 되었으니, 어디서든 가져가서 사용이 가능해 졌습니다. 하지만 호출은 가능하지만 MyApp() 함수가 Greeting()함수를 포함하여 MainActivity에서만 사용하는 화면으로 고정되어 좀더 flexible한 형태가 필요하기에 아래와 같이 변경점은 param으로 받을수 있도록 바꿔 봅니다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyApp {
                Greeting("Android!")
            }
        }
    }
}

@Composable
fun MyApp(content: @Composable () -> Unit) {
    ComposeTest1Theme {
        Surface(color = Color.Yellow) {
            content()
        }
    }
}

MyApp()에 param으로 composable 함수를 받도록 지정합니다. 이때 param 앞에 @Composable 함수가 들어올거라는 표기를 해주어 함수가 수행될때 같이 그려질수 있도록 합니다. 또한 Composable 함수는 return이 없으므로 람다의 반환값은 Unit 형태로 선언됩니다.

이번엔 실제로 화면에 표시하기 위한 contents를 구성해 봅니다.

@Composable
fun TestScreenContents() {
    MyApp {
        Column {
            Greeting("Welcome")
            Divider(color = Color.DarkGray)
            Greeting("Compose world!!")
        }
    }
}

두개의 Text 사이에 Divider를 이용하여 가로선을 넣습니다. 그리고 위에서 부터 차례대로 배치되도록 Column으로 감싸 줍니다.

여기서 Column은  Vertical orientation을 갖는 LinearLayout과 동일한 역할을 합니다. (만약 가로로 배치하고 싶다면 Row를 사용하면 됩니다.)

이제 setContent에서 TestScreenContents()를 호출하도록 하고 preview에서도 동일하게 호출하여 실제로 UI에서 어떻게 보일지를 확인 합니다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyApp {
                TestScreenContents()
            }
        }
    }
}

...

@Composable
fun TestScreenContents() {
    Column {
        Greeting("Welcome")
        Divider(color = Color.DarkGray)
        Greeting("Compose world!!")
    }    
}

...

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    MyApp {
        TestScreenContents()
    }
}

그리고 refresh를 해 보면 아래와 같이 원하는대로 출력된 모습을 미리 확인해 볼 수 있습니다.

여기서 Compose는 Kotlin의 여러 일반 함수들을 그대로 가져다 쓸수 있다는 장점을 적용한다면 반복적인 Greeting() 호출을 아래와 같이 for문을 이용해 수행할 수 도 있습니다.

@Composable
fun TestScreenContents(texts: List<String> = listOf("Welcome", "compose world")) {
    Column {
        texts.forEach {
            Greeting(it)
            Divider(color = Color.DarkGray)
        }
    }
}

Compose의 State

Compose는 data를 UI로 변환하여 사용자에게 보여주는 역할을 합니다. 따라서 data가 변경된다면, UI역시 변경되어야 하기 때문에 data의 상태 변화에 따라 composable 함수가 다시 호출 되어야 합니다. Compose는 이런 수동적인 호출을 막기위해 data가 변경되면 이를 observing 하여 자동으로 재호출하여 UI를 그릴 수 있는 방법을 제공합니다. 이렇게 데이터의 상태 변화에 따라 자동 호출되어 UI가 다시 그려지는 작업을 recomposing 이라고 합니다.

recomposing 작업은 Compose가 data의 변경점을 확인하여 UI 구조를 update하도록 하는 custom Kotlin compiler plugin을 내부적으로 사용하고 있기에 가능합니다.

한가지 예로 TestScreenContents() 함수 내부에 Greeting("Android")라는 형태로 함수를 호출하게 되면 TestScreenContents()recomposing 되더라도 Greeting함수의 param은 고정된 값이므로 UI tree에서 해당 Text가 다시 그려지지 않습니다. 즉 Greeting() 함수는 호출되지 않으며, 필요한 부분에 대한 함수가 재호출 되도록 설계되어 있습니다.

만약 Composable 함수 내부에 상태를 추가하고자 한다면 composable mutable memory를 사용하는 mutableStateOf를 사용해야 하며, recomposing시에도 (함수가 재 호출 되더라도) 해당 값을 유지하려면 remember 를 사용하도록 합니다.

@Composable
fun TestScreenContents(texts: List<String> = listOf("Welcome", "compose world")) {
    Column {
        texts.forEach {
            Greeting(it)
            Divider(color = Color.DarkGray)
        }
        CounterButton()
    }
}

@Composable
fun CounterButton() {
    val count = remember { mutableStateOf(0) }

    Button(onClick = { count.value++ }) {
        Text("Clicked Count: ${count.value}")
    }
}

직접 단말에 올려보면 버튼을 클릭시마다 count가 올라가는걸 확인할 수 있습니다. 또한 recomposing이 발생하더라도 Greeting()이나 Divider는 재호출되지 않고 해당 count 변수를 내부적으로 observing 하고 있는 CounterButton()에 대해서만 재호출되게 됩니다.

만약 CoutnerButton()을 다른곳에서 정의하여 두개 이상의 버튼을 화면에 배치한다고 하면 두개의 component가 각각의 상태로 따로 갖게 됩니다. CounterButton이라는 class가 두개 생기고 count는 그 클래스의 private member 변수라고 이해하면 좀더 이해하기 쉽습니다.

State hoisting

Composable 함수가 재호출되고 recomposing의 여부를 결정하는 상태를 함수 내부가 아닌 외부로 노출시키는 작업을 state hoisting 이라고 합니다. state 를 hoisting 시키면 불필요하게 상태가 중복되는걸 막을수 있고 이에 따라 발생되는 버그도 방지할 수 있습니다. 또한 recomposing의 대상의 되는 state를 함수 외부에서 관리하여 테스트를 쉽게 할수 있도록 만들고 composable 함수의 재 사용성도 높일 수 있습니다.

물론 caller가 굳이 해당 Composable 함수의 상태를 알 필요가 없다면 hositing할 필요는 없습니다.

위 예제에 이어서 CounterButton 내부에 정의된 counter를 이를 호출하는 TestScreenContents()함수가 관심이 있다면 이 counter 함수를 Caller쪽으로 끌어 올리고 CounterButton에는 이 값을 넘겨 주도록 합니다. 물론 CounterButton의 동작에 따라 state도 변경되어야 하면 이 state가 변경되면 recomposing 되어야 하므로 아래와 같이 param을 정의 합니다.

@Composable
fun TestScreenContents(texts: List<String> = listOf("Welcome", "compose world")) {
    val count = remember { mutableStateOf(0) }

    Column {
        texts.forEach {
            Greeting(it)
            Divider(color = Color.DarkGray)
        }
        CounterButton(count.value) { newCnt -> count.value = newCnt }
    }
}

@Composable
fun CounterButton(count: Int, updateCount: (Int) -> Unit) {
    Button(onClick = { updateCount(count + 1) }) {
        Text("Clicked Count: $count")
    }
}

state 가 외부에 정의되었지만 버튼 클릭시마다 count의 값이 변경되고 이에 영향을 받는 CounterButton함수는 recomposing 됩니다. 만약 이 count 값에 관심있는 다른 Composable 함수가 있어 같은 방식으로 (param)으로 넘겨 받았다면 CounterButton의 클릭시 같이 recomposing의 대상이 됩니다.

아래과 같이 CounterButton2() 함수를 하나 더 추가하여 테스트해 보겠습니다.

@Composable
fun TestScreenContents(texts: List<String> = listOf("Welcome", "compose world")) {
  ...
        CounterButton1(count.value) { newCnt -> count.value = newCnt }
        CounterButton2(count)
  ...
}

@Composable
fun CounterButton1(count: Int, updateCount: (Int) -> Unit) {
    Log.i("ComposeTest", "FirstBtn() is called #1")
    Button(onClick = { updateCount(count + 1) }) {
        Log.i("ComposeTest", "FirstBtn() is called #2")
        Text("Clicked Count#1: $count")
    }
}

@Composable
fun CounterButton2(count: MutableState<Int>) {
    Log.i("ComposeTest", "SecondBtn() is called #1")
    Button(onClick = { count.value = count.value + 1}) {
        Log.i("ComposeTest", "SecondBtn() is called #2")
        Text("Clicked Count#2: ${count.value}")
    }
}

CounterButton1() 함수는 이전에 만들어둔 함수와 동일하고, CounterButton2() 함수는 MutableState()를 직접 넘겨 받아 내부에서 count를 증가 시킵니다.

어떤 버튼을 클릭하든 count 변수가 변경되면서 두개의 버튼 모두 recomposing 됩니다. 하지만 넣어둔 로그는 아래와 같이 다르게 찍힙니다.

일단 화면이 로딩되면 넣어놓은 네개의 로그가 모드 출력됩니다.

CounterButton1() 클릭시 출력되는 로그는 아래와 같습니다.

첫번째 버튼에 대한 로그는 모두 찍혔으나 두번째 버튼의 경우 Button 내부의 로그만 출력됩니다.

CounterButton2()를 클릭한 경우에도 로그는 동일합니다. 

위에서 언급했듯이 Compose는 연관된 상태가 변경됨에 따라 필요한 부분만 업데이트 하도록 custom compiler를 가지고 있으며, 실제 동작시에 상태 변수가 변경시 이 변수가 언급된 함수들만 골라서 업데이트 됩니다. 또한 위 예제 처럼 이 state 변수가 어떤 형태로 전달되고, 배치되는지에 따라 재호출되는 영역도 달라진 다는걸 알 수 있습니다.

References

[1] Jetpack Compose basic

[2] https://developer.android.com/codelabs/jetpack-compose-basics?continue=https%3A%2F%2Fdeveloper.android.com%2Fcourses%2Fpathways%2Fcompose%23codelab-https%3A%2F%2Fdeveloper.android.com%2Fcodelabs%2Fjetpack-compose-basics#0 

반응형