[Android] remember는 어떻게 값을 기억할까?

신민준·6일 전

Jetpack Compose

목록 보기
3/3
post-thumbnail

들어가며

아직 이전 글들도 마무리하지 못하고 벌써 3번째 글이 나오고 있다...
이번주 내로 무조건 마무리해야겠다..

이번에 알아볼 것은 remember이다.
remember는 Jetpack Compose에서 상태를 기억하기 위해 사용되는 API로 Recomposition에서 상태값을 기억하지만 configure Change에서는 값을 유지하지 못한다.

그렇다면 어째서 remember는 recomposition에서 값을 유지하고 configure change에서는 기억하지 못하는 걸까?
내부 코드를 분석해보자.

remember

remember의 내부는 다음과 같다.

@Composable
public inline fun <T> remember(crossinline calculation: @DisallowComposableCalls () -> T): T =
    currentComposer.cache(false, calculation)
    
@Composable
public inline fun <T> remember(
    key1: Any?,
    crossinline calculation: @DisallowComposableCalls () -> T,
): T {
    return currentComposer.cache(currentComposer.changed(key1), calculation)
}

key가 여러 개인 remember의 경우 key 파라미터만 추가되는 구조고 각 key마다 currentComposer.changed(key) 연산을 해준다.

이 함수에 대해 주석은 다음과 같이 작성되어 있다.

Remember the value returned by calculation if key1 compares equal (==) to the value it had in the previous composition, otherwise produce and remember a new value by calling calculation

만약 key 값이 이전과 동일하다면 이전 값을 기억하고 그렇지 않다면 calculation을 수행하여 새로운 값을 생성하고 기억한다.

key 값이 없다면 재연산을 안한다.

그렇다면 일단 값의 변경 여부를 판별하는 currentComposer.changed()를 확인해보자.

currentComposer.changed()

    @ComposeCompilerApi
    override fun changed(value: Any?): Boolean {
        return if (nextSlot() != value) {
            updateValue(value)
            true
        } else {
            false
        }
    }

changed의 내부는 위와 같다.

nextSlot()

여기서 nextSlot()이라는 함수를 key 값과 비교하는데 이 nextSlot()은 다음과 같다.

internal fun nextSlot(): Any? =
    if (inserting) {
        validateNodeNotExpected()
        Composer.Empty
    } else
        reader.next().let {
            if (reusing && it !is ReusableRememberObserverHolder) Composer.Empty else it
        }

한 줄씩 설명하면

  • inserting : 현재 트리에 노드를 삽입하는 작업을 예약 중인 경우, 첫 트리 구성에서는 항상 참이다.
  • Composer.Empty : 비교 대상이 없다는 의미로 Empty 반환
  • reader.next() : 슬롯 테이블의 reader를 사용해 슬롯 테이블의 현재 값을 가져오고 reader의 인덱스를 +1, 그룹의 끝이면 Empty 반환
  • if (reusing && it !is ReusableRememberObserverHolder) : 노드가 재사용중이라면 Empty 그렇지 않다면 원래 값 반환

노드 재사용이란?

Compose는 UI를 그릴 때 Slot Table에 UI 구조를 저장한다.

reusing == true라는 것은 "UI의 껍데기(Node)는 그대로 두고, 그 안에 들어가는 데이터(State)만 싹 갈아 끼우겠다"는 의미

보통 다음과 같은 상황에서 발생:

  • Lazy Layout (리스트): 스크롤 시 화면에서 사라진 리스트 아이템 노드를 새로 나타나는 아이템을 위해 재사용할 때
  • MovableContent: UI 요소가 트리 상의 한 위치에서 다른 위치로 이동할 때 (예: 화면 가로/세로 전환 시 공유 요소 이동)

이렇게 슬롯 테이블에서 저장된 값을 가져오고 changed()에서 if (nextSlot() != value)로 key 값과 비교한다.

만약 같다면 false를 그렇지 않고 다르다면 updateValue()로 값을 갱신후 true를 반환한다.

그럼 updateValue()를 살펴보자

updateValue()

internal fun updateValue(value: Any?) {
    if (inserting) {
        writer.update(value)
    } else {
        if (reader.hadNext) {
            val groupSlotIndex = reader.groupSlotIndex - 1
            
            if (changeListWriter.pastParent) { 
                changeListWriter.updateAnchoredValue(value, reader.anchor(reader.parent), groupSlotIndex)
            } else {
                changeListWriter.updateValue(value, groupSlotIndex)
            }
        } else {
            changeListWriter.appendValue(reader.anchor(reader.parent), value)
        }
    }
}

이것도 하나씩 설명하자면
1. inserting으로 처음 그리는 중인지 확인 후 값 갱신
2. 그렇지 않다면 reader.hadNext로 이미 값이 있는 상태에서 수정하는지 확인(hadNext는 이전 next()의 결과 Boolean을 저장하는 값)
3. val groupSlotIndex = reader.groupSlotIndex - 1, -1을 하는 이유는 nextSlot()시 인덱스가 +1 되었기에 방금 읽었던 자리를 수정하기 위함
4. if (changeListWriter.pastParent)로 이미 부모 그룹을 지나쳐버렸다면 앵커를 사용하여 수정, 그렇지 않다면 방금 위치로 수정
5. hadNext에서 false 즉 읽을 것이 없었다면 append

이렇게 값을 가져오고 갱신하는 것까지 완료했다.
그렇다면 이제 다시 remember로 돌아가 currentComposer.cache() 연산을 수행해줄 차례이다.

currentComposer.cache

@ComposeCompilerApi
public inline fun <T> Composer.cache(invalid: Boolean, block: @DisallowComposableCalls () -> T): T {
    @Suppress("UNCHECKED_CAST")
    return rememberedValue().let {
        if (invalid || it === Composer.Empty) {
            val value = block()
            updateRememberedValue(value)
            value
        } else it
    } as T
}

위와 같이 되어있고 rememberValue()라는 함수로 값을 가져온다.
이 함수는 이름 그대로 기억하고 있는 값을 가져오는 것이다.

구현은 다음과 같이 되어있다.

override fun rememberedValue(): Any? = nextSlotForCache()

@PublishedApi
@OptIn(InternalComposeApi::class)
internal fun nextSlotForCache(): Any? {
    return if (inserting) {
        validateNodeNotExpected()
        Composer.Empty
    } else
        reader.next().let {
            if (reusing && it !is ReusableRememberObserverHolder) Composer.Empty
            else if (it is RememberObserverHolder) it.wrapped else it
        }
}

위에서 본 nextSlot()과 거의 유사하다.
if(it is RememberObserverHolder)로 해당 값이 RememberObserver 인터페이스를 구현한 객체(예: DisposableEffect, LaunchedEffect 등)를 감싸고 있는 래퍼(Wrapper)인지 확인한다.

이렇게 기억하고 있는 값을 가져온 후 이전에 검사한 invalid와 해당 값이 Empty인지를 확인하고 해당 값을 block으로 계산한 값을 updateRememberedValue()로 갱신 후 반환한다.

마무리

즉, 모든 기억과 갱신은 Composer와 내부의 슬롯 테이블에 의존하고 있는 것을 확인할 수 있다.

그렇기 때문에 Configure Change시 해당 Activity와 연결된 Composition, 슬롯 테이블이 모두 날라가기 때문에 값을 기억하지 못한다.

다음에는 rememberSaveable을 분석해볼 예정이다.

profile
안드로이드 외길

0개의 댓글