
mutableStateOf
우리는 지난번 글에서 State와 MutableState를 살펴보았고, 그 과정에서 RecomposeScope와 리컴포지션 과정에 대해 알아보았다.
글이 길어져서 다루지 못했지만, 나는 사실 한 가지가 더 궁금했다.
State(MutableState) 인스턴스를 생성할 때 우리는 mutableStateOf 함수를 사용한다. mutableState는 무엇을 어떻게 생성해서 뱉어내는가?
이제부터 그것을 알아보려고 한다.
mutableStateOf 함수는 다음과 같이 생겼다.
@StateFactoryMarker
fun <T> mutableStateOf(
value: T,
policy: SnapshotMutationPolicy<T> = structuralEqualityPolicy()
): MutableState<T> = createSnapshotMutableState(value, policy)
Return a new [MutableState] initialized with the passed in [value].
The MutableState class is a single value holder whose reads and writes are observed by Compose. Additionally, writes to it are transacted as part of the [Snapshot] system.
주어진 [value]로 초기화된 새로운 [MutableState]를 반환합니다.
MutableState 클래스는 단일 값을 저장하는 컨테이너이며, 이 값의 읽기 및 쓰기는 Compose에 의해 관찰됩니다. 또한, 이 값에 대한 변경 사항은 [Snapshot] 시스템의 일부로 트랜잭션됩니다.
바로 createSnapshotMutableState 함수를 호출하기 때문에 크게 볼만한 건 없다.
policy: SnapshotMutationPolicy<T> 파라미터 정도? 일단 나중에 알아보도록 하자.
createSnapshotMutableState 함수는 다음과 같다.
internal actual fun <T> createSnapshotMutableState(
value: T,
policy: SnapshotMutationPolicy<T>
): SnapshotMutableState<T> = ParcelableSnapshotMutableState(value, policy)
단지 ParcelableSnapshotMutableState 인스턴스를 생성해서 반환한다.
여기서 처음에 당황했다.
어라..? 나는 Parcelable하지 않은 객체도 mutableStateOf의 인자로 사용했었는데?
ParcelableSnapshotMutableState
ParcelableSnapshotMutableState는 무엇인가?
@SuppressLint("BanParcelableUsage")
internal class ParcelableSnapshotMutableState<T>(
value: T,
policy: SnapshotMutationPolicy<T>
) : SnapshotMutableStateImpl<T>(value, policy), Parcelable {
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeValue(value)
parcel.writeInt(
when (policy) {
neverEqualPolicy<Any?>() -> PolicyNeverEquals
structuralEqualityPolicy<Any?>() -> PolicyStructuralEquality
referentialEqualityPolicy<Any?>() -> PolicyReferentialEquality
else -> throw IllegalStateException(
"Only known types of MutableState's SnapshotMutationPolicy are supported"
)
}
)
}
override fun describeContents(): Int {
return 0
}
companion object { ... }
}
SnapshotMutableStateImpl을 상속하고 Parcelable을 구현한다.
단지 SnapshotMutableStateImpl을 Parcelable하게 만든 것뿐이다. 다른 추가적인 기능 변화는 없다.
mutableStateOf는 왜 SnapshotMutableStateImpl가 아닌 ParcelableSnapshotMutableState를 반환할까?
앞선 글에서 우리는 remember 메서드를 살펴보았다.
그리고 함께 살펴보진 않았지만 rememberSavable이라는 메서드도 존재한다.
remember는 상태가 리컴포지션으로부터 살아남도록 해준다면, rememberSavable은 비정상적 종료에 대응하기 위해 Bundle에 상태를 저장하고 꺼내 온다.
갑자기 이 이야기를 왜 하냐고?
rememberSaveable로 상태를 관리할 때 상태 값이 Parcelable 해야 하기 때문이다.
우리가 mutableStateOf를 호출하면 ParcelableSnapshotMutableState가 반환된다.
그리고 그 상태를 remember 메서드로 관리한다면 Parcelable 인터페이스의 함수는 호출되지 않는다.
그러므로 우리가 mutableStateOf의 인자로 Parcelable한 객체를 넘기던, 그렇지 않은 객체를 넘기던 상관이 없다.
그러나 rememberSaveable로 관리한다면 Parcelable 인터페이스의 함수가 호출될 것이다. 그러므로 mutableStateOf 함수의 인자로 Parcelable한 객체를 넘겨야 한다. 혹은 custom Saver를 함께 넘겨야 한다.
Saver
Saver는 androidx.compose.runtime.saveable에 위치한 인터페이스로 Original에 해당하는 타입을 Saveable에 해당하는 타입으로 변환하여 저장하며 그 반대로 복원하는 역할을 한다. (Original과 Saveable은 실제 타입이 아닌 제네릭 타입 파라미터다)
interface Saver<Original, Saveable : Any> {
// Convert the value into a saveable one. If null is returned the value will not be saved.
fun SaverScope.save(value: Original): Saveable?
// Convert the restored value back to the original Class. If null is returned the value will
// not be restored and would be initialized again instead.
fun restore(value: Saveable): Original?
}
저장될 수 있는 타입은 SaveableStateRegistry에 의해 결정되며, 기본적으로 번들에 저장될 수 있는 모든 것이 저장 가능하다.
그럼 예시로 하나 구현해보자. 시간을 나타내는 Time이라는 객체를 저장하는 Saver다.
Bundle에 저장 가능한 형태로 변경해야 하므로 String 형태로 변경했다.
data class Time(val hour: Int, val minute: Int)
val saver: Saver<Time, String> = object : Saver<Time, String> {
override fun restore(value: String): Time {
val (hour, minute) = timeString.split(":").map { it.toInt() }
return Time(hour, minute)
}
override fun SaverScope.save(value: Time): String {
return "${value.hour}:${value.minute}"
}
}
하지만 우리는 예시처럼 Time과 같은 단순한 객체가 아닌 MutableState<Time>을 저장하게 된다.
위에서 ParcelableSnapshotMutableState의 writeToParcel 함수를 보면 값과 정책을 저장하는 것을 알 수 있다.
정책은 또 상수로 변환해서 저장하고 있다.
이렇게 귀찮게 Saver를 구현하느니 그냥 Parcelable한 객체를 만들기로 하자.
만약 rememberSavable에 (custom Saver를 넘기지 않으면서) Parcelable하지 않은 객체를 갖는 상태를 넘긴다면 컴파일 에러는 발생하지 않지만, IllegalArgumentException(런타임)이 발생하며 아래와 같은 오류를 볼 수 있다.
java.lang.IllegalArgumentException: MutableState containing Category(name=, color=1) cannot be saved using the current SaveableStateRegistry. The default implementation only supports types which can be stored inside the Bundle. Please consider implementing a custom Saver for this class and pass it as a stateSaver parameter to rememberSaveable().
문득 자바 Collections의 정렬 API가 떠올랐다.
Comparable한 요소를 넘기던가, Comparator를 함께 넘기던가. (Parcelable을 넘기던가 Saver를 함께 넘기던가)
자 이제 mutableStateOf가 왜 Parcelable한 SnapshotMutableStateImpl를 반환하는지 알았으니, 다시 우리가 궁금해야할 것은 SnapshotMutableStateImpl이다.
SnapshotMutableStateImpl (1)
SnapshotMutableStateImpl은 다음과 같이 생겼다.
internal open class SnapshotMutableStateImpl<T>(
value: T,
override val policy: SnapshotMutationPolicy<T>
) : StateObjectImpl(), SnapshotMutableState<T> {
@Suppress("UNCHECKED_CAST")
override var value: T
get() = next.readable(this).value
set(value) = next.withCurrent {
if (!policy.equivalent(it.value, value)) {
next.overwritable(this, it) { this.value = value }
}
}
private var next: StateStateRecord<T> = StateStateRecord(value).also {
if (Snapshot.isInSnapshot) {
it.next = StateStateRecord(value).also { next ->
next.snapshotId = Snapshot.PreexistingSnapshotId
}
}
}
override val firstStateRecord: StateRecord
get() = next
override fun prependStateRecord(value: StateRecord) {
@Suppress("UNCHECKED_CAST")
next = value as StateStateRecord<T>
}
@Suppress("UNCHECKED_CAST")
override fun mergeRecords(
previous: StateRecord,
current: StateRecord,
applied: StateRecord
): StateRecord? {
val previousRecord = previous as StateStateRecord<T>
val currentRecord = current as StateStateRecord<T>
val appliedRecord = applied as StateStateRecord<T>
return if (policy.equivalent(currentRecord.value, appliedRecord.value))
current
else {
val merged = policy.merge(
previousRecord.value,
currentRecord.value,
appliedRecord.value
)
if (merged != null) {
appliedRecord.create().also {
(it as StateStateRecord<T>).value = merged
}
} else {
null
}
}
}
private class StateStateRecord<T>(myValue: T) : StateRecord() {
override fun assign(value: StateRecord) {
@Suppress("UNCHECKED_CAST")
this.value = (value as StateStateRecord<T>).value
}
override fun create(): StateRecord = StateStateRecord(value)
var value: T = myValue
}
...
}
추상 클래스인 StateObjectImpl를 상속하고 SnapshotMutableState 인터페이스를 구현한다.
다음은 StateObjectImpl 추상클래스의 주석과 선언부다.
/**
* A [StateObject] that allows to record reader type when observed to optimize recording of
* modifications. Currently only reads in [Composition] and [SnapshotStateObserver] is supported.
* The methods are intentionally restricted to the internal types, as the API is expected to change.
*/
internal abstract class StateObjectImpl internal constructor() : StateObject { ... }
/**
* Interface implemented by all snapshot aware state objects. Used by this module to maintain the
* state records of a state object.
*/
@JvmDefaultWithCompatibility
interface StateObject { ... }
[StateObject]를 구현한 클래스이며, 관찰될 때 읽기 유형(reader type)을 기록하여 변경 사항 기록(recording of modifications)을 최적화할 수 있도록 합니다. 현재는 [Composition] 및 [SnapshotStateObserver]에서의 읽기만 지원됩니다. 해당 메서드들은 internal 접근제한자를 통해 내부적으로 제한되어 있으며, API가 변경될 가능성이 있기 때문에 의도적으로 외부에서 직접 사용하지 않도록 설계되었습니다.
스냅샷을 인식하는 모든 상태 객체(snapshot-aware state objects)가 구현하는 인터페이스입니다. 이 모듈에서는 상태 객체의 상태 레코드를 유지하는 데 사용됩니다.
주석을 정리하자면 StateObjectImpl은 StateObject를 구현하면서 Compose 스냅샷 시스템에서 “상태 객체”로 동작하기 위한 기반 클래스이다.
그리고 SnapshotMutableStateImpl은 이를 구현한다.
다음은 SnapshotMutableState 인터페이스이다.
/**
* 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. Writes to it are transacted as part of the [Snapshot] system.
*
* In general for correctness: Anything that is mutable, that is read during composition or written
* to during composition, should be a [SnapshotMutableState].
*/
interface SnapshotMutableState<T> : MutableState<T> {
/**
* A policy to control how changes are handled in a mutable snapshot.
*/
val policy: SnapshotMutationPolicy<T>
}
변경 가능한 값(holder)으로, [Composable] 함수가 실행되는 동안 [value] 속성을 읽으면 현재 [RecomposeScope]가 해당 값의 변경 사항을 구독(subscribe)하게 됩니다. [value] 속성이 변경되면, 해당 값을 구독하고 있는 모든 [RecomposeScope]의 recomposition이 예약됩니다. 이 값에 대한 쓰기 작업은 [Snapshot] 시스템의 일부로 트랜잭션(transaction) 처리됩니다.
일반적으로 정확한 동작을 위해: Composition 중에 읽히거나, Composition 중에 쓰기가 발생할 수 있는 모든 변경 가능한 객체는 [SnapshotMutableState]여야 합니다.
변경 가능한 스냅샷에서 값의 변경이 어떻게 처리될지를 제어하는 정책(policy)입니다.
단순히 MutableState에 policy 프로퍼티가 추가된 것이 전부다.
주석의 설명도 이전 글에서 읽었던 MutableState의 주석과 비슷한 것을 알 수 있다.
(하나 다른 점이라면 Snapshot이라는 단어가 추가됐다는 것)
두 가지를 조합해 정리하면 SnapshotMutableStateImpl은 스냅샷을 인식하는 상태 객체이자, 값의 변경을 처리하는 정책을 갖고 있다고 할 수 있다.
SnapshotMutableStateImpl의 대략적인 역할은 알았다. 이제 그 구현부를 살펴볼 차례다.
하지만 그전에 잠시, 앞서 mutableStateOf 함수의 파라미터에서 보고 넘어갔던 policy에 대해 잠시 알아보자.
Policy
정책은 SnapshotMutationPolicy라는 인터페이스를 사용한다. 그리고 이는 세 가지로 구현된다.
@JvmDefaultWithCompatibility
interface SnapshotMutationPolicy<T> {
fun equivalent(a: T, b: T): Boolean
fun merge(previous: T, current: T, applied: T): T? = null
}

NeverEqualPolicy
- 상태 값이 변경될 때마다 무조건 새로운 값이라고 판단한다. 값이 들어오면 변경으로 인식한다.
ReferentialEqualityPolicy
- 참조 동일성(코틀린의 ===)을 통해 비교한다. 상태 값의 내용이 같더라도, 객체 인스턴스 자체가 새롭게 만들어졌다면 변경으로 인식한다.
StructuralEqualityPolicy
- equals(코틀린의 == 비교)를 통해 값의 내용이 동일하지 않을 때만 변경으로 인식한다. 데이터 클래스를 사용하는 경우 적합하다.
- mutableStateOf 함수의 디폴트 파라미터로 우리가 일반적으로 사용하는 정책이다
이 외에도 SnapshotMutationPolicy를 직접 구현하여 커스텀 정책을 사용할 수도 있다.
그럼 다시 돌아가서 SnapshotMutableStateImpl의 구현부를 살펴보자.
SnapshotMutableStateImpl 구현부 (1)
외부에서 State를 사용할 때는 우리가 mutableStateOf의 매개변수로 넘긴 값이 State의 value 프로퍼티로 바로 사용되는 것처럼 보였지만, 실제로는 그렇지 않은 것을 볼 수 있다.
우리가 매개변수로 넘긴 값은 StateStateRecord 타입의 next 프로퍼티를 초기화하는데 사용된다.
그리고 State의 value 프로퍼티는 그 next 프로퍼티에 추가적인 로직을 실행하고 값을 받아온다.
value(param) → next → value(property)
internal open class SnapshotMutableStateImpl<T>(
value: T,
override val policy: SnapshotMutationPolicy<T>
) : StateObjectImpl(), SnapshotMutableState<T> {
@Suppress("UNCHECKED_CAST")
override var value: T
get() = next.readable(this).value
set(value) = next.withCurrent {
if (!policy.equivalent(it.value, value)) {
next.overwritable(this, it) { this.value = value }
}
}
private var next: StateStateRecord<T> = StateStateRecord(value).also {
if (Snapshot.isInSnapshot) {
it.next = StateStateRecord(value).also { next ->
next.snapshotId = Snapshot.PreexistingSnapshotId
}
}
}
...
}
그럼 우선 next 프로퍼티를 살펴봐야겠다. next를 알아야 value의 getter, setter 로직도 알 수 있을테니.
next 프로퍼티는 StateStateRecord 타입이다. StateStateRecord에 대해 알아보자.
StateStateRecord
StateStateRecord는 SnapshotMutableStateImpl 안에 private class로 선언되어 있다.
internal open class SnapshotMutableStateImpl<T>(
value: T,
override val policy: SnapshotMutationPolicy<T>
) : StateObjectImpl(), SnapshotMutableState<T> {
...
private class StateStateRecord<T>(myValue: T) : StateRecord() {
override fun assign(value: StateRecord) {
@Suppress("UNCHECKED_CAST")
this.value = (value as StateStateRecord<T>).value
}
override fun create(): StateRecord = StateStateRecord(value)
var value: T = myValue
}
...
}
그리고 StateRecord라는 추상 클래스를 상속하고 있다. StateRecord는 다음과 같다.
abstract class StateRecord {
internal var snapshotId: Int = currentSnapshot().id
internal var next: StateRecord? = null
abstract fun assign(value: StateRecord)
abstract fun create(): StateRecord
}
snapshotId: Int
해당 StateRecord가 어느 스냅샷 시점에서 생성되었는지 나타내는 고유 ID를 보관한다.
Compose는 스냅샷 ID를 비교하여, 특정 시점의 상태가 최신 상태인지 혹은 이미 과거 버전인지 등을 파악한다.
next: StateRecord?
같은 상태 객체에 대한 여러 시점의 버전을 연결하는 링크드 리스트 구조를 형성한다.
fun assign(value: StateRecord)
동일한 상태 객체의 다른 상태 레코드로부터 값을 복사한다.
fun create(): StateRecord
동일한 상태 객체에 대한 새로운 상태 레코드를 생성한다.
StateStateRecord와 StateRecord를 정리하면
StateRecord는 상태 객체의 스냅샷이며 Compose의 스냅샷 시스템은 한 상태에 대해 여러 StateRecord(버전)을 만들고, 각 버전이 어느 시점(snapshotId)의 값인지 명시한다.
StateStateRecord의 value에 값을 저장하고 next로 다음 노드를 가리키면서 링크드 리스트 구조를 만든다.
여러 버전이 링크드 리스트에서 체이닝 되며, 이는 최신 상태로의 접근이나 되돌리기(rollback) 같은 것들을 가능하게 할 것이다.
여러 스레드에서 동일한 상태를 변경하더라도, 각각의 버전(시점)은 StateRecord로 분리되어 관리되므로 충돌을 최소화할 수 있다.
즉 상태 변경에 대한 동시성 이슈를 해결하기 위해 존재하는 것 같다.
StateStateRecord가 무엇을 하는지 대충은 이해했으니 다시 SnapshotMutableStateImpl의 구현부로 가보자.
SnapshotMutableStateImpl 구현부 (2)
internal open class SnapshotMutableStateImpl<T>(
value: T,
override val policy: SnapshotMutationPolicy<T>
) : StateObjectImpl(), SnapshotMutableState<T> {
@Suppress("UNCHECKED_CAST")
override var value: T
get() = next.readable(this).value
set(value) = next.withCurrent {
if (!policy.equivalent(it.value, value)) {
next.overwritable(this, it) { this.value = value }
}
}
private var next: StateStateRecord<T> = StateStateRecord(value).also {
if (Snapshot.isInSnapshot) {
it.next = StateStateRecord(value).also { next ->
next.snapshotId = Snapshot.PreexistingSnapshotId
}
}
}
...
}
value 프로퍼티
앞에서도 말했듯 우리가 상태를 초기화하기 위해 mutableStateOf에 넘긴 value 파라미터는 value 프로퍼티가 아닌 next 프로퍼티를 초기화하는데 사용된다.
그리고 SnapshotMutableStateImpl의 value 프로퍼티는 next 프로퍼티를 감싸는 getter와 setter, 즉 계산된 프로퍼티라는 것을 알 수 있다.
그럼 value 프로퍼티는 next 프로퍼티에 어떤 가공 과정을 더해서 get하고 set할까?
getter
getter를 보면 StateRecord의 확장함수인 readable를 호출하고 반환된 StateRecord의 value를 반환한다.
get() = next.readable(this).value
fun <T : StateRecord> T.readable(state: StateObject): T
readable은 무엇일까?
readable 함수의 주석을 읽어보면 다음과 같이 적혀있다.
Return the current readable state record for the current snapshot. It is assumed that this is the first record of state
현재 스냅샷에 대해 현재 읽을 수 있는 상태 레코드를 반환합니다. 이것이 상태 레코드의 첫 번째 레코드라고 가정합니다.
그리고 readable의 구현부는 다음과 같다.
- 현재 스냅샷 정보를 가져온다.
- 해당 스냅샷 정보와 현재 상태(SnapshotMutableStateImpl)의 next(LinkedList의 첫번째 노드(StateRecord))를 인자로 오버로딩된 다른 readable 함수를 다시 호출한다.
fun <T : StateRecord> T.readable(state: StateObject): T {
val snapshot = Snapshot.current
snapshot.readObserver?.invoke(state)
return readable(this, snapshot.id, snapshot.invalid) ?: sync {
val syncSnapshot = Snapshot.current
@Suppress("UNCHECKED_CAST")
readable(state.firstStateRecord as T, syncSnapshot.id, syncSnapshot.invalid) ?: readError()
}
}
오버로딩된 readable 함수를 살펴보면 다음 로직을 수행한다.
- LinkedList의 첫번째 노드부터 끝까지 순회하며 유효한 snapshotId에 대해 가장 큰 snapshotId를 가진 StateRecord를 반환한다.
private fun <T : StateRecord> readable(r: T, id: Int, invalid: SnapshotIdSet): T? {
// The readable record is the valid record with the highest snapshotId
var current: StateRecord? = r
var candidate: StateRecord? = null
while (current != null) { // current가 null이면 더 이상 next가 없으므로 LinkedList 순회 끝
if (valid(current, id, invalid)) {
candidate = if (candidate == null) current
else if (candidate.snapshotId < current.snapshotId) current else candidate
}
current = current.next // current를 다음 노드로 변경
}
if (candidate != null) {
@Suppress("UNCHECKED_CAST")
return candidate as T
}
return null
}
그러므로 value 프로퍼티의 getter는 즉 StateRecord LinkedList에서 유효한 가장 최신 StateRecord를 찾아서 반환한다고 볼 수 있다.
setter
우리가 상태에 값을 할당할 때 사용하는 setter는 어떨까? 하나씩 짚어보자.
set(value) = next.withCurrent {
if (!policy.equivalent(it.value, value)) {
next.overwritable(this, it) { this.value = value }
}
}
StateRecord의 확장함수인 withCurrent를 호출하여 아까 getter에서처럼 유효한 최신 StateRecord 인자로 람다 블럭을 실행시킨다.
즉, 이름 그대로 현재 StateRecord로 블럭을 실행시키겠다는 뜻이다.
next.withCurrent { stateRecord: StateRecord -> ... }
아까 살펴봤던 SnapshotMutationPolicy의 equivalent 함수를 이용해 정책에 따라 값이 같은지 확인한다.
(우리는 StructuralEqualityPolicy를 사용하게 될 것이다)
그리고 값이 다르면 블럭을 실행한다.
if (!policy.equivalent(it.value, value)) { ... }
StateRecord의 확장함수인 overwritable 함수를 호출한다.
next.overwritable(this, it) { this.value = value }
호출된 overwritable 함수는 다음과 같다.
internal inline fun <T : StateRecord, R> T.overwritable(
state: StateObject,
candidate: T,
block: T.() -> R
): R {
var snapshot: Snapshot = snapshotInitializer
return sync {
snapshot = Snapshot.current
this.overwritableRecord(state, snapshot, candidate).block()
}.also {
notifyWrite(snapshot, state)
}
}
이 함수를 한 줄씩 살펴보자.
먼저 새로운 스냅샷 버전을 생성한다.
var snapshot: Snapshot = snapshotInitializer
snapshotInitializer의 내부를 따라 들어가보면 다음 로직을 새로운 버전을 생성한다.
const val PreexistingSnapshotId = 1
private var nextSnapshotId = Snapshot.PreexistingSnapshotId + 1
AtomicReference(
GlobalSnapshot(
id = nextSnapshotId++, // 전역으로 관리되는 스냅샷 버전
invalid = SnapshotIdSet.EMPTY
).also {
openSnapshots = openSnapshots.set(it.id)
}
)
전역으로 관리되는 스냅샷의 현재 버전에서 1을 더한 새로운 버전을 생성하고 가져온다.
다시 overwritable 함수로 돌아가자.
생성된 스냅샷 정보, 상태객체, 새로운 값을 인자로 StateRecord의 확장함수인 overwritableRecord를 호출한다.
this.overwritableRecord(state, snapshot, candidate).block()
overwritableRecord 함수는 이름처럼 새로운 데이터로 덮어씌울 StateRecord를 반환한다.
내부 구현을 살펴보면 다음과 같이 동작한다.
- 현재 스냅샷 id가 현재 StateRecord의 snapshotId와 같다면 적절하다고 판단하여 해당 StateRecord를 반환한다.
- 그렇지 않다면 새로운 StateRecord를 생성하고 StateObject(SnapshotMutableStateImpl)의 prependStateRecord 함수를 호출해 상태 객체의 next 프로퍼티를 새로운 StateRecord로 변경하고 StateRecord를 반환한다.
- 위 두 가지 StateRecord에 block 함수를 실행시킨다. 여기서 새로운 값이 StateRecord에 할당된다.
(아래 코드에서 this.value = value가 block이다)
next.overwritable(this, it) { this.value = value }
그리고 이 상태 객체에 쓰기 작업이 일어났다는 것을 알린다.
notifyWrite(snapshot, state)
함수의 내부를 살펴봤지만 어떤 일을 하는지 명확히 알 수는 없었다.
다만 함수명으로 추측하건데 내가 궁금해하던, 상태가 변경되었음을 어떻게 알려줄 것인가에 대한 대답이 아닐까 싶다.
정리하면 value 프로퍼티의 setter는 새로운 스냅샷 버전을 생성하고, 인자로 넘어온 value를 담은 StateRecord를 next에 할당하며 관찰자들에게 알리는 역할을 하고 있다.
이로써 next(StateStateRecord) 프로퍼티와 value 프로퍼티의 getter와 setter를 모두 살펴보았다.
SnapshotMutableStateImpl에 다른 로직들도 있지만, 두 프로퍼티가 가장 중요하다고 생각했다.
굳이 언급하자면 prependStateRecord 함수, merge 함수 등이 있다.
prependStateRecord 함수는 위에서 잠시 언급되었는데, SnapshotMutableStateImpl의 next 프로퍼티를 새로운 StateRecord로 변경한다. 그러나 로직이 매우 간단하여 따로 언급하지는 않았다.
merge 함수는 긴 코드 라인을 차지하고 있지만, 살펴본 결과 커스텀 policy를 제공하지 않으면 그저 null을 반환하므로 크게 하는 일이 없었다.
마무리
이로써 mutableStateOf가 어떤 로직으로 작동하는지, 어떤 객체를 뱉는지, 그 객체는 어떤 역할과 동작을 하는지 알아보았다.
이번에도 역시 글이 너무 길어졌다. 과연 끝까지 보는 사람이 있을지 의문이다. 혹시 끝까지 본 사람이 있다면 댓글에 당근을 남겨달라.
글을 작성하기 위해 많은 코드를 읽고 이해하려고 노력했다. 여전히 이해하지 못한 부분도 많다. 그래도 여러번 반복해서 읽으니 조금이나마 눈이 트이며 이해가 되기 시작했다. 그러나 이해가 잘못된 부분이 있을 수 있으니, 언제든 잘못된 부분은 지적해주시면 감사하겠다.
글을 쓰며 State(MutableState)를 사용하며 크게 신경 써보지 않은 동시성 문제를 해결하기 위해 컴포즈는 내부적으로 (스냅샷과 StateRecord)를 통해 많은 노력을 하고 있다는 것을 깨닫고 재밌었다. 내가 편히 사용하고 있는 API 이면엔 이런 노력이 숨어있었구나 싶다.
이외에도 StateRecord를 관리하기 위해 익숙한 자료구조인 LinkedList가 사용되고, Comparable&Comparator와 비슷한 구조가 Saver에 사용되는 등 재밌는 포인트들이 있었다. 앞으로는 더 많은 것들이 나의 시야에 들어오길 바란다.
'학습' 카테고리의 다른 글
Jetpack Compose의 State와 MutableState 1편 (부제: RecomposeScope) (0) | 2025.01.26 |
---|---|
RecyclerView Drag&Drop, Swipe 1편 (1) | 2025.01.16 |

mutableStateOf
우리는 지난번 글에서 State와 MutableState를 살펴보았고, 그 과정에서 RecomposeScope와 리컴포지션 과정에 대해 알아보았다.
글이 길어져서 다루지 못했지만, 나는 사실 한 가지가 더 궁금했다.
State(MutableState) 인스턴스를 생성할 때 우리는 mutableStateOf 함수를 사용한다. mutableState는 무엇을 어떻게 생성해서 뱉어내는가?
이제부터 그것을 알아보려고 한다.
mutableStateOf 함수는 다음과 같이 생겼다.
@StateFactoryMarker
fun <T> mutableStateOf(
value: T,
policy: SnapshotMutationPolicy<T> = structuralEqualityPolicy()
): MutableState<T> = createSnapshotMutableState(value, policy)
Return a new [MutableState] initialized with the passed in [value].
The MutableState class is a single value holder whose reads and writes are observed by Compose. Additionally, writes to it are transacted as part of the [Snapshot] system.
주어진 [value]로 초기화된 새로운 [MutableState]를 반환합니다.
MutableState 클래스는 단일 값을 저장하는 컨테이너이며, 이 값의 읽기 및 쓰기는 Compose에 의해 관찰됩니다. 또한, 이 값에 대한 변경 사항은 [Snapshot] 시스템의 일부로 트랜잭션됩니다.
바로 createSnapshotMutableState 함수를 호출하기 때문에 크게 볼만한 건 없다.
policy: SnapshotMutationPolicy<T> 파라미터 정도? 일단 나중에 알아보도록 하자.
createSnapshotMutableState 함수는 다음과 같다.
internal actual fun <T> createSnapshotMutableState(
value: T,
policy: SnapshotMutationPolicy<T>
): SnapshotMutableState<T> = ParcelableSnapshotMutableState(value, policy)
단지 ParcelableSnapshotMutableState 인스턴스를 생성해서 반환한다.
여기서 처음에 당황했다.
어라..? 나는 Parcelable하지 않은 객체도 mutableStateOf의 인자로 사용했었는데?
ParcelableSnapshotMutableState
ParcelableSnapshotMutableState는 무엇인가?
@SuppressLint("BanParcelableUsage")
internal class ParcelableSnapshotMutableState<T>(
value: T,
policy: SnapshotMutationPolicy<T>
) : SnapshotMutableStateImpl<T>(value, policy), Parcelable {
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeValue(value)
parcel.writeInt(
when (policy) {
neverEqualPolicy<Any?>() -> PolicyNeverEquals
structuralEqualityPolicy<Any?>() -> PolicyStructuralEquality
referentialEqualityPolicy<Any?>() -> PolicyReferentialEquality
else -> throw IllegalStateException(
"Only known types of MutableState's SnapshotMutationPolicy are supported"
)
}
)
}
override fun describeContents(): Int {
return 0
}
companion object { ... }
}
SnapshotMutableStateImpl을 상속하고 Parcelable을 구현한다.
단지 SnapshotMutableStateImpl을 Parcelable하게 만든 것뿐이다. 다른 추가적인 기능 변화는 없다.
mutableStateOf는 왜 SnapshotMutableStateImpl가 아닌 ParcelableSnapshotMutableState를 반환할까?
앞선 글에서 우리는 remember 메서드를 살펴보았다.
그리고 함께 살펴보진 않았지만 rememberSavable이라는 메서드도 존재한다.
remember는 상태가 리컴포지션으로부터 살아남도록 해준다면, rememberSavable은 비정상적 종료에 대응하기 위해 Bundle에 상태를 저장하고 꺼내 온다.
갑자기 이 이야기를 왜 하냐고?
rememberSaveable로 상태를 관리할 때 상태 값이 Parcelable 해야 하기 때문이다.
우리가 mutableStateOf를 호출하면 ParcelableSnapshotMutableState가 반환된다.
그리고 그 상태를 remember 메서드로 관리한다면 Parcelable 인터페이스의 함수는 호출되지 않는다.
그러므로 우리가 mutableStateOf의 인자로 Parcelable한 객체를 넘기던, 그렇지 않은 객체를 넘기던 상관이 없다.
그러나 rememberSaveable로 관리한다면 Parcelable 인터페이스의 함수가 호출될 것이다. 그러므로 mutableStateOf 함수의 인자로 Parcelable한 객체를 넘겨야 한다. 혹은 custom Saver를 함께 넘겨야 한다.
Saver
Saver는 androidx.compose.runtime.saveable에 위치한 인터페이스로 Original에 해당하는 타입을 Saveable에 해당하는 타입으로 변환하여 저장하며 그 반대로 복원하는 역할을 한다. (Original과 Saveable은 실제 타입이 아닌 제네릭 타입 파라미터다)
interface Saver<Original, Saveable : Any> {
// Convert the value into a saveable one. If null is returned the value will not be saved.
fun SaverScope.save(value: Original): Saveable?
// Convert the restored value back to the original Class. If null is returned the value will
// not be restored and would be initialized again instead.
fun restore(value: Saveable): Original?
}
저장될 수 있는 타입은 SaveableStateRegistry에 의해 결정되며, 기본적으로 번들에 저장될 수 있는 모든 것이 저장 가능하다.
그럼 예시로 하나 구현해보자. 시간을 나타내는 Time이라는 객체를 저장하는 Saver다.
Bundle에 저장 가능한 형태로 변경해야 하므로 String 형태로 변경했다.
data class Time(val hour: Int, val minute: Int)
val saver: Saver<Time, String> = object : Saver<Time, String> {
override fun restore(value: String): Time {
val (hour, minute) = timeString.split(":").map { it.toInt() }
return Time(hour, minute)
}
override fun SaverScope.save(value: Time): String {
return "${value.hour}:${value.minute}"
}
}
하지만 우리는 예시처럼 Time과 같은 단순한 객체가 아닌 MutableState<Time>을 저장하게 된다.
위에서 ParcelableSnapshotMutableState의 writeToParcel 함수를 보면 값과 정책을 저장하는 것을 알 수 있다.
정책은 또 상수로 변환해서 저장하고 있다.
이렇게 귀찮게 Saver를 구현하느니 그냥 Parcelable한 객체를 만들기로 하자.
만약 rememberSavable에 (custom Saver를 넘기지 않으면서) Parcelable하지 않은 객체를 갖는 상태를 넘긴다면 컴파일 에러는 발생하지 않지만, IllegalArgumentException(런타임)이 발생하며 아래와 같은 오류를 볼 수 있다.
java.lang.IllegalArgumentException: MutableState containing Category(name=, color=1) cannot be saved using the current SaveableStateRegistry. The default implementation only supports types which can be stored inside the Bundle. Please consider implementing a custom Saver for this class and pass it as a stateSaver parameter to rememberSaveable().
문득 자바 Collections의 정렬 API가 떠올랐다.
Comparable한 요소를 넘기던가, Comparator를 함께 넘기던가. (Parcelable을 넘기던가 Saver를 함께 넘기던가)
자 이제 mutableStateOf가 왜 Parcelable한 SnapshotMutableStateImpl를 반환하는지 알았으니, 다시 우리가 궁금해야할 것은 SnapshotMutableStateImpl이다.
SnapshotMutableStateImpl (1)
SnapshotMutableStateImpl은 다음과 같이 생겼다.
internal open class SnapshotMutableStateImpl<T>(
value: T,
override val policy: SnapshotMutationPolicy<T>
) : StateObjectImpl(), SnapshotMutableState<T> {
@Suppress("UNCHECKED_CAST")
override var value: T
get() = next.readable(this).value
set(value) = next.withCurrent {
if (!policy.equivalent(it.value, value)) {
next.overwritable(this, it) { this.value = value }
}
}
private var next: StateStateRecord<T> = StateStateRecord(value).also {
if (Snapshot.isInSnapshot) {
it.next = StateStateRecord(value).also { next ->
next.snapshotId = Snapshot.PreexistingSnapshotId
}
}
}
override val firstStateRecord: StateRecord
get() = next
override fun prependStateRecord(value: StateRecord) {
@Suppress("UNCHECKED_CAST")
next = value as StateStateRecord<T>
}
@Suppress("UNCHECKED_CAST")
override fun mergeRecords(
previous: StateRecord,
current: StateRecord,
applied: StateRecord
): StateRecord? {
val previousRecord = previous as StateStateRecord<T>
val currentRecord = current as StateStateRecord<T>
val appliedRecord = applied as StateStateRecord<T>
return if (policy.equivalent(currentRecord.value, appliedRecord.value))
current
else {
val merged = policy.merge(
previousRecord.value,
currentRecord.value,
appliedRecord.value
)
if (merged != null) {
appliedRecord.create().also {
(it as StateStateRecord<T>).value = merged
}
} else {
null
}
}
}
private class StateStateRecord<T>(myValue: T) : StateRecord() {
override fun assign(value: StateRecord) {
@Suppress("UNCHECKED_CAST")
this.value = (value as StateStateRecord<T>).value
}
override fun create(): StateRecord = StateStateRecord(value)
var value: T = myValue
}
...
}
추상 클래스인 StateObjectImpl를 상속하고 SnapshotMutableState 인터페이스를 구현한다.
다음은 StateObjectImpl 추상클래스의 주석과 선언부다.
/**
* A [StateObject] that allows to record reader type when observed to optimize recording of
* modifications. Currently only reads in [Composition] and [SnapshotStateObserver] is supported.
* The methods are intentionally restricted to the internal types, as the API is expected to change.
*/
internal abstract class StateObjectImpl internal constructor() : StateObject { ... }
/**
* Interface implemented by all snapshot aware state objects. Used by this module to maintain the
* state records of a state object.
*/
@JvmDefaultWithCompatibility
interface StateObject { ... }
[StateObject]를 구현한 클래스이며, 관찰될 때 읽기 유형(reader type)을 기록하여 변경 사항 기록(recording of modifications)을 최적화할 수 있도록 합니다. 현재는 [Composition] 및 [SnapshotStateObserver]에서의 읽기만 지원됩니다. 해당 메서드들은 internal 접근제한자를 통해 내부적으로 제한되어 있으며, API가 변경될 가능성이 있기 때문에 의도적으로 외부에서 직접 사용하지 않도록 설계되었습니다.
스냅샷을 인식하는 모든 상태 객체(snapshot-aware state objects)가 구현하는 인터페이스입니다. 이 모듈에서는 상태 객체의 상태 레코드를 유지하는 데 사용됩니다.
주석을 정리하자면 StateObjectImpl은 StateObject를 구현하면서 Compose 스냅샷 시스템에서 “상태 객체”로 동작하기 위한 기반 클래스이다.
그리고 SnapshotMutableStateImpl은 이를 구현한다.
다음은 SnapshotMutableState 인터페이스이다.
/**
* 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. Writes to it are transacted as part of the [Snapshot] system.
*
* In general for correctness: Anything that is mutable, that is read during composition or written
* to during composition, should be a [SnapshotMutableState].
*/
interface SnapshotMutableState<T> : MutableState<T> {
/**
* A policy to control how changes are handled in a mutable snapshot.
*/
val policy: SnapshotMutationPolicy<T>
}
변경 가능한 값(holder)으로, [Composable] 함수가 실행되는 동안 [value] 속성을 읽으면 현재 [RecomposeScope]가 해당 값의 변경 사항을 구독(subscribe)하게 됩니다. [value] 속성이 변경되면, 해당 값을 구독하고 있는 모든 [RecomposeScope]의 recomposition이 예약됩니다. 이 값에 대한 쓰기 작업은 [Snapshot] 시스템의 일부로 트랜잭션(transaction) 처리됩니다.
일반적으로 정확한 동작을 위해: Composition 중에 읽히거나, Composition 중에 쓰기가 발생할 수 있는 모든 변경 가능한 객체는 [SnapshotMutableState]여야 합니다.
변경 가능한 스냅샷에서 값의 변경이 어떻게 처리될지를 제어하는 정책(policy)입니다.
단순히 MutableState에 policy 프로퍼티가 추가된 것이 전부다.
주석의 설명도 이전 글에서 읽었던 MutableState의 주석과 비슷한 것을 알 수 있다.
(하나 다른 점이라면 Snapshot이라는 단어가 추가됐다는 것)
두 가지를 조합해 정리하면 SnapshotMutableStateImpl은 스냅샷을 인식하는 상태 객체이자, 값의 변경을 처리하는 정책을 갖고 있다고 할 수 있다.
SnapshotMutableStateImpl의 대략적인 역할은 알았다. 이제 그 구현부를 살펴볼 차례다.
하지만 그전에 잠시, 앞서 mutableStateOf 함수의 파라미터에서 보고 넘어갔던 policy에 대해 잠시 알아보자.
Policy
정책은 SnapshotMutationPolicy라는 인터페이스를 사용한다. 그리고 이는 세 가지로 구현된다.
@JvmDefaultWithCompatibility
interface SnapshotMutationPolicy<T> {
fun equivalent(a: T, b: T): Boolean
fun merge(previous: T, current: T, applied: T): T? = null
}

NeverEqualPolicy
- 상태 값이 변경될 때마다 무조건 새로운 값이라고 판단한다. 값이 들어오면 변경으로 인식한다.
ReferentialEqualityPolicy
- 참조 동일성(코틀린의 ===)을 통해 비교한다. 상태 값의 내용이 같더라도, 객체 인스턴스 자체가 새롭게 만들어졌다면 변경으로 인식한다.
StructuralEqualityPolicy
- equals(코틀린의 == 비교)를 통해 값의 내용이 동일하지 않을 때만 변경으로 인식한다. 데이터 클래스를 사용하는 경우 적합하다.
- mutableStateOf 함수의 디폴트 파라미터로 우리가 일반적으로 사용하는 정책이다
이 외에도 SnapshotMutationPolicy를 직접 구현하여 커스텀 정책을 사용할 수도 있다.
그럼 다시 돌아가서 SnapshotMutableStateImpl의 구현부를 살펴보자.
SnapshotMutableStateImpl 구현부 (1)
외부에서 State를 사용할 때는 우리가 mutableStateOf의 매개변수로 넘긴 값이 State의 value 프로퍼티로 바로 사용되는 것처럼 보였지만, 실제로는 그렇지 않은 것을 볼 수 있다.
우리가 매개변수로 넘긴 값은 StateStateRecord 타입의 next 프로퍼티를 초기화하는데 사용된다.
그리고 State의 value 프로퍼티는 그 next 프로퍼티에 추가적인 로직을 실행하고 값을 받아온다.
value(param) → next → value(property)
internal open class SnapshotMutableStateImpl<T>(
value: T,
override val policy: SnapshotMutationPolicy<T>
) : StateObjectImpl(), SnapshotMutableState<T> {
@Suppress("UNCHECKED_CAST")
override var value: T
get() = next.readable(this).value
set(value) = next.withCurrent {
if (!policy.equivalent(it.value, value)) {
next.overwritable(this, it) { this.value = value }
}
}
private var next: StateStateRecord<T> = StateStateRecord(value).also {
if (Snapshot.isInSnapshot) {
it.next = StateStateRecord(value).also { next ->
next.snapshotId = Snapshot.PreexistingSnapshotId
}
}
}
...
}
그럼 우선 next 프로퍼티를 살펴봐야겠다. next를 알아야 value의 getter, setter 로직도 알 수 있을테니.
next 프로퍼티는 StateStateRecord 타입이다. StateStateRecord에 대해 알아보자.
StateStateRecord
StateStateRecord는 SnapshotMutableStateImpl 안에 private class로 선언되어 있다.
internal open class SnapshotMutableStateImpl<T>(
value: T,
override val policy: SnapshotMutationPolicy<T>
) : StateObjectImpl(), SnapshotMutableState<T> {
...
private class StateStateRecord<T>(myValue: T) : StateRecord() {
override fun assign(value: StateRecord) {
@Suppress("UNCHECKED_CAST")
this.value = (value as StateStateRecord<T>).value
}
override fun create(): StateRecord = StateStateRecord(value)
var value: T = myValue
}
...
}
그리고 StateRecord라는 추상 클래스를 상속하고 있다. StateRecord는 다음과 같다.
abstract class StateRecord {
internal var snapshotId: Int = currentSnapshot().id
internal var next: StateRecord? = null
abstract fun assign(value: StateRecord)
abstract fun create(): StateRecord
}
snapshotId: Int
해당 StateRecord가 어느 스냅샷 시점에서 생성되었는지 나타내는 고유 ID를 보관한다.
Compose는 스냅샷 ID를 비교하여, 특정 시점의 상태가 최신 상태인지 혹은 이미 과거 버전인지 등을 파악한다.
next: StateRecord?
같은 상태 객체에 대한 여러 시점의 버전을 연결하는 링크드 리스트 구조를 형성한다.
fun assign(value: StateRecord)
동일한 상태 객체의 다른 상태 레코드로부터 값을 복사한다.
fun create(): StateRecord
동일한 상태 객체에 대한 새로운 상태 레코드를 생성한다.
StateStateRecord와 StateRecord를 정리하면
StateRecord는 상태 객체의 스냅샷이며 Compose의 스냅샷 시스템은 한 상태에 대해 여러 StateRecord(버전)을 만들고, 각 버전이 어느 시점(snapshotId)의 값인지 명시한다.
StateStateRecord의 value에 값을 저장하고 next로 다음 노드를 가리키면서 링크드 리스트 구조를 만든다.
여러 버전이 링크드 리스트에서 체이닝 되며, 이는 최신 상태로의 접근이나 되돌리기(rollback) 같은 것들을 가능하게 할 것이다.
여러 스레드에서 동일한 상태를 변경하더라도, 각각의 버전(시점)은 StateRecord로 분리되어 관리되므로 충돌을 최소화할 수 있다.
즉 상태 변경에 대한 동시성 이슈를 해결하기 위해 존재하는 것 같다.
StateStateRecord가 무엇을 하는지 대충은 이해했으니 다시 SnapshotMutableStateImpl의 구현부로 가보자.
SnapshotMutableStateImpl 구현부 (2)
internal open class SnapshotMutableStateImpl<T>(
value: T,
override val policy: SnapshotMutationPolicy<T>
) : StateObjectImpl(), SnapshotMutableState<T> {
@Suppress("UNCHECKED_CAST")
override var value: T
get() = next.readable(this).value
set(value) = next.withCurrent {
if (!policy.equivalent(it.value, value)) {
next.overwritable(this, it) { this.value = value }
}
}
private var next: StateStateRecord<T> = StateStateRecord(value).also {
if (Snapshot.isInSnapshot) {
it.next = StateStateRecord(value).also { next ->
next.snapshotId = Snapshot.PreexistingSnapshotId
}
}
}
...
}
value 프로퍼티
앞에서도 말했듯 우리가 상태를 초기화하기 위해 mutableStateOf에 넘긴 value 파라미터는 value 프로퍼티가 아닌 next 프로퍼티를 초기화하는데 사용된다.
그리고 SnapshotMutableStateImpl의 value 프로퍼티는 next 프로퍼티를 감싸는 getter와 setter, 즉 계산된 프로퍼티라는 것을 알 수 있다.
그럼 value 프로퍼티는 next 프로퍼티에 어떤 가공 과정을 더해서 get하고 set할까?
getter
getter를 보면 StateRecord의 확장함수인 readable를 호출하고 반환된 StateRecord의 value를 반환한다.
get() = next.readable(this).value
fun <T : StateRecord> T.readable(state: StateObject): T
readable은 무엇일까?
readable 함수의 주석을 읽어보면 다음과 같이 적혀있다.
Return the current readable state record for the current snapshot. It is assumed that this is the first record of state
현재 스냅샷에 대해 현재 읽을 수 있는 상태 레코드를 반환합니다. 이것이 상태 레코드의 첫 번째 레코드라고 가정합니다.
그리고 readable의 구현부는 다음과 같다.
- 현재 스냅샷 정보를 가져온다.
- 해당 스냅샷 정보와 현재 상태(SnapshotMutableStateImpl)의 next(LinkedList의 첫번째 노드(StateRecord))를 인자로 오버로딩된 다른 readable 함수를 다시 호출한다.
fun <T : StateRecord> T.readable(state: StateObject): T {
val snapshot = Snapshot.current
snapshot.readObserver?.invoke(state)
return readable(this, snapshot.id, snapshot.invalid) ?: sync {
val syncSnapshot = Snapshot.current
@Suppress("UNCHECKED_CAST")
readable(state.firstStateRecord as T, syncSnapshot.id, syncSnapshot.invalid) ?: readError()
}
}
오버로딩된 readable 함수를 살펴보면 다음 로직을 수행한다.
- LinkedList의 첫번째 노드부터 끝까지 순회하며 유효한 snapshotId에 대해 가장 큰 snapshotId를 가진 StateRecord를 반환한다.
private fun <T : StateRecord> readable(r: T, id: Int, invalid: SnapshotIdSet): T? {
// The readable record is the valid record with the highest snapshotId
var current: StateRecord? = r
var candidate: StateRecord? = null
while (current != null) { // current가 null이면 더 이상 next가 없으므로 LinkedList 순회 끝
if (valid(current, id, invalid)) {
candidate = if (candidate == null) current
else if (candidate.snapshotId < current.snapshotId) current else candidate
}
current = current.next // current를 다음 노드로 변경
}
if (candidate != null) {
@Suppress("UNCHECKED_CAST")
return candidate as T
}
return null
}
그러므로 value 프로퍼티의 getter는 즉 StateRecord LinkedList에서 유효한 가장 최신 StateRecord를 찾아서 반환한다고 볼 수 있다.
setter
우리가 상태에 값을 할당할 때 사용하는 setter는 어떨까? 하나씩 짚어보자.
set(value) = next.withCurrent {
if (!policy.equivalent(it.value, value)) {
next.overwritable(this, it) { this.value = value }
}
}
StateRecord의 확장함수인 withCurrent를 호출하여 아까 getter에서처럼 유효한 최신 StateRecord 인자로 람다 블럭을 실행시킨다.
즉, 이름 그대로 현재 StateRecord로 블럭을 실행시키겠다는 뜻이다.
next.withCurrent { stateRecord: StateRecord -> ... }
아까 살펴봤던 SnapshotMutationPolicy의 equivalent 함수를 이용해 정책에 따라 값이 같은지 확인한다.
(우리는 StructuralEqualityPolicy를 사용하게 될 것이다)
그리고 값이 다르면 블럭을 실행한다.
if (!policy.equivalent(it.value, value)) { ... }
StateRecord의 확장함수인 overwritable 함수를 호출한다.
next.overwritable(this, it) { this.value = value }
호출된 overwritable 함수는 다음과 같다.
internal inline fun <T : StateRecord, R> T.overwritable(
state: StateObject,
candidate: T,
block: T.() -> R
): R {
var snapshot: Snapshot = snapshotInitializer
return sync {
snapshot = Snapshot.current
this.overwritableRecord(state, snapshot, candidate).block()
}.also {
notifyWrite(snapshot, state)
}
}
이 함수를 한 줄씩 살펴보자.
먼저 새로운 스냅샷 버전을 생성한다.
var snapshot: Snapshot = snapshotInitializer
snapshotInitializer의 내부를 따라 들어가보면 다음 로직을 새로운 버전을 생성한다.
const val PreexistingSnapshotId = 1
private var nextSnapshotId = Snapshot.PreexistingSnapshotId + 1
AtomicReference(
GlobalSnapshot(
id = nextSnapshotId++, // 전역으로 관리되는 스냅샷 버전
invalid = SnapshotIdSet.EMPTY
).also {
openSnapshots = openSnapshots.set(it.id)
}
)
전역으로 관리되는 스냅샷의 현재 버전에서 1을 더한 새로운 버전을 생성하고 가져온다.
다시 overwritable 함수로 돌아가자.
생성된 스냅샷 정보, 상태객체, 새로운 값을 인자로 StateRecord의 확장함수인 overwritableRecord를 호출한다.
this.overwritableRecord(state, snapshot, candidate).block()
overwritableRecord 함수는 이름처럼 새로운 데이터로 덮어씌울 StateRecord를 반환한다.
내부 구현을 살펴보면 다음과 같이 동작한다.
- 현재 스냅샷 id가 현재 StateRecord의 snapshotId와 같다면 적절하다고 판단하여 해당 StateRecord를 반환한다.
- 그렇지 않다면 새로운 StateRecord를 생성하고 StateObject(SnapshotMutableStateImpl)의 prependStateRecord 함수를 호출해 상태 객체의 next 프로퍼티를 새로운 StateRecord로 변경하고 StateRecord를 반환한다.
- 위 두 가지 StateRecord에 block 함수를 실행시킨다. 여기서 새로운 값이 StateRecord에 할당된다.
(아래 코드에서 this.value = value가 block이다)
next.overwritable(this, it) { this.value = value }
그리고 이 상태 객체에 쓰기 작업이 일어났다는 것을 알린다.
notifyWrite(snapshot, state)
함수의 내부를 살펴봤지만 어떤 일을 하는지 명확히 알 수는 없었다.
다만 함수명으로 추측하건데 내가 궁금해하던, 상태가 변경되었음을 어떻게 알려줄 것인가에 대한 대답이 아닐까 싶다.
정리하면 value 프로퍼티의 setter는 새로운 스냅샷 버전을 생성하고, 인자로 넘어온 value를 담은 StateRecord를 next에 할당하며 관찰자들에게 알리는 역할을 하고 있다.
이로써 next(StateStateRecord) 프로퍼티와 value 프로퍼티의 getter와 setter를 모두 살펴보았다.
SnapshotMutableStateImpl에 다른 로직들도 있지만, 두 프로퍼티가 가장 중요하다고 생각했다.
굳이 언급하자면 prependStateRecord 함수, merge 함수 등이 있다.
prependStateRecord 함수는 위에서 잠시 언급되었는데, SnapshotMutableStateImpl의 next 프로퍼티를 새로운 StateRecord로 변경한다. 그러나 로직이 매우 간단하여 따로 언급하지는 않았다.
merge 함수는 긴 코드 라인을 차지하고 있지만, 살펴본 결과 커스텀 policy를 제공하지 않으면 그저 null을 반환하므로 크게 하는 일이 없었다.
마무리
이로써 mutableStateOf가 어떤 로직으로 작동하는지, 어떤 객체를 뱉는지, 그 객체는 어떤 역할과 동작을 하는지 알아보았다.
이번에도 역시 글이 너무 길어졌다. 과연 끝까지 보는 사람이 있을지 의문이다. 혹시 끝까지 본 사람이 있다면 댓글에 당근을 남겨달라.
글을 작성하기 위해 많은 코드를 읽고 이해하려고 노력했다. 여전히 이해하지 못한 부분도 많다. 그래도 여러번 반복해서 읽으니 조금이나마 눈이 트이며 이해가 되기 시작했다. 그러나 이해가 잘못된 부분이 있을 수 있으니, 언제든 잘못된 부분은 지적해주시면 감사하겠다.
글을 쓰며 State(MutableState)를 사용하며 크게 신경 써보지 않은 동시성 문제를 해결하기 위해 컴포즈는 내부적으로 (스냅샷과 StateRecord)를 통해 많은 노력을 하고 있다는 것을 깨닫고 재밌었다. 내가 편히 사용하고 있는 API 이면엔 이런 노력이 숨어있었구나 싶다.
이외에도 StateRecord를 관리하기 위해 익숙한 자료구조인 LinkedList가 사용되고, Comparable&Comparator와 비슷한 구조가 Saver에 사용되는 등 재밌는 포인트들이 있었다. 앞으로는 더 많은 것들이 나의 시야에 들어오길 바란다.
'학습' 카테고리의 다른 글
Jetpack Compose의 State와 MutableState 1편 (부제: RecomposeScope) (0) | 2025.01.26 |
---|---|
RecyclerView Drag&Drop, Swipe 1편 (1) | 2025.01.16 |