UnderStanding Jetpack Compose 1

최희창·2023년 2월 25일
0

Jetpack Compose

목록 보기
1/9

About

https://medium.com/androiddevelopers/understanding-jetpack-compose-part-1-of-2-ca316fe39050
블로그를 공부하며 한글로 번역한 내용입니다.

Introduction

세련된 UI를 빠르고 효율적으로 생성하는 기술적 문제를 해결하기 위해 앱 개발자에게 최신 UI 도구 키트 Jetpack Compose를 도입했습니다.

두 개의 게시물에 걸쳐 Compose의 이점을 설명하고 내부적으로 어떻게 작동하는지 살펴보겠습니다.

해당 글에서는 Copose가 해결하는 문제, 해당 디자인 결정의 이유, 이러한 결정이 앱 개발자에게 어떻게 도움이 되는지에 대해 설명합니다. 또한 Compose의 mental model 그리고 Compose 코드를 어떻게 생각해야 하는지 API를 어떻게 구성해야 하는지를 설명합니다.

Compose는 어떤 문제를 해결할까요?

책임의 분리는 잘 알려진 소프트웨어 디자인 원칙입니다. 또한 앱 개발자로서 배우는 근본적인 것 중 하나입니다. 하지만 잘 알려져 있음에도 불구하고 이 원칙이 실제로 지켜지고 있는지 파악하기 힘든 경우가 많습니다. 이 원칙을 Coupling과 Cohesion의 관점에서 생각한다면 도움이 될 수 있습니다.

코드를 작성할 때 여러 단위로 구성된 모듈을 만듭니다. Coupling은 서로 다른 모듈간의 종속성이며 한 모듈의 일부가 다른 모듈의 일부에 영향을 미치는 방식을 반영합니다.
대신에 응집력은 모듈내 단위 간의 관계이며 모듈 내 단위가 얼마나 잘 그룹화되어 있는지 나타냅니다.

유지 관리 가능한 소프트웨어를 작성할 때의 목표는 Coupling을 최소화하고 Chesion을 최대화하는것입니다.

고도로 결합된 모듈이 있는 경우 한 곳에서 코드를 변경한다는 것은 다른 모듈에 대해 다른 많은 변경을 수행해야 함을 의미합니다. 설상가상으로 결합은 암시적일 수 있으므로 전혀 관련이 없어 보이는 변경으로 인해 예기치 않은 일이 중단될 수 있습니다.

관심사 분리는 가능한 한 많은 관련 코드를 함께 그룹화하여 코드를 쉽게 유지 관리하고 앱이 커짐에 따라 확장할 수 있도록 하는 것입니다.

현재 Android 개발의 맥락에서 좀 더 실질적으로 살펴보고 ViewModel과 Xml layout의 예를 들어보겠습니다.

뷰 모델은 레이아웃에 데이터를 제공합니다. 여기에 숨겨진 많은 종속성이 있을 수 있습니다. 그 중 하나의 예는 findViewById입니다.

이러한 API를 사용하려면 Xml 레이아웃이 정의되는 방법에 대한 지식이 필요하며 둘 사이에 결합이 생성됩니다. 시간이 지남에 따라 앱이 커져 이러한 종속성이 오래되지 않도록 해야 합니다.

대부분의 최신 앱은 UI를 동적으로 표시하고 실행 중에 진화합니다. 결과적으로 이러한 종속성이 레이아웃 XML에 의해 정적으로 충족되는지 확인해야 할 뿐만 아니라 프로그램 수명 동안에도 충족될 것입니다. 만약 런타임에서 요소가 View Hierarchy에서 사라진다면 이것은 NullReferenceException을 유발시킬 수 있다.일반적으로 ViewModel은 Kotlin과 같은 프로그래밍 언어로 정의되고 레이아웃은 XML로 정의 됩니다. 이러한 언어의 차이로 인해 ViewModel과 레이아웃 XML이 때때로 밀접하게 관련될 수 있지만 강제로 분리되어 있습니다. 즉 매우 타이트하게 결합되어 있습니다.

만약 동일한 언어로 UI의 구조인 레이아웃을 정의한다면 어떻게 될까요?그러면 동일한 언어로 작업하게 되므로 이전에 암시적이었던 일부 종속성이 더 명시적으로 되기 시작할 수 있습니다. 또한 코드를 리팩토링하고 Coupling을 줄이고 Cohesion을 높일 수 있는 위치로 이동할 수 있습니다.

이것이 UI와 함께 로직을 섞을수 있는 것을 제안한다는 것이라고 알 수 있습니다. 실제로 앱의 구조에 상관없이 UI 관련된 로직이 있을 것이다. 프레임워크는 이를 바꿀 수 없다.

그러나 프레임워크는 분리를 더 쉽게 만드는 도구를 제공할 수 있다. 바로 이 도구가 Composable 기능이다. 함수는 코드의 다른 부분에서 우려 사항을 분리하기 위해 오랫동안 사용해 온것이다. 이러한 유형의 리팩토링을 수행하고 신뢰할 수 있고 유지 관리가 가능하며 깔끔한 코드를 작성하기 위해 습득한 기술은 Composable 함수에도 동일하게 적용할 수 있다.

Anatomy of a Composable function

@Composable
fun App(appData: AppData) {
  val derivedData = compute(appData)
  Header()
  if (appData.isOwner) {
    EditButton()
  }
  Body {
    for (item in derivedData.items) {
      Item(item)
    }
  }
}

위 경우 appData 클래스 객체를 매개변수로 데이터를 수신합니다. 이상적으로 해당 데이터는 Composable 함수가 변경하지 않는 변하지 않는 데이터입니다. Composable 함수는 data를 변환시키는 함수여야 합니다. 그래서 우리는 Header()와 Body() 같이 Hierarchy를 만드는데 data를 이용할 수 있습니다.

이는 우리가 다른 Composable 함수를 호출하고 해당 호출이 계층 구조의 UI를 나타냄을 의미합니다. Kotlin은 동적으로 작업을 수행해야 하는 모든 언어 수준을 사용할 수 있어 보다 복잡한 UI 논리를 처리하기 위해 제어 흐름을 위한 if문과 for 루프를 포함할 수 있습니다.

Composable 함수는 Kotlin의 Lambda 문법을 자주 활용합니다. Body()는 Composable한 Lambda를 매개변수로 갖는 Composable 함수입니다. 그것은 구조의 계층을 암시합니다. Body()는 item의 sets들을 감싸고 있다.

The declarative UI

선언적이라는 말은 유행어이지만 중요한 말입니다. 선언적 프로그래밍을 얘기할 때 우리는 명령형 프로그래밍과 비교해서 말을 합니다.

읽지 않은 메시지 아이콘이 있는 이메일 앱을 생각해봅시다. 메시지가 없으면 빈 봉투를 렌더링하고 메시지가 있으면 봉투에 종이를 렌더링하고 메시지가 100개이상이면 아이콘에 불이 붙은것 처럼 렌더링합니다.
명령형 인터페이스를 사용하면 다음과 같이 업데이트 횟수 함수를 작성할 수 있습니다.

fun updateCount(count: Int) {
  if (count > 0 && !hasBadge()) {
    addBadge()
  } else if (count == 0 && hasBadge()) {
    removeBadge()
  }
  if (count > 99 && !hasFire()) {
    addFire()
    setBadgeText("99+")
  } else if (count <= 99 && hasFire()) {
    removeFire()
  }
  if (count > 0 && !hasPaper()) {
   addPaper()
  } else if (count == 0 && hasPaper()) {
   removePaper()
  }
  if (count <= 99) {
    setBadgeText("$count")
  }
}

해당 코드에서는 우리는 새로운 count 갯수를 받고 해당 상태를 반영한 UI를 업데이트 하여야 한다. 비교적 간단한 예임에도 불구하고 코너케이스가 많고 쉽지 않습니다.

이 논리를 선언형 인터페이스에 작성하면 다음과 같을 수 있다.

@Composable
fun BadgeEnvelope(count: Int) {
	Envelope(fire = count > 99, paper = count > 0) {
    	if (count>0) {
        	Badge(text="$count")
        }
     }
}
  • 카운트가 99를 넘으면 발사합니다.
  • 카운트가 0을 넘으면 종이를 보여줍니다.
  • 카운트가 0을 초과하면 카운트 배지를 렌더링합니다.

이것이 선언형 API의 의미입니다. 우리가 작성하는 코드는 우리가 원하는 UI를 설명하지만 해당 상태로 전환하는 방법은 설명하지 않습니다. 여기서 중요한 점은 이와 같은 선언적 코드를 작성할 때 더 이상 UI의 이전 상태에 대해 걱정할 필요가 없으며 현재 상태를 지정하기만 하면 된다는 것입니다. 프레임워크는 한 상태에서 다른 상태로 이동하는 방법을 제어하므로 더 이상 그것에 대해 생각할 필요가 없습니다.

Composition vs Inheritance

소프트웨어에서 컴포지션은 더 복잡한 코드 단위를 형성하기 위해 더 간단한 코드의 여러 단위가 함께 모이는 방법입니다. Jetpack Compose에서는 클래스가 아닌 함수로만 작업하기 때문에 함수의 Composition은 상당히 다르지만 상속에 비해 많은 이점이 있습니다.

View가 있고 Input을 추가하고 싶다고 가정해봅시다. 상속을 이용하면 다음과 같이 코드를 구성합니다.

class Input : View() { /* ... */ }
class ValidatedInput : Input() { /* ... */ }
class DateInput : ValidatedInput() { /* ... */ }
class DateRangeInput : ??? { /* ... */ }

View는 기본 클래스입니다. ValidatedInput은 Input의 하위 클래스입니다. 그리고 날짜의 유효성을 검사하기 위해 DateInput클래스는 ValidatedInput의 하위 클래스입니다. 그러나 만약 시작 날짜와 종료 날짜라는 두 날짜에 대해 유효성 검사를 의미하는 날짜 범위 입력을 만들고 싶습니다. DateInput의 하위 클래스로 둘 수 있지만 2번 비교를 해야하지만 2번 상속을 할 수 없습니다. 이것이 상속의 한계입니다.

Compose에서는 이것은 어려운 일이 아닙니다. 다음과 같은 베이스 Input composable 함수로 시작한다고 해봅시다.

@Composable
fun <T> Input(value: T, onChange: (T) -> Unit){
	/*...*/
}

ValidatedInput을 만들 때는 단지 Input을 함수 안에서 호출하기만 하면 됩니다. 아래와 같이 표현될 수 있습니다.

@Composable
fun ValidatedInput(value:T, onChange:(T) -> Unit, isValid: Boolean) {
	InputDecoration(color=if(isValid) blue else red) {
 	   Input(value, onChange)
    }
 }

그리고 DataInput을 위해 ValidatedInput을 직접 호출하면 됩니다.

@Composable
fun DateInput(value: DateTime, onChange: (DateTime) -> Unit) {
	ValidatedInput(value,
    onChange = { ... onChange(...) },
    isValid = isValidDate(value)
    )
 }

이제 우리는 날짜 범위 입력에 어려움이 없어졌습니다.

@Composable
fun DateRangeInput(value: DateRange, onChange: (DateRange) -> Unit) {
	DateInput(value=value.start, ...)
    DateInput(value=value.end, ...)
 }

Compose의 Composition 모델에는 상속 모델에서 겪었던 이 문제를 해결합니다.

다른 종류의 Composition 문제는 데코레이션을 추상화하는 것입니다. 상속을 이용하는 다음 예제를 봅시다.

class FancyBox : View() { /* ... */ }
class Story : View() { /* ... */ }
class EditForm : FormView() { /* ... */ }
class FancyStory : ??? { /* ... */ }
class FancyEditForm : ??? { /* ... */ }

FancyBox는 다른 View를 묘사하는 View이다. Story와 EditForm도 마찬가지 이다. 만약에 FancyStory와 FancyEditForm을 코드로 짜고싶다면 어떻게 해야할까? FancyBox를 상속해야할까? Story를 상속해야할까? 명확하지 않다. 이유는 상속해야 할 하나의 부모가 필요하기 때문이다.

하지만 Compose는 이것을 잘 해결할 수 있다.

@Composable
fun FancyBox(children: @Composable ()->Unit){
	Box(fancy) { children() }
}
@Composable fun Story(...) { /* ... */ }
@Composable fun EditForm(...) { /*...*/ }
@Composable fun FancyStory(...) {
	FancyBox { Story(...) }
}
@Composable fun FancyEditForm(...) {
	FancyBox { EditForm(...) }
 }

우리는 자식으로 Composable Lambda를 가질 수 있으므로 다른 것을 감싸는 형식으로 정의할 수 있습니다. 우리가 FancyStroy를 만들고 싶다면 FancyBox의 child에서 Story를 호출 할 수 있습니다. FancyEditForm도 마찬가지 입니다.

Capsulation

Copose가 잘 수행하는 또 다른 기능은 캡슐화입니다. 이는 Public한 Composable 함수를 만들 때 고려해야 할 사항입니다. Composable 함수는 parameter들을 받고 있으므로 그것들을 컨트롤할 수 없습니다. 반면에 Composable은 상태를 만들고 관리할 수 있어 다른 Composable 함수의 Parameter로 상태와 데이터들을 보낼 수 있습니다.

해당 상태를 관리하고 있으므로 상태를 변경하려는 경우 하위 Composable이 콜백을 사용하여 변경 사항을 백업하도록 신호를 보낼 수 있습니다.

Recomposition

이것은 Composable 함수가 언제든 다시 호출될 수 있음을 의미합니다. 매우 큰 Composable 계층 구조가 있는 경우 계층 구조의 일부가 변경될 때 전체 계층 구조를 다시 계산할 필요가 없습니다. 따라서 Composable 함수는 재시작될 수 있으며 당신은 이를 사용하여 몇 가지 강력한 작업을 수행할 수 있습니다.

Bind 함수를 예로 봅시다.

fun bind(liveMsgs: LiveData<MessageData>) {
	liveMsgs.observe(this) { msgs ->
    	update(msgs)
     }
 }

우리는 LiveData가 있고 구독하고 싶은 View가 존재합니다. 이를 위해 lifecycle owner와 함께 obser 메서드를 호출한 다음 Lambda를 전달합니다. Lambda는 LiveData가 업데이트될 때 마다 호출되며 그럴때마다 우리는 View를 업데이트합니다.

Compose에서는 이러한 관계를 뒤집을 수 있습니다.

@Composable
fun Messages(liveMsgs: LiveData<MessageData>) {
	val msgs by liveMsgs.observeAsState()
    for (msg in msgs) {
    	Message(msg)
    }
}

LiveData를 받고 Compose의 observeAsState 함수를 호출하는 Message Composable이 있습니다. ObserveAsState 함수는 LiveData<'T'>를 State<'T'>로 매핑할 것입니다. 이것은 함수의 주변 본문에서 값을 사용할 수 있다는 것을 말합니다. State 객체는 LiveData 객체에 구독되어 있으므로 LiveData가 업데이트될때마다 State 객체도 업데이트 됩니다. 이는 또한 State객체를 읽을 때마다 State인스턴스를 읽는 주변 Composable 함수도 이러한 변경사항을 구독함을 의미합니다. 결국 LifecycleOwner를 명시하는 것과 콜백을 업데이트하는 것이 필요가 없게 됩니다.

Final thoughts

Compose는 책임 분리를 효과적으로 하기 위한 UI 정의에 대해 현대적인 접근을 제공합니다. Composable 함수는 일반적인 Kotlin 함수와 매우 유사하고 함수를 작성하고 리팩토링하는데 이는 안드로이드 개발 기술과 적합할 것입니다.

다음 글에서는 Compose의 자세한 구현과 컴파일러에 대해 설명 드리겠습니다.

profile
heec.choi

0개의 댓글