IT/안드로이드

[Google I/O 2024] 확장가능한 Compose API 설계 가이드

루카스강 2024. 6. 29. 16:30

이 문서는 Google I/O 2024 에서 발표된 Designing scalable Compose APIs (https://www.youtube.com/watch?v=JvbyGcqdWBA) 의 내용을 정리한 것입니다. 원 내용은 위 링크에서 확인하실 수 있고 아래 내용은 좋은 내용을 더 많은 사람들이 접할 수 있도록 발표 내용을 한국어로 번역하였습니다.

 

서론

Jetpack Compose의 기본 구성요소는 Composable 함수입니다. UI를 구축할 때 많은 양의 함수를 작성하게 되며, 이 함수들은 다른 함수들을 호출합니다. 따라서 우리는 자연스럽게 API 개발자가 되며, 큰 앱을 개발하든, 외부 라이브러리를 개발하든 고품질 Compose 코드를 작성하기 위한 가이드라인을 설정하는 것이 중요합니다.

이렇게 하면 최소한의 수정으로 발전하기 쉽게 하고, Compose 생태계 전반에서 일관성을 유지하며, 직관적이고 알려진 패턴을 따르며, 다른 사람들을 올바른 설계 방향으로 안내함으로써 더 좋은 설계를 장려할 수 있습니다.

  • Scalable (확장 가능) 
  • Consistent (일관성 유지)
  • Guides others (다른 사람을 위한 가이드라인 제공)

전형적인 Compose API를 개발하기 위한 모범 사례와 가이드라인을 다루겠습니다.

 

Think and Plan (새로운 API를 구상하고 계획하기)

Designing scalable Compose APIs 중

새로운 Composable 함수를 만들때는 해당 API 가 단일 문제를 해결하는지 확인해야 합니다.

예를 들어 사용자가 제한된 선택항목들 사이에서 간결하게 옵션을 선택하도록 하는 컴포넌트가 필요하다고 가정해 봅시다. 이 문제를 해결하기 위해서 FilterChip 컴포넌트를 생각해 볼 수 있습니다. 

특정 usecase를 기반으로 API를 작성할 경우 이토록 쉽고 명확하게 문제를 해결할 수 있습니다.

만약 다른 제안이나, 빠른 작업 등 추가적인 기능이 필요하다면, 이러한 기능은 별도의 컴포넌트로 분리하는 것이 좋습니다. 이는 더 많은 사용자 정의 옵션을 제공하며, API를 보다 명확하게 유지할 수 있습니다. 

 

유사한 UI를 가지고 있지만, 다른 용도를 가진 FilterChip 이 필요하다면 어떻게 접근할 수 있을까요?

 

사용자가 FilterChip을 완전히 변경할 수 있도록 더 많은 사용자 정의 옵션을 만들 수가 있을 것입니다.

또는 이를 완전히 별개의 구성요소로 만들 수 있을 것입니다.

 

상위 수준의 API와 하위 수준의 API

앱 디자인 설계에 대해서는 의견이 분분하지만, 다소 유사한 구성요소, 변형에 대한 일관된 요구 사항을 가지고 있다면, 새로운 구성 요소를 계층적으로 상위 수준의 API로 구축하는 것이 좋습니다.

 

상위 수준의 API에서는 더 제한적이고, 특정 동작과 기본값을 제공하고, custom 옵션을 적게 제공합니다.

하위 수준의 API 에서는 모든 계층에 대한 기대치와, common surface를 정의합니다. 또한 사용자 정의에 대해 더 개방적이어야 합니다.

이는 하위 수준 API에서 상위 수준 API로 이동할 때 해당 컴포넌트의 자기주장이 강해지고, 사용자 정의 옵션은 적게 제공하는 것을 의미합니다. 

계층화 결정은 설계 요구사항과 밀접하게 연관이 있으므로 현명하게 선택을 해야 합니다.

 

새로운 컴포넌트 또는 기존 컴포넌트의 결합

ChipGroup 같은 컴포넌트를 생각해 봅시다. ChipGroup 은 가로 또는 세로 방향으로 칩을 재배열하고, 추가 스타일을 적용합니다.

칩을 수직 또는 수평으로 배열하는 것은 기존의 Column 및 Row로 쉽게 달성할 수 있습니다. 스타일링은 Modifier와 데코레이션을 적용하여 처리할 수 있습니다. 따라서 ChipGroup API에서 나오는 맞춤 동작은 없습니다.

이는 새로운 API가 필요하지 않다는 좋은 신호입니다.

새로운 API는 그 존재를 정당화해야 합니다. 새로운 API를 만들고 유지하고, 사용하는 방법을 배우는 데는 작업이 필요합니다. 새로운 컴포넌트를 만드는 것과 기존의 컴포넌트에 위임하는 것 사이에서 적절히 선택하세요.

 

명명 규칙 (Naming Conventions)

Composable 함수의 명명 규칙

Designing scalable Compose APIs 중 Composable function naming conventions

Composable 함수는 PascalCase로 명명되며, 함수가 단위를 반환하는 경우에는 명사로 명명됩니다.

기본적으로 단위를 반환하기 때문에 클래스 명명규칙을 따릅니다. 이는 요소가 재구성되더라도 일관된 정체성을 유지하도록 돕고, Compose의 선언적 모델을 강화합니다.

값을 반환하는 Composable 함수는 소문자로 시작하는 Kotlin 표준 함수 명명 규칙을 따릅니다. 내부적으로 remember를 사용하여 가변 객체를 반환하는 Composable 함수는 그 특성을 명확히 나타내기 위해 remember로 접두사를 붙여야 합니다.

 

접두사가 없는 컴포넌트는 기본적으로 장식된 상태로 바로 사용 가능한 기본 컴포넌트를 나타낼 수 있습니다.

Basic 접두사를 통해 더 많은 wrapping 이 가능할 것을, API를 그대로 사용할 것을 예상할 수 있도록 사용해 보세요.

한 수준 위 계층으로는 FilterChip, InputChip과 같은 독창적인 API 로더 많은 스타일 변형이 있음을 나타냅니다.

일반적인 회사 이름이나 모듈이름, 또는 기능을 접두사로 사용하는 것은 지양하세요.

 

composition 은 특정 컴포넌트 하위 트리에 범위가 지정된 글로벌과 같은 값을 제공합니다.

이를 알리기 위해 설명이 포함된 Local을 접두사로 사용하세요.

 

매개변수 설계 (Designing Parameters)

 

명확하고 설명적인 매개변수

API의 매개변수는 명확하고 설명적이어야 합니다. 명확한 매개변수는 API를 쉽게 이해하고 사용할 수 있게 하며, 설명적인 입력은 API를 처음부터 읽기 쉽게 하고 사용자가 한눈에 이해할 수 있게 합니다.

 

컴포지션 로컬 또는 implicit input (절대적인 인풋), 암시적이고 모호한 입력은 최대한 피해야 합니다. 추적이 어려워집니다.

val LocalChipBorder = compositionLocalOf<...>(...)

@Composable
fun Chip(...) {
   val border = LocalChipBorder.current
}

 

대신 API를 열고 명시적인 매개변수를 사용하면 사용자가 정의 레이어를 쉽게 추가할 수 있습니다.

@Composable
fun Chip(
   border: BorderStroke,
) {
   // Set border
}

 

 

수정자 (Modifiers)

Compose에서 수정자는 중요한 역할을 합니다.

Compose 사용자는 이미 Modifier를 사용하는 방법에 대한 기대치를 가지고 있습니다. 따라서 모든 UI 구성요소는 매개변수로 Modifier 유형을 가져야 합니다. 

이는 첫 번째 선택적 매개변수이므로 이름 매개변수 없이 설정할 수 있어야 하며, 일관된 위치를 가져야 합니다. (First optional param) 

기본 no-op 값을 가지고 있으므로 사용자가 기존 체인에 추가된 자체 수정자를 제공할 때 기능이 손실되지 않으며, 단일 구성요소에 하나의 Modifier 매개변수를 가져야합니다. (Only Modifier type param)

여러 개의 Modifier가 필요할 경우 API를 다시 설계해야 할 신호일 수 있습니다. 수정자는 컴포넌트의 루트 최외곽 레이아웃에 한 번만 적용되어야 합니다. (Applied once to root)

@Composable
fun Chip(
   modifier: Modifier,
) {
    RootLayout(
        modifier = modifier,
    )
}

 

명확한 매개변수와 Modifier 활용

Modifier가 컴포저블의 동작과 외관을 설명하는 반면, 명확한 매개변수는 API의 주요 목적을 나타내는 핵심 부분입니다. 모든 API는 그 동작과 UI를 나타내는 핵심 부분을 명확한 매개변수로 설정해야 합니다.

Modifier로 커스터마이징 할 수 없는 동작이나 장식을 추가해야 하는 경우 명확한 매개변수로 설정되어야 합니다. (반면, 핵심이 아닌 경우 Modifier를 통해 수정할 수 있습니다.)

 

매개변수의 순서

매개변수의 순서를 정할 때는 API의 가독성과 사용 편의성을 위해 신중하게 고려해야 합니다.

 

다음은 사용자가 API의 목적과 커스터마이제이션 옵션을 이해하는 데 도움이 되는 지침입니다.

1. 필수 매개변수: API의 주요 목적을 나타내며, 기본값이 없고 이름 매개변수 없이 사용할 수 있어야 합니다.

2. Modifier : API를 커스터마이징하고 Compose 기대에 맞춥니다.

3. 선택적 매개변수: 기본값을 가지며, 사용자가 필요에 따라 재정의할 수 있습니다. 의미상 유사한 매개변수는 그룹화하여 함께 둘 수 있습니다. 

4. 후행 composable content 람다 : 중첩된 slot content 가 있을 경우 마지막에 위치시킵니다.

@Composable
fun Chip(
   // 필수 매개변수, 기본값이 없음.
   onClick: () -> Unit,
   label: @Composable () -> Unit,
   // Modifier
   modifier: Modifier,
   // 선택적 매개변수, 그룹화 시키기
   enable: Boolean = true,
   icon: @Composable (()->Unit)? = null,
   shape: Shape = ChipDefaults...,
   colors: ChipColors = ChipDefaults...,
   // 후행 Composable lambda
   content: @Composable () -> Unit,
) {
    RootLayout(
        modifier = modifier,
    )
}

 

기본 매개변수 값과 null 가능성

기본 매개변수 값을 사용하여 빈 값을 알리는 것은 API 설명에 의미를 부여합니다.

  • Nullbale (null 가능성) : 이 API 기능이 사용 가능하지만, 특정 사용 사례에서는 필요하지 않을 수 있음을 암시 (Feature not required)
  • Empty (빈 값) : 이 기능은 필수이며, 빈 값으로 사용할 수 있거나, 특정 사용 사례에 따라 더 구체적인 값으로 재정의 할 수 있음을 암시 (Requried but can be empty / overriden)
  • Default (기본값) : Null 이 아니고, 의미가 있어야 하며, 사용자에게 명확해야 합니다. 공개적으로 사용 가능해야 하며, 구성 요소가 어디에서나 동일한 일관된 결과를 제공해야 합니다.

위 값들의 차이를 이해하고 신중하게 선택하세요.

 

@Composable
fun Chip(
   // nullable : 없을 수 있음
   icon: @Composable (() -> Unit)? = null,
   // Empty : 필수, 재정의 가능
   content: @Composable () -> Unit = {},
   // Default
   enable: Boolean = true,
   shape: Shape = ChipDefaults.shape,
   colors: ChipColors = ChipDefaults.colors(),
)

 

스타일링 및 커스터마이제이션

API의 일부 매개변수는 스타일링 및 커스터마이제이션 옵션을 타겟으로 합니다. 이러한 매개변수에는 기본값을 제공하여 API가 독립적이고 그대로 사용할 수 있도록 하는 것이 좋습니다.

 

기본 스타일링 값이 짧고 예측 가능한 경우, 단순한 인라인 상수로 유지하는 것이 충분할 수 있습니다.

enable: Boolean = true,
elevation: Dp = 8.dp

 

그러나 칩과 같은 일부 API는 많은 커스터마이제이션 (활성화, 비활성화, 선택상태 등을 포함한), 다른 모양, 색상, 테두리 등을 가질 수 있습니다. 이러한 경우 기본값 객체를 외부에서 단일 장소에 매핑하는 것을 고려하십시오.

이러한 객체는 공개적으로 사용 가능해야 하며, 컴포넌트가 어디서나 사용될 수 있도록 해야 합니다. 스타일링은 선택된 상태에 따라 다른 배경색을 설정하는 칩과 같이 조건부로 설정될 수 있습니다. 이러한 조건을 처리하는 것은 기본값 객체에 위임할 수 있습니다.

 

shape: Shape = ChipDefaults.shape,
colors: ChipColors = ChipDefaults.colors(),
border: BorderStroke? = ChipDefaults.border(enabled)

// Publicly avaialbe
object ChipDefaults {
  val shape: Shape ...
}

 

슬롯 매개변수

Compose에서 슬롯 매개변수는 부모 컴포넌트 내부의 열린 공간을 지정하여 자식 컴포넌트로 채우는 composable lambda 매개변수입니다.

예를 들어 Chip 컴포넌트의 경우, 사용자가 텍스트, 이미지, 아이콘 또는 다른 콘텐츠를 삽입할 수 있도록 하고 싶습니다.

slot api를 하나만 가질 경우, 마지막 콘텐츠 매개변수로 배치하여 일관성을 유지하고 후행 람다로 사용할 수 있게 합니다.

여러 slot api가 필요한 경우, 해당 유형의 여러 매개변수를 named param으로 제공할 수 있습니다.  (아이콘 또는 라벨)

슬롯을 사용하면 Chip은 전달된 중첩된 content가 무엇인지 상관하지 않습니다. 대신 주된 책임인 선택 처리, 클릭 처리 등에 집중하여 사용자에게 더 많은 유연성을 제공합니다.

 

상태 관리 (State Management)

컴포넌트는 내부 및 외부에서 발생하는 변경사항을 처리하기 위해 상태가 필요합니다. 예를 들어 Chip 컴포넌트는 활성 및 비활성 상태를 가지며, 이는 자주 변경되며 UI에 영향을 미칩니다.

Composable 함수에서 상태를 관리하는 방법에는 두 가지가 있습니다:

1. 상태를 소유하고 관리하는 방식 (Stateful): 함수가 자신의 상태를 관리하며, remembermutableStateOf를 사용합니다.

2. 상태를 외부에서 관리하는 방식 (Stateless): 상태를 외부에서 받아와 사용하며, 함수는 상태를 소유하지 않고 외부에서 전달된 값을 사용합니다.

 

가능한 한 상태를 소유하지 않고 외부에서 전달받는 Stateless 방식을 선호해야 합니다. 이는 API를 더 재사용 가능하고 테스트 가능하게 만듭니다.

호출자가 변경을 지시하고, 구성요소는 따르기만 하면 됩니다. 이를 state hoisting이라고 부릅니다.

 

MutableState 또는 Immutable State를 직접 전달하지 마세요.

// Call site
enabled.value = true

@Composable
fun Chip(
   enabled: MutableState<Boolean>,
) { 
   // In the API
   enabled.value = false
}

 

호출자와 Composable 함수가 모두 상태를 소유하게 되어 제어가 복잡해지고 여기저기서 값을 변경할 수 있으므로 추적이 어려워집니다.

이벤트 처리

Chip 컴포넌트는 이제 API를 통해 외부에서 제어되고 있지만, 여전히 클릭을 처리하고, 이를 적절히 호출자에게 전달할 방법이 필요합니다.

클릭 이벤트와 같은 이벤트는 람다 매개변수로 받아들여 전달할 수 있습니다. 이벤트를 이렇게 추출하면 API를 더 재사용 가능하고 테스트 가능하며 미리 보기 가능하게 만듭니다.

 

Verifying

시맨틱스 (Semantics)

API가 접근성을 지원하도록 설정해야 합니다. Compose는 시맨틱 속성을 사용하여 접근성 서비스를 통해 정보를 전달합니다. 단순한 컴포넌트는 일반적으로 1~3개의 시맨틱스를 필요로 하며, 더 복잡한 컴포넌트는 더 많은 시맨틱스를 필요로 합니다.

구성 요소가 사용자에게 올바르게 설명될 수 있도록 시맨틱 속성을 추가하는 것이 중요합니다.

예를 들어, Image 컴포넌트는 contentDescription이라는 필수 매개변수를 사용하여 이미지의 내용을 설명합니다.

@Composable
fun Image(
   painter : Painter,
   contentDescription: String?,   // non-optional
   modifier: Modifier = Modifier,
)

 

Chip 컴포넌트도 예를 들어보겠습니다.

contentDesciprion을 매개변수로 지정하고, modifier semantics를 통해 루트 구성 요소에 적용할 수 있습니다.

하지만 사용자가 자신만의 modifier semantics 를 제공하고 기본 설정을 재정의하려고 할 수도 있다는 점을 염두에 두세요.

API를 설계할 때는 내장할 것과 사용자에게 요청할 것에 대해 정보를 바탕으로 결정을 내려야 합니다.

 

@Composable
fun Chip(
   contentDescription: String?,	// non-optional
   modifier: Modifier = Modifier
) {
   modifier = Modifier.semantics {
      this.contentDescription = contentDescription
   }
}

// modifier semantics 재정의
Chip(
  modifier = Modifier.semantics {
     this.contentDescription = contentDescription
  }
)

 

Slot API를 사용한 경우 외부에 시멘틱을 위임할 수 있습니다. 문맥상 적절한 경우, slot 된 content description을 부모에 병합할 수 있습니다. 이를 merged semantics라고 부르며 Modifier.semantics(mergeDescendants = true)를 통해 병합할 수 있습니다. 이는 child 컴포넌트의 데이터를 수집하고, 구성요소에 전달하여 단일 엔터티로 처리되도록 합니다.

 

테스트 가능성 (Testability)

API가 독립적으로 테스트 가능하도록 설계되어야 합니다. 예를 들어, FilterChip 컴포넌트의 다양한 상태 (선택됨, 선택되지 않음)를 쉽게 테스트할 수 있어야 합니다. 이를 위해 상태를 명시적으로 매개변수로 노출하면 테스트에서 쉽게 변경하고 검증할 수 있습니다.

사용자 상호작용을 테스트할 때는 다양한 상태와 이벤트를 시뮬레이션 할 수 있어야합니다. 이러한 상태를 더 쉽게 테스트 할 수 있도록 ineractionSource를 hoisting 하면 테스트에서 PressInteraction과 같은 상호작용을 검증할 수 있습니다.

Tip : Modifier 매개변수를 사용하면 testTag를 전달할 수 있어 요소를 식별하는데 도움이 됩니다. 이는 테스트에서 특정 요소를 찾기 위한 고유한 태그를 제공하여 테스트의 안정성과 명확성을 높입니다.

 

@Composable
fun Chip(
    interactionSource: MutableInteractionSource,
    modifier: Modifier = Modifier
)

@Test
fun chip_pressed() {
    val interactionSource = MutableInteractionSource()

    composeTestRule.setContent {
        Chip(...
            interactionSource = interactionSource,
            modifier = Modifier.testTag("Chippy")
        )
    }
    runBlocking {
         interactionSource.emit(
            PressInteraction.Press(...)
         )
    }
}

 

문서화 (Documentation)

API의 장기적인 안정성과 유지관리를 위해 적절한 문서화를 고려하세요.

문서화는 KTDoc 가이드라인을 따라야 하며, API의 기능과 목적을 명확히 전달하고, 매개변수의 설명과, 사용 예시를 포함해야 합니다.

외부 사용을 위해 하위호환성을 지원하는 것도 중요합니다. 가이드라인을 참고하세요 (goo.gle/kotlin-back-compat)