API Guidelines for Jetpack Compose 번역본

윤성현·2024년 12월 20일
post-thumbnail

원본 링크 : API GuideLines for Jetpack Compose
Last updated: March 10, 2021

대상 독자

Compose API 가이드라인은 관용적인 Jetpack Compose API를 작성하기 위한 패턴, 모범 사례 및 규범적인 스타일 가이드라인을 설명합니다. Jetpack Compose 코드는 계층적으로 구축되므로, Jetpack Compose를 사용하는 모든 개발자는 자신이 사용할 API를 직접 구축하게 됩니다.

이 문서는 @Composable, remember {}, CompositionLocal을 포함한 Jetpack Compose의 런타임 API에 대한 기본적인 이해를 전제로 합니다.

각 가이드라인의 요구사항 수준은 RFC2119에 명시된 용어를 사용하여 각 개발자 대상 그룹별로 지정됩니다. 특정 가이드라인에 대해 대상 그룹이 명시적으로 언급되지 않은 경우, 해당 그룹에 대해서는 “선택사항”으로 간주됩니다.

Jetpack Compose 프레임워크 개발

androidx.compose 라이브러리와 도구에 대한 기여는 일관성을 촉진하고, 소비자 코드의 모든 계층에서 기대치와 모범 사례를 설정하기 위해 일반적으로 이 지침을 엄격하게 따릅니다.

Jetpack Compose 기반 라이브러리 개발

Jetpack Compose를 대상으로 하여, @Composable 함수와 앱 및 기타 라이브러리에서 지원하는 타입의 public API를 노출하는 외부 라이브러리 생태계가 생기는 것을 기대하고, 그런 상황을 바라고 있습니다. 이러한 라이브러리도 Jetpack Compose 프레임워크 개발과 동일한 수준으로 이 지침을 따르는 것이 이상적이지만, 조직적 우선순위와 로컬 일관성의 필요성에 따라 일부 스타일 가이드라인을 완화하는 것이 적절할 수 있습니다.

Jetpack Compose 기반 앱 개발

앱 개발은 종종 조직의 강력한 우선순위와 규범, 그리고 기존 앱 아키텍처와 통합해야 하는 등 요구사항의 영향을 받습니다. 이는 단순히 스타일적인 편차뿐만 아니라 구조적인 편차도 필요할 수 있습니다. 가능한 한, 이 문서에서는 이러한 상황에 더 적절할 수 있는 앱 개발의 대안적 접근 방식을 나열할 것입니다.

코틀린 스타일

기본 스타일 가이드라인

Jetpack Compose 프레임워크 개발에서는 아래의 추가 조정 사항을 포함하여, 기준으로서 Kotlin Coding Conventions 에 명시된 코딩 규칙을 반드시 따라야 합니다.

Jetpack Compose 라이브러리 및 앱 개발 또한 동일한 지침을 따르는 것이 권장됩니다.

Why

Kotlin Coding Conventions는 Kotlin 생태계 전반에 걸쳐 일관성 있는 표준을 수립합니다. 이 문서에서 이어지는 Jetpack Compose를 위한 추가적인 스타일 지침은 Compose의 언어 확장, 정신 모델, 의도된 데이터 흐름을 고려하여 작성되었으며, Compose 고유의 패턴에 대한 일관된 관습과 기대치를 정립하기 위해 마련되었습니다.

싱글톤, 상수, sealed class 및 enum class 값

Jetpack Compose 프레임워크 개발에서는 여기에 문서화 된 것처럼 불변인 상수를 PascalCase 규칙을 따라 명명해야 하며, CAPITALS_AND_UNDERSCORES 방식의 사용은 대체되어야 합니다. Enum class 값 또한 같은 섹션에 문서화된 대로 PascalCase를 사용하여 이름을 지정해야 합니다.

라이브러리 개발에서는 Jetpack Compose를 대상으로 하거나 확장할 때 이 규칙을 따르는 것이 권장됩니다.

앱 개발에서는 이 규칙을 따를 수 있습니다.

Why

Jetpack Compose는 시간의 경과나 스레드 간에 안정성을 보장할 수 없는 싱글톤이나 동반 객체(companion object) 상태의 사용 생성을 지양합니다. 이는 싱글톤 객체와 다른 형태의 상수 간 구분의 유용성을 줄입니다. 이러한 접근 방식은 API의 형태에 대한 일관된 예상을 만들며, 코드를 사용할 때는 구현 세부 사항이 최상위 val, companion object, enum class, 또는 중첩된 Object 서브클래스를 가진 sealed class 여부와 관계없이 동일한 의미와 의도를 가집니다. 따라서 myFunction(Foo)myFunction(Foo.Bar)는 호출 코드의 관점에서 구체적인 구현 세부 사항에 관계없이 동일한 의미와 의도를 가집니다.

코드베이스에서 이미 CAPITALS_AND_UNDERSCORES 스타일을 강하게 사용하고 있는 라이브러리 및 앱 코드는 해당 패턴과의 로컬 일관성을 유지하는 선택을 할 수 있습니다.

 Do

const val DefaultKeyName = "__defaultKey"

val StructurallyEqual: ComparisonPolicy = StructurallyEqualsImpl(...)

object ReferenceEqual : ComparisonPolicy {
    // ...
}

sealed class LoadResult<T> {
    object Loading : LoadResult<Nothing>()
    class Done(val result: T) : LoadResult<T>()
    class Error(val cause: Throwable) : LoadResult<Nothing>()
}

enum class Status {
    Idle,
    Busy
}

Don't

const val DEFAULT_KEY_NAME = "__defaultKey"

val STRUCTURALLY_EQUAL: ComparisonPolicy = StructurallyEqualsImpl(...)

object ReferenceEqual : ComparisonPolicy {
    // ...
}

sealed class LoadResult<T> {
    object Loading : LoadResult<Nothing>()
    class Done(val result: T) : LoadResult<T>()
    class Error(val cause: Throwable) : LoadResult<Nothing>()
}

enum class Status {
    IDLE,
    BUSY
}

Compose 작성 기준

Compose 컴파일러 플러그인 및 런타임은 Kotlin을 위한 새로운 언어 기능과 이들과 상호 작용할 수 있는 수단을 구축합니다. 이 계층은 시간이 지남에 따라 변경 가능한 트리 데이터 구조를 구성하고 관리하기 위한 선언적 프로그래밍 모델을 추가합니다. 컴포즈 UI는 컴포즈 런타임이 관리할 수 있는 트리 유형의 한 예이지만 이 용도로만 제한되지는 않습니다.

이 섹션에서는 컴포즈 런타임 기능을 기반으로 하는 @Composable 함수 및 API에 대한 가이드라인을 간략하게 설명합니다. 이 가이드라인은 관리되는 트리 유형에 관계없이 모든 Compose 런타임 기반 API에 적용됩니다.

Unit @Composable 함수를 엔티티로 명명하기

Jetpack Compose 프레임워크 개발 및 라이브러리 개발에서는 Unit을 반환하고 @Composable 주석이 붙은 모든 함수를 반드시 PascalCase를 사용하여 명명해야 하며, 그 이름은 반드시 명사여야 합니다. 동사나 동사구, 전치사화된 명사, 형용사, 부사가 아니어야 합니다. 명사 앞에는 설명하는 형용사를 붙일 수 있습니다. 이 지침은 함수가 UI 요소를 생성하는지 여부와 관계없이 적용됩니다.

앱 개발에서도 이와 같은 규칙을 따라야 합니다.

Why

컴포저블 함수가 Unit을 반환하는 경우, 이는 선언적 엔티티(declarative entity)로 간주되며, 컴포지션에서 존재하거나 존재하지 않을 수 있는 요소로 다뤄집니다. 따라서 클래스의 명명 규칙을 따릅니다. 컴포저블의 존재 여부는 호출자의 제어 흐름(control flow)에 따른 평가 결과에 의해 결정되며, 이는 재구성(recomposition) 동안 지속적인 정체성(persistent identity)이 유지되고, 해당 지속적인 정체성을 위한 생명 주기(lifecycle)를 형성합니다. 이러한 명명 규칙은 이 선언적 사고 모델을 촉진하고 강화하는 데 기여합니다.

 Do

// 이 함수는 PascalCased 명사로 시각적 UI 요소를 설명합니다
@Composable
fun FancyButton(text: String, onClick: () -> Unit) {

 Do

// 이 함수는 PascalCased 명사로 composition에 존재하는 비시각적 요소를 설명합니다.
@Composable
fun BackButtonHandler(onBackPressed: () -> Unit) {

Don't

// 이 함수는 명사이지만 PascalCase가 아닙니다!
@Composable
fun fancyButton(text: String, onClick: () -> Unit) {

Don't

// 이 함수는 PascalCased이지만 명사가 아닙니다!
@Composable
fun RenderFancyButton(text: String, onClick: () -> Unit) {

Don't

// 이 함수는 PascalCase도 아니고 명사도 아닙니다!
@Composable
fun drawProfileImage(image: ImageAsset) {

값을 반환하는 @Composable 함수의 명명 규칙

Jetpack Compose 프레임워크 개발 및 라이브러리 개발에서는 @Composable로 어노테이션된 함수 중 Unit이 아닌 값을 반환하는 함수에 대해, 반드시 Kotlin 코딩 컨벤션의 함수 명명 규칙을 따라야 합니다.

Jetpack Compose 프레임워크 개발 및 라이브러리 개발에서는 @Composable로 어노테이션된 함수를 PascalCase 타입 이름으로 명명하며 함수의 추상 반환 타입과 일치시키는 방식(즉, Kotlin 코딩 컨벤션에서의 팩토리 함수 예외 규칙)을 사용해서는 안 됩니다.

Why

이 팩토리 함수 컨벤션은 @Composable 함수 외부에서는 유용하고 허용되지만, @Composable 함수와 함께 사용될 때 호출자에게 부적절한 기대를 갖게 하는 단점이 있습니다.

팩토리 함수를 @Composable로 표시하는 주된 동기는 컴포지션을 사용하여 객체의 관리 수명 주기를 설정하거나 CompositionLocal을 객체의 구성 입력으로 사용하는 것입니다. 전자는 Compose의 remember {} API를 사용하여 재구성에 걸쳐 객체 인스턴스를 캐시하고 유지하는 것을 의미하며, 이는 생성자 호출처럼 보이는 팩토리 연산에 대한 호출자의 기대를 깨뜨릴 수 있습니다. (다음 섹션 참조) 후자의 동기는 팩토리 함수 이름에 표현되어야 하는 보이지 않는 입력을 의미합니다.

또한, Unit을 반환하는 @Composable 함수의 선언적 엔티티라는 정신 모델은 "가상 DOM(virtual DOM)" 정신 모델과 혼동되어서는 안 됩니다. PascalCase 명사로 명명된 @Composable 함수에서 값을 반환하면 이러한 혼동을 조장할 수 있으며, 호이스팅된 상태 객체로 더 잘 표현할 수 있고 현재 UI 엔티티에 대해 상태 저장을 제어하는 surface를 리턴하는 바람직하지 않은 스타일을 야기할 수 있습니다.

상태 호이스팅 패턴에 대한 자세한 내용은 이 문서의 디자인 패턴 섹션에서 확인할 수 있습니다.

 Do


// 현재 CompositionLocal 설정을 기반으로 스타일을 반환합니다
// 이 함수는 반환값의 출처를 명확히 나타냅니다
@Composable
fun defaultStyle(): Style {

Don't

// 현재 CompositionLocal 설정을 기반으로 스타일을 반환합니다
// 이 함수는 마치 컨텍스트와 무관한 객체를 생성하는 것처럼 보입니다!
@Composable
fun Style(): Style {

remember{}를 사용해 객체를 반환하는 @Composable 함수의 명명 규칙

Jetpack Compose 프레임워크 개발 및 라이브러리 개발에서는 내부적으로 remember {}를 사용하여 변경 가능한 객체를 반환하는 모든 @Composable 팩토리 함수에 remember 접두어(prefix)를 반드시 붙여야 합니다.

앱 개발에서도 동일한 규칙을 따르는 것이 권장됩니다.

Why

시간이 지나면서 변할 수 있고 재구성(recomposition) 간에도 유지되는 객체는 관찰 가능한 부작용을 동반하며, 이는 호출자에게 명확히 전달되어야 합니다. 이 명명 규칙은 호출자가 호출 시점에서 객체의 지속성을 얻기 위해 remember {}를 중복 사용하지 않아도 된다는 신호를 제공하는 역할도 합니다.

 Do

// 이 호출이 컴포지션에서 벗어날 때 취소될 CoroutineScope를 반환합니다
// 이 함수는 동작을 설명하기 위해 remember 접두어를 사용합니다
@Composable
fun rememberCoroutineScope(): CoroutineScope {

Don't

// 이 호출이 컴포지션에서 벗어날 때 취소될 CoroutineScope를 반환합니다
// 이 함수의 이름은 자동으로 취소되는 동작을 암시하지 않습니다!
@Composable
fun createCoroutineScope(): CoroutineScope {

객체를 반환하는 것만으로는 함수를 팩토리 함수로 간주하기에 충분하지 않으며, 그것이 함수의 주된 목적이어야 합니다. 예를 들어, @Composable 함수인 Flow<T>.collectAsState()를 고려해보면 이 함수의 주요 목적은 Flow에 대한 구독(subscription)을 설정하는 것입니다. 반환된 State<T> 객체를 remember {}로 관리하는 것은 부수적인 역할에 불과합니다.

CompositionLocal의 명명 규칙

CompositionLocal은 컴포지션 범위에서 키-값 테이블의 키 역할을 합니다. CompositionLocal은 특정 컴포지션 서브트리에 전역 변수와 유사한 값을 제공하는 데 사용될 수 있습니다.

Jetpack Compose 프레임워크 개발 및 라이브러리 개발에서는 CompositionLocal 키의 이름에 "CompositionLocal" 또는 "Local"이라는 명사 접미사를 사용해서는 안 됩니다. CompositionLocal 키는 해당 값에 기반한 설명적인 이름을 가져야 합니다.

Jetpack Compose 프레임워크 개발 및 라이브러리 개발에서는 더 적절한 설명이 되는 이름이 없는 경우에 한해 CompositionLocal 키 이름에 "Local"을 접두사로 사용할 수 있습니다.

 Do

// 여기서 "Local"은 형용사로 사용되었고, "Theme"이 명사입니다.
val LocalTheme = staticCompositionLocalOf<Theme>()

Don't

// 여기서 "Local"은 명사로 사용되었습니다!
val ThemeLocal = staticCompositionLocalOf<Theme>()

안정적인 타입 (Stable types)

Compose 런타임은 타입이나 함수를 안정적(stable)으로 표시할 수 있는 두 가지 어노테이션을 제공합니다. 이를 통해 Compose 컴파일러 플러그인이 최적화를 수행할 수 있도록 하며, 안전한 타입만을 받는 함수 호출을 건너뛸 수 있습니다. 이는 해당 함수의 결과가 입력이 변경되지 않는 한 변경되지 않기 때문입니다.

Compose 컴파일러 플러그인은 자동으로 타입의 이러한 속성을 추론할 수 있지만, 안정성을 추론할 수 없고 단순히 보장해야 하는 인터페이스 및 기타 타입의 경우 명시적으로 주석을 달 수 있습니다. 이러한 타입들을 총칭하여 “안정적인 타입(stable types)”이라고 합니다.

@Immutable은 객체가 생성된 후 어떤 속성의 값도 절대 변경되지 않으며, 모든 메서드가 참조 투명(referentially transparent) 하다는 것을 나타냅니다. const 표현식에서 사용할 수 있는 모든 코틀린 타입(원시 타입과 문자열)은 @Immutable로 간주됩니다.

@Stable은 타입에 적용될 때, 해당 타입이 변경 가능(mutable) 하다는 것을 나타내지만, public 속성이나 메서드 동작이 이전 호출과 다른 결과를 반환할 경우 Compose 런타임에 알림이 제공됩니다. (실제로 이 알림은 mutableStateOf()에 의해 반환되는 @Stable MutableState 객체를 통해 Snapshot 시스템에 의해 지원됩니다.) 이러한 타입은 다른 @Stable 또는 @Immutable 타입을 사용하여 속성을 뒷받침할 수 있습니다.

Such a type may only back its properties using other @Stable or @Immutable types.

  • 마지막 문장 번역이 매끄럽지 못함

Jetpack Compose 프레임워크 개발, 라이브러리 개발 및 앱 개발에서는 @Stable 타입의 사용자 정의 .equals() 구현에서 두 참조 ab@Stable 타입 T일 때, a.equals(b)항상 동일한 값을 반환해야 함을 보장해야 합니다. 이는 a미래 변경 사항이 b에도 반영되어야 하고 그 반대도 성립해야 함을 의미합니다.

이 제약 조건은 a === b일 경우 항상 암묵적으로 충족됩니다. 객체에 대한 .equals()의 기본 참조 동등성 구현은 항상 이 계약의 올바른 구현으로 간주됩니다.

Jetpack Compose 프레임워크 개발 및 라이브러리 개발에서는 공개 API의 일부로 노출하는 @Stable@Immutable 타입을 올바르게 주석을 달아야 합니다.

Jetpack Compose 프레임워크 개발 및 라이브러리 개발에서는 이전 안정적(stable) 릴리즈에서 이미 @Stable 또는 @Immutable로 선언되었다면, 해당 어노테이션을 제거해서는 안 됩니다.

Jetpack Compose 프레임워크 개발 및 라이브러리 개발에서는 이전 안정적 릴리스에서 해당 어노테이션이 없었던 기존의 비최종 타입(non-final type)에 @Stable 또는 @Immutable 어노테이션을 추가해서는 안 됩니다.

Why?

@Stable@Immutable은 Compose 컴파일러 플러그인이 생성하는 코드의 바이너리 호환성에 영향을 미치는 행동 계약(behavioral contracts)입니다.
라이브러리는 구현이 올바르지 않을 수 있는 기존 비최종 타입(non-final types)에 대해 더 엄격한 계약을 선언해서는 안 됩니다. 또한, 기존 코드가 의존할 수 있는 이전에 선언된 계약을 라이브러리 타입이 더 이상 준수하지 않는다고 선언해서도 안 됩니다.

@Stable 또는 @Immutable로 어노테이션된 타입에 대해 안정성 계약(stable contract)을 잘못 구현하면, 해당 타입을 매개변수나 수신자로 사용하는 @Composable 함수에서 올바르지 않은 동작이 발생할 수 있습니다.

값을 리턴하거나 구성요소를 생성하는 작업 (Emit XOR return a value)

@Composable 함수는 구성 요소를 컴포지션에 포함시키거나 값을 반환해야 하며, 두 작업을 동시에 수행해서는 안 됩니다. 만약 컴포저블 함수가 호출자에게 추가 제어 요소를 제공해야 하는 경우, 해당 제어 요소나 콜백은 호출자가 컴포저블 함수에 매개변수로 제공해야 합니다.

Jetpack Compose 프레임워크 및 라이브러리 개발에서는 트리 노드를 생성하면서 동시에 값을 반환하는 단일 @Composable 함수를 노출해서는 안 됩니다.

Why

Emit 작업은 컴포지션에 표시되는 내용이 나타날 순서대로 발생해야 합니다.
리턴 값을 사용하여 호출자와 통신하는 것은 호출 코드의 모양을 제한하고, 그 앞에서 선언된 다른 선언적 호출과의 상호작용을 방해합니다.

 Do

// inputState 인터페이스 객체에 변경 요청을 호출하는 텍스트 입력 필드 요소를 생성(Emit)
@Composable
fun InputField(inputState: InputState) {
// ...

// 입력 필드와의 상호작용은 순서에 의존하지 않습니다
val inputState = remember { InputState() }

Button("Clear input", onClick = { inputState.clear() })

InputField(inputState)

 Don't

// 텍스트 입력 필드 요소를 생성(Emit)하고 입력 값의 홀더를 반환
@Composable
fun InputField(): UserInputState {
// ...

// InputField와의 상호작용이 어려워집니다.
Button("Clear input", onClick = { TODO("???") })
val inputState = InputField()

매개변수를 전달하여 composable과 통신하는 것은 해당 매개변수들을 호출자의 매개변수로 사용되는 타입에 그룹화할 수 있는 기능을 제공합니다.

interface DetailCardState {
    val actionRailState: ActionRailState
    // ...
}

@Composable
fun DetailCard(state: DetailCardState) {
    Surface {
        // ...
        ActionRail(state.actionRailState)
    }
}

@Composable
fun ActionRail(state: ActionRailState) {
    // ...
}

이 패턴에 대한 자세한 내용은 아래의 Compose API 디자인 패턴 섹션에 있는 hoisted state types 관련 내용을 참조하세요.

Compose UI API 구조

Compose UI는 Compose 런타임을 기반으로 구축된 UI 툴킷입니다. 이 섹션에서는 Compose UI 툴킷을 사용하고 확장하는 API에 대한 가이드라인을 설명합니다.

Compose UI 요소

단 하나의 Compose UI 트리 노드를 생성(emit)하는 @Composable 함수를 요소(element)라고 합니다.

예시:

@Composable
fun SimpleLabel(
    text: String,
    modifier: Modifier = Modifier
) {

Jetpack Compose 프레임워크 개발 및 라이브러리 개발에서는 이 섹션의 모든 가이드라인을 “반드시” 준수해야 합니다.

Jetpack Compose 앱 개발에서는 이 섹션의 모든 가이드라인을 따르는 것이 권장됩니다.

요소(Elements)는 Unit을 반환합니다 (Elements return Unit)

요소는 반드시 emit() 함수를 호출하거나 다른 Compose UI 요소 함수를 호출하여 루트 UI 노드를 생성(emit)해야 하며, 값을 반환해서는 안 됩니다. 컴포지션의 상태가 아닌 요소의 모든 동작은 요소 함수에 전달된 매개변수를 통해 “반드시” 제공되어야 합니다.

Why?

요소는 Compose UI 컴포지션에서 선언적 엔터티입니다. 컴포지션에서 요소의 존재 또는 부재가 최종 UI에 보여지는지 여부를 결정합니다. 값을 반환하는 것은 필요하지 않습니다. 생성된 요소를 제어하는 방법은 요소 함수에서 값을 반환하는 방식이 아니라, 해당 요소 함수에 전달된 매개변수로 제공되어야 합니다. 더 자세한 내용은 이 문서의 Compose API 디자인 패턴 섹션에서 "hoisted state" 부분을 참조하세요.

 Do

@Composable
fun FancyButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {

 Don't

interface ButtonState {
    val clicks: Flow<ClickEvent>
    val measuredSize: Size
}

@Composable
fun FancyButton(
    text: String,
    modifier: Modifier = Modifier
): ButtonState {

요소는 Modifier 매개변수를 받아들이고 존중합니다

요소 함수는 “반드시” Modifier 타입의 매개변수를 받아야 합니다. 이 매개변수는 “modifier”로 명명되어야 하며, “반드시” 요소 함수의 매개변수 목록에서 첫 번째 선택적(optional) 매개변수로 나타나야 합니다. 요소 함수는 여러 개의 Modifier 매개변수를 받아서는 안 됩니다.

요소 함수의 내용이 자연스러운 최소 크기를 가지는 경우(즉, minWidth와 minHeight가 0인 제약 조건에서 비제로 크기로 측정될 수 있는 경우), modifier 매개변수의 기본값으로 Modifier(빈 Modifier를 나타내는 Modifier 타입의 companion object)를 사용해야 합니다. 측정 가능한 콘텐츠 크기가 없는 요소 함수(예: 사용자가 제공한 콘텐츠를 사용 가능한 크기로 그리는 Canvas)는 modifier 매개변수를 요구하고 기본값을 생략할 수 있습니다.

요소 함수는 생성하는 Compose UI 노드에 modifier 매개변수를 “반드시” 제공해야 하며, 이를 호출하는 루트 요소 함수에 전달해야 합니다. 요소 함수가 Compose UI 레이아웃 노드를 직접 생성하는 경우, “반드시” 해당 노드에 modifier를 제공해야 합니다.

요소 함수는 생성하는 Compose UI 노드에 연결된 modifier 체인을 전달하기 전에, 수신한 modifier 매개변수의 끝 부분에 추가적인 modifier를 연결할 수 있습니다.

요소 함수는 생성하는 Compose UI 노드에 전달하기 전에 수신된 modifier 매개변수의 시작에 추가 modifier를 연결해서는 안 됩니다.

Why?

Modifier는 Compose UI에서 요소에 외부 동작을 추가하는 표준 수단이며, 개별 또는 기본 요소 API 표면에서 공통 동작을 분리할 수 있게 해줍니다. 이를 통해 modifier를 사용하여 요소에 표준 동작을 장식할 수 있으므로. 요소 API 더 작고 명확하게 만들 수 있습니다.

표준 방식대로 Modifier를 받지 않는 요소 함수는 이러한 장식을 허용하지 않으며, 소비하는 코드가 원하는 modifier를 대신 래퍼 레이아웃에 적용할 수 있도록 요소 함수 호출을 추가 Compose UI 레이아웃으로 래핑하도록 유도합니다. 이는 요소를 수정하려는 개발자의 행동을 막지 못하고, 개발자로 하여금 원하는 결과를 얻기 위해 더 깊은 트리 구조를 만드는 등의 더 비효율적인 UI 코드를 작성하게 만듭니다.

Modifier가 첫 번째 선택적 파라미터 위치를 차지하는 이유는, 개발자들이 공통적인 상황에서 요소를 호출할 때 항상 마지막 위치 파라미터로 Modifier를 제공할 수 있다는 일관된 기대를 형성하기 위함입니다.

더 자세한 내용은 아래에 있는 Compose UI modifier 섹션을 참조하세요.

 Do

@Composable
fun FancyButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) = Text(
    text = text,
    modifier = modifier.surface(elevation = 4.dp)
        .clickable(onClick)
        .padding(horizontal = 32.dp, vertical = 16.dp)
)

Compose UI 레이아웃

하나 이상의 @Composable 함수 매개변수를 수락하는 Compose UI 요소를 레이아웃이라고 합니다.

예시:

@Composable
fun SimpleRow(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {

Jetpack Compose 프레임워크 개발 및 라이브러리 개발에서는 이 섹션의 모든 가이드라인을 “반드시 따라야 합니다.

Jetpack Compose 앱 개발에서는 이 섹션의 모든 가이드라인을 따르는 것이 권장됩니다.

레이아웃 함수가 하나의 @Composable 함수 파라미터만 받는 경우, 해당 파라미터의 이름은 content로 사용해야 합니다.

레이아웃 함수가 두 개 이상의 @Composable 함수 파라미터를 받는 경우, 기본적이거나 가장 일반적인 @Composable 함수 파라미터에 content라는 이름을 사용해야 합니다.

레이아웃 함수는 기본적이거나 가장 일반적인 @Composable 함수 파라미터를 파라미터 목록의 마지막 위치에 두어, Kotlin의 후행 람다(trailing lambda) 문법을 사용할 수 있도록 해야 합니다.

Compose UI modifiers

ModifierModifier.Element 인터페이스를 구현하는 객체들의 불변이며 순서가 있는 컬렉션입니다.
Modifier는 Compose UI 요소의 범용 데코레이터(decorator)로, 불투명하고 캡슐화된 방식으로 요소에 걸쳐있는(cross-cutting) 동작을 구현하고 추가할 수 있습니다. Modifier의 예시에는 요소의 크기 조정, 패딩 변경, 요소 아래 혹은 겹쳐서 콘텐츠 그리기, 또는 UI 요소의 경계 상자 내에서 터치 이벤트 수신 등이 포함됩니다.

Jetpack Compose 프레임워크 개발 및 라이브러리 개발에서는 이 섹션의 모든 가이드라인을 “반드시” 따라야 합니다.

Modifier 팩토리 함수

Modifier 체인은 Kotlin의 확장 함수로 표현되는 유창한(flient) 빌더 문법을 사용하여 구성되며, 이러한 확장 함수들은 팩토리 역할을 합니다.

예시:

Modifier.preferredSize(50.dp)
    .backgroundColor(Color.Blue)
    .padding(10.dp)

Modifier API는 “절대로” Modifier.Element 인터페이스를 구현 타입을 외부에 공개해서는 안 됩니다.

Modifier API는 “반드시” 다음과 같은 스타일의 팩토리 함수로 제공되어야 합니다.

fun Modifier.myModifier(
    param1: ...,
    paramN: ...
): Modifier = then(MyModifierImpl(param1, ... paramN))

레이아웃 범위 modifiers (Layout-scoped modifiers)

안드로이드의 View 시스템에는 LayoutParams라는 개념이 있습니다 - 이는 ViewGroup의 자식 뷰와 함께 불투명하게 저장되는 객체 유형으로, 해당 뷰를 측정하고 위치시킬 ViewGroup에 특정한 레이아웃 지침을 제공합니다.

Compose UI의 Modifier는 ParentDataModifier와 레이아웃 콘텐츠 함수에 대한 수신자 스코프(receiver scope) 객체를 사용하여 관련된 패턴을 제공합니다:

Example

@Stable
interface WeightScope {
    fun Modifier.weight(weight: Float): Modifier
}

@Composable
fun WeightedRow(
    modifier: Modifier = Modifier,
    content: @Composable WeightScope.() -> Unit
) {
// ...

// Usage:
WeightedRow {
    Text("Hello", Modifier.weight(1f))
    Text("World", Modifier.weight(2f))
}

Jetpack Compose 프레임워크 개발 및 라이브러리 개발에서는 범위가 지정된(scoped) modifier 팩토리 함수를 사용하여 상위 레이아웃 컴포저블에 특정한 parent data modifier를 제공해야 합니다.

Compose API 디자인 패턴

이 섹션에서는 Jetpack Compose API를 설계할 때 자주 맞닥뜨리는 상황에 대해 유용한 패턴을 제시합니다.

stateless와 제어 가능한 @Composable 함수를 선호니다

여기서 "상태 없음(stateless)"이란 자체적으로 어떤 상태도 유지하지 않고, 대신 호출자가 소유하고 제공하는 외부 상태 파라미터를 받는 @Composable 함수를 의미합니다. "제어 가능(controlled)"이란 호출자가 컴포저블에 제공된 상태를 완전히 제어할 수 있다는 개념을 말합니다.

 Do

@Composable
fun Checkbox(
    isChecked: Boolean,
    onToggle: () -> Unit
) {
// ...

// 사용 예시: (호출자가 optIn 값을 변경하며 진실의 출처(Source of truth)를 소유함)
Checkbox(
    myState.optIn,
    onToggle = { myState.optIn = !myState.optIn }
)

 Don't

@Composable
fun Checkbox(
    initialValue: Boolean,
    onChecked: (Boolean) -> Unit
) {
    var checkedState by remember { mutableStateOf(initialValue) }
// ...

// 사용 예시: (Checkbox가 checked 상태를 소유하고, 호출자에게 상태 변경을 알림)
// 호출자는 유효성 검증 정책을 구현하기 어려움.
Checkbox(false, onToggled = { callerCheckedState = it })

상태와 이벤트의 분리

Compose의 mutableStateOf() 값 홀더는 Snapshot 시스템을 통해 관찰 가능하며, 변경사항 발생 시 관찰자들에게 알릴 수 있습니다. 이는 Compose UI의 재구성, 재배치 또는 다시 그리기를 요청하는 주요 메커니즘입니다. 관찰 가능한 상태와 효과적으로 협력하기 위해서는 상태(state)이벤트(event)의 차이를 인지하는 것이 중요합니다.

관찰 가능한 이벤트는 특정 시점에 발생하고 폐기됩니다. 이벤트가 발생한 시점에 등록된 모든 관찰자(observer)는 알림을 받습니다. 스트림의 모든 개별 이벤트는 관련성이 있다고 가정되며 서로 기반을 둘 수 있습니다. 반복되는 동일한 이벤트에는 의미가 있으므로 등록된 관찰자는 건너뛰지 않고 모든 이벤트를 관찰해야 합니다.

관찰 가능한 상태는 한 값에서 다른 값(서로 다른 값)으로 변경될 때, 상태 변경 이벤트를 발생시킵니다. 상태 변경 이벤트는 통합(conflation)되며, 오직 가장 최신 상태만이 중요합니다. 따라서 상태 변경 관찰자는 멱등성(idempotent)을 가져야 합니다. 즉, 동일한 상태 값이 주어지면 관찰자는 동일한 결과를 생성해야 합니다. 상태 관찰자가 중간 상태를 건너뛰거나, 동일한 상태에 대해 여러 번 실행할 수 있으며, 결과는 동일해야 합니다.

Compose는 입력으로 이벤트가 아닌 상태를 다룹니다. 컴포저블 함수는 상태 관찰자로, 함수의 파라미터와 실행 도중 읽는 모든 mutableStateOf() 값 홀더들이 해당 함수의 입력이 됩니다.

호이스팅된 상태(Hoisted state) 타입

상태가 없는 파라미터(stateless parameters)와 다수의 이벤트 콜백 파라미터를 사용하는 패턴은 결국 관리하기 어려운 규모에 도달하게 됩니다. 컴포저블 함수의 파라미터 목록이 증가함에 따라, 상태와 콜백을 묶어 인터페이스 형태로 추출하는 방식을 고려해볼 수 있습니다. 이를 통해 호출자는 하나의 응집력 있는 정책 객체(policy object)를 단위로 제공할 수 있게 됩니다.

Before

@Composable
fun VerticalScroller(
    scrollPosition: Int,
    scrollRange: Int,
    onScrollPositionChange: (Int) -> Unit,
    onScrollRangeChange: (Int) -> Unit
) {

After

@Stable
interface VerticalScrollerState {
    var scrollPosition: Int
    var scrollRange: Int
}

@Composable
fun VerticalScroller(
    verticalScrollerState: VerticalScrollerState
) {

위 예시에서, VerticalScrollerState의 구현은 관련된 var 프로퍼티들의 사용자 정의 get/set 동작을 사용하여 정책을 적용하거나 상태 저장을 다른 곳에 위임할 수 있습니다.

Jetpack Compose 프레임워크 및 라이브러리 개발에서는 상호 관련된 정책을 모으고 그룹화하기 위한 호이스팅된 상태 타입을 선언해야 합니다. 위에서 예로 든 VerticalScrollerStatescrollPositionscrollRange 속성 간에 이러한 의존성을 보여줍니다. 내부적 일관성을 유지하기 위해, 이 상태 객체는 scrollPosition을 설정할 때 유효 범위로 고정해야 합니다. (또는 오류를 보고해야 합니다.) 이러한 속성들은 함께 다루어야 하는 일관성 문제를 가지므로 그룹화되어야 합니다.

Jetpack Compose 프레임워크 및 라이브러리 개발에서는 호이스팅된 상태 타입을 @Stable로 선언하고 @Stable 계약을 올바르게 구현해야 합니다.

Jetpack Compose 프레임워크 및 라이브러리 개발에서는 특정 컴포저블 함수에 특화된 호이스팅된 상태 타입의 이름을 해당 컴포저블 함수 이름에 “State”를 접미사로 붙여야 합니다.

호이스팅된 상태 객체를 통한 기본 정책 (Default policies through hoisted state objects)

이러한 정책 객체의 커스텀 구현이나 외부 소유권은 필요하지 않을 수 있습니다. Kotlin의 기본 인자, Compose의 remember {} API, 그리고 Kotlin의 "확장 생성자(extension constructor)" 패턴을 사용하여, API는 간단한 사용을 위한 기본적인 상태 처리 정책을 제공하면서 원할 때 더 정교한 사용을 허용할 수 있습니다.

Example:

fun VerticalScrollerState(): VerticalScrollerState =
    VerticalScrollerStateImpl()

private class VerticalScrollerStateImpl(
    scrollPosition: Int = 0,
    scrollRange: Int = 0
) : VerticalScrollerState {
    private var _scrollPosition by
        mutableStateOf(scrollPosition, structuralEqualityPolicy())

    override var scrollPosition: Int
        get() = _scrollPosition
        set(value) {
            _scrollPosition = value.coerceIn(0, scrollRange)
        }

    private var _scrollRange by
        mutableStateOf(scrollRange, structuralEqualityPolicy())

    override var scrollRange: Int
        get() = _scrollRange
        set(value) {
            require(value >= 0) { "$value must be > 0" }
            _scrollRange = value
            scrollPosition = scrollPosition
        }
}

@Composable
fun VerticalScroller(
    verticalScrollerState: VerticalScrollerState =
        remember { VerticalScrollerState() }
) {

Jetpack Compose 프레임워크 및 라이브러리 개발에서는 호이스팅된 상태 타입을 final 클래스가 아니라면 abstractopen 클래스로 선언하지 말고, 인터페이스로 선언하는 것이 좋습니다.

이러한 사용 사례를 지원하기 위해 open이나 abstract 클래스를 확장 가능하게 설계할 때, 확장하는 개발자가 유지하기 어렵거나 불가능할 정도로 내부적 일관성을 위한 상태 동기화에 대한 숨겨진 요구사항이 쉽게 생길 수 있습니다. 반면, 자유롭게 구현 가능한 인터페이스를 사용하면 Kotlin의 internal 범위 속성이나 기능을 통해 컴포저블 함수와 호이스팅된 상태 객체 사이에 비공개 계약(private contract)이 형성되는 것을 강력히 방지합니다.

Jetpack Compose 프레임워크 및 라이브러리 개발에서는 기본 인자로 기억되는 기본(default) 상태 구현체를 제공해야 합니다. 만약 호출자에 의해 상태 객체가 설정되지 않으면 컴포저블 함수가 제대로 작동하지 않을 수 있으며, 상태 객체를 필수 파라미터로 요구할 수 있습니다.

Jetpack Compose 프레임워크 및 라이브러리 개발에서는 null 값을 사용하여 컴포저블 함수가 내부적으로 자체 상태를 remember {} 해야 함을 나타내는 신호(sentinel)로 삼아서는 안 됩니다. 이는 null이 호출자에게 의미 있는 값일 수 있고, 실수로 컴포저블 함수에 null을 전달하는 경우 일관되지 않거나 예기치 않은 동작을 유발할 수 있기 때문입니다.

 Do

@Composable
fun VerticalScroller(
    verticalScrollerState: VerticalScrollerState =
        remember { VerticalScrollerState() }
) {

 Don't

// null을 기본값으로 사용하면, 입력 파라미터가 null과 비-null 사이에서 변동될 때 예상치 못한 동작을 일으킬 수 있습니다.
@Composable
fun VerticalScroller(
    verticalScrollerState: VerticalScrollerState? = null
) {
    val realState = verticalScrollerState ?:
        remember { VerticalScrollerState() }

호이스팅된 상태 타입의 확장성(Extensibility)

호이스팅된 상태 타입은 해당 타입을 받아들이는 컴포저블 함수의 동작에 영향을 주는 정책 및 검증 로직을 구현하는 경우가 많습니다. 구체적이며 특히 final로 선언된 호이스팅된 상태 타입은 상태 객체가 참조하는 진실의 출처(source of truth)를 해당 타입이 포함(containment)하고 소유(ownership)하고 있음을 암시합니다.

극단적인 경우, 이는 반응형 UI API 설계의 장점을 훼손할 수 있습니다. 왜냐하면 여러 진실의 출처를 만들어내어, 앱 코드가 여러 객체 간에 데이터를 동기화해야 하는 상황을 초래할 수 있기 때문입니다. 다음 예시를 고려해보세요:

// 다른 팀 또는 라이브러리에 의해 정의됨
data class PersonData(val name: String, val avatarUrl: String)

class FooState {
    val currentPersonData: PersonData

    fun setPersonName(name: String)
    fun setPersonAvatarUrl(url: String)
}

// UI 레이어에서 정의되었으며, 다른 팀에 의해 관리됨
class BarState {
    var name: String
    var avatarUrl: String
}

@Composable
fun Bar(barState: BarState) {

이러한 API들은 함께 사용하기 어렵습니다. 그 이유는 FooStateBarState 클래스 모두 자신들이 표현하는 데이터에 대한 진실의 출처(source of truth)가 되고자 하기 때문입니다. 실제로 서로 다른 팀이나 라이브러리, 모듈에서는 시스템 간에 공유되어야 하는 데이터에 대해 단일하고 통합된 타입에 동의할 수 없는 경우가 자주 발생합니다. 이러한 설계는 앱 개발자로 하여금 잠재적으로 오류가 발생하기 쉬운 데이터 동기화를 수행하도록 만들게 됩니다.

더욱 유연한 접근 방식은 이 두 호이스팅된 상태 타입을 모두 인터페이스로 정의하는 것입니다. 이렇게 하면 통합을 담당하는 개발자가 한 쪽을 다른 쪽에 맞춰 정의하거나, 두 타입 모두를 제3의 타입을 기준으로 정의할 수 있어, 시스템의 상태 관리에서 단일한 진실의 원천을 유지할 수 있습니다:

@Stable
interface FooState {
    val currentPersonData: PersonData

    fun setPersonName(name: String)
    fun setPersonAvatarUrl(url: String)
}

@Stable
interface BarState {
    var name: String
    var avatarUrl: String
}

class MyState(
    name: String,
    avatarUrl: String
) : FooState, BarState {
    override var name by mutableStateOf(name)
    override var avatarUrl by mutableStateOf(avatarUrl)

    override val currentPersonData: PersonData
        get() = PersonData(name, avatarUrl)

    override fun setPersonName(name: String) {
        this.name = name
    }

    override fun setPersonAvatarUrl(url: String) {
        this.avatarUrl = url
    }
}

Jetpack Compose 프레임워크 및 라이브러리 개발에서는 사용자 정의 구현을 허용하기 위해 호이스팅된 상태 타입을 인터페이스로 선언해야 합니다. 추가적인 표준 정책 적용이 필요하다면, 추상 클래스를 고려할 수 있습니다.

Jetpack Compose 프레임워크 및 라이브러리 개발에서는 호이스팅된 상태 타입의 기본 구현을 제공하기 위한 팩토리 함수를, 해당 타입과 동일한 이름으로 제공해야 합니다. 이를 통해 구체적인 타입을 제공하는 것과 동일하게 단순한 API 경험을 소비자에게 제공할 수 있습니다. 예를 들어:

@Stable
interface FooState {
    // ...
}

fun FooState(): FooState = FooStateImpl(...)

private class FooStateImpl(...) : FooState {
    // ...
}

// Usage
val state = remember { FooState() }

앱 개발에서는 인터페이스가 제공하는 추상화가 실제로 필요해질 때까지는 더 단순한 구체 타입을 선호하는 것이 좋습니다. 인터페이스 기반의 추상화가 필요해졌을 때, 위에서 설명한 것처럼 기본 구현을 위한 팩토리 함수를 추가하는 것은 사용 지점의 리팩토링이 필요하지 않은 소스 호환 가능한 변경입니다.

0개의 댓글