Jetpack Compose - 기초

MUNGI JO·2024년 9월 4일

Android Jetpack Compose

목록 보기
1/7

서론

Jetpack Compose에 대해서 간략히 알아보고 간단한 어플리케이션 하나 생성해서 테스트 후 CleanArchitecture + MVVM 구조로 프로젝트를 진행해보고자 한다.

Jetpack Compose?

보통 Android를 사용한다면 XML을 통해 UI를 구성하고 그걸 Activity 혹은 Fragment에서 inflate하여 사용한다. 하지만 Compose의 경우 React나 Flutter 같은 코드로 UI를 구성하는 선언형 UI ToolKit으로 XML 대신 Kotlin코드로 UI를 구성한다.

1. 선언형 UI 구성
Jetpack Compose에서 UI는 선언형(declarative)으로 구성된다. 즉, UI의 상태를 기반으로 UI가 자동으로 갱신되게 된다. 상태(State)가 변경되면 Compose가 자동으로 UI를 재구성하며, 개발자는 수동으로 UI를 갱신할 필요가 없다.

@Composable 어노테이션
@Composable 어노테이션을 사용하여 함수가 Compose의 Composable 함수임을 나타낸다. Composable 함수는 UI 구성 요소를 정의하는 특별한 함수로, 반환값이 없고 UI를 그리기 위한 코드를 포함한다. 이러한 함수는 상태에 따라 자동으로 UI를 갱신하며, stateless와 stateful로 구분될 수 있다.

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

상태변화 Composable

  • Stateless Composable: 내부적으로 자체 상태를 유지하지 않는 Composable 함수를 의미한다. 필요한 모든 데이터를 인자로 받아 UI를 렌더링하며, 상태는 외부에서 관리된다. 이러한 특성 때문에 구현이 단순하며, 재사용 가능성과 테스트 가능성이 높다.
@Composable
fun StatelessCounter(count: Int, onIncrement: () -> Unit) {
    Column {
        Text(text = "Count: $count")
        Button(onClick = onIncrement) {
            Text(text = "Increase Count")
        }
    }
}

StatelessCounter 함수는 외부에서 count 값을 받아와 표시하며, 버튼을 클릭할 때 호출되는 onIncrement 콜백 함수도 외부에서 전달받는다. 상태 관리를 외부에서 하므로 내부적으로 상태를 유지하지 않는다.

  • Stateful Composable: 자체적으로 하나 이상의 상태를 관리하는 Composable 함수다. 상태를 저장할 때는 remember 키워드를 사용하여 mutableStateOf 함수에 저장하고자 하는 상태의 값을 저장한다. 이렇게 저장된 상태는 UI의 동적인 부분을 반영하고, Composable 함수는 자신의 상태에 대한 전체적인 제어권을 가진다.
@Composable
fun StatefulCounter() {
    var count by remember { mutableStateOf(0) }

    Column {
        Text(text = "Count: $count")
        Button(onClick = { count++ }) {
            Text(text = "Increase Count")
        }
    }
}

StatefulCounter 함수는 내부적으로 count 상태를 관리하며, remember 키워드를 사용해 상태를 저장하고 있다. 버튼 클릭 시 상태가 변경되며, 이에 따라 UI가 자동으로 갱신된다. 내부적으로 상태가 캡슐화되어 있고, 상태 관리 로직과 UI 렌더링이 같은 곳에 위치한다.

2. 계층 구조
Jetpack Compose에서는 UI를 계층 구조로 구성한다. 여러 개의 Composable 함수를 중첩하여 복잡한 UI를 구성할 수 있으며, 각 Composable 함수는 독립적인 역할을 수행한다. 이러한 구조는 유연성과 모듈성을 높이며, 코드의 재사용성을 높이는 데 기여한다.

@Composable
fun MyApp() {
    Column {
        Greeting(name = "World")
        StatefulCounter()
    }
}

위의 MyApp 함수는 Column을 사용해 Greeting과 StatefulCounter를 계층 구조로 배치한 예시이다. 이처럼 Jetpack Compose에서는 UI를 계층적으로 구성하며, 각 Composable 함수는 독립적으로 동작하고 필요에 따라 상호작용할 수 있다.

3. 재사용성: Composable 함수의 재사용성
Composable 함수는 특정 UI 요소를 독립적으로 정의하고, 이 정의된 함수를 필요에 따라 여러 곳에서 재사용할 수 있다. 이렇게 하면 동일한 UI 코드를 반복해서 작성할 필요가 없으며, 코드의 가독성과 유지보수성이 크게 향상된다.

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

위의 Greeting 함수는 name이라는 인자를 받아서 텍스트를 표시하는 Composable 함수다. 이 함수는 다양한 화면이나 다른 Composable 함수에서 재사용할 수 있다.

Basic codelab

xml과 달리 Text, Button등은 모두 구성 가능한 함수로 제공되며 Composable로 선언 된 함수 내부에서 사용된다.

Empty 프로젝트로 프로젝트를 생성하면 기본적으로 jetpack compose로 프로젝트가 생성되게 되는데 본인이 만든 프로젝트의 이름으로 최상위 컴포저블함수가 Theme이름이 붙으면서 생성되고 그 밑에 Scaffold가 적용되어 있는 것을 확인할 수 있다.

ComponentActivity는 Jetpack Compose를 사용하기 위한 Activity로 기존의 Activity를 상속받으며 생성되기에 intent나 Ui처리를 그대로 사용할 수 있다. Jetpack Compose를 사용하기 위한 Activity라고 보면 된다.

Scaffold는 화면의 기본구조를 잡는 역할로 기본적인 UI를 구성하는 데 도움을 주는 기능을 내포하고 있다. Appbar, NavigationBar, FloatingActionButton, 등의 UI 요소를 쉽게 배치 가능하다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            Codelab_basic_1Theme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    Greeting(
                        name = "Android",
                        modifier = Modifier.padding(innerPadding)
                    )
                }
            }
        }
    }
}

여기서 프로젝트 이름의 테마는 Theme파일에 자동 생성되며 MaterialTheme 및 dark, light 테마 까지 적용되는 것을 확인할 수 있다. 동적 색상또한 지원된다.

// 컴포저블 함수
@Composable
fun Codelab_basic_1Theme(
    darkTheme: Boolean = isSystemInDarkTheme(), // 다크모드 시스템 확인
    // Dynamic color is available on Android 12+
    dynamicColor: Boolean = true, // 동적 색상 기능 12이상
    content: @Composable () -> Unit // 화면에 그려질 컴포저블 요소 (setContent로 받음)
) {
    val colorScheme = when {
        // 동적 색상 정의 - 디바이스별로 개인화된 컬러를 지원
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
        }
        // 테마 모드에 따라서 적용
        darkTheme -> DarkColorScheme
        else -> LightColorScheme
    }

    // Google 제공 Material 3 Design 시스템을 적용하기 위해 사용
    MaterialTheme(
        colorScheme = colorScheme, // 색상 Pallete 정의 객체
        typography = Typography, // 텍스트 스타일 정의하는 객체
        content = content
    )
}

리컴포지션(Recomposition)

Compose 앱은 구성 가능한 함수를 호출해 데이터를 UI로 변환한다. 데이터가 변경되면 Compose는 변경된 데이터를 기반으로 해당 함수를 다시 실행해 UI를 업데이트하는데, 이를 리컴포지션이라고 한다.

Compose는 데이터가 변경된 컴포저블만 다시 렌더링하고, 영향받지 않은 컴포저블은 건너뛰어 불필요한 리컴포지션을 줄인다. 이렇게 필요한 부분만 다시 컴포지션하기 때문에, 성능이 크게 저하되지 않는다.

리컴포지션은 UI 업데이트 과정으로 볼 수 있다. mutableStateOf로 상태를 선언하면, 해당 상태가 변경될 때 리컴포지션을 트리거한다. 이때 mutableStateOf는 기본적으로 상태를 가지지 않는 컴포저블을 상태를 가지는 컴포저블로 변환한다.

여러 리컴포지션 간 상태를 유지하려면 remember를 사용해 변경된 상태를 기억해야 한다. 즉, mutableStateOfremember는 상호 의존적이다. remember가 상태를 메모리에 저장해 리컴포지션이 발생해도 상태가 유지되도록 보장하는 역할을 하기 때문이다.

단순히 remember와 mutablestateof 코드를 넣으면 상태 변화가 된다는 것에 의아할 수도 있는데 이 코드를 넣게 되면 구성가능한 함수 Composable로 선언된 함수가 자동으로 해당 상태를 구독한다. 그러면 상태가 변경될 때 이러한 필드를 읽는 컴포저블이 재구성되어 업데이트를 표시한다. 이후 리컴포지션이 해당 상태를 가지는 UI를 변경시키는 것.

🍕 주의할점은 remember는 상태를 변경시키는 것이 아니라 리컴포지션이 발생해도 상태를 기억하여 유지하기 위함이며 mutableStateOf가 상태가 변경될 때 UI를 자동으로 다시 그리도록 하는 상태변화 감지 래퍼 역할이다.

// 현재 stateful composable
@Composable
fun MyComposable() {
    // 상태 관리
      val cound = remember {
        mutableStateOf(0)
    }

    // 상태를 참조하지 않음 - 리컴포지션되지 않음
    Text(text = "This text doesn't depend on state")

    // 상태를 참조함 - 상태 변경 시 리컴포지션됨
    Text(text = "This text depends on state: $count")

    Button(onClick = {
        // 버튼 클릭 시 상태 변경, 리컴포지션 발생
        count++
    }) {
        Text("Increase count")
    }

    // 상태를 참조하지 않음 - 리컴포지션되지 않음
    Text(text = "Another text not depending on state")
}

LazyColumn vs Recyclerview

LayzColumn은 Compose에서 지원하는 기존의 Android Xml방식에서 사용한 Recyclerview와 같다고 보면 되는데 세부적인 차이가 존재한다.

1. 재활용(Recycling)의 차이
RecyclerView는 뷰 재활용(view recycling) 방식을 사용한다. 이는 스크롤할 때 이미 사용한 뷰를 다시 사용하여 메모리와 성능을 최적화하는 방식이다. 즉, 화면에서 사라진 뷰를 버리지 않고 다시 사용하는 것.

반면에 LazyColumn은 재활용을 하지 않는다. 그 대신, 새로운 컴포저블을 방출(렌더링)한다. 하지만 이 방식은 성능 문제를 일으키지 않는데 그 이유는 컴포저블을 생성하는 비용이 Android View를 인스턴스화하는 것보다 훨씬 적기 때문이다. Jetpack Compose는 기존의 Android View 시스템보다 더 가벼운 방식으로 UI를 그리기 때문에, 새로운 컴포저블을 생성해도 성능에 큰 영향을 미치지 않는다.

2. 성능 유지
LazyColumn은 새로운 컴포저블을 렌더링하더라도 성능 저하가 발생하지 않도록 최적화되어 있다. 그 이유는 Compose 자체가 상태 관리와 UI 그리기를 효율적으로 처리하도록 설계되어 있기 때문이다.
또한, LazyColumn은 화면에서 벗어난 컴포저블을 메모리에서 유지하지 않고, 필요하지 않으면 메모리에서 제거하여 효율적인 메모리 관리를 한다.

👉 요약
RecyclerView는 뷰 재활용을 통해 메모리와 성능을 최적화한다.
LazyColumn은 컴포저블을 재활용하지 않고, 새로 렌더링하지만, 컴포저블 생성 비용이 적기 때문에 성능 저하가 없다.
Jetpack Compose는 가벼운 UI 그리기와 상태 관리로 성능을 유지한다.

remember vs rememberSaveable

remember는 분명 상태를 저장하기 위해서 사용하는 키워드였지만 화면을 회전하거나 다른 화면으로 이동 시에 그 상태가 초기화된다. 설명했듯이 리컴포지션 사이에서만 state가 유지되고 아예 화면이 파괴되고 재생성되는 상황에서는 값 또한 초기값으로 돌아가기 때문이다. 따라서 이런 상황을 방지하기 위해 ViewModel을 사용하곤 하는데 여기선 remeberSaveable을 사용해서 방지할 수도 있다.

rememberSaveable을 사용하게 되면 화면이 파괴되고 재생성 되더라도 그 값이 유지되는데 SavedInstanceState을 사용해서 상태를 자동으로 저장하게 된다.

내부적으로 Android의 SavedInstanceState 기능을 사용해 상태를 번들로 저장한 뒤, Activity가 다시 생성될 때 이 번들로부터 상태를 복원한다.

🍕 번들(Bundle)이란 라이브러리 패키지를 묶은 번들이 아닌 데이터를 저장하고 전달하기 위한 객체라고 보면 된다. 데이터를 키-값 쌍(Map)으로 묶어서 저장하는 일종의 컨테이너 역할을 하기 때문에 데이터를 묶어서 전달하는 것에서 번들이라는 용어를 사용했다. value에는 기본자료형부터 Serializable, Parcelable 같은 복잡한 타입이 올 수 있으며 클래스를 직렬화 할때는 클래스에implements Serializable 또는 implements Parcelable을 해야 한다.

텍스트 스타일 일부 변경

Jetpack Compose에서 Text 컴포저블은 텍스트를 화면에 표시하는 역할을 하며, 이때 text 속성에 전달되는 값은 기본적으로 AnnotatedString 객체로 처리된다. AnnotatedString은 텍스트와 더불어 텍스트에 적용할 스타일 정보를 함께 포함하는 객체이다. 이를 통해 텍스트의 특정 부분에 다양한 스타일(글꼴 크기, 색상, 기울임 등)을 적용할 수 있다.

@Immutable
class AnnotatedString internal constructor(
    val text: String,
    internal val spanStylesOrNull: List<Range<SpanStyle>>? = null,
    internal val paragraphStylesOrNull: List<Range<ParagraphStyle>>? = null,
    internal val annotations: List<Range<out Any>>? = null
)

AnnotatedString 클래스는 기본적으로 텍스트의 스팬 스타일(SpanStyle)단락 스타일(ParagraphStyle)을 지정할 수 있는 필드를 가지고 있다. 하지만 일반적으로 Text 컴포저블을 사용할 때는 String 타입의 텍스트를 전달해도 문제가 없다. 이는 내부적으로 String을 AnnotatedString으로 변환하여 처리하기 때문이다.

텍스트의 스타일을 부분적으로 변경할 때는 buildAnnotatedString을 사용하여 AnnotatedString 객체를 생성하고, 그 내부에서 withStyle을 사용하여 특정 범위의 텍스트에 스타일을 적용한다. 이를 통해 텍스트의 일부에만 굵게, 기울임, 폰트 크기 등을 적용할 수 있다.

Text(
    text = buildAnnotatedString {
        withStyle(
            style = SpanStyle(
                fontWeight = FontWeight.Bold,
                fontStyle = FontStyle.Italic,
                fontSize = MaterialTheme.typography.headlineLarge.fontSize
            )
        ) {
            append("Jetpack")
        }
        append(" Compose")
    },
    style = TextStyle.Default.copy(
        fontSize = MaterialTheme.typography.headlineSmall.fontSize,
        color = Color.Unspecified
    )
)

buildAnnotatedString: AnnotatedString 객체를 생성하는 함수로, 텍스트의 각 부분에 스타일을 적용할 수 있다.
withStyle: 특정 스타일을 적용하는 역할을 하며, SpanStyle을 통해 텍스트의 굵기, 기울임, 폰트 크기 등을 설정할 수 있다.
append: 텍스트를 이어붙이는 역할을 한다. 특정 스타일을 적용하지 않는 텍스트는 기본 스타일로 표시된다.

수정자(Modifier)?

Jetpack Compose에서 Modifier는 UI 요소의 외부 동작을 추가하는 핵심적인 기능을 담당한다. Modifier는 UI 요소의 크기, 레이아웃, 동작, 상호작용을 변경하거나 추가하는 데 사용되며, 컴포저블 함수의 API를 간결하게 유지하는 데 기여한다.

Compose UI의 각 요소는 기능을 수행하며 특정 API를 제공하지만, 이 API의 복잡성을 줄이고 재사용성을 높이기 위해 Modifier는 필수적이다. Modifier는 요소의 크기, 패딩, 정렬과 같은 레이아웃 속성을 설정할 수 있을 뿐만 아니라, 클릭, 스크롤과 같은 사용자 상호작용을 정의하는 데에도 사용된다. 이를 통해 UI 요소가 더 유연하게 동작할 수 있으며, 상위 요소나 하위 요소에 영향을 주지 않으면서도 독립적인 제어를 가능하게 한다.

Modifier의 기능

1. 컴포저블의 크기, 레이아웃, 동작 및 모양 변경
2. 접근성 라벨과 같은 정보 추가
3. 사용자 입력 처리
4. 요소를 클릭 가능, 스크롤 가능, 드래그 가능 또는 확대/축소 가능하게 만드는 높은 수준의 상호작용 추가

자세한 기능은 공식문서에서 확인 가능하다.

Modifier 체이닝

Modifier는 체이닝 방식으로 여러 속성을 연달아 적용할 수 있다. 이때 순서에 따라 컴포저블의 동작이 달라지므로, 원하는 결과를 얻기 위해 순서를 고려해야 한다.

Text(
    text = "Hello, World!",
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
        .background(Color.Blue)
        .clickable { /* Handle click */ }
)

커스텀 Modifier

기본 제공되는 수정자 외에도 개발자가 직접 정의하여 사용할 수 있어서 확장성 또한 좋다.

fun Modifier.customPadding(padding: Dp): Modifier = this.then(
    PaddingModifier(padding)
)

Modifier 사용 시의 이점

Modifier를 활용하면 UI 코드의 중복을 줄일 수 있으며, 보다 모듈화된 UI 구조를 설계할 수 있다. Modifier는 컴포저블 함수 내부에서만 작동하는 것이 아니라 외부에서 동작을 추가하거나 수정할 수 있기 때문에, 특정 UI 요소에 추가적인 동작이나 모양을 쉽게 덧붙일 수 있는 강력한 도구다. 이를 통해 더 효율적이고 간결한 코드를 작성할 수 있으며, UI 트리 구조의 깊이를 줄여 퍼포먼스 최적화에도 기여할 수 있다.

Modifier 사용 시의 주의사항

Modifier는 기본적으로 불변성을 가진다. 즉, 기존의 Modifier 객체를 수정하는 것이 아니라, 기존 Modifier에 새로운 동작을 덧붙여 새로운 Modifier 객체를 반환하는 방식으로 동작한다. 이로 인해 Modifier가 여러 개 연결된 체이닝 패턴을 자주 사용하게 된다. 개발자는 Modifier를 사용하면서 중복된 동작이 발생하지 않도록 주의해야 하며, 각 요소가 필요한 Modifier만을 사용하여 최적화된 UI 트리를 구성해야 한다.

자세한 사항은 공식문서에서 확인할 수 있다.

참고 자료

Android Developer
Jetpack Compose - codelab

profile
안녕하세요. 개발에 이제 막 뛰어든 신입 개발자 입니다.

0개의 댓글