Jetpack Compose 런타임에서 일어나는 마법 완전히 파헤치기 — Invalidation

#3 컴포즈 런타임 분석 — 리컴포지션

Ji Sungbin
성빈랜드
16 min readSep 7, 2022

--

Photo by Ricardo Gomez Angel on Unsplash

컴포즈 런타임 분석 2편이 올라온지 2달만에 작성되는 3편 입니다. 너무 늦어진 만큼 더 최선을 다해서, 이번 글에서는 리컴포지션 프로세스에 대해 알아보려고 합니다.

시작하기 전에 한 가지 기본 지식이 필요합니다. 다들 아시다시피 리컴포지션은 RestartableGroup 에서만 가능하고, 리컴포지션이 진행되기 위한 유일한 조건은 컴포저블에서 참조하고 있는 StateObject 의 쓰기 이벤트 발생 입니다. 즉, 스냅샷 시스템을 기반으로 리컴포지션이 구현돼 있습니다.

이 글에는 아래와 같은 내용들이 포함됩니다. 아직 읽지 않으신 분들은 읽고 이 글을 읽어주시면 보다 정확하게 이해하실 수 있습니다.

이제 시작해 보도록 하겠습니다. 가장 먼저 봐야할 포인트는 StateObject 가 읽혔다는걸 기록하는 함수 입니다.

위 함수의 5번째 줄을 보면 현재 컴포저블의 RecomposeScope 를 읽힌 StateObject 와 함께 observations 변수에 추가하고 있습니다. 이어서 8번째 줄을 보면 만약 읽힌 StateObject 가 DerivedState 라면 해당 DerivedState 가 참조하는 StateObject 를 DerivedState 의 모음에 추가하고 있음을 볼 수 있습니다.

observations 에 컴포저블의 RecomposeScope 를 함께 추가하고 있다는게 추후 중요하게 작용합니다. 이 점은 기억해 주시기 바랍니다. 이 과정으로 리컴포지션이 진행되기 위한 모든 조건이 끝납니다. 이어서 StateObject 에 새로운 StateRecord 가 쓰였을 때 실행되는 람다를 생성하는 recordWriteOf 를 보겠습니다.

새로운 StateRecord 가 기록된 StateObject 와 DerivedState 에 invalidateScopeOfLocked 함수를 실행하고 있습니다. invalidationScopeOfLocked 에서는 observations 에서 인자로 받은 StateObject 를 사용중인 RecomposeScope 를 순회하며 invalidateForResult 를 호출합니다.

observations.forEachScopeOf(value) 덕분에 StateObject 에 새로운 StateRecord 가 쓰였다고 모든 컴포저블을 리컴포지션 하는게 아닌, 해당 StateObject 를 참조하고 있는 컴포저블만 순회하며 리컴포지션을 진행하게 됩니다. 이러한 이유로 리컴포지션을 줄이기 위해선 StateObject 를 최대한 늦게 읽는 것이 중요합니다. 도넛홀 건너뛰기의 내부적인 원리가 됩니다.

forEachScopeOf 를 순회하며 해당 RecomposeScope 에 invalidateForResult 를 호출한 결과로 무효화가 성공적으로 진행됐음(정확히는 진행될 예정)을 뜻하는 InvalidationResult.IMMINENT 가 반환됐다면 중복 observations + invalidation 을 방지하기 위해 무효화가 진행됨을 뜻하는 observationsProcessed 변수에 해당 StateObject 와 RecomposeScope 를 추가합니다.

invalidateForResult 함수를 보겠습니다.

invalidateForResult 는 RecomposeScope 와 StateObject 를 그대로 사용하여 invalidate 함수를 델리게이트 합니다. invalidate 에서는 몇 가지 조건을 검사하고, 이상이 없을 때 invalidateChecked 를 호출하고 있습니다.

  • RecomposeScope 의 Anchor 가 유효해야 함
  • 현재 컴포저블이 리컴포지션 가능한 상태여야 함

RecomposeScope 는 모든 컴포저블에서 어떠한 위치든 할당될 수 있으므로 기본값으로 Anchor 가 지정돼 있지 않습니다. 또한 두 번째 조건인 RecomposeScope#canRecompose 는 해당 컴포저블을 리컴포지션 할 수 있는 람다 인스턴스를 가지고 있는지 여부를 검사하는 로직입니다. 우리는 이미 리컴포지션이 해당 컴포저블을 re-invoke 하는 식으로 작동된다는 것을 알고 있습니다. 이 이유로 리컴포지션이 진행되기 위해선 해당 컴포저블의 람다 인스턴스를 가지고 있어야 하며, 이를 두 번째 조건으로 검사하게 됩니다.

이 조건을 모두 만족하기 위해선 RecomposeScope 생성과 동시에 현재 위치로 Anchor 를 지정해 주고, 해당 컴포저블의 람다 인스턴스를 어딘가에 할당시켜야 할 것으로 보입니다. 저는 이 글을 시작하면서 아래와 같이 말했습니다.

… 리컴포지션은 RestartableGroup 에서만 가능하고 …

또한 역시 우리는 이미 RestartableGroup 이 리컴포지션 하는 방법을 가르친다는 것을 알고 있습니다. 이젠 다들 예상 하셨다시피 위 조건은 RestartableGroup 이 형성되면서 만족됩니다. 이를 통해 왜 컴포저블이 RestartableGroup 에서만 리컴포지션이 가능한 것인지 확인하였습니다. RestartableGroup 형성의 세부 과정은 이 글의 마지막 부분에서 다루게 됩니다.

이어서 리컴포지션이 될 조건을 모두 만족했을 때 실행되는 함수인 invalidateChecked 를 보겠습니다.

이 함수가 리컴포지션의 핵심이 됩니다. 이 함수는 크게 4가지의 역할을 갖습니다.

  • 7~11, 24~26: MovableContent 의 리컴포지션 방식 위임
  • 13~15: 이미 컴포지션 프로세스가 진행 중이라면 바로 리컴포지션 목록에 추가
  • 16~20: 리컴포지션 요청한 인스턴스가 유효하다면 리컴포지션 예정 목록에 추가
  • 27: 리컴포지션 진행

먼저 MovableContent 의 리컴포지션 방식 위임이 나옵니다. MovableContent 는 일반 컴포저블에 비해 리컴포지션 방식이 다릅니다. 일반적인 컴포저블은 리컴포지션시에 SlotTable 을 아예 새로운 정보로 덮어쓰는 반면, MovableContent 의 리컴포지션은 기존 값을 덮어씌우지 않고 SlotTable 의 새로운 위치로 옮기는 역할을 합니다. 이를 충족하기 위해 MovableContent 의 리컴포지션은 새로운 위치(대상 위치)의 Composer 에 위치를 옮기는 무효화를 요청하는 것으로 델리게이트 됩니다.

두 번째로는 이미 Composer 가 컴포지션을 진행중에 있다면 바로 리컴포지션 목록에 추가하고 있습니다. 컴포즈는 여러 컴포지션이 동시에 진행될 수 있습니다. 따라서 만약 기존에 진행 중이던 컴포지션 과정이 있다면 리컴포지션을 요청하는게 아닌, 바로 이어서 리컴포지션을 진행하기 위해 shortcut 목적으로 존재합니다.

shortcut 으로 리컴포지션을 진행하는데 사용되는 tryImminentInvalidation 함수는 아래와 같이 생겼습니다.

간단하게 invalidations 배열에 현재 RecomposeScope 를 추가하고 있습니다. 여기서 사용되는 invalidations 배열은 ComposerImpl#invalidations 입니다. 이 배열을 순회하며 컴포저블 람다 인스턴스를 re-invoke 하게 됩니다. 기존에 진행 중이던 컴포지션 프로세스에 바로 이어서 진행하는 것이기 때문에 더 이상 invalidateChecked 함수 진행을 할 필요가 없어서 return 으로 끝내고 있습니다.

세 번째로 현재 컴포지션 과정이 아니라면 새로운 리컴포지션을 요청하기 위해 리컴포지션을 요청한 인스턴스가 유효하다면 invalidations 배열에 해당 RecomposeScope 를 추가하고 있습니다. 여기서 사용되는 invalidations 배열은 CompositionImpl#invalidations 입니다. 여기서, 왜 invalidations 배열을 하나로 안쓰고 2개로 나눠서 사용했는지 의문이 들 수 있습니다.

모든 컴포저블은 Composition 에서 관리되며, 스냅샷 역시 Composition 에서 관리됩니다. 즉, 새로운 StateRecord 가 기록돼 리컴포지션이 필요한 RecomposeScope 도Composition 에서 관리됩니다. 하지만 컴포지션은 Composition 이 아닌 Composer 에서 관리됩니다. 따라서 리컴포지션을 수행하기 위해선 Composition 에서 스냅샷에 의해 리컴포지션이 필요한 RecomposeScope 를 가져와야 합니다. 이런 이유로 전체 RecomposeScope 와 리컴포지션이 필요한 RecomposeScope 를 CompositionImpl 과 ComposerImpl 이 2개의 클래스에서 받게 됩니다.

마지막으로 parent.invalidate(this) 를 통해 리컴포지션을 진행합니다.

이렇게 총 4가지의 역할을 거쳐 invalidateChecked 함수가 끝납니다. 이 4가지의 역할중 가장 중요한 parent.invalidate(this) 를 보겠습니다.

유레카! 여기에서 deriveStateLocked().resume(Unit) 을 통해 리컴포지션을 시작하게 됩니다. 리컴포지션의 비밀이 밝혀졌습니다. deriveStateLocked() 위를 보면 compositionInvalidations += composition 으로 무효화할 Composition 을 받는 과정이 있습니다. 이 과정을 이해하기 위해 runRecomposeAndApplyChanges 과정을 복습해 보겠습니다.

compositionInvalidations 를 toRecompose 로 옮기고 있고, toRecompose 를 순회하며 performRecompose 를 하고 있습니다. 이후 과정도 쭉 복습해 보겠습니다.

performRecompose 를 따라 recompose 가 호출되고, recompose 에서 takeInvalidations() 의 결과로 composer 에 recompose 를 호출하고 있습니다.

이 부분을 통해 아까 invalidateChecked 에서 추가된 CompositionImpl#invalidationsComposerImpl#invalidations 로 옮기게 됩니다.

이렇게 해서 doCompose 를 통해 invokeComposable 이 실행되고, 이어서 RestartableGroup 형성이 시작됩니다. 이제 RestartableGroup 형성의 세부 과정을 알아보겠습니다. 간단하게 HelloWorld 를 보여주는 컴포저블이 있습니다. 이 컴포저블을 아래와 같이 RestartableGroup 이 형성됩니다.

RestartableGroup 이 형성된 after 에서 크게 4가지를 주목해야 합니다.

  • Composer#startRestartGroup
  • changed == 0 && Composer#skipping
  • Composer#endRestartGroup
  • ScopeUpdateScope#updateScope

처음을 보면 Composer#startRestartGroup 으로 RestartableGroup 을 시작하고 있습니다.

startRestartGroup 은 그룹을 시작하고 새로운 RecomposeScope 를 추가하는 간단한 함수 입니다.

두 번째로 changed 가 0 인지 확인하고, Composer$skipping 이 true 라면 Composer#skipToGroupEnd 를 호출하고 있습니다. changed 가 0 이라는건 바뀐 인자값이 없다는걸 뜻하고, Composer#skipping 은 현재 컴포저블이 리컴포지션을 건너뛸 수 있는지 여부를 나타냅니다. 컴포즈는 꼭 인자의 변경의 아니더라도 내부에서 참조하고 있는 StateObject 혹은 CompositionLocal 과 같이 스냅샷 값이 변경되면 리컴포지션을 해야 합니다. changed 의 경우 인자의 변경 여부를 나타내고, Composer#skipping 의 경우 내부에서 참조하고 있는 객체의 변경 여부를 나타냅니다. 만약 내부에서 참조하고 있는 객체들에 변경 사항이 없다면 해당 컴포저블은 리컴포지션을 건너뛸 수 있기에 Composer#skipping 이 true 가 나옵니다.

이 2개의 조건을 모두 만족한다면 Composer#skipToGroupEnd 가 호출됩니다. skipToGroupEnd 에 대해선 추후 알아보도록 하겠습니다.

이어서 세 번째와 네 번째로 composer.endRestartGroup()?.updateScope 를 하고 있습니다. Composer#endRestartGroup 은 아래와 같이 생겼습니다.

endRestartGroup 함수를 보면 먼저 마지막으로 추가된 RecomposeScope 를 가져오고 있습니다(3번째 줄). invalidateStack 배열에는 무효화가 진행될 수 있는 RecomposeScope 가 저장되며, addRecomposeScope() 에서 추가됩니다. (즉, startRestartGroup 에서 추가됩니다.)

다음으로 6번째 줄을 보면 RecomposeScope#end 를 하고 있습니다. 이 과정은 해당 컴포저블이 더 이상 읽지 않는 StateObject 가 생기면 해당 StateObject 의 observation 을 지우는 과정 입니다.

마지막으로 가장 중요한 9~24번째 줄을 보겠습니다. 이 과정에서는 다음과 같은 조건이 성립된다면 RecomposeScope 에 현재 위치로 Anchor 를 지정하게 됩니다. (invalidate 의 첫 번째 조건)

  • RecomposeScope 가 유효해야 함
  • RecomposeScope 가 skipped 되지 않아야 함 (즉, 내부에서 읽는 스냅샷 값이 있어야 함)
  • RecomposeScope 가 사용되야 함 (즉, 내부에서 리컴포지션을 요청하는 분기가 있어야 함)

위와 같은 조건들을 모두 만족한다면 해당 컴포저블이 리컴포지션이 될 수 있음을 나타내기에 RecomposeScope 에 현재 위치로 Anchor 를 지정하고, RecomposeScope 를 반환합니다. 이렇듯 리컴포지션을 위한 초기화 과정은 모든 컴포저블에 대해 발생하는게 아닌 리컴포지션이 발생할 것이라고 예상되는 컴포저블에 대해서만 진행됩니다.

따라서 위 RestartableGroup 이 형성된 코드의 마지막 과정을 보면 composer.endRestartGroup()?.updateScope 에서 ?. 으로 endRestartGroup 에서 유요한 RecomposeScope 가 반환됐을 때만 이어서 updateScope 를 사용하고 있음을 확인할 수 있습니다. endRestartGroup() 에 이어서 호출되는 updateScope 함수를 보겠습니다.

간단하게 현재 RecomposeScope 의 block 을 업데이트 해주는 함수 입니다. 이 부분을 이해하기 위해 글 상단부에서 언급했던 invalidate 의 두 번째 조건을 다시 보겠습니다.

두 번째 조건인 RecomposeScope#canRecompose 는 해당 컴포저블을 리컴포지션 할 수 있는 “람다 인스턴스”를 가지고 있는지 여부를 검사하는 로직입니다.

위 updateScope 함수가 컴포저블을 리컴포지션 하기 위해 해당 컴포저블의 람다 인스턴스를 저장하는 역할을 해줍니다. 이 함수로 invalidate 가 될 수 있는 조건을 모두 충족시키게 됩니다. 따라서 updateScope 의 실제 사용을 보면 해당 컴포저블을 그대로 사용하고 있음을 볼 수 있습니다.

이렇게 해서 한 가지 빼고 RestartableGroup 형성을 모두 보았습니다.

위에서 건너뛰었던 Composer#skipToGroupEnd 를 보겠습니다.

이 함수는 컴포저블의 리컴포지션을 건너뛸 수 있을 때 실행된다고 했습니다. 하지만 10번째 줄을 보면 recompose 로 시작하는 함수가 보입니다. 이는 하위 컴포저블들을 리컴포지션 하기 위해 사용됩니다. 상위 컴포저블이 리컴포지션을 건너뛸 수 있다고 해서, 상위가 포함하고 있는 하위 컴포저블도 모두 리컴포지션을 건너뛸 수 있다고 확신할 수 없기 때문입니다. 7~11번째 줄에 보이듯이 두 분기에 의해 실행되는 결과가 달라집니다.

  1. invalidations 가 비어있을 경우 요청된 리컴포지션이 없는 것이므로 현재 Composer 의 SlotTable reader 를 해당 컴포저블의 마지막 그룹으로 바로 이동시켜 컴포지션을 하기 위해 SlotTable 을 순회하는 시간을 O(1) 로 바로 끝냄으로써 리컴포지션을 shortcut 으로 종료합니다.
  2. 만약 invalidations 가 비어있지 않을 경우 요청된 리컴포지션이 있는 것이므로 해당 컴포저블의 마지막 그룹까지 순회하며 필요한 리컴포지션을 수행하는 recomposeToGroupEnd 함수를 호출합니다.

여기에서 검사에 사용되는 invalidations 는 ComposerImpl 의 invalidations 입니다. 이 과정에서 핵심 함수로 보이는 recomposeToGroupEnd 함수를 보겠습니다.

54줄이나 되는 꽤 긴 함수가 나왔습니다. 우리는 리컴포지션 과정을 이해하는게 목적이지 직접 구현하는게 목적이 아닙니다. 따라서 리컴포지션 과정의 이해하기 위해 꼭 봐야하는 부분으로 요약해서 아래 부분만 보도록 하겠습니다.

기존의 함수에서 5배나 줄었습니다! 컴포저블의 re-invoke 가 7번째 줄에 있는 firstInRange.scope.compose(this) 를 통해 실행됩니다. firstInRange 는 현재 컴포저블 범위 안에 있는 무효화 요청을 순회하면서 교체되고, 해당 RecomposeScope 에 현재 Composer 를 그대로 사용하여 아래 함수를 실행합니다.

그 결과로 composer.endRestartGroup()?.updateScope 로 설정된 block 이 invoke 됨으로써 리컴포지션 과정이 모두 끝나게 됩니다.

끝!

이번 글에서는 컴포즈 내부에서 리컴포지션이 어떤 프로세스를 거쳐 진행되는지를 알아보았습니다. 일부 개념을 제외하면 생각보다 어렵지 않아서 거의 2달이 걸린 초기 컴포지션 다이빙 보다는 금방 파악할 수 있었습니다. 다음 런타임 파헤지기는 컴포즈가 실제로 어떻게 UI 그리는지를 알아보는(구체화 과정 이후 UI 가 draw 되는 과정) “Jetpack Compose 런타임에서 일어나는 마법 완전히 파헤치기 — Canvas” 가 될 예정입니다. 아마 해당 글이 런타임 파헤지기 시리즈의 마지막이 될 것으로 보고 있습니다.

이번에도 끝까지 읽어주셔서 감사합니다.

[목차로 돌아가기]

안드로이드 개발자 분들을 위한 카카오톡 오픈 채팅방을 운영하고 있습니다.

추가로 성빈랜드에서 함께 코루틴과 안드로이드 프레임워크 내부 딥다이빙을 진행할 스터디원을 모집하고 있습니다. 자세한 내용은 아래 글을 참고해 주세요.

--

--

Ji Sungbin
성빈랜드

Experience Engineers for us. I love development that creates references.