서론
(서론은 공식문서에서 발췌한 내용이 대부분이다. 이미 알고 있다면 패스해도 좋다)
이번 주제는 Jetpack Compose의 State와 MutableState이다.
지난 번에 작성했던 RecyclerView 글에 대한 후속편도 적어야 하는데, 시간이 부족하다..
얼른 시간 내서 작성해보도록 하겠다.
이제는 역사가 유구한 프로젝트가 아니라면 대부분 컴포즈를 사용할 것이다. 그럼 다들 좋다고 사용하는 컴포즈는 어떻게 다를까?
컴포즈 이전에 우리는 명령형 UI 형태로 뷰 객체의 프로퍼티를 수정하거나, 메서드를 호출해서 화면을 업데이트 했다.
binding.tv.text = "안녕하세요"
binding.tv.textSize = dpToPx(context, 14)
binding.tv.updatePadding(top = 20)
그러나 컴포즈는 선언형 UI로 전혀 다른 모습을 갖는다.
@Composable
fun CustomText(text: Int, textSize: Int, paddingTop: Int) {
Text(
text = "안녕하세요",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimary,
)
}
우리가 사용하던 기존의 뷰 시스템에는 View LifeCycle이 존재한다.
간단하게 보면 Measure - Layout - Draw 단계를 갖는다.
우리는 화면을 업데이트하고 싶을 때 변경이 발생하는 뷰 객체의 invalidate 혹은 requestLayout 함수를 호출했다.
invalidate를 호출하면 Draw 과정을 거치고, requestLayout을 호출하면 Measure, Layout, Draw 과정을 모두 거치게 된다.
(위의 예시에서는 호출하는 모습이 보이지 않지만, 프로퍼티를 수정하고 메서드를 호출할 때 내부적으로 호출될 것이다)
Composable LifeCycle
그렇다면 Composable은 어떻게 화면을 업데이트 할까?
공식문서에 따르면 Composable의 생명주기는 다음과 같다.
Composable의 생명주기는 Activity, Fragment, View와 비교했을 때 훨씬 간단하다.
- Composable 함수가 최초로 실행되면서 Composition을 생성한다.
- Composition은 UI를 나타내는 composable 함수들의 트리 구조로 이루어져 있다.
- UI를 나타내기 위해 호출된 composable 함수들을 추적한다.
- 상태가 변경되면 Recomposition을 예약한다.
- Recomposition은 상태 변경에 반응하여 변경될 가능성이 있는 Composable 함수들을 다시 실행하고, 변경 사항을 반영하기 위해 Composition을 업데이트하는 과정이다.
요약하자면 Compose는 상태가 변경되면 Composable 함수를 다시 실행시키므로 Composition을 업데이트(변경사항을 반영)한다.
여기서 Composable에 영향을 미친다는 "상태"란 무엇일까?
공식문서에는 다음과 같은 내용이 있다.
Compose는 선언형(declarative) UI 프레임워크이므로 기본적으로 이를 업데이트하는 유일한 방법은 동일한 composable 함수를 새로운 인수로 호출하는 것입니다.
이러한 인수는 UI 상태를 나타냅니다. 상태가 업데이트될 때마다 Recomposition이 발생합니다.
아하! 그럼 Composable 함수를 호출하는 매개변수를 바꾸면 되는거구나?!
그럼 다음 예시를 보자. 아래와 같은 TextField가 있다. (EditText라고 생각하면 된다)
@Composable
private fun MyTextField() {
OutlinedTextField(
value = "",
onValueChange = { },
)
}
value에는 TextField에 보여질 값을 넣어주어야 하고
onvalueChange 람다에서는 우리가 타이핑 할 때마다 변경되는 값이 주어진다.
@Composable
private fun MyTextField() {
var content: String = ""
OutlinedTextField(
value = content,
onValueChange = { content = it },
)
}
value와 onValueChange를 이해하고 위와 같이 구현해보았다.
onValueChange에서 변경되는 값을 받아 content에 저장하고 value에 content를 담아 주었다.
이제 작동하겠지?
아쉽게도 그렇지 않다. 이유가 무엇일까?
우리는 아까 "상태"가 변경되면 Composable이 다시 실행되어 Composition을 업데이트한다고 배웠다.
그리고 Composable 함수의 인수는 UI 상태를 나타내며 새로운 인수로 Composable 함수를 호출해야 한다.
그럼 우리는 이렇게 말해볼 수 있다.
"OutlinedTextField의 value가 변경되었잖아!!"
content가 변경된 것은 아무도 알지 못하기 때문에 OutlineTextField는 새로운 인수로 호출되지 않는다. 그저 content가 변경되었을 뿐이다.
그럼 어떻게 해야할까? 아! MyTextField의 매개변수로 value를 받으면 되지 않을까?
@Composable
private fun MyTextField(content: String, contentChanged: (String) -> Unit) {
OutlinedTextField(
value = content,
onValueChange = { contentChanged(it) },
)
}
오! 분명 일리가 있다. content는 MyTextField의 인수이므로 MyTextField가 새로 호출될 것이고
그럼 OutlinedTextField 또한 새로 호출될 것이다. 그럼 화면은 업데이트 될거다!
좋다. 그런데 한 가지 문제가 있다. MyTextField를 호출하는 곳에서는 어떻게 해야하지?
@Composable
private fun MainScreen() {
MyTextField(
content = "",
contentChanged = {},
)
}
또다시 같은 문제가 발생했다.
즉, 우리가 만든 MyTextField는 그저 OutlinedTextField를 똑같이 한 번 감싼 것 뿐이다. 전혀 의미 없는 행동이었다.
그럼 어떻게 해야할까? 구글이 OutlinedTextField를 잘못 만든걸까?
이럴 때를 위해 State가 존재한다!!
State
쉽게 설명하자면, State는 Composable 함수의 인수는 아니지만 컴포즈가 "상태"로써 구독한다. 또한 State 변경 시 해당 값을 사용하는 Composition을 업데이트한다.
그럼 State가 어떻게 생겼는지 한 번 보자.
우리가 코틀린 컬렉션에서 많이 봤듯이 State 또한 read-only와 mutable한 두 가지 State가 존재한다.
다음은 State와 MutableState 코드이다.
/*
* A value holder where reads to the [value] property during the execution
* of a [Composable] function, the current [RecomposeScope] will be subscribed
* to changes of that value.
*/
@Stable
interface State<out T> {
val value: T
}
/**
* A mutable value holder where reads to the [value] property during the
* execution of a [Composable] function, the current [RecomposeScope] will be
* subscribed to changes of that value. When the [value] property is written to
* and changed, a recomposition of any subscribed [RecomposeScope]s will be
* scheduled. If [value] is written to with the same value, no recompositions
* will be scheduled.
*/
@Stable
interface MutableState<T> : State<T> {
override var value: T
operator fun component1(): T
operator fun component2(): (T) -> Unit
}
두 인터페이스에 적힌 주석을 읽어보자.
Composable 함수 실행 중 value 프로퍼티를 읽을 때, 현재 RecomposeScope가 해당 값의 변경 사항에 구독됩니다.
MutableState는 변경 가능한 값 저장소로, [Composable] 함수 실행 중 [value] 속성을 읽으면 현재 [RecomposeScope]가 해당 값의 변경 사항에 구독됩니다. [value] 프로퍼티에 값이 쓰여지거나 변경되면, 구독 중인 [RecomposeScope]의 recomposition이 예약됩니다. 만약 [value]가 동일한 값으로 쓰여진다면, recomposition은 예약되지 않습니다.
정리하자면 다음과 같다.
State의 value 프로퍼티를 읽을 때 현재 RecomposeScope가 State에 구독되며 value 프로퍼티에 값이 쓰이거나 변경되면 구독된 RecomposeScope에 리컴포지션이 발생한다.
대충 무슨 말인지 알겠다. 그런데 우리가 모르는 용어가 하나 있다. RecomposeScope이다.
RecomposeScope가 뭐길래 State를 구독하고 recomposition을 관리하는 걸까?
RecomposeScope
그래서 RecomposeScope 인터페이스와 그 구현체를 찾아보았다.
/**
* Represents a recomposable scope or section of the composition hierarchy.
* Can be used to manually invalidate the scope to schedule it for recomposition.
*/
interface RecomposeScope {
/**
* Invalidate the corresponding scope, requesting the composer recompose this scope.
* This method is thread safe.
*/
fun invalidate()
}
/**
* A RecomposeScope is created for a region of the composition that can be recomposed independently of the rest of the composition.
* The composer will position the slot table to the location stored in anchor and call block when recomposition is requested.
* It is created by Composer. startRestartGroup and is used to track how to restart the group
*/
internal class RecomposeScopeImpl(
owner: RecomposeScopeOwner?
) : ScopeUpdateScope, RecomposeScope
주석을 해석하면 다음과 같다.
Composition 계층 구조에서 recomposition이 가능한 범위(scope) 또는 섹션을 나타냅니다. 해당 범위를 수동으로 무효화(invalidate)하여 recomposition이 예약되도록 할 수 있습니다.
해당 범위를 무효화하여 composer가 이 범위를 다시 recomposition하도록 요청합니다. 이 메서드는 스레드 안전(thread-safe)합니다.
RecomposeScope는 Composition 내에서 독립적으로 recomposition될 수 있는 영역을 나타냅니다.
Composer는 [anchor]에 저장된 위치로 슬롯 테이블을 이동시키고, recomposition이 요청될 때 [block]을 호출합니다.
이 클래스는 [Composer.startRestartGroup]에 의해 생성되며, 그룹을 다시 시작하는 방법을 추적하는 데 사용됩니다.
주석만으로는 이해가 어려울 것 같으니 RecomposeScopeImpl의 코드를 좀 더 자세히 들여다보자.
...
/**
* The lambda to call to restart the scopes composition.
*/
private var block: ((Composer, Int) -> Unit)? = null
/**
* Restart the scope's composition. It is an error if [block] was not updated. The code
* generated by the compiler ensures that when the recompose scope is used then [block] will
* be set but it might occur if the compiler is out-of-date (or ahead of the runtime) or
* incorrect direct calls to [Composer.startRestartGroup] and [Composer.endRestartGroup].
*/
@OptIn(ExperimentalComposeRuntimeApi::class)
fun compose(composer: Composer) {
val block = block
val observer = observer
if (observer != null && block != null) {
observer.onBeginScopeComposition(this)
try {
block(composer, 1)
} finally {
observer.onEndScopeComposition(this)
}
return
}
block?.invoke(composer, 1) ?: error("Invalid restart scope")
}
/**
* Invalidate the group which will cause [owner] to request this scope be recomposed.
*
* Unlike [invalidateForResult], this method is thread safe and calls the thread safe
* invalidate on the composer.
*/
override fun invalidate() {
owner?.invalidate(this, null)
}
...
각 항목의 주석을 살펴보자. (GPT로 번역해봤다)
block
해당 범위의 composition을 다시 시작하기 위해 호출할 람다 함수입니다.
compose
해당 범위의 composition을 다시 시작합니다. [block]이 업데이트되지 않은 상태에서 이 메서드를 호출하면 오류가 발생합니다.
컴파일러가 생성하는 코드는 recompose scope가 사용될 때 [block]이 설정되도록 보장하지만, 컴파일러가 구버전(또는 런타임보다 최신 버전)이거나, [Composer.startRestartGroup] 및 [Composer.endRestartGroup]에 잘못된 직접 호출이 있는 경우 이 문제가 발생할 수 있습니다.
invalidate
그룹을 무효화하여 [owner]가 이 scope의 recomposition을 요청하도록 합니다.
[invalidateForResult]와는 달리, 이 메서드는 스레드 안전하며, composer에서 스레드 안전한 invalidate를 호출합니다.
읽어보면 block과 invalidate가 둘 다 recomposition을 시키는 역할로 보여 헷갈릴 수 있다.
정리하면 Composer가 invalidate를 호출하여 RecomposeScope에 해당하는 범위에 recomposition을 예약하고, 이후 실제로 리컴포지션이 이뤄질 때 compose 함수가 호출되고 내부에서 block 람다가 불리게 된다.
Composer란 Coroutine의 Continuation처럼 모든 Composable 함수의 마지막 매개변수로 추가되며 Composable 함수를 Compose Runtime에 연결하는 역할을 한다.
즉, Composable 함수와 컴포즈 런타임 패키지에 있는 클래스들이 Composer를 통해 연결된다고 생각하면 된다.
쉽게 말하면 다음과 같다.
- block: 실제 리컴포지션 수행 로직
- compose: 전후처리와 함께 block을 호출하여 리컴포지션을 수행
- invalidate: 리컴포지션을 예약함 (리컴포지션이 필요함을 알림)
왜 바로 compose 함수(block 람다)를 호출하여 리컴포지션 시키지 않고 invalidate로 리컴포지션을 예약하는 걸까?
컴포즈는 상태 변경이 발생할때마다 매번 리컴포지션을 반복하지 않기 때문이다.
리컴포지션 수행 방식
컴포즈는 상태 변화를 감지할 때마다 바로 UI를 업데이트하는 것이 아니라 스케줄링 과정을 거친다.
그러므로 짧은 시간에 여러 상태가 연달아 바뀌더라도, 그때마다 즉시 리컴포지션을 반복하지 않고, 묶어서 처리할 수 있다.
리컴포지션 예약
RecomposeScopeImpl의 invalidate 함수 호출 내부 구현을 따라가보면 다음과 같다.
- RecomposeScopeImpl의 invalidate 함수에서 자신(this)을 매개변수로 RecomposeScopeOwner의 invalidate를 호출한다.
- RecomposeScopeOwner의 invalidate는 CompositionImpl이 구현하고 있으며 내부에서 invalidateChecked를 호출한다.
- invalidateChecked에서는 CompositionImpl 자신(this)을 매개변수로 담아 CompositionContext의 invalidate를 호출한다.
- CompositionContext의 invalidate는 Recomposer가 구현하며 구현부는 다음과 같다.
internal override fun invalidate(composition: ControlledComposition) {
synchronized(stateLock) {
if (composition !in compositionInvalidations) {
compositionInvalidations += composition
deriveStateLocked()
} else null
}?.resume(Unit)
}
보다시피 composition을 compositionInvalidations라는 컬렉션에 추가하는 것을 볼 수 있다.
compositionInvalidations는 다음과 같이 생겼다.
private val compositionInvalidations = mutableVectorOf<ControlledComposition>()
RecomposeScope에서 invalidate를 통해 예약한 리컴포지션이 Recomposer의 compositionInvalidations 프로퍼티에 쌓이는 구조이다.
또한 Recomposer의 runRecomposeAndApplyChanges라는 함수를 잘 살펴보면 앞서 모아놓은 Composition들을 처리하는 모습도 확인해볼 수 있다.
리컴포지션 수행
모든 동작을 파악하기는 어렵지만, runRecomposeAndApplyChanges 함수의 동작을 간추려 설명해보겠다.
- compositionInvalidations에 저장된 composition들을 toRecompose로 옮기고 compositionInvalidations를 비운다. (그 사이에 다른 컴포지션들이 쌓일 수 있으니까)
- toRecompose에 쌓인 컴포지션들을 매개변수로 넘겨 private 함수인 performRecompose 함수를 호출하고 그 결과로 반환된 리컴포지션된 ControlledComposition을 toApply에 담는다.
- performRecompose 함수는 내부적으로 ControlledComposition을 구현하는 CompositionImpl의 recompose 함수를 호출한다.
- ControlledComposition의 recompose 함수는 해당 컴포지션이 들고 있던 Composer의 recompose 함수를 호출한다.
- Composer의 recompose 함수는 private 함수인 doCompose를 호출하여 리컴포지션을 수행하고, 해당 컴포지션은 업데이트된다.
- toApply에 저장된 컴포지션들은 해당 컴포지션(ControlledComposition)의 applyChanges 함수를 호출하여 변경된 컴포지션을 적용하여 화면을 업데이트한다.
class Recomposer(
effectCoroutineContext: CoroutineContext
) : CompositionContext() {
...
private val compositionInvalidations = mutableVectorOf<ControlledComposition>()
...
suspend fun runRecomposeAndApplyChanges() = recompositionRunner { parentFrameClock ->
val toRecompose = mutableListOf<ControlledComposition>()
val toApply = mutableListOf<ControlledComposition>()
...
while (shouldKeepRecomposing) {
...
synchronized(stateLock) {
compositionInvalidations.forEach {
toRecompose += it
}
compositionInvalidations.clear()
}
...
while (toRecompose.isNotEmpty() || toInsert.isNotEmpty()) {
try {
toRecompose.fastForEach { composition ->
performRecompose(composition, modifiedValues)?.let {
toApply += it
}
alreadyComposed.add(composition)
}
}
...
}
...
}
}
...
}
이와 같이 컴포즈는 변경사항을 매번 바로 적용하는 것이 아니라 여러 변경사항을 묶어서 처리할 수 있다.
그렇기 때문에 RecomposeScopeImpl의 compose 함수(block 람다)를 바로 호출하지 않고 invalidate 함수를 호출하여 리컴포지션을 예약하는 것이다.
정리하면 다음과 같다.
- 상태 변경이 일어남!
- 스냅샷 시스템에 의해 상태 변경이 노티됨
- 구독중이던 RecomposeScope의 invalidate가 호출됨
- Recomposer의 compositionInvalidations에 리컴포지션이 필요한 컴포지션 목록이 쌓임
- 다음 스케줄링 시점에 Recomposer가 compositionInvalidations에 쌓인 컴포지션 목록에 대해 리컴포지션을 수행하여 컴포지션을 업데이트하고 적용한다.
- 그리고 이 과정에서 Composer에 의해 RecomposeScopeImpl의 compose 함수가 불리게 된다.
나도 스케줄링이 어떻게 되고 어떤 시점에 Recomposer가 리컴포지션을 수행하는지는 알아보지 못했다.
지금까지만 해도 State에 대해 알아보려다 너무 먼 길을 와버린 것 같아 아찔했다.
그래도 컴포즈가 리컴포지션을 어떻게 처리하는지 조금이나마 알게 되었으니 나쁘지만은 않다.
자 그럼 다시 원래 내용으로 돌아가보자.
Remember
이제 State에 대해 알았으니 이를 활용해서 MyTextField의 문제를 해결해보자.
@Composable
private fun MyTextField() {
val content: String = mutableStateOf("")
OutlinedTextField(
value = content.value,
onValueChange = { content.value = it },
)
}
자 이제 State를 활용했으니 컴포즈는 content의 변화를 감지할 것이고 OutlinedTextField 또한 업데이트 될 수 있을 것이다.
하지만 막상 이 코드를 실행해보면 MyTextField에서 어떤 글자도 볼 수 없다.
이번엔 뭐가 또 문제란 말인가? State로 상태 변화도 감지시켰는데 왜 안된단 말인가?
컴포즈의 리컴포지션이 어떻게 동작하는지 다시 생각해보자.
- 글자를 입력한다.
- onValueChange에 의해 content가 변경된다.
- content가 변경되었으므로 구독중인 Composable 함수들은 리컴포지션 된다.
- MyTextField composable 함수가 새롭게 실행된다.
- content가 mutableStateOf에 의해 ""로 초기화된다.
- value에 ""가 대입된다.
이런 과정으로 우리가 아무리 타이핑을 해도 value에 빈 문자열만이 들어가게 된다.
그럼 어떻게 해야할까?
우리는 State가 초기 컴포지션에서만 초기화되고 리컴포지션 단계에서는 이전 상태가 기억되길 바란다.
그리고 이를 위한 메서드가 있다. 바로 remember이다.
/**
* Remember the value produced by [calculation]. [calculation] will only be evaluated during the
* composition. Recomposition will always return the value produced by composition.
*/
@Composable
inline fun <T> remember(crossinline calculation: @DisallowComposableCalls () -> T): T =
currentComposer.cache(false, calculation)
remember는 매개변수인 calculation 람다에 의해 생성된 값을 기억해주는 함수이다.
주석에 보이다시피 calculation은 컴포지션 단계에서만 실행되며 리컴포지션은 언제나 컴포지션 단계에서 생성된 값을 반환한다.
이 어마어마한 녀석을 사용해서 우리의 숙원을 달성해보자.
@Composable
private fun MyTextField() {
val content: String = remember { mutableStateOf("") }
OutlinedTextField(
value = content.value,
onValueChange = { content.value = it },
)
}
축하한다! 드디어 우리가 꿈에 그리던 컴포넌트를 만들어냈다. 이제는 진짜 원하던대로 동작한다.
Property Delegation
State를 사용하면서 늘 State의 value 프로퍼티에 접근해야 하는 부분이 귀찮지는 않았는가?
이런 우리의 마음을 알아챈 구글이 State와 MutableState에 property delegation을 지원한다.
@Suppress("NOTHING_TO_INLINE")
inline operator fun <T> State<T>.getValue(thisObj: Any?, property: KProperty<*>): T = value
@Suppress("NOTHING_TO_INLINE")
inline operator fun <T> MutableState<T>.setValue(thisObj: Any?, property: KProperty<*>, value: T) {
this.value = value
}
위와 같이 property delegation을 지원하기 때문에 아래와 같이 간결하게 코드 작성이 가능하다.
@Composable
private fun MyTextField() {
var content by remember { mutableStateOf("") }
OutlinedTextField(
value = content,
onValueChange = { content = it },
)
}
원래는 mutableStateOf 함수를 파고 들어가면서 어떻게 State가 동작하는지, remember는 어떻게 동작하는지, rememberSavable은 무엇인지에 대해서도 다루고 싶었다. 아쉽게도 글이 너무 길어지고 필자의 시간이 부족한 관계로 우선 글을 여기서 마친다.
State에 대한 글이었는데 리컴포지션에 대한 이야기가 대부분의 비중을 차지하는 것 같아 민망하다.
내부적으로 컴포즈 런타임의 클래스들이 어떻게 얽혀있는지를 알아보는데 굉장히 많은 시간이 소요됐다.
살펴보면서 봤던 모든 것들을 담지 못해 아쉽다.
하지만 글을 쓰면서 문득 그런 생각이 든다. 이걸 알아야할까?
그냥 State와 MutableState를 어떻게 사용해야 하는지만 잘 알면 되지 않을까?
구글 개발자들이 추상화해서 잘 포장해놓은 API를 굳이 이렇게 들어가 볼 필요는 없는 것 같기도...
이상 글을 마친다.
나에게 시간이 허락된다면 원래 적으려고 했던 내용들로 2편을 적고싶다..!
'학습' 카테고리의 다른 글
Jetpack Compose의 State와 MutableState 2편 (부제: mutableStateOf의 내부 구현은?) (0) | 2025.02.14 |
---|---|
RecyclerView Drag&Drop, Swipe 1편 (1) | 2025.01.16 |