[공식문서 번역] Thinking in Compose 읽어보기

tom·2022년 2월 21일
0

Android

목록 보기
4/6
post-thumbnail

오늘은 Jetpack Compose 의 가장 첫 부분인 Thinking in Compose 공식문서를 번역하며 읽은 것을 공유하려고 합니다. 아래 링크는 아티클의 링크입니다.
Thinking in Compose


Thinking in Compose

Jetpack Compose는 안드로이드를 위한 현대적인 선언형 UI Toolkit입니다. Compose는 view를 불필요하게 변경하지 않고 UI를 렌더링할 수 있는 선언형 API를 제공하여 UI를 더 쉽게 작성하고 유지할 수 있게 해줍니다.

The declarative programming paradigm

전통적으로 안드로이드의 view 계층구조는 UI 위젯의 tree 구조로 표현되어 왔습니다.

사용자 상호작용과 같은 요인으로 인해 앱의 상태가 변경되면, UI 계층구조는 데이터를 표출하기 위해 업데이트 되어야 할 것입니다.

대부분의 UI 업데이트 방식은 findViewById() 와 같은 함수를 사용하여 tree를 탐색하거나, button.setText(String), container.addChild(View), img.setImageBitmap(Bitmap) 과 같은 메소드를 호출하여 node를 변경하는 방법입니다.

이러한 방법들은 위젯의 내부 상태를 변경합니다.

view들을 직접 조작하는 것은 error 비중을 높이게 만듭니다. 어떤 한 데이터가 여러 군데에서 렌더링 된다면, 이들 중 하나를 업데이트 하는 것을 잊기 쉽습니다. 또한 예상치 못한 두 번의 업데이트가 충돌할 때, 비정상적인 상태를 만들기도 합니다.

지난 몇년간, 모든 업계가 UI를 빌드하거나 업데이트하는것에 대한 개발 시간을 굉장히 단순화하는 선언형 UI 모델로 전환하기 시작했습니다.

이 기술은 처음부터 전체적인 화면을 재생성하며, 필요한 변경사항만 적용하도록 되어있습니다.

이러한 접근은 stateful view 계층구조를 직접 업데이트 하는 복잡함을 피할 수 있습니다.

전체적인 화면을 재생성하는 데에 있어서 한가지 문제점은, 시간, 컴퓨팅파워 및 배터리 사용 측면에서 잠재적인 비용이 많이 든다는 것입니다. 이 비용을 줄이기 위해서, Compose는 지능적으로 다시그려야 하는 부분만을 골라냅니다. 이 부분은 UI 컴포넌트를 디자인하는데 여러 시사점이 있는데, Recomposition 부분에서 논해보도록 합시다.

A simple composable function

Compose를 사용하면, 데이터를 가져와 UI에 내보내는 역할을 하는 composable 함수를 정의하여 UI를 빌드할 수 있습니다. 아래는 아주 간단한 예시로 String 데이터를 가져와 Text UI에 내보내는 역할을 하는 Greeting 위젯입니다.

@Composable
fun Greeting(name: String) {
	Text("Hello $name")
}

이 함수에 대해 몇 가지 짚고 넘어가야할 점들이 있습니다.

  • 함수에 @Composable 어노테이션이 붙어있습니다. 모든 Composable 함수들은 해당 어노테이션을 가져야 합니다. 이 어노테이션은 Compose compiler에 이 함수가 데이터를 UI로 나타내기 위한 함수라는 것을 알려주는 역할을 합니다.
  • 함수는 데이터를 받아와야합니다. Composable 함수들은 UI를 표현하는 앱 로직 수행을 허용하는 parameter를 받아올 수 있습니다. 위 경우에서는 String parameter를 받아온 것을 볼 수 있습니다.
  • 함수가 UI에 텍스트를 표시합니다. 텍스트 UI 요소를 생성하는 Text() composable 함수를 사용했기 때문입니다. Composable 함수들은 다른 composable 함수들을 호출하는 형식으로 UI 계층구조를 내보냅니다.
  • 함수가 아무것도 반환하지 않습니다. UI 위젯을 구성하는 대신 원하는 화면의 상태를 설명하므로 UI를 내보내는 기능을 하는 Compose 함수들은 아무것도 반환하지 않아도 됩니다.
  • 함수가 빠르고, idempotent(멱등성)을 가지며, side-effect 가 없습니다.
    • 동일한 argument를 사용하여 여러 번 호출을 해도 동일한 방식으로 동작하며, 전역변수 혹은 random() 메소드 호출과 같이 다른 값을 사용하지 않습니다.

    • 프로퍼티나 전역변수의 변경과 같은 side-effects 없이 UI를 나타냅니다.

      일반적으로, 모든 composable 함수들은 이러한 속성들을 지키며 작성되어야 합니다. 이러한 이유에 관해서는 Recomposition 부분에서 논하도록 합니다.

The declarative paradigm shift

많은 명령형(imperative) 객체지향 UI toolkit들에서는 UI를 위젯 트리를 인스턴스화 하여 초기화합니다. 현재는 XML 레이아웃 파일을 가져오는 방식을 사용합니다. 각각의 위젯은 자체적인 내부 상태를 저장하며, getter와 setter 메소드를 앱 로직이 위젯과 상호작용할 수 있도록 드러냅니다.

Compose의 선언형 접근에서는, 위젯은 상태가 없으며, setter와 getter 함수가 없습니다. 사실은 위젯은 객체로 나타내지지 않습니다. UI를 다른 인자를 가지는 같은 composable 함수를 통해 업데이트할 수 있습니다. 따라서 Guide to app architecture에 설명된 대로, [ViewModel](https://developer.android.com/reference/androidx/lifecycle/ViewModel)과 같은 아키텍처 패턴에 상태를 쉽게 제공할 수 있습니다. 그 다음, composable 함수들은 observable 데이터가 업데이트 될 때마다 현재 애플리케이션의 상태를 UI로 변환하는 책임을 지게됩니다.

Figure 2. 앱 로직은 최상위(top-level) composable 함수에 데이터를 제공합니다. 해당 함수는 데이터를 사용하여 다른 composable 함수들을 호출하고, 그 composable 함수들에 적절한 데이터를 전달하여 계층구조로 이동하는 방식을 통해 UI를 표현합니다.

사용자가 UI와 상호작용을 할 때, UI는 onClick과 같은 이벤트를 발생시킵니다. 해당 이벤트들은 앱의 상태를 변경할 수 있도록 앱 로직에 알려져야 합니다. 상태가 변경될 때, composable 함수들은 새로운 데이터와 함께 다시 호출됩니다. 이러면 UI 요소가 다시 그려지게 되는데 — 이 과정을 Recomposition 이라고 합니다.

Figure 3. 사용자가 UI 요소와 상호작용하여 이벤트가 발생하였습니다. 앱 로직은 이벤트에 대해 반응을 하게되고, 그 이후에 composable 함수들은 필요에 따라 자동으로 새로운 parameter들과 함께 다시 호출됩니다.

Dynamic content

composable 함수들은 XML이 아닌 Kotlin으로 작성되었기 때문에, 다른 Kotlin 코드처럼 동적(dynamic)일 수 있습니다. 예를 들어보면, 사용자 목록 UI 빌드해볼 때를 가정하겠습니다:

@Composable
fun Greeting(names: List<String>) {
	for (name in names) {
		Text("Hello $name")
	}
}

이 함수는 사용자의 이름 목록을 가져와 각 사용자에 대한 인사말을 생성합니다. Composable 함수들은 상당히 정교합니다. 특정한 UI 요소를 보여주고 싶을 때는 if 구문을 사용하여 결정할 수 있습니다. loop도 사용할 수 있습니다. helper 함수들을 호출할 수도 있습니다. 기본적인 언어의 유연성을 충분히 갖추고 있습니다. 이와 같은 힘과 유연성이 Jetpack Compose의 핵심 장점 중 하나입니다.

Recomposition

명령형 UI 모델에서는, 위젯을 바꾸기 위해서는 setter를 호출하여 내부 상태를 바꾸어 주었습니다. Compose 에서는 새로운 데이터를 가지고 composable 함수만 호출하면 됩니다. 이렇게 하면 함수가 recompose되며, 필요한 경우 함수가 내보내는 위젯이 새로운 데이터와 함께 다시 그려집니다. Compose 프레임워크는 지능적으로 바뀐 컴포넌트들에 대해서만 recompose 하도록 되어있습니다.

예를 들어, 버튼을 표현하는 다음의 composable 함수를 보겠습니다:

@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
	Button(onClick = onClick) {
		Text("I've been clicked $clicks times")
	}
}

버튼이 클릭될 때 마다, caller는 clicks의 값을 업데이트합니다. Compose는 새로운 값을 보여주기 휘애서 lambda를 Text 함수와 함께 다시 호출하게됩니다; 이러한 과정을 recomposition이라고 부릅니다. 다른 함수들은 해당 값과 의존성이 없기 때문에 recompose 되지 않습니다.

앞서 논했듯이, 모든 UI 트리를 recomposing 하는 것은 컴퓨팅 파워와 배터리 수명 측면에서 비용이 많이 들게됩니다. Compose는 해당 문제를 intelligent recomposition으로 해결합니다.

Recomposition은 변경사항을 입력했을 때 composable 함수를 재호출하는 과정입니다. 이 과정은 함수의 입력이 변경되었을 때만 일어납니다. 새로운 입력에 따라 Compose가 recompose 되었을 때, 변경된 부분의 함수나 lambda만 호출하고 나머지는 건너뛰게 됩니다. 변경된 parameter들을 가지지 않은 함수나 lambda를 건너뜀으로서 Compose는 효율적으로 recompose할 수 있습니다.

함수의 recomposition이 건너뛰어질 수 있으므로, composable 함수들의 실행으로부터 나오는 side-effect에 의존하지 않아야 합니다. 이렇게 되면 사용자가 앱에서 이상하고 예측할 수 없는 동작을 경험하게 될 수 있습니다. side-effect는 앱의 나머지 부분에 나타나는 모든 변경사항입니다. 예를 들어보면, 다음과 같은 동작들이 위험한 side-effect들입니다:

  • shared object의 프로퍼티에 writing 하는 것
  • ViewModel 내부의 observable이 업데이트 되는 것
  • shared preferences 가 업데이트 되는 것

Composable 함수들은 애니메이션이 렌더링되는 경우와 같은 모든 프레임에서 재실행될 수 있습니다. Composable 함수들은 애니메이션 도중에 jank를 피하기 위해 빨라야합니다. shared preferences에서 데이터를 읽어 오는것과 같이 비용이 높은 작업을 수행하려고 한다면, Background Coroutine에서 수행하고 결과값을 composable 함수의 parameter로 전달해야 합니다.

예를 들어, 다음 코드는 SharedPreferences의 값을 업데이트하기 위한 composable 함수를 만듭니다. composable 함수는 shared preferences 직접 read/write를 하게 해서는 안됩니다. 대신에, background coroutine이 있는 ViewModel로 read/write 코드를 옮겨야 합니다. 앱 로직은 업데이트를 트리거하기 위해 현재 값과 callback을 전달합니다.

@Composable
fun SharedPrefsToggle(
	text: String,
	value: Boolean,
	onValueChanged: (Boolean) -> Unit
) {
	Row {
		Text(text)
		Checkbox(checked = value, onCheckedChange = onValueChanged)
	}
}

이 문서에서는 Compose를 프로그래밍할 때 알아둬야 할 몇 가지 사항들을 다루고 있습니다.

  • Composable 함수들은 임의의 순서로 실행할 수 있습니다.
  • Composable 함수들은 병렬로 실행할 수 있습니다.
  • Recomposition은 최대한 많은 composable 함수들과 lambda들을 건너뜁니다.
  • Recomposition은 optimistic하여 cancel할 수 있습니다.
  • 하나의 composable 함수는 애니메이션의 모든 프레임에서와 같이 자주 실행될 수 있습니다.

이어지는 섹션에서는 recomposition을 지원하기 위한 composable 함수를 구성하는 방법에 대해 알아보려고 합니다. 모든 경우에 있어서, composable 함수를 빠르고, idempotent 하며, side-effect가 없이 유지하는게 가장 중요합니다.

Composable functions can execute in any order

composable 함수의 코드를 보면, 보이는 순서대로 코드가 동작한다고 생각할 수도 있습니다. 하지만 꼭 그것이 그렇지만은 않습니다. 만약 composable 함수가 다른 composable 함수들을 내부에서 호출하고 있다면, 그 함수들은 임의의 순서대로 동작할 수도 있습니다. Compose는 일부 UI 요소들이 다른 요소들보다 우선 순위가 높은것을 인지하고 먼저 그리게 하는 옵션을 갖추고 있습니다.

예를 한번 들어보겠습니다. 아래와 같이 Tab layout 내에 세 개의 화면을 그리는 코드를 보겠습니다:

@Composable
fun ButtonRow() {
	MyFancyNavigation {
		StartScreen()
		MiddleScreen()
		EndScreen()
	}
}

StartScreen, MiddleScreen, EndScreen 함수들의 호출은 임의의 순서로 수행될 수 있습니다. 이는 예를 들어 StartScreen()에서 전역 변수(a side-effect)를 설정하고 MiddleScreen()에서 해당 변수의 변경 사항을 활용할 수 없음을 의미합니다.

Composable functions can run in parallel

Compose는 composable 함수들을 병렬실행함으로써 recomposition을 최적화 할 수 있습니다. 이로 인해 Compose가 여러개의 코어를 사용하여 장점을 얻게되고, 또한 composable 함수들을 낮은 우선순위로 실행하게 됩니다.

이 최적화는 background thread pool 내에서 composable 함수가 실행될 수 있음을 의미합니다. 만약 composable 함수가 ViewModel 내의 함수를 호출하는 경우, Compose는 동시에 여러 thread에서 호출할 수 있을것입니다.

애플리케이션이 정확하게 동작하는 것을 보장하기 위해서는, 모든 composable 함수들이 side-effect가 없어야 합니다. 대신에, onClick과 같은 콜백에서 UI thread에서 실행되는 side-effect들을 트리거합니다.

composable 함수가 실행되었을 때, 함수를 호출한 thread와 다른 thread에서 실행되어야 합니다. 즉, composable lambda 내의 변수를 수정하는 것은 thread-safe도 아닐 뿐 더러, composable lambda가 허용하지 않는 side-effect이기 때문에 피해야 합니다.

다음은 list와 그 개수를 나타내는 composable을 보여주고 있습니다:

@Composable
fun ListComposable(myList: List<String>) {
	Row(horizontalArrangement = Arrangement.SpaceBetween) {
		Column {
			for (item in myList) {
				Text("Item: $item")
			}
		}
		Text("Count: ${myList.size}")
	}
}

해당 코드는 side-effect가 없고, 입력된 리스트에 따라 UI를 변경시킵니다. 규모가 작은 리스트를 표현하기에 아주 적절한 코드입니다. 하지만, 함수가 지역 변수에 writing 하는 경우, 이 코드는 thread-safe 하지 않거나 정확하지 않습니다:

@Composable
@Deprecated("Example with bug")
fun ListWithBug(myList: List<String>) {
	var items = 0
		
	Row(horizontalArrangement = Arrangement.SpaceBetween) {
		Column {
			for (item in myList) {
				Text("Item: $item")
					items++ // Avoid! Side-effect of the column recomposing.
				}
			}
		Text("Count: $items")
	}
}

위의 예시에서는, items 변수가 모든 recomposition마다 수정되었습니다. 애니메이션의 모든 프레임 또는 리스트가 업데이트 될 때마다 발생할 수 있습니다. 어쨋든, UI는 잘못된 개수를 표현할 것입니다. 이러한 이유를 기반으로, Compose는 이렇게 writing 하는것을 지원하지 않습니다; 이러한 write들을 금지함으로써 프레임워크에서 composable lambda를 실행하도록 thread를 변경할 수 있습니다.

Recomposition skips as much as possible

UI의 일부가 유효하지 않았을 때, Compose는 업데이트 되어야 하는 해당 부분만 recompose 하기 위해 최선을 다할 것입니다. 다시 말해 UI tree에서 Button의 위 또는 아래에 있는 composable들중 어떤 것도 실행하지 않고 해당 compsable을 다시 실행하는 것을 건너뛸 수 있습니다.

모든 composable 함수와 lambda는 스스로 recompose 할 수 있습니다. 다음은 리스트를 렌더링할 때 recomposition이 몇몇 요소들을 skip하는 방법을 보여주는 예시입니다:

/**
 * Display a list of names the user can click with a header
 */
@Composable
fun NamePicker(
	header: String,
	names: List<String>,
	onNameClicked: (String) -> Unit
) {
	Column {
		// this will recompose when [header] changes, but not when [names] changes
		Text(header, style = MaterialTheme.typography.h5)
		Divider()
				
		// LazyColumn is the Compose version of a RecyclerView.
		// The lambda passed to items() is similar to a RecyclerView.ViewHolder.
		LazyColumn {
			items(names) { name ->
				// When an item's [name] updates, the adapter for that item
				// will recompose. This will not recompose when [header] changes
				NamePickerItem(name, onNameClicked)
			}
		}
	}
}

/**
 * Display a single name the user can click
 */
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
	Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}

이러한 각각의 scope들은 recomposition 중에 실행할 수 있는 유일한 항목일 수 있습니다. Compose는 header가 변경될 때 상위 항목을 실행하지 않고 Column lambda로 skip할 수 있습니다. 또한 Column이 실행될 때, Compose는 names가 변경되지 않았다면 LazyColumnItems를 skip할 수 있습니다.

다시 한 번 말하지만, 모든 composable 함수나 lambda를 실행할 때에는 side-effect가 없어야 합니다. side-effect를 다루어야 한다면, callback을 통해서 트리거 해야합니다.

Recomposition is optimistic

Recomposition은 composable 함수의 인자가 변경되었다고 Compose가 판단할 때마다 시작합니다. Recomposition은 optimistic(낙관적)입니다. 이 말인 즉슨, Compose는 인자가 또 변경되기 전에 recomposition을 끝낸다는 것입니다. 만약에 recomposition이 끝나기 전에 인자가 변경되었다면, Compose는 진행하고 있던 recomposition을 취소하고 새로운 인자를 가지고 다시 시작해야 할 것입니다.

recomposition이 취소되었을 때, Compose는 recomposition에서 UI tree를 폐기합니다. 표시되는 UI에 따라 side-effect가 있는 경우에는, composition이 취소되더라도 side-effect는 적용됩니다. 이로 인해서 앱의 상태가 일치하지 않을 수 있습니다.

optimistic(낙관적) recomposition을 쉽게 다룰 수 있도록, 모든 composable 함수들과 lambda들을 idempotent(멱등성), side-effect가 없도록 보장해야 합니다.

Composable functions might run quite frequently

몇몇의 경우, composable 함수가 모든 애니메이션 프레임에서 동작할 수도 있습니다. 만약 함수가 기기 저장소를 읽어오는 것과 같이 비용이 높은 동작을 수행한다면, 해당 함수를 UI jank를 일으킬 수 있습니다.

예를 들어, 만약 위젯이 기기의 설정을 읽어오려고 시도한다면, 해당 설정들을 1초에 100번 정도 읽어와야 할 수 있습니다. 이것은 앱의 성능에 치명적인 시도입니다.

만약 composable 함수가 데이터가 필요하다면, 함수의 인자로 정의되어야 합니다. 비용이 많이 드는 작업을 compsition 밖 다른 thread로 옮기고 mutableStateOf 또는 LiveData를 사용하여 Compose에 데이터를 전달하는것이 좋습니다.

profile
🌱 주니어 안드로이드 개발자 최우영입니다.

0개의 댓글