안녕하세요. 스포카 제품팀의 안드로이드 개발자 김진우입니다.

드디어 이번에 키친보드 안드로이드 앱에 Jetpack Compose를 도입하게 되었습니다.
그동안의 Jetpack Compose 도입하기 위해 검토했던 부분과 소소한 팁들을 공유드릴 겸 기술 블로그를 올리게 되었습니다.

android-compose-spoqa-foundation

도입 배경

수년간 XML 기반의 Android 앱을 개발하면서 가장 불편했던 점은 빈번한 작업 컨텍스트 전환의 번거로움이었습니다.

기본적으로 XML 기반의 UI를 개발하려면, res/layout 디렉토리에 XML Layout을 정의한 후, Java나 Kotlin 코드 레벨에서 View를 연결하여 UI를 핸들링합니다. 뿐만 아니라, Color나 Shape 속성을 반영하기 위해 res/color 및 res/drawable 디렉토리에 접근해야 하며, UI State(enabled, focused 등)를 세부적으로 표현하기 위해 selector를 정의하기도 합니다.

또한 List UI 개발하려면, RecyclerView + Adapter + ViewHolder + XML Layout 구조를 각각 연동시켜야 하므로, 마찬가지의 빈번한 작업 컨텍스트 전환을 요구합니다.

android-xml-ui-development-flow

이러한 일련의 과정들에 대한 작업 컨텍스트의 전환은 XML 기반의 앱 개발 과정에선 필수적이기에 개발자 입장에서는 피로를 느끼게 됩니다.

그래서 이러한 불편함을 해결해 줄 수 있으며, 보다 직관적이고 단순하게 UI를 표현할 수 있는 Jetpack Compose를 도입하게 되었습니다.

참고) https://developer.android.com/jetpack/compose/why-adopt?hl=ko

선언형 UI란?

선언형 UI는 상태에 따라 어떤 UI를 렌더링할지 정의하는 패러다임입니다.

declarative-ui

선언형 UI의 장점은 UI 상태와 연관된 UI 컴포넌트를 코드로 직접 설명하기 때문에 코드가 더욱 이해하기 쉬워집니다. 이에 따라 UI 상태 변경 시 자동으로 UI가 업데이트되어 복잡한 상태 관리 코드를 작성할 필요가 줄어듭니다. 그리고 UI 내에서 상태 변경을 직접 관리하지 않기 때문에, 버그 발생의 가능성이 줄어듭니다.

선언형 UI의 단점은 UI 상태에 따라 UI가 자동으로 업데이트되므로, 개발자가 UI 업데이트를 미세 조정하거나 완전히 제어하는 것이 어려울 수 있습니다.

Jetpack Compose란?

Jetpack Compose는 Android 앱을 개발하기 위한 현대적인 선언형 UI 프레임워크로, 명령형으로 UI를 조작하지 않고 선언형으로 UI를 렌더링 할 수 있게 해주는 라이브러리입니다.

android-jetpack-compose

Compose UI의 장점을 정리해 보자면 아래와 같습니다.

  1. 기존 XML UI와의 호환성 : 기존 XML UI와 Compose UI를 상호 운용할 수 있습니다.
    참고) https://developer.android.com/jetpack/compose/interop/interop-apis?hl=ko

  2. 작업 컨텍스트 전환 최소화 : Compose UI는 Kotlin 기반이기 때문에 모든 UI 관련 코드는 Kotlin 디렉토리만 탐색하면 됩니다.

  3. 간단한 List UI 개발 방법 : Compose UI에서 List UI는 기본적으로 LazyList를 사용합니다. 이를 사용 시, Composable로 정의된 함수를 그대로 사용할 수 있기 때문에, 기존 XML 방식에 비해 보일러 플레이트 코드를 최소화시킬 수 있습니다.

  4. 선형적인 레이아웃 작성 : 기존 XML UI에선 중첩 LinearLayout에 대한 성능 저하 이슈로 인해 View 간의 제약 조건을 설정하여 레이아웃을 정의하는 ConstraintLayout 사용을 지향해야만 했습니다. 하지만 Compose UI는 설계 구조상 Row, Column를 중첩시켜도 성능에 큰 지장이 없기 때문에 가독성 좋은 선형적인 레이아웃 코드를 작성할 수 있습니다.

  5. 라이브러리 수정 용이 : 오픈소스 라이선스에서 허용한 범위 내에서 라이브러리 내 Composable 함수 코드를 가져와서 커스터마이징을 쉽게 할 수 있습니다. (ex. Image Picker, Calendar 등)

Compose UI의 단점을 정리해 보자면 아래와 같습니다.

  1. 높은 러닝 커브 : 전통적인 Android UI 개발과는 매우 다른 접근 방식을 사용하기 때문에, 기존의 개발 경험을 가진 사람들도 Compose에 적응하기 위해 시간이 필요합니다. 선언적 UI 패러다임은 명령형 UI 패러다임과 다르게 동작하므로, 이해하고 효과적으로 사용하기 위해서는 새로운 사고 방식을 필요로 합니다.

  2. 초기 작업 속도 : Compose UI를 완벽하게 이해 및 적응하기 전까지는 작업 속도가 낮아질 걸로 예상됩니다. 하지만 적응만 된다면, 기존 XML 작업 속도보다 훨씬 빠른 UI 작업이 가능합니다.

  3. 정립되지 않은 아키텍쳐 : Compose UI의 장점을 극대화하기 위한 아키텍쳐는 현재까지도 개발자간의 많은 토론 주제로 남아있기에, 어떤 설계가 좋은 설계인지에 대한 연구와 고민이 필요합니다.

  4. Debug Mode 성능 이슈 : Debug Mode에서는 Compose UI의 성능이 저하됩니다. (애니메이션이 버벅거리는 현상 등)
    참고) https://developer.android.com/jetpack/compose/performance

  5. TextField MaxLength, InputFilter 기본 지원 안됨 : Compose UI의 TextField는 MaxLength 속성과 InputFilter 속성이 기본 지원하지 않기 때문에, 개발자의 커스텀 로직 개발이 필요합니다.

  6. LazyList 스크롤바 기본 지원 안됨 : List UI의 스크롤바가 기본 지원하지 않기 때문에, 개발자의 커스텀 UI 개발이 필요합니다.

  7. LazyList 스크롤 페이징 버그 : Android 12 OverScroll Stretch Effect와 LazyList 간의 호환성 버그로 인해 스크롤 페이징 인터랙션이 부자연스러운 결과가 나타납니다. (유저가 스크롤 행위를 종료했을 때만, 다음 페이지가 렌더링되는 현상)
    임시 해결 방안) OverScroll Effect 비활성화
    참고) https://issuetracker.google.com/issues/233515751

Jetpack Compose 도입 Tip

1. Compose UI의 Presentation Layer 디자인 패턴은 MVI(Model-View-Intent) 패턴을 채택하는 것이 좋습니다.

Google 공식 문서에 따르면 Compose UI는 단방향 데이터 흐름 설계(UDF)를 지향하고 있습니다. 단방향 데이터 흐름(UDF)은 상태는 아래로 이동하고 이벤트는 위로 이동하는 디자인 패턴입니다. 단방향 데이터 흐름을 따라 UI에 상태를 표시하는 컴포저블과 상태를 저장하고 변경하는 앱 부분을 서로 분리할 수 있습니다.
참고) https://developer.android.com/jetpack/compose/architecture?hl=ko

2. Compose 관련 라이브러리에 포함된 모든 Composable 함수는 프로젝트 내에서 한번 Wrapping 처리하여 사용하는 것이 좋습니다.

Compose 관련 라이브러리는 아직까지 실험적이고 불안정한 부분들이 많기 때문에 버전을 올리는 일이 빈번할 것인데, 자칫 파라미터 값들이 상당 부분 변경된다면 해당 함수를 사용한 프로젝트 모든 부분에서 빌드 에러를 낼 수 있기 때문입니다.

@Composable
fun SFRow(
    modifier: Modifier = Modifier,
    horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
    verticalAlignment: Alignment.Vertical = Alignment.Top,
    content: @Composable RowScope.() -> Unit
) = Row(
    modifier = modifier,
    horizontalArrangement = horizontalArrangement,
    verticalAlignment = verticalAlignment,
    content = content
)
@Composable
fun SFColumn(
    modifier: Modifier = Modifier,
    verticalArrangement: Arrangement.Vertical = Arrangement.Top,
    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
    content: @Composable ColumnScope.() -> Unit
) = Column(
    modifier = modifier,
    verticalArrangement = verticalArrangement,
    horizontalAlignment = horizontalAlignment,
    content = content
)

3. 유용한 Modifier 확장 함수 구현 예제

  • 멀티 터치를 비활성화하는 Modifier 확장 함수 : Compose UI는 기본적으로 멀티 터치를 허용하기에, 여러개의 버튼을 동시에 누를 수 있습니다. 이를 제한하기 위해 아래 Modifier 확장 함수를 이용하여 전역적인 MaterialTheme 영역 단에서 멀티 터치를 비활성화 처리할 수 있습니다.
    fun Modifier.disableMultiTouch() = composed {
      val coroutineScope = rememberCoroutineScope()
      pointerInput(Unit) {
          coroutineScope.launch {
              var currentId: Long = -1L
              awaitPointerEventScope {
                  while (true) {
                      awaitPointerEvent(PointerEventPass.Initial).changes.forEach { pointerInfo ->
                          when {
                              pointerInfo.pressed && currentId == -1L -> currentId = pointerInfo.id.value
                              pointerInfo.pressed.not() && currentId == pointerInfo.id.value -> currentId = -1
                              pointerInfo.id.value != currentId && currentId != -1L -> pointerInfo.consume()
                              else -> Unit
                          }
                      }
                  }
              }
          }
      }
    }
    
    @Composable
    fun SFTheme(content: @Composable () -> Unit) {
      MaterialTheme(
          content = {
              SFBox(
                  modifier = Modifier.disableMultiTouch()
              ) {
                  content()
              }
          }
      )
    }
    
  • 일정 시간 동안 연속 클릭/토글 제한하는 Modifier 확장 함수 : 사용자가 연속적으로 버튼을 클릭하면 서버에 동일한 API를 다수 요청하여 오류를 발생시킬 수 있습니다. (일명 “따닥 이슈”) 이를 해결하기 위해 아래 Modifier 확장 함수를 이용하여 일정 시간 동안 연속 클릭/토글 제한할 수 있습니다.
    fun Modifier.throttleClickable(
      throttleTime: Long = 100,
      interactionSource: MutableInteractionSource,
      indication: Indication?,
      enabled: Boolean = true,
      onClickLabel: String? = null,
      role: Role? = null,
      onClick: () -> Unit
    ) = composed {
      val lastClickTimestamp = remember { mutableStateOf(0L) }
      val coroutineScope = rememberCoroutineScope()
    
      clickable(
          interactionSource = interactionSource,
          indication = indication,
          enabled = enabled,
          onClickLabel = onClickLabel,
          role = role,
          onClick = {
              val currentTimestamp = System.currentTimeMillis()
              if (currentTimestamp - lastClickTimestamp.value >= throttleTime) {
                  coroutineScope.launch {
                      withContext(Dispatchers.Main) {
                          onClick()
                      }
                  }
                  lastClickTimestamp.value = currentTimestamp
              }
          }
      )
    }
    
    fun Modifier.throttleToggleable(
      throttleTime: Long = 100,
      value: Boolean,
      interactionSource: MutableInteractionSource,
      indication: Indication?,
      enabled: Boolean = true,
      role: Role? = null,
      onValueChange: (Boolean) -> Unit
    ) = composed {
      val lastClickTimestamp = remember { mutableStateOf(0L) }
      val coroutineScope = rememberCoroutineScope()
    
      toggleable(
          value = value,
          interactionSource = interactionSource,
          indication = indication,
          enabled = enabled,
          role = role,
          onValueChange = {
              val currentTimestamp = System.currentTimeMillis()
              if (currentTimestamp - lastClickTimestamp.value >= throttleTime) {
                  coroutineScope.launch {
                      withContext(Dispatchers.Main) {
                          onValueChange(it)
                      }
                  }
                  lastClickTimestamp.value = currentTimestamp
              }
          }
      )
    }
    
  • Figma의 Drop Shadow를 구현하는 Modifier 확장 함수 : 기존 XML UI에선 Figma의 Drop Shadow를 구현하는 방법이 까다로웠으나, Compose UI에선 아래 Modifier 확장 함수를 이용하여 Figma의 Drop Shadow를 쉽게 구현할 수 있습니다.
    fun Modifier.dropShadow(
      color: Color = Color.Black,
      borderRadius: Dp = 0.dp,
      offsetX: Dp = 0.dp,
      offsetY: Dp = 0.dp,
      blurRadius: Dp = 0.dp,
      spreadRadius: Dp = 0.dp,
      modifier: Modifier = Modifier
    ) = then(
      modifier.drawBehind {
          drawIntoCanvas { canvas ->
              val paint = Paint()
              val frameworkPaint = paint.asFrameworkPaint()
              val spreadPixel = spreadRadius.toPx()
              val leftPixel = (0f - spreadPixel) + offsetX.toPx()
              val topPixel = (0f - spreadPixel) + offsetY.toPx()
              val rightPixel = size.width + spreadPixel
              val bottomPixel = size.height + spreadPixel
    
              frameworkPaint.color = color.toArgb()
    
              if (blurRadius != 0.dp) {
                  frameworkPaint.maskFilter = BlurMaskFilter(
                      blurRadius.toPx(),
                      BlurMaskFilter.Blur.NORMAL
                  )
              }
    
              canvas.drawRoundRect(
                  left = leftPixel,
                  top = topPixel,
                  right = rightPixel,
                  bottom = bottomPixel,
                  radiusX = borderRadius.toPx(),
                  radiusY = borderRadius.toPx(),
                  paint = paint
              )
          }
      }
    )
    

3. 효율적인 Compose UI 테스트 자동화 환경 구축하기 위해선 Modifier의 semantics 와 testTag 확장 함수를 활용하는 것이 좋습니다.

아래와 같은 코드를 추가한다면, Appium / UIAutomator2 와 같은 UI Inspector에서 뷰에 접근할 때, Tree 구조의 XPath가 아닌 ResourceId로 명시적 접근이 가능하기에 효율적으로 테스트 코드를 작성할 수 있습니다.

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SFTheme(content: @Composable () -> Unit) {
    MaterialTheme(
        content = {
            SFBox(
                modifier = Modifier.semantics { testTagsAsResourceId = true }
            ) {
                content()
            }
        }
    )
}
@Composable
fun TestTagExample() {
    SFTheme {
        SFButton(
            modifier = Modifier
                .fillMaxWidth()
                .testTag("btnConfirm"),
            type = ButtonType.PRIMARY,
            size = ButtonSize.LARGE,
            text = stringResource(R.string.confirm),
            onClick = ::onConfirmButtonClick
        )
    }
}

마무리

지금까지 저희 키친보드 안드로이드 앱에 Jetpack Compose를 도입했을 당시 고려했던 부분, 그리고 소소한 Jetpack Compose 도입 Tip들을 알아보았습니다.

특히 안정적이고 익숙했던 XML 방식에 비해 아직까지는 불안정하고 익숙하지 않은 Compose 도입을 앞두고 많은 것들을 고려했기 때문에, Compose의 장점 및 단점 그리고 Compose로 가능한 것과 불가능한 것에 대한 부분들을 자세히 기술하였습니다.

현재 Compose 도입을 검토 및 고려 중인 분들에게 조금이나마 도움이 되었으면 좋겠습니다.

긴 글 읽어주셔서 감사합니다.

스포카에서는 “식자재 시장을 디지털화한다” 라는 슬로건 아래, 매장과 식자재 유통사에 도움되는 여러 솔루션들을 개발하고 있습니다.
더 나은 제품으로 세상을 바꾸는 성장의 과정에 동참 하실 분들은 채용 정보 페이지를 확인해주세요!