Jetpack Compose Internals를 참고하여 작성하였습니다
상태 스냅샷 시스템
상태를 표현하고 상태 변경 사항을 전파해 반응형 경험을 제공하는 방법
스냅샷 상태 (What snapshot state is)
변경 사항을 기억하고 관찰할 수 있는 분리된 상태이며 ComposeRuntime에 정의된 상태 스냅샷 시스템의 일부
(mutableStateOf, mutableStateListof, derivedStateOf 등의 함수를 호출할 때 얻는 상태)
스냅샷 상태 시스템은
- 상태 변경과 변경 전파를 모델링하고 조정
- 독립적 형태로 작성되어 다른 라이브러리에서도 사용이 가능
스냅샷 상태 시스템의 특징
- 스냅샷 상태가 자동으로 관찰되는 메커니즘
- Compose 컴파일러가 모든 Composable 함수와 표현식을 래핑
- 함수 본문 내 모든 스냅샷 상태 읽기를 자동으로 추적
- Composable이 읽는 상태가 변경될 때마다 RecomposeScope 무효화 (invalidation)
- 다음 Recomposition에서 해당 함수를 다시 실행
- 동시성 제어 시스템을 사용한 모델링
- 스냅샷은 상태 격리의 중요한 개념을 나타내며, 동시성 맥락에서 적용되는 격리 수준
- Compose에서는 동시성 처리를 해결하기 위해
- 가변적 상태를 활용해 Composable 함수가 상태 업데이트에 자동으로 반응하도록 설계
- 상태 스냅샷 시스템으로 상태 격리 및 변경 사항 전파를 구현
- 스레드 전체에서 가변적 상태로 안전하게 작업이 가능
동시성 제어 시스템 (Concurrency control systems)과 Compose의 상태 스냅샷 시스템
동시성 제어
- 동시 작업에 대한 올바른 결과를 보장하는 조정 및 동기화 방법
- 시스템 전체의 정확성을 보장하는 규칙 제공
- 조정은 일반적으로 성능에 영향을 미침
- 성능을 크게 저하시키지 않도록 효율적인 접근 방식을 설계하는 것이 중요
- ex) DBMS의 트랜잭션 시스템
조정(Coordination)
동시성 제어 맥락에서 여러 스레드나 프로세스가 공유 자원이나 상태에 안전하게 접근하고 수정할 수 있도록 하는 방법
락(Lock), 뮤텍스(Mutex), 세마포어(Semaphore), 트랜잭션(Transaction) 등등
트랜잭션 메모리와 상태 스냅샷
- 트랜잭션 메모리
- 로드 및 저장 작업 그룹이 원자적인 방식으로 실행되도록 하여 동시성 프로그래밍을 단순화
- Compose 상태 스냅샷 시스템
- 스냅샷의 상태 변경사항이 다른 스냅샷에 전파될 때, 상태 쓰기 작업이 원자적인 단일 작업으로 작용
- 장점
- 병렬 시스템에서 공유 데이터 조정 단순화
- 변경사항을 쉽게 중단, 롤백, 재현할 수 있다
- 재현 가능한 변경 히스토리 유지
동시성 제어 시스템의 유형
- 낙관적 (Optimistic)
- 읽기/쓰기를 차단하지 않고 안전하다고 가정
- 규칙 위반 시 커밋 단계에서 트랜잭션 중단
- 중단된 트랜잭션은 즉시 재실행
- 중단 빈도가 낮을 때 효과적
- Jetpack Compose의 접근 방식
- 상태 업데이트 충돌은 변경 사항을 전파할 시에만 보고, 그렇지 않으면 자동으로 병합되거나 삭제
- 비관적 (Pessimistic)
- 규칙 위반 가능성이 있는 작업을 미리 차단
- 위반 가능성이 사라질 때까지 대기
- 반낙관적 (Semi-optimistic)
- 낙관적과 비관적 방식의 하이브리드
- 특정 상황에서만 작업 차단, 나머지는 낙관적 접근
다중 버전 동시성 제어 (MVCC / Multiversion concurrency control)
Compose가 상태 스냅샷 시스템 구현에 사용하는 방법
데이터 객체가 수정될 때마다 새로운 버전을 생성해 동시성과 성능 향상
객체의 여러 최신 관련 버전을 읽을 수 있도록 지원
Compose 전역 상태는 composition 전체에서 공유되며 다중 스레드에 걸쳐서 존재한다
Recomposition은 병렬로 실행될 수 있어 Composable 함수는 동시에 실행될 수 있어야 한다
병렬로 실행될 시 스냅샷 상태를 동시에 읽거나 수정하기 위해 상태 격리가 필수적이다
격리(Isolation)의 중요성
격리는 동시성 제어의 핵심 속성
데이터 동시 접근 환경에서 정확성을 보장
단순한 격리 방법은 작성자가 작업을 완료할 때까지 모든 구독자를 차단하는 것 (비효율적)
Compose는 MVCC를 사용해서 스냅샷 격리를 구현함으로써 여러 스레드에서 UI 상태를 효율적이고 안전하게 관리하면서 더 나은 성능을 제공
MVCC 특징
- MVCC의 격리 구현 방식
- 데이터의 다중 복사본(스냅샷) 유지
- 각 스레드가 특정 시점의 격리된 상태 스냅샷으로 작업
- 상태의 다중 버전 관리
- 한 스레드의 수정 사항은 모든 로컬 변경 사항이 완료되고 전파될 때까지 다른 스레드에게 보여지지 않는다
- 불변성 활용
- 데이터 쓰기 시 원본 수정 대신 새로운 복사본 생성
- 메모리에 동일 데이터의 다중 버전 저장해 객체의 모든 변경 히스토리 보존
- Compose에서는 이를 "상태 기록"이라 부름
- 일관성 있는 특정 시점의 뷰 생성
- 백업 파일의 속성이며 객체에 대한 모든 참조가 일관되게 유지된다는 것을 의미
- Compose에서는 각 스냅샷에 할당된 고유한 ID로 스냅샷 식별
- 스냅샷 ID는 단조롭게 증가하는 값으로 자연스럽게 순서 유지
- ID로 구분되어 읽기와 쓰기는 잠금장치 없이 서로 격리
스냅샷 (The Snapshot)
스냅샷은 언제든지 생성이 가능하고 특정 시점의 프로그램의 현재 상태(모든 스냅샷 상태 객체)를 반영한다
Compose Runtime은 현재 상태를 모델링하기 위해 Snapshot 클래스 제공
스냅샷의 특징
- 여러 스냅샷 동시에 생성 가능
- 각 스냅샷은 프로그램 상태로부터 자체 격리된 복사본 보유
- 해당 시점의 모든 스냅샷 상태 객체(State 인터페이스 구현체)에 대한 현재 상태의 복사본
- 한 스냅샷에서의 상태 객체 업데이트는 다른 스냅샷의 동일 객체 복사본에 영향을 주지 않음
- 스냅샷 간 격리를 보장
스냅샷 생성
- Snapshot.takeSnapshot() 정적 메서드 호출
- 모든 상태 객체의 현재 값을 저장
- dispose() 함수 호출 전까지 보존
스냅샷 Lifecycle
- 생성된 상태 (created) -> 활성 상태 (active) -> 폐기된 상태 (disposed)
- 스냅샷 사용 완료 시 반드시 폐기 (메모리 누수 발생)
스냅샷 구별
- 스냅샷 생성 시 고유 ID 부여
- ID로 다른 스냅샷이 유지하는 잠재적인 동일한 상태의 다른 버전들과 구별
- 프로그램 상태 버전화 가능
예시) 스냅샷 동작 예시 코드
class Dog {
val name = mutableStateOf("")
}
fun main() {
val dog = Dog()
dog.name.value = "Spot"
val snapshot = Snapshot.takeSnapshot()
dog.name.value = "Fido"
println(dog.name.value)
snapshot.enter {
// 람다식에서 읽은 모든 상태는 스냅샷에서 값을 가져온다
println(dog.name.value)
}
println(dog.name.value)
}
// 결과
Fido
Spot
Fido
snapshot의 enter는 스냅샷에 진입하는 함수이며, 람다를 스냅샷의 컨텍스트에서 실행한다
inline fun <T> enter(block: () -> T): T {
val previous = makeCurrent()
try {
// 이전 스냅샷을 previous에 저장하고 이미 현재 스냅샷이 활성화된 상태이므로
// block 내에서 수행되는 모든 작업은 해당 스냅샷의 컨텍스트에서 실행
return block()
} finally {
restoreCurrent(previous)
}
}
// 현재 스레드의 활성 스냅샷을 이 스냅샷으로 설정
// 호출 전에 활성화되어 있던 이전 스냅샷을 반환해 previous에 저장
internal open fun makeCurrent(): Snapshot? {
val previous = threadSnapshot.get()
threadSnapshot.set(this)
return previous
}
// 이전에 활성화되어 있던 스냅샷을 다시 현재 스레드의 활성 스냅샷으로 복원
internal open fun restoreCurrent(snapshot: Snapshot?) {
threadSnapshot.set(snapshot)
}
takeSnapshot() 함수를 통해 생성된 기본적인 스냅샷은 읽기 전용(read-only)이다
fun takeSnapshot(
readObserver: ((Any) -> Unit)? = null
): Snapshot = currentSnapshot().takeNestedSnapshot(readObserver)
하지만 모든 상태가 읽기 전용인 것은 아니며 값을 업데이트해야 할 수도 있다
Compose는 상태 값을 변경할 수 있는 Snapshot 구현체(MutableSnapshot)를 제공하고 부가적인 추가 구현체도 제공한다
sealed class Snapshot(...) {
class ReadonlySnapshot(...) : Snapshot() {...}
class NestedReadonlySnapshot(...) : Snapshot() {...}
open class MutableSnapshot(...) : Snapshot() {...}
class NestedMutableSnapshot(...) : MutableSnapshot() {...}
class GlobalSnapshot(...) : MutableSnapshot() {...}
class TransparentObserverMutableSnapshot(...) : MutableSnapshot() {...}
}
- ReadonlySnapshot
- 스냅샷 상태 개체 수정 X, 읽기만 가능
- MutableSnapshot
- 스냅샷 상태 개체 읽고 수정 가능
- NestedReadonlySnapshot, NestedMutableSnapshot
- 스냅샷은 트리를 형성하는데, 하위에 대한 읽기 전용 및 가변적인 스냅샷
- 스냅샷에는 중첩된 스냅샷이 여러 개 있을 수 있다
- GlobalSnapshot
- 전역(공유) 상태를 보유하는 가변적인 스냅샷
- 궁극적으로 모든 스냅샷의 루트
- TransparentObserverMutableSnapshot
- 특별한 경우에 사용
- 상태 격리를 적용하지 않고, 상태 객체를 읽고 쓸 때마다 읽기 미 쓰기 관찰자에게 알리기 위한 역할로 존재
스냅샷 트리 (The snapshot tree)
스냅샷은 트리를 형성하며 중첩된 스냅샷이 제한 없이 포함될 수 있다
트리의 루트는 전역 상태를 보유하는 GlobalSnapshot
중첩된 스냅샷의 특징
- 독립적인 스냅샷의 복사본과 유사
- 상위 스냅샷을 활성 상태로 유지하면서 독립적으로 삭제 가능
- Compose의 subcomposition 처리 시 자주 사용됨
- LazyList 아이템 생성 시, BoxWithConstraints 생성 시, SubcomposeLayout 등 사용 시 중첩 스냅샷 사용
- Subcomposition 필요 시 생성되어 해당 상태를 저장하고 격리
- Subcomposition이 사라지면 상위 composition과 스냅샷은 유지하며 중첩 스냅샷만 삭제 가능
- 중첩된 스냅샷의 변경 사항은 상위 스냅샷에 전파됨
- 생성 -> Snapshot.takeNestedSnapshot(), MutableSnapshot.takeNestedMutableSnapshot()
스냅샷과 쓰레딩 (Snapshots and threading)
스냅샷은 스레드 범위 외부에 있는 별도의 구조
스레드는 현재 스냅샷을 가질 수 있지만 스냅샷이 반드시 스레드에 바인딩 될 필요는 없다
스레드는 임의로 스냅샷에 들어오고 나갈 수 있으며 자식 스냅샷은 별도의 스레드에 의해 접근이 가능하다
하위 스냅샷의 변경 사항은 상위 스냅샷에 통지되고, 모든 스레드의 변경 사항은 서로 독립적이다
Snpashot.current를 통해 스레드의 현재 스냅샷을 검색할 수 있다
읽고 쓰기 관찰하기 (Observing reads and writes)
Compose Runtime은 관찰하고 있는 상태 값이 변경될 때 Recomposition 트리거
스냅샷에서 값을 읽는 작업의 경우
스냅샷 생성 시(takeSnapshot()) ReadonlySnapshot 반환 (읽기 O, 수정 X)
takeSnapshot 함수 호출 시 선택적 매개변수로 readObserver 전달이 가능하다
이 관찰자는 enter 호출 내에서 스냅샷의 상태 객체를 읽을 때마다 알림을 수신한다
또한, 중첩된 스냅샷에 대한 읽기 작업은 모든 상위 스냅샷과 관련된 관찰자에게 전달되어야 하기에
readObserver뿐 아니라 상위 스냅샷의 readObserver에도 알린다
예시) snapshotFlow
// State<T> 객체를 Flow로 반환, 값 수집 시 block 람다식 실행
fun <T> snapshotFlow(
block: () -> T
): Flow<T> = flow {
val readSet = MutableScatterSet<Any>()
// 모든 상태에 대해 변경될 때마다 읽어들이는 동작 기록
val readObserver: (Any) -> Unit = {
if (it is StateObjectImpl) {
it.recordReadIn(ReaderKind.SnapshotFlow)
}
readSet.add(it)
}
...
// readObserver는 enter 호출 내에서 스냅샷의 상태 객체를 읽을 때마다 알림 수신
try {
var lastValue = Snapshot.takeSnapshot(readObserver).run {
try {
enter(block)
} finally {
dispose()
}
}
emit(lastValue)
...
} finally {
unregisterApplyObserver.dispose()
}
}
스냅샷에 값을 쓰는 작업의 경우
writeObserver는 가변적인 스냅샷 생성 시 전달이 가능하다
가변적인 스냅샷은 보유한 상태값을 변경할 수 있는 스냅샷이다
가변적인 스냅샷 생성 -> Snapshot.takeMutableSnapshot 호출
fun takeMutableSnapshot(
readObserver: ((Any) -> Unit)? = null, // 읽기 관찰자
writeObserver: ((Any) -> Unit)? = null // 쓰기 관찰자
): MutableSnapshot =
(currentSnapshot() as? MutableSnapshot)?.takeNestedMutableSnapshot(
readObserver,
writeObserver
) ?: error("Cannot create a mutable snapshot of an read-only snapshot")
예시) Recomposer.kt
composition에 대한 모든 읽기와 쓰기를 추적하여 필요 시 자동으로 recomposition 트리거
// composition에 대한 읽기 관찰자 생성
private fun readObserverOf(composition: ControlledComposition): (Any) -> Unit {
return { value ->
composition.recordReadOf(value)
}
}
// composition에 대한 쓰기 관찰자 생성
private fun writeObserverOf(
composition: ControlledComposition,
modifiedValues: IdentityArraySet<Any>?
): (Any) -> Unit {
return { value ->
composition.recordWriteOf(value)
modifiedValues?.add(value)
}
}
// 가변 스냅샷 생성
// 초기 composition 생성할 때와 모든 Recomposition에 대해 호출됨
private inline fun <T> composing(
composition: ControlledComposition,
modifiedValues: IdentityArraySet<Any>?,
block: () -> T
): T {
val snapshot = Snapshot.takeMutableSnapshot(
readObserverOf(composition), writeObserverOf(composition, modifiedValues)
)
try {
return snapshot.enter(block)
} finally {
// composition 중 발생하는 모든 변경 사항을 다른 스냅샷 및 전역 상태에 전파
applyAndCheck(snapshot)
}
}
가변적인 스냅샷 (MutableSnapshots)
값 업데이트(상태)를 추적해 Recomposition을 자동으로 트리거할 때 사용
값 업데이트를 추적하는 가변적인 스냅샷 상태 작업에 활용
특징
- 모든 상태 객체는 스냅샷 내부에서 변경되지 않는 한 생성 시점과 동일한 값 유지
- MutableSnapshot에서 발생한 변경 사항은 다른 스냅샷과 격리
- 변경 사항은 트리 아래쪽에서 위쪽으로 전파
- 중첩된 하위 가변 스냅샷은 먼저 변경 사항 적용 후 상위로 전파 (apply 함수)
- 루트 스냅샷 적용 시 변경 사항이 전역 상태에 도달
- 모든 중첩 스냅샷이 적용된 후에 전역 상태 변경 가능
- 수명 주기
- apply 및 dispose 호출로 종료
- 상태 수정 사항을 다른 스냅샷에 전파하고 리소스 leak 방지
- Composition에서의 활용
- Composition은 가변 스냅샷을 사용해 Composable 함수 변경 사항을 일시적으로 격리
- 나중에 composition 적용 시 전역 상태에 반영
- MutableSnapshot.apply 실패 시 계산된 스냅샷과 변경 사항 삭제되고 새 composition 예약
- apply로 전파된 변경 사항은 원자적으로 적용, 전역 상태나 상위 스냅샷은 모든 변경 사항을 단일 원자성 변경으로 처리
예시1) apply 함수 동작 방식
class Address {
var streetname: MutableState<String> = mutableStateOf("")
}
fun main() {
val address = Address()
address.streetname.value = "Some street"
val snapshot = Snapshot.takeMutableSnapshot()
println(address.streetname.value)
snapshot.enter {
// 스냅샷 context에서 실행
address.streetname.value = "Another street"
println(address.streetname.value)
}
println(address.streetname.value)
// 변경 사항 전파, enter 호출 범위 내 수행된 상태 업데이트가 추적되고 전파
snapshot.apply()
println(address.streetname.value)
}
//
Some street
Another street
Some street
Another street
예시2) withMutableSnapshot
fun main() {
val address = Address()
address.streetname.value = "Some street"
Snapshot.withMutableSnapshot {
println(address.streetname.value)
address.streetname.value = "Another street"
println(address.streetname.value)
}
println(address.streetname.value)
}
//
Some street
Another street
Another street
// 내부적으로 apply 호출
inline fun <R> withMutableSnapshot(
block: () -> R
): R = takeMutableSnapshot().run {
try {
enter(block).also { apply().check() }
} finally {
dispose()
}
}
글로벌 스냅샷과 중첩된 스냅샷 (GlobalSnapshot and nested snapshots)
GlobalSnapshot
- 전역 상태를 유지하는 가변적인 스냅샷 타입
- Bottom-Up 방식으로 다른 스냅샷으로부터 업데이트 수신
- 단 하나만 존재 (Root), 중첩 X
- apply 함수 존재 X, dispose 호출 X
GlobalSnapshot 변경 방법
- Snapshot.advanceGlobalSnapshot 함수를 호출해 변경 (고급화된(advanced) 글로벌 스냅샷)
- 내부적으로 advance 인라인 함수 사용해 변경
- 작동 방식
- 이전 전역 스냅샷 삭제
- 이전 스냅샷의 유효 상태를 수용하는 새로운 스냅샷 생성
Jetpack Compose에서의 전역 스냅샷
- 스냅샷 시스템 초기화 시 전역 스냅샷 생성 (JVM에서는 SnapshotKt.class가 Java/Android 런타임에 의해 초기화될 때 생성)
- 이후 Composer 생성 시 전역 스냅샷 관리자 시작
- 각 composition 처리
- 자체적으로 중첩된 가변적인 스냅샷 생성하고 트리에 연결
- 읽기 및 쓰기 관찰자 등록 후 추적
private inline fun <T> composing(
composition: ControlledComposition,
modifiedValues: IdentityArraySet<Any>?,
block: () -> T
): T {
// composition을 위한 가변 스냅샷 생성. 자동으로 글로벌 스냅샷의 자식 스냅샷으로 트리에 연결
val snapshot = Snapshot.takeMutableSnapshot(
// 읽기, 쓰기 관찰자 등록
readObserverOf(composition), writeObserverOf(composition, modifiedValues)
)
try {
return snapshot.enter(block)
} finally {
applyAndCheck(snapshot)
}
}
- 모든 subcomposition은 자체적으로 중첩된 스냅샷을 생성하고 트리에 연결해 무효화(invalidation) 지원
- Composition 생성 시 GlobalSnapshotManager.ensureStarted 함수 수행
- Compose UI와 같은 플랫폼과의 통합 계약 일부
- 전역 상태에 대한 모든 쓰기 작업 관찰 시작
- AndroidUiDispatcher.Main 컨텍스트에서 스냅샷 적용 시 주기적으로 알림 전달
상태 객체 및 상태 기록 (StateObjects and StateRecords)
다중 버전 동시성 제어는 상태가 기록될 때마다 새로운 버전이 생성되도록 보장하고 Compose는 이를 준수하기에
동일한 스냅샷 상태 객체의 여러 버전이 저장될 수 있다
이로 인한 성능 최적화
- 스냅샷 생성 비용 -> O(1)
- 상태 객체 수에 무관
- 스냅샷 커밋 비용 -> O(N)
- N은 변경된 객체 수
- 가비지 컬렉션 효율성
- 스냅샷에는 스냅샷 데이터 목록이 없어(임시 목록만 존재) 가비지 컬렉터가 자유롭게 상태 객체 수집 가능
상태에 대한 모델링
- 내부적으로 스냅샷 상태 객체는 StateObject로 모델링
- 저장된 각각의 버전은 StateRecord로 모델링
- 각 기록은 단일 버전의 상태 데이터 보유
- 스냅샷에 표시되는 버전은 스냅샷 생성 시점의 가장 최신의 유효한 버전
상태 기록의 유효성
- 유효성은 특정 스냅샷과 관련
- 기록은 생성된 스냅샷 ID와 연결
- 유효한 기록 조건
- 기록 ID가 스냅샷 ID보다 작거나 같음
- 스냅샷의 유효하지 않은 객체 집합(Set)에 포함되지 않음
- 유효하지 않은 기록
- 현재 스냅샷 이후 생성된 기록
- 스냅샷 생성 시 이미 열려있던 스냅샷의 기록 (유효하지 않은 집합에 추가됨)
- 적용되기 전 폐기된 스냅샷에서의 기록 (플래그를 통해)
StateObject
interface StateObject {
val firstStateRecord: StateRecord
fun prependStateRecord(value: StateRecord)
fun mergeRecords(
previous: StateRecord,
current: StateRecord,
applied: StateRecord
): StateRecord? = null
}
- 생성된 가변적인 스냅샷 상태 객체는 StateObject 구현
- mutableStateOf, mutableStateListOf, derivedStateOf 등이 반환하는 인스턴스
- firstStateRecord
- 상태 기록을 담고 있는 LinkedList의 첫 번째 요소에 대한 포인터
- prependStateRecord
- 상태 기록 리스트의 맨 앞에 새 기록을 추가
- 호출 되어 추가될 시 새로운 firstStateRecord가 됨
- mergeRecords
- 충돌하는 상태 변경을 병합하는 함수
- 기본 구현은 null (병합 불가), 구현체에서 오버라이드 해 사용 가능
예시) mutableStateOf(value)
// 내부적으로 StateObject 구현
fun <T> mutableStateOf(
value: T,
policy: SnapshotMutationPolicy<T> = structuralEqualityPolicy()
): MutableState<T> = createSnapshotMutableState(value, policy)
createSnapshotMutableState -> ParcelableSnapshotMutableState -> SnapshotMutableStateImpl
-> StateObjectImpl -> StateObject
StateObject를 구현하기에 다양한 버전의 상태(value)를 저장하는 기록 정보를 LinkedList를 사용해 유지
상태를 읽을 때마다 기록된 목록을 순회하여 가장 최근 값 중 유효한 항목을 찾아서 반환
StateRecord
abstract class StateRecord {
// 기록이 생성된 스냅샷의 ID, 스냅샷과 기록 간의 관계 확인 가능
internal var snapshotId: Int = currentSnapshot().id
// 다음 상태 기록에 대한 참조, 상태 기록들은 LinkedList로 연결
internal var next: StateRecord? = null
// 다른 상태 기록에서 현재 기록으로 값 복사, 값 전달 시 사용
abstract fun assign(value: StateRecord)
// 같은 상태 객체에 대한 새 상태 기록 생성, 스냅샷 시스템이 상태 변경 시 새 기록을 만들 때 사용
abstract fun create(): StateRecord
}
상태 읽기 과정
- 상태를 읽을 때마다 기록된 목록(StateRecord) LinkedList 순회
- 가장 최근 값 중 유효한 기록(가장 높은 스냅샷 ID) 찾기
- 스냅샷 생성 시 모든 상태 객체의 최신 유효 상태 캡처 (스냅샷 수명 동안 사용됨)
읽기와 쓰기 상태 (Reading and writing state)
객체를 읽을 때 주어진 스냅샷 상태(StateObject)에 대한 기록(StateRecord)된 목록을 순회하여 가장 최근 값 중 유효한 것을 찾는다
예시) BasicTextField
@Composable
fun BasicTextField(...) {
...
var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = value)) }
...
}
내부적으로 텍스트 값의 가변적인 상태를 생성하고 remember (SnapshotMutableState 상태 객체 생성)
fun <T> mutableStateOf(
value: T,
policy: SnapshotMutationPolicy<T> = structuralEqualityPolicy()
): MutableState<T> = createSnapshotMutableState(value, policy)
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)
...
}
- 상태 읽기 시 (getter)
- next 상태 기록(LinkedList 첫 번째 기록)에 대한 참조를 통해 readable 함수 호출하여 순회 시작
- readable 함수는 현재 스냅샷에 대해 유효하고 읽을 수 있는 상태 찾기 위해 순회 수행
- 등록된 모든 읽기 관찰자에게 알림
- 각 상태 기록에 대해 유효 조건 확인 후 값 반환
- 상태 쓰기 시 (setter)
- withCurrent 함수는 내부적으로 readable 함수를 호출해 현재 유효한 최신 상태 기록을 가져와 후행 람다식 실행
- 이후 제공된 policy를 사용해 새 값과 현재 값 비교
- 값이 다를 경우 쓰기 프로세스 시작 (overwritable 함수)
- 등록된 모든 쓰기 관찰자에게 값 변경 알림
오래된 기록 제거 또는 재사용하기 (Removing or resuing obsolete records)
오래된 기록 관리의 필요성
- 다중 버전 동시성 제어는 동일한 상태의 여러 버전(기록)을 저장
- 더 이상 사용되지 않고 절대 읽을 수 없는 버전을 제거해야 함
- Compose에서도 오래된 기록을 재활용하는 방법이 있음
열려있는 스냅샷(open snapshots)
- 새로 생성된 스냅샷은 열려 있는 스냅샷 set에 추가되고 닫힐 때까지 유지
- 열려 있는 동안, 스냅샷의 모든 상태 기록은 다른 스냅샷에서 유효하지 않음 (read X)
- 스냅샷이 닫히면 생성된 모든 스냅샷에 대해 기록이 자동으로 유효해짐 (read O)
Compose에서 사용되지 않는 기록의 재활용
- 가장 낮은 순서의 열려있는 스냅샷 추적
- 공개 스냅샷의 ID Set 추적
- ID는 단조롭게 생성되며 증가
- 재사용 가능한 기록 식별
- 기록이 유효하지만 가장 낮은 순서의 열린 스냅샷에 표시되지 않는 경우
다른 스냅샷에서 선택되지 않아 안전하게 재사용
- 기록이 유효하지만 가장 낮은 순서의 열린 스냅샷에 표시되지 않는 경우
- 기록 재사용 시 장점
- 일반적으로 가변 상태 객체에 1 - 2개의 기록만 유지해 성능 향상
변경 사항 전파하기 (Change propagation)
스냅샷 종료 (closing)
- 스냅샷을 닫으면, 열려 있는 스냅샷 ID 집합에서 해당 ID가 제거되고,
이 결과로 생성된 모든 새로운 스냅샷에서 관련 상태 기록을 볼 수 있게 됨
스냅샷 고급화 (Advancing)
- 스냅샷을 닫고 즉시 새 스냅샷으로 교체
- 새로운 스냅샷은 증가된 ID를 얻음
변경 사항 전파 과정
- snapshot.apply 함수 호출 시 해당 범위 내 모든 로컬 변경 사항이 상위나 전역 상태로 전파
- 활성 스냅샷을 담는 집합 목록에서 스냅샷 제거
- 단순히 스냅샷 ID를 열린 스냅샷의 집합 목록에서 제거하면 모든 새로운 스냅샷은 해당 기록을 유효하게 처리
- 상태 충돌(쓰기 작업 시)이 없거나 해결된 후에만 전파될 수 있음
- 충돌 시 처리
- 여러 스냅샷이 동일한 상태 객체 변경 시 충돌 가능성 존재
- 가변적인 스냅샷이 변경 사항을 적용할 시 충돌을 감지해 가능한 한 병합 시도
- 2가지 상황에 따른 처리 방식
- 보류 중인 로컬 변경 사항이 없는 경우
- 가변 스냅샷 -> 미리 닫힘 (열린 스냅샷 집합에서 제거)
- 글로벌 스냅샷 -> 고급화 됨 (닫히고 새로운 글로벌 스냅샷으로 대체)
- 전역 스냅샷 변경 감지 및 적용 관찰자(ApplyObserver)에게 알림
- 보류 중인 로컬 변경 사항이 있는 경우
- 충돌 감지 및 낙관적으로 병합된 기록들 계산
- 모든 보류 중인 변경사항이 현재 값과 다른지 확인
- 실제 변경사항(다른 경우), 낙관적 병합법으로 유지할 기록 결정
- 병합 필요 시 새로운 기록을 생성, 스냅샷 ID 할당, 리스트 앞부분에 추가
- 보류 중인 로컬 변경 사항이 없는 경우
- 적용 실패 시
- 보류 중인 로컨 변경사항이 없을 때와 동일한 과정 수행
- 가변 스냅샷 닫기, 전역 스냅샷 고급화, 적용 관찰자에게 알림
- 중첩된 가변 스냅샷의 경우
- 변경사항을 전역 스냅샷이 아닌 부모에게 전파
- 수정된 모든 상태 객체를 부모의 수정 목록에 추가
- 부모의 무효 스냅샷 집합에서 자신의 ID 제거
쓰기 충돌 병합하기 (Merging write conflicts)
병합을 수행하기 위해 가변적인 스냅샷은 수정된 상태(로컬 변경) 목록을 순회하고 모든 변경에 대해
- 부모 스냅샷 / 전역 상태에서 현재 값(상태 기록) 얻음
- 변경 적용 전 이전 값 얻음
- 변경 적용 후 상태 객체가 가질 값 얻음
- 병합 정책에 의존하는 상태 객체에 위임해 세 값을 자동 병합 시도
현재 Runtime에는 적절한 병합 지원 정책이 없고 충돌 시 런타임 예외 발생
Compose는 상태 객체 접근 시 고유 키 사용으로 충돌 방지
예시) mutableStateOf의 StructuralEqualityPolicy
fun <T> mutableStateOf(
value: T,
policy: SnapshotMutationPolicy<T> = structuralEqualityPolicy()
): MutableState<T> = createSnapshotMutableState(value, policy)
fun <T> structuralEqualityPolicy(): SnapshotMutationPolicy<T> =
StructuralEqualityPolicy as SnapshotMutationPolicy<T>
// 동등 비교를 통해 모든 속성이 비교되어 충돌 방지
private object StructuralEqualityPolicy : SnapshotMutationPolicy<Any?> {
override fun equivalent(a: Any?, b: Any?) = a == b
override fun toString() = "StructuralEqualityPolicy"
}
사용자 정의 병합 정책
Compose는 SnapshotMutationPolicy 인터페이스를 제공해 사용자 정의 병합 정책 지원
fun counterPolicy(): SnapshotMutationPolicy<Int> = object : SnapshotMutationPolicy<Int> {
override fun equivalent(a: Int, b: Int): Boolean = a == b
override fun merge(previous: Int, current: Int, applied: Int) =
current + (applied - previous)
}
- 두 값이 동일할 때만 상태 값이 동등하다고 간주
- 새로 적용된 값과 이전 값의 차이를 현재 값에 가산해 병합 얻음
- 결과적으로 현재 값은 항상 변경된 총량 반영
예시) 사용자 정의된 counterPolicy 정책을 사용한 코드
val state = mutableStateOf(0, counterPolicy())
val snapshot1 = Snapshot.takeMutableSnapshot()
val snapshot2 = Snapshot.takeMutableSnapshot()
try {
// 10개 객체 생성
snapshot1.enter { state.value += 10 }
// 20개 객체 생성
snapshot2.enter { state.value += 20 }
snapshot1.apply().check()
snapshot2.apply().check()
// 총 30개 객체 생성
} finally {
snapshot1.dispose()
snapshot2.dispose()
}