Jetpack Compose용 Kotlin

Jetpack Compose는 Kotlin을 중심으로 빌드되었습니다. 일부 경우에 Kotlin은 좋은 Compose 코드를 더 쉽게 작성할 수 있게 하는 특수 관용구를 제공합니다. 다른 프로그래밍 언어로 생각하고 그 언어를 Kotlin으로 마음속으로 번역하면 Compose의 강점 중 일부를 놓칠 수 있으며 관용적으로 작성된 Kotlin 코드를 이해하기 어려울 수 있습니다. Kotlin의 스타일에 더 익숙해지면 이러한 위험을 피하는 데 도움이 될 수 있습니다.

기본값 인수

Kotlin 함수를 작성할 때 함수 인수의 기본값을 지정할 수 있습니다. 이 기본값은 호출자가 명시적으로 값을 전달하지 않는 경우에 사용됩니다. 이 기능은 오버로드된 함수의 필요성을 줄여줍니다.

예를 들어 정사각형을 그리는 함수를 작성한다고 가정해 보겠습니다. 이 함수에는 각 변의 길이를 지정하는 단일 필수 매개변수인 sideLength가 있을 수 있습니다. 그리고 thickness, edgeColor 등의 몇 가지 선택적 매개변수가 있을 수 있습니다. 호출자가 이러한 매개변수를 지정하지 않으면 함수는 기본값을 사용합니다. 다른 언어에서는 다음과 같이 여러 함수를 작성해야 할 것으로 예상할 수 있습니다.

// We don't need to do this in Kotlin!
void drawSquare(int sideLength) { }

void drawSquare(int sideLength, int thickness) { }

void drawSquare(int sideLength, int thickness, Color edgeColor) { }

Kotlin에서는 다음과 같이 단일 함수를 작성하고 인수의 기본값을 지정할 수 있습니다.

fun drawSquare(
    sideLength: Int,
    thickness: Int = 2,
    edgeColor: Color = Color.Black
) {
}

이 기능을 사용하면 중복 함수를 여러 개 작성하지 않아도 되는 이점 외에도 코드를 훨씬 더 명확하게 읽을 수 있습니다. 호출자가 인수 값을 지정하지 않으면 기본값을 사용할 의사가 있음을 나타냅니다. 또한 이름이 지정된 매개변수를 사용하면 진행 상황을 훨씬 더 쉽게 확인할 수 있습니다. 코드를 검토하고 다음과 같은 함수 호출을 살펴보는 경우 drawSquare() 코드를 확인하지 않고서는 매개변수의 의미를 모를 수 있습니다.

drawSquare(30, 5, Color.Red);

이에 반해, 다음 코드는 자체 문서화합니다.

drawSquare(sideLength = 30, thickness = 5, edgeColor = Color.Red)

대부분의 Compose 라이브러리는 기본값 인수를 사용합니다. 작성하는 구성 가능한 함수에도 똑같이 하는 것이 좋습니다. 이렇게 하면 컴포저블을 맞춤설정할 수 있지만 여전히 기본 동작을 간단하게 호출할 수 있습니다. 예를 들어 다음과 같은 간단한 텍스트 요소를 생성할 수 있습니다.

Text(text = "Hello, Android!")

이 코드는 다음과 같이 더 많은 Text 매개변수가 명시적으로 설정되는 훨씬 더 상세한 코드와 동일한 효과를 냅니다.

Text(
    text = "Hello, Android!",
    color = Color.Unspecified,
    fontSize = TextUnit.Unspecified,
    letterSpacing = TextUnit.Unspecified,
    overflow = TextOverflow.Clip
)

첫 번째 코드 스니펫은 훨씬 더 간단하고 읽기도 쉬울 뿐만 아니라 자체 문서화합니다. text 매개변수만 지정함으로써 다른 모든 매개변수에 기본값을 사용하겠다는 점을 문서화합니다. 이에 반해, 두 번째 스니펫은 설정된 값이 함수의 기본값이 되더라도 다른 매개변수의 값을 명시적으로 설정하겠다는 것을 의미합니다.

고차 함수 및 람다 표현식

Kotlin은 다른 함수를 매개변수로 받는 함수인 고차 함수를 지원합니다. Compose는 이 접근 방식을 기반으로 합니다. 예를 들어 구성 가능한 함수 ButtononClick 람다 매개변수를 제공합니다. 이 매개변수의 값은 사용자가 버튼을 클릭할 때 버튼이 호출하는 함수입니다.

Button(
    // ...
    onClick = myClickFunction
)
// ...

고차 함수는 함수로 평가되는 표현식인 람다 표현식과 자연스럽게 쌍을 이룹니다. 함수가 한 번만 필요하면 이 함수를 고차 함수로 전달하기 위해 다른 곳에서 정의할 필요가 없습니다. 대신 람다 표현식을 사용하여 바로 함수를 정의하기만 하면 됩니다. 이전 예에서는 myClickFunction()이 다른 곳에 정의되어 있다고 가정합니다. 그러나 여기에서 해당 함수만 사용한다면 람다 표현식을 사용하여 함수를 인라인으로 정의하는 것이 더 간단합니다.

Button(
    // ...
    onClick = {
        // do something
        // do something else
    }
) { /* ... */ }

후행 람다

Kotlin은 마지막 매개변수가 람다인 고차 함수를 호출하기 위한 특수 문법을 제공합니다. 이 매개변수로 람다 표현식을 전달하려면 후행 람다 문법을 사용하면 됩니다. 람다 표현식을 괄호 안에 넣는 대신 그 뒤에 놓습니다. 이는 Compose에서 일반적인 상황이므로 코드가 어떻게 보이는지 잘 알고 있어야 합니다.

예를 들어, 구성 가능한 함수인 Column()과 같이 모든 레이아웃의 마지막 매개변수는 하위 UI 요소를 내보내는 함수인 content입니다. 세 개의 텍스트 요소가 포함된 열을 만든 후 몇 가지 서식을 적용해야 한다고 가정해 보겠습니다. 이 코드는 작동하지만 매우 번거롭습니다.

Column(
    modifier = Modifier.padding(16.dp),
    content = {
        Text("Some text")
        Text("Some more text")
        Text("Last text")
    }
)

content 매개변수는 함수 서명의 마지막 매개변수이며 값을 람다 표현식으로 전달하기 때문에 이 매개변수를 괄호에서 꺼낼 수 있습니다.

Column(modifier = Modifier.padding(16.dp)) {
    Text("Some text")
    Text("Some more text")
    Text("Last text")
}

두 예의 의미는 정확히 동일합니다. 중괄호는 content 매개변수에 전달되는 람다 표현식을 정의합니다.

실제로 전달하는 유일한 매개변수가 후행 람다인 경우(즉, 마지막 매개변수가 람다이고 다른 매개변수를 전달하지 않는다면) 괄호를 모두 생략할 수 있습니다. 예를 들어 Column에 수정자를 전달할 필요가 없다고 가정해 보겠습니다. 그러면 다음과 같이 코드를 작성할 수 있습니다.

Column {
    Text("Some text")
    Text("Some more text")
    Text("Last text")
}

이 문법은 Compose에서, 특히 Column과 같은 레이아웃 요소에서 매우 일반적입니다. 마지막 매개변수는 요소의 하위 요소를 정의하는 람다 표현식이며 이러한 하위 요소는 함수 호출 이후 중괄호로 지정됩니다.

범위 및 수신자

일부 메서드와 속성은 특정 범위에서만 사용할 수 있습니다. 제한된 범위를 사용하면 필요한 곳에 기능을 제공하고 적절하지 않은 곳에서 실수로 기능을 사용하는 것을 방지할 수 있습니다.

Compose에서 사용된 예를 살펴보겠습니다. Row 레이아웃 컴포저블을 호출하면 콘텐츠 람다가 RowScope 내에서 자동으로 호출됩니다. 이를 통해 RowRow 내에서만 유효한 기능을 노출할 수 있습니다. 아래 예는 Rowalign 수정자의 행별 값을 어떻게 노출했는지 보여줍니다.

Row {
    Text(
        text = "Hello world",
        // This Text is inside a RowScope so it has access to
        // Alignment.CenterVertically but not to
        // Alignment.CenterHorizontally, which would be available
        // in a ColumnScope.
        modifier = Modifier.align(Alignment.CenterVertically)
    )
}

일부 API는 수신자 범위에서 호출되는 람다를 허용합니다. 이러한 람다는 매개변수 선언을 기반으로 다른 곳에 정의된 속성 및 함수에 액세스할 수 있습니다.

Box(
    modifier = Modifier.drawBehind {
        // This method accepts a lambda of type DrawScope.() -> Unit
        // therefore in this lambda we can access properties and functions
        // available from DrawScope, such as the `drawRectangle` function.
        drawRect(
            /*...*/
            /* ...
        )
    }
)

자세한 내용은 Kotlin 문서의 수신자가 있는 함수 리터럴을 참고하세요.

위임된 속성

Kotlin은 위임된 속성을 지원합니다. 이러한 속성은 필드인 것처럼 호출되지만 속성의 값은 표현식을 평가하여 동적으로 결정됩니다. by 문법의 사용을 통해 이러한 속성을 인식할 수 있습니다.

class DelegatingClass {
    var name: String by nameGetterFunction()

    // ...
}

다른 코드는 다음과 같은 코드로 속성에 액세스할 수 있습니다.

val myDC = DelegatingClass()
println("The name property is: " + myDC.name)

println()이 실행되면 nameGetterFunction()이 호출되어 문자열 값을 반환합니다.

이러한 위임된 속성은 상태 지원 속성 사용 시 특히 유용합니다.

var showDialog by remember { mutableStateOf(false) }

// Updating the var automatically triggers a state change
showDialog = true

디스트럭처링 데이터 클래스

데이터 클래스를 정의하면 디스트럭처링 선언을 통해 데이터에 쉽게 액세스할 수 있습니다. 예를 들어 Person 클래스를 정의한다고 가정해 보겠습니다.

data class Person(val name: String, val age: Int)

이 유형의 객체가 있다면 다음과 같은 코드를 사용하여 값에 액세스할 수 있습니다.

val mary = Person(name = "Mary", age = 35)

// ...

val (name, age) = mary

Compose 함수에서는 이러한 종류의 코드를 흔히 보게 됩니다.

Row {

    val (image, title, subtitle) = createRefs()

    // The `createRefs` function returns a data object;
    // the first three components are extracted into the
    // image, title, and subtitle variables.

    // ...
}

데이터 클래스는 다른 많은 유용한 기능을 제공합니다. 예를 들어 데이터 클래스 정의 시 컴파일러는 equals()copy()와 같은 유용한 함수를 자동으로 정의합니다. 자세한 내용은 데이터 클래스 문서를 참조하세요.

싱글톤 객체

Kotlin을 사용하면 인스턴스가 항상 한 개만 있는 클래스인 싱글톤을 쉽게 선언할 수 있습니다. 이러한 싱글톤은 object 키워드를 사용하여 선언됩니다. Compose는 이러한 객체를 자주 사용합니다. 예를 들어 MaterialTheme은 싱글톤 객체로 정의됩니다. MaterialTheme.colors, shapestypography 속성에는 모두 현재 테마의 값이 포함되어 있습니다.

형식이 안전한 빌더 및 DSL

Kotlin에서는 형식이 안전한 빌더를 사용하여 도메인별 언어 (DSL)를 만들 수 있습니다. DSL을 사용하면 유지관리 및 읽기에 보다 쉬운 방식으로 복잡한 계층적 데이터 구조를 빌드할 수 있습니다.

Jetpack Compose는 LazyRowLazyColumn 같은 일부 API에 DSL을 사용합니다.

@Composable
fun MessageList(messages: List<Message>) {
    LazyColumn {
        // Add a single item as a header
        item {
            Text("Message List")
        }

        // Add list of messages
        items(messages) { message ->
            Message(message)
        }
    }
}

Kotlin은 수신자가 있는 함수 리터럴을 사용해 형식이 안전한 빌더를 보장합니다. Canvas 컴포저블을 예로 들면 이 컴포저블은 DrawScope를 수신자로 사용하는 함수(onDraw: DrawScope.() -> Unit)를 매개변수로 취합니다. 이를 통해 코드 블록에서 DrawScope에 정의된 멤버 함수를 호출할 수 있습니다.

Canvas(Modifier.size(120.dp)) {
    // Draw grey background, drawRect function is provided by the receiver
    drawRect(color = Color.Gray)

    // Inset content by 10 pixels on the left/right sides
    // and 12 by the top/bottom
    inset(10.0f, 12.0f) {
        val quadrantSize = size / 2.0f

        // Draw a rectangle within the inset bounds
        drawRect(
            size = quadrantSize,
            color = Color.Red
        )

        rotate(45.0f) {
            drawRect(size = quadrantSize, color = Color.Blue)
        }
    }
}

Kotlin 문서에서 형식이 안전한 빌더와 DSL에 관해 자세히 알아보세요.

Kotlin 코루틴

코루틴은 Kotlin의 언어 수준에서 비동기 프로그래밍 지원을 제공합니다. 코루틴은 스레드를 차단하지 않고 실행을 정지할 수 있습니다. 반응형 UI는 본질적으로 비동기이며 Jetpack Compose는 콜백을 사용하는 대신 API 수준에서 코루틴을 이용하여 이를 해결합니다.

Jetpack Compose는 UI 레이어 내에서 코루틴을 안전하게 사용하도록 하는 API를 제공합니다. rememberCoroutineScope 함수는 이벤트 핸들러에서 코루틴을 만들고 Compose 정지 API를 호출할 수 있는 CoroutineScope를 반환합니다. ScrollStateanimateScrollTo API를 사용하는 아래 예를 참고하세요.

// Create a CoroutineScope that follows this composable's lifecycle
val composableScope = rememberCoroutineScope()
Button(
    // ...
    onClick = {
        // Create a new coroutine that scrolls to the top of the list
        // and call the ViewModel to load data
        composableScope.launch {
            scrollState.animateScrollTo(0) // This is a suspend function
            viewModel.loadData()
        }
    }
) { /* ... */ }

기본적으로 코루틴은 코드 블록을 순차적으로 실행합니다. 실행 중인 코루틴 중 정지 함수를 호출하는 코루틴은 정지 함수가 반환될 때까지 자체 실행을 정지합니다. 정지 함수가 그 실행을 다른 CoroutineDispatcher로 옮기더라도 마찬가지입니다. 이전 예에서는 정지 함수 animateScrollTo가 반환될 때까지 loadData가 실행되지 않습니다.

코드를 동시에 실행하려면 새 코루틴을 만들어야 합니다. 위의 예에서 화면 상단으로의 스크롤과 viewModel에서의 데이터 로드를 동시에 로드하려면 코루틴 두 개가 필요합니다.

// Create a CoroutineScope that follows this composable's lifecycle
val composableScope = rememberCoroutineScope()
Button( // ...
    onClick = {
        // Scroll to the top and load data in parallel by creating a new
        // coroutine per independent work to do
        composableScope.launch {
            scrollState.animateScrollTo(0)
        }
        composableScope.launch {
            viewModel.loadData()
        }
    }
) { /* ... */ }

코루틴을 사용하면 비동기 API를 더 쉽게 결합할 수 있습니다. 다음 예에서는 사용자가 화면을 탭할 때 요소의 위치를 애니메이션하기 위해 pointerInput 수정자를 애니메이션 API와 결합해 보겠습니다.

@Composable
fun MoveBoxWhereTapped() {
    // Creates an `Animatable` to animate Offset and `remember` it.
    val animatedOffset = remember {
        Animatable(Offset(0f, 0f), Offset.VectorConverter)
    }

    Box(
        // The pointerInput modifier takes a suspend block of code
        Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                // Create a new CoroutineScope to be able to create new
                // coroutines inside a suspend function
                coroutineScope {
                    while (true) {
                        // Wait for the user to tap on the screen
                        val offset = awaitPointerEventScope {
                            awaitFirstDown().position
                        }
                        // Launch a new coroutine to asynchronously animate to
                        // where the user tapped on the screen
                        launch {
                            // Animate to the pressed position
                            animatedOffset.animateTo(offset)
                        }
                    }
                }
            }
    ) {
        Text("Tap anywhere", Modifier.align(Alignment.Center))
        Box(
            Modifier
                .offset {
                    // Use the animated offset as the offset of this Box
                    IntOffset(
                        animatedOffset.value.x.roundToInt(),
                        animatedOffset.value.y.roundToInt()
                    )
                }
                .size(40.dp)
                .background(Color(0xff3c1361), CircleShape)
        )
    }

코루틴에 관한 자세한 내용은 Android의 Kotlin 코루틴 가이드를 참고하세요.