2019년, 구글은 Jetpack Compose에 대해 공식적으로 발표했다. Jetpack compose는 네이티브 UI를 코드레벨로 구현할 수 있는 안드로이드 최신 도구 키트 중 하나로, 안드로이드 개발자들이 화면을 더 쉽고 빠르게 개발할 수 있도록 구글이 만든 라이브러리다. 코드레벨, 즉 코틀린을 사용해서 화면을 만들기 때문에 컴포즈를 사용한다면 기존의 XML을 사용할 필요가 없다. Flutter를 사용해봤다면 Jetpack compose와 Flutter의 UI 구현 방식(선언형 UI)이 꽤 비슷하기 때문에 Jetpack compose를 사용하는데에 큰 어려움은 없을 것이다.
다트나 컴포즈를 써본 사람들 중 일부는 이전의 나처럼 '아..XML로 화면 만드는게 훨씬 더 쉬운데, 이건 뭔가 사용하기 어렵고, 가독성도 별로인 것 같아.'라고 느꼈을 수 있다. 당장은 아니지만, 안드로이드 개발을 하는 우리는 언젠가 Jetpack Compose에 익숙해져야 한다. 왜 우리는 Jetpack compose를 써야 하는지 이유를 잘 설명해놓은 미디엄 글이 있어서 일부를 조금 의역해서 가져와봤다. 블로그 주소를 아래 링크에 걸어놓을테니 원문을 보고싶다면 참고하자.
소프트웨어공학을 배우면 결합도(Coupling)와 응집도(Cohesion)의 개념에 대해 배울 수 있다. 우리는 유지보수가 쉬운 소프트웨어를 만들기 위해 결합도는 최소화하고, 응집도는 최대화해야 한다. 결합도가 높으면 모듈끼리 서로 의존도가 높기 때문에 하나의 모듈에서 발생한 에러가 다른 모듈에도 영향을 미칠 수 있고, 앱이 커질수록 유지 보수가 힘들어지기 때문에 가능한 한 관련된 코드들을 모듈화하는 것이 좋다.
결합도와 응집도를 안드로이드 개발의 맥락에서 살펴보자. 일단 뷰모델과 XML 레이아웃을 예시로 들어보겠다.
뷰모델은 데이터를 레이아웃에 제공하고, 제공받은 데이터가 레이아웃에 표시된다. 사실 이것은 뷰모델과 레이아웃 간에 의존성이 있음을 의미하고, 이것은 두 모듈 간 서로 결합도가 높다는 것을 뜻한다. 특히 액티비티나 프래그먼트에서 findViewById를 사용한다면 더 잘 체감이 될 것이다(findViewById 사용 시 잘못된 또는 존재하지 않는 뷰의 id를 넘겨주면 널 포인터 에러가 발생해 앱이 강제로 종료될 수 있다).
둘 사이의 결합도를 낮추고 싶지만, 일반적으로 뷰 모델은 kotlin 또는 Java로 작성되고, 레이아웃 파일은 XML로 작성된다. 이러한 언어의 차이로 인해 둘이 서로 밀접하게 결합되어 있음에도 해결할 수가 없는 단점이 존재한다. 그럼 다른 시각에서 접근해보자. 만약 UI도 소스코드와 동일한 언어로 작성할 수 있다면 어떨까?
앞서 말했던 문제를 해결하기 위해 Google은 2021년 7월 Jetpack Compose의 정식 버전을 출시했다. Jetpack Compose는 뷰 모델과 UI 모두 코틀린으로 작성할 수 있어 좀 더 쉽게 코드를 리팩토링할 수 있고, 결합도는 낮추고 응집력을 높일 수 있다.
유지보수가 간편하다는 장점 외에 Jetpack Compose를 사용하면 얻을 수 있는 이점들은 다음과 같다.
1. 코드 감소가 가능하다.
적은 수의 코드로 더 많은 작업을 하고 전체 버그 클래스를 방지할 수 있으므로 코드가 간단하며 유지 관리하기가 쉽다.
2. 직관적이다.
앱 상태가 변경되면 UI가 자동으로 업데이트된다.
3. 빠르게 개발할 수 있다.
각각의 액티비티마다 하나의 거대한 xml파일을 사용하는 대신 모듈 방식으로 화면 구축이 가능하여 재사용, 확장성에 용이하기 때문에 개발에 속도를 낼 수 있다.
4. 성능이 강력하다.
Compose를 사용하면 Android 플랫폼 API에 직접 액세스 할 수 있기 때문에 Material Design, Dark theme, Animation 등의 기능을 사용해서 더 역동적이고 멋진 UI를 만들 수 있다.
build.gradle(Module)
buildFeatures {
// Enables Jetpack Compose for this module
compose true
}
dependencies {
// Integration with activities
implementation 'androidx.activity:activity-compose:1.3.1'
// Compose Material Design
implementation 'androidx.compose.material:material:1.0.5'
// Animations
implementation 'androidx.compose.animation:animation:1.0.5'
// Tooling support (Previews, etc.)
implementation 'androidx.compose.ui:ui-tooling:1.0.5'
// Integration with ViewModels
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha07'
// UI Tests
androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.0.5'
}
컴포즈는 코드레벨로 화면을 구현하기 때문에 xml을 사용하지 않는다고 했다. 즉, 액티비티나 프래그먼트에서 setContentView()를 사용하지 않고 setContent()를 사용해서 UI를 설정한다.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
}
// with Compose
class MainAcitivty : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MainTheme()
}
}
}
@Composable
fun MainTheme() {
...
}
컴포즈 사용법을 알아내기 위해 구글링을 하다보면, 예제 코드에서 제일 먼저 @Composable이라는 것을 보게 된다. 우린 코드만 봐도 '아, 저게 붙여진 함수는 화면을 구현하는 함수구나'라는 걸 알 수 있다. Composable은 컴포즈로 화면을 만들기 위한 핵심 함수로, @Composable
로 지정할 수 있다. 우리는 하나의 화면을 만들기 위해 여러개의 컴포저블 함수를 만들어 사용할 수 있고, 컴포저블 함수 내에서 필요한 구성 요소들(ex 텍스트, 버튼, 이미지)을 원하는 형태로 배치 및 구성할 수 있다. 컴포저블 함수는 데이터를 수신할 수 있고, UI를 만들기 위해 수신받은 데이터를 사용하며, 사용자가 화면에서 볼 수 있는 UI 구성요소를 내보낸다.
@Composable
fun Greeting(names: List<String>) {
for (name in names) {
Text("Hello %name")
}
}
// progressState가 1이 되면 화면에 진행바 표시
@Composable
fun mainContent() {
var progressState by rememeber { mutableStateOf(0) }
if (progressState == 1){
CircularProgressIndicator(
modifier = Modifier.padding(16.dp),
color = colorResource(R.color.mint),
strokeWidth = Dp(value = 4F))
}
}
@Composable
fun ButtonRow() {
MyFancyNavigation {
StartScreen()
MiddleScreen()
EndScreen()
}
}
// 위 컴포저블 내의 각 컴포저블이 순서대로 실행된다는 걸 보장할 수는 없다.
// 컴포즈에는 일부 UI요소가 다른 UI 요소보다 우선순위가 높다는 것을 인식하고, 그 요소를 먼저 그리는 똑똑한 옵션이 있다 !
xml을 사용하여 화면을 구축했을 때는 레이아웃 프리뷰로 화면을 미리 확인할 수 있었다. 컴포즈도 비슷한 기능을 제공한다. 미리보기 기능을 사용하고 싶은 컴포저블 함수에 @Preview를 추가하면 되는데, 파라미터가 없는 레퍼런스용 컴포저블에 @Preview를 추가한 다음 해당 함수 내에서 필요한 컴포저블을 호출하는 것이 좋다.
@Preview
@Composable
fun ComposablePreview() {
SimpleComposable("World")
}
@Composable
fun SimpleComposable(name: String) {
Text("Hello $name")
}
모든 컴포저블은 자체적으로 수명주기를 갖고 있다. 컴포저블의 수명 주기는 3단계로 구성되며, 액티비티나 프래그먼트의 수명주기보다 간단하다.
하나의 컴포저블은 UI가 업데이트 될 때마다 재구성(Recompose)된다. 그럼 컴포지션과 리컴포지션은 무엇일까?
컴포즈는 레이아웃과 완전히 다르게 작동한다. 일단 앱이 화면을 그리면 변경할 수 없다. 각 컴포저블에 전달된 값을 변경할 수 없기 때문에, 컴포저블이 수신하는 상태(State)를 변경해야 한다.
여기서 우리는 컴포즈가 화면을 업데이트 하는 방식에 대해 알기 위해 상태, 리컴포지션(Recomposition)의 개념에 대해 짚고 넘어가야 한다.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
PreviewContent()
}
}
@Preview
@Composable
fun PreviewContent() {
Scaffold(
modifier = Modifier
.fillMaxSize(),
content = { HelloContent("Hello!") },
backgroundColor = Color.White
)
}
@Composable
fun HelloContent(s: String) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = s,
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5,
color = Color.Black
)
OutlinedTextField(
value = "",
onValueChange = { },
label = {
Text(
text = "Name",
color = Color.Black
)
}
)
}
}
}
컴포즈의 OutlinedTextField는 XML로 화면 구현 시 사용했던 EditText와 동일한 역할을 한다. 위 예제를 실행하면 아무런 일이 일어나지 않는데, OutlinedTextField가 자체적으로 업데이트되지 않기 때문이다. TextField는 value 속성이 변경될 때 업데이트 되는데, 이는 컴포즈의 리컴포지션 작동 방식과 관련이 있다.
리컴포지션이란 데이터가 변경될 때 컴포지션을 업데이트 하기 위해 컴포저블을 다시 실행하는 것으로, 컴포저블의 입력값이 변경될 때만 실행된다.
컴포지션(Composition)이란 컴포저블 함수를 실행하면 생성되는 것으로, UI를 그리는 역할을 한다(UI를 기술하는 컴포저블의 트리 구조). 초기 컴포지션 시 컴포즈는 컴포지션에서 호출하는 컴포저블을 추적한 후, 다음 앱 상태가 변경되면 리컴포지션을 예약한다. 이후 상태가 변경된 컴포저블만 리컴포지션, 즉 UI가 다시 그려진다.
컴포지션은 초기 컴포지션을 통해서만 생성되고 리컴포지션을 통해서만 업데이트된다. 따라서, 컴포저블을 업데이트하는 유일한 방법은 리컴포지션을 하는 것이다. 컴포즈는 변경된 컴포저블만 지능적으로 리컴포지션 할 수 있는 아주 똑똑한 라이브러리이기 때문에, 전체 UI 트리를 재구성해서 컴퓨팅 비용을 많이 소모했던 기존의 레이아웃 방식에 비해 더 효율적이라고 할 수 있다.
종종 리컴포지션이 끝나기 전에 상태가 변경되는 경우가 있는데, 이 경우 컴포즈는 리컴포지션을 취소하고 새 상태 값으로 리컴포지션을 다시 시작한다. 리컴포지션시 따로 데이터를 저장해 두지 않으면, 리컴포지션 되었을 때 데이터가 초기화된다(이건 후술할 remember()와 관련이 있기 때문에 상태에서 다시 언급하도록 하겠다).
상태란 앱 실행 중에 변할 수 있는 어떠한 값이다. 사용자가 입력하는 값, 데이터베이스에서 가져온 값 같은 것들이 모두 상태가 될 수 있다.
상태에 대한 이해를 돕기 위해 리컴포지션에서 사용했던 예제를 다시 보자.
@Composable
fun HelloContent(s: String) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = s,
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5,
color = Color.Black
)
OutlinedTextField(
value = "",
onValueChange = { },
label = {
Text(
text = "Name",
color = Color.Black
)
}
)
}
}
위 예제를 실행하면 아무런 일이 일어나지 않는데, OutlinedTextField가 자체적으로 업데이트되지 않기 때문이라고 했었다. TextField는 value 속성이 변경될 때 업데이트 되기 때문에, mutableStateOf를 사용해서 TextField의 상태를 만들어주고 onValueChange에서 input 값이 바뀔 때 value가 세팅되도록 해보자.
@Composable
fun HelloContent(s: String) {
var input = mutableStateOf("")
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = s,
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5,
color = Color.Black
)
OutlinedTextField(
value = input.value,
onValueChange = { input.value = it },
label = {
Text(
text = "Name",
color = Color.Black
)
}
)
}
}
하지만 위 예제 또한 아무런 반응이 없을 것이다. 컴포즈는 위 예제의 OutlinedTextField의 value 값이 변경될 때마다 리컴포지션을 수행하는데, 이 때 input 값이 다시 mutableStateOf("")로 할당되기 때문에 아무런 반응이 없는 것이다.
위 예제처럼 리컴포지션시 따로 데이터를 저장해 두지 않으면, 리컴포지션 되었을 때 데이터가 초기화된다. 이러한 문제를 해결하기 위해 컴포즈는 메모리에 값을 저장할 수 있도록 remember()라는 함수를 제공한다. 빈 문자열을 최초 값으로 갖는 mutableStateOf()를 사용해 remember()로 값을 저장하면, remember()를 사용한 input 변수는 리컴포지션을 통해 String 값을 유지할 수 있는 것이다.
@Composable
fun HelloContent(s: String) {
var input by remember { mutableStateOf("") }
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = s,
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5,
color = Color.Black
)
OutlinedTextField(
value = input.value,
onValueChange = { input.value = it },
label = {
Text(
text = "Name",
color = Color.Black
)
}
)
}
}
remember()를 사용한 변수 선언은 다음과 같이 할 수 있다.
val mutableState = remember { mutableStateOf(default) }
var value by remember { mutableStateOf(default) }
val (value, setValue) = remember { mutableStateOf(default) }
화면 회전 시에도 데이터를 저장해두고 싶다면 rememberSaveable을 사용하자.
val mutableState by rememberSaveable { mutableStateOf(default) }
remember()를 사용하여 객체를 저장하는 컴포저블은 내부에 상태를 생성하기 때문에 컴포저블을 Stateful하게 만든다. 이게 무슨 말인지 아래 예제를 보면서 확인해보자.
@Composable
fun HelloContent() {
Column(modifier = Modifier.padding(16.dp)) {
var name by remember { mutableStateOf("") }
if (name.isNotEmpty()) {
Text(
text = "Hello, $name!",
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5
)
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") }
)
}
}
위 예제에서 HelloContent는 내부적으로 name 변수에 대한 상태를 저장하고 변경할 수 있으므로 Stateful 컴포저블이라고 할 수 있다. Stateful 컴포저블은 재사용과 테스트하기가 어렵다는 단점이 있다.
Stateless 컴포저블이란 아래 예제처럼 상태가 없는 컴포저블이다.
@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Hello, $name",
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5
)
OutlinedTextField(
value = name,
onValueChange = onNameChange,
label = { Text("Name") }
)
}
}
하위 컴포저블에 있던 상태 변수를 상위 컴포저블로 끌어올리는 것(hoisting)
상태 호이스팅이란 컴포저블을 Stateless로 만들기 위해 상태를 상위 컴포저블에 놓는 패턴이다. Stateful 컴포저블은 앞서 말했듯 재사용과 테스트가 어렵기 때문에, 컴포저블을 재사용하고 싶다면 아래 예제처럼 Stateful한 버전은 상위 컴포저블(HelloScreen)로, Stateless한 버전은 하위 컴포저블(HelloContent)에 만드는 것이 좋다. 이렇게 만들면 HelloScreen에 name과 관련된 여러 컴포저블이 있을 경우 재사용에 좋기 때문이다. 다만 컴포저블 함수의 부모가 제어 할 필요 없는 상태는 끌어올릴 필요가 없다.
@Composable
fun HelloScreen() {
var name by rememberSaveable { mutableStateOf("") }
HelloContent(name = name, onNameChange = { name = it })
}
@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Hello, $name",
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.h5
)
OutlinedTextField(
value = name,
onValueChange = onNameChange,
label = { Text("Name") }
)
}
}
해당 포스팅에선 Jetpack Compose의 개념과 써야하는 이유, 핵심 요소들(Composable, Recomposition, State) 등 Compose를 쓴다면 알아야 할 것들에 대해 소개했다. 다음 포스팅에선 컴포즈를 사용하면서 Navigation, ViewModel과 같은 다른 제트팩 라이브러리나 Hilt와 같은 외부 라이브러리들을 사용하는 방법에 대해 다룰 예정이다.