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

#2 컴포즈 런타임 분석 — 초기 컴포지션

Ji Sungbin
성빈랜드
13 min readJul 14, 2022

--

Photo by Paul Carroll on Unsplash

“Jetpack Compose 런타임에서 일어나는 마법 완전히 파헤치기 — Recomposer” 글과 이어지는 내용입니다. 1편을 읽지 않으신 분은 1편을 먼저 읽어 주세요.

이번 내용에선 RememberObserver 가 등장합니다. 아래 글을 참고해 주세요.

컴포즈 내부를 다루는 시리즈다 보니 이 내용을 이해하기 위해선 다른 개념들을 먼저 이해하고 있어야 하는 상황이 종종 발생합니다. 지금 이 글을 이해하기 위해서도 8개의 글을 먼저 읽어야 합니다. 최대한 쉽게 설명하고 싶은데 어쩔 수 없는점 양해 부탁드립니다.

저번 내용에서는 Recomposer 생성 후 끝납습니다. 이번 글에서는 이 이후 과정을 알아보겠습니다.

Recomposer 생성 후 AbstractComposeView#setContent 가 호출됩니다.

먼저 15번 라인을 보면 글로벌 스냅샷 write 이벤트 감지가 시작되고 있습니다. 이후 21번 라인에서 doSetContent 로 Composition 생성 단계가 시작됩니다.

Composition 생성

6번 라인을 보면 Composition 을 만들어주고, 이어서 WrappedComposition 으로 해당 Composition 을 래핑하고 있습니다.

중요하진 않지만 알고 있을만한 포인트는 Composition 으로 만들어지는 객체는 Composition 이 아닌 ControlledComposition 입니다.

Composition 함수가 사용하는 CompositionImpl 은 ControlledComposition 을 상속받아 구현하고 있고, ControlledComposition 은 Composition 을 상속받고 있습니다.

Composition 과 ControlledComposition 의 정의를 보면 Composition 은 “컴포지션” 정의에 맞는 딱 4개의 필드로만 구성돼 있고, ControlledComposition 은 Composition 을 자유롭게 제어하기 위해 많은 필드로 구성돼 있습니다.

다음으로 Composition 을 래핑하는 WrappedComposition 의 정의를 보겠습니다.

WrappedComposition 은 Composition 과 추가로 LifecycleEventObserver 를 상속받고 있습니다.

즉, WrappedComposition 은 Owner 를 통해 Android View System 과 통신할 수 있도록 도와주는 Composition 의 래퍼이고, ControlledComposition 은 컴포지션이 무효화되고 이후에 리컴포지션되는 방법과 시기를 자유롭게 제어하기 위한 래퍼라고 볼 수 있습니다.

Composition 자체로는 할 수 있는게 초기 컴포지션 밖에 없기 때문에 많은 컴포즈 내부 API 에서 ControlledComposition 을 주로 사용하고 있습니다.

다시 본문으로 돌아와서 doSetCompose 가 생성하는 WrappedComposition 구현에 대해 보겠습니다.

우선 disposed, addedToLifecycle, lastContent 변수를 만들어 주고, setContent 구현에서 13번과 15번 라인을 보면 disposed 가 false 일 때 lastContent 와 addedToLifecycle 을 업데이트하고 있습니다.

disposed 는 onStateChanged 구현에서 업데이트 됩니다.

만약 ON_DESTROY 를 받았다면 dispose() 를 호출하여 disposed 를 true 로 바꾸고, addedToLifecycle 에서 observer 를 지워주고 있습니다. 또한 원래 Composition 의 dispose() 도 호출하고 있습니다.

ON_CREATE 를 받았고 dispose 되지 않았다면 lastContent 로 setContent 를 호출하고 있습니다.

이렇게 다시 setContent 가 시작된다면 addedToLifecycle 의 값이 null 이 아니기 때문에 18번 라인의 original.setContent 로 초기 컴포지션 과정이 시작됩니다.

이 초기 컴포지션 과정에 대해 알아보기 전에 original.dispose() 를 먼저 보겠습니다.

Composition#dispose

이 함수는 해당 Composition 이 사용중인 모든 정보를 지우는 작업을 합니다.

동작을 해석해 보겠습니다.

  1. 사용중인 컴포저블을 지웁니다. (4번 라인)
  2. 슬릇 테이블에 저장된 데이터들을 지웁니다. (10번 라인)
  3. 배치된 노드를 루트부터 모두 지웁니다. (12번 라인)
  4. onForgotten 을 호출합니다. (13번 라인)
  5. 관련 정보들과 Composition 을 모두 지우고 비활성화 합니다. (17번, 19번 라인)

10번 라인에서 사용되는 SlotWriter#removeCurrentGroup 을 보겠습니다.

만약 지울 슬릇이 RememberObserver 라면 forgetting 해주고 있습니다. 이 덕분에 위 Composition#dispose 의 4번 동작이 가능한 것입니다.

다시 Composition#dispose 로 돌아와서 17번과 19번 라인해서 하는 dispose 과정을 보겠습니다. 먼저 composer.dispose() 를 보면 여기에서 사용하는 composer 는 ControlledComposition 을 만드는 과정에서 생성됩니다.

Composer 란 컴포즈 컴파일러가 컴포즈 트리를 빌드하고 상호작용 할 수 있게 도와주는 역할을 합니다. 이러한 Composer 를 생성함과 동시에 Recomposer 에 registerComposer 를 해주고 있습니다. 메서드 이름에서 부터 알 수 있듯이 Recomposer 가 관리할 Composer 를 등록해 줍니다.

composer.dispose() 와 parent.unregisterComposition(this) 는 Composition 이 사용하는 정보를 지우고 비활성화 하는것으로 구현됩니다.

이제 초기 컴포지션 과정을 보겠습니다.

Recomposer#composeInitial

original.setContent 이 실행되면 결국 Recomposer#composeInitial 로 델리게이트됩니다.

Recomposer#composeInitial 는 4가지 작업을 하고 있습니다.

  1. composing snapshot 활성화
  2. Snapshot 시스템 초기화
  3. Recomposer 가 관리하는 Composition 목록 업데이트
  4. 초기 컴포지션 과정 동안 생성된 Change 적용

27번 라인을 보면 우리에게 익숙한 composing snapshot 이 보이고, 29번 라인에서 ControlledComposition#composeContent 를 호출하고 있습니다. 이 함수는 추후 알아보겠습니다.

32번 라인에서는 컴포지션이 이미 진행중이 아니라면 StateObject 가 초기화 됐다고 가정하고 스냅샷 시스템에게 알립니다. 이렇게 알려진 StateObject 들에 대해서만 observer 가 활성화 됩니다.

36번 라인에서는 Recomposer 가 관리하는 Composition 목록에 composition 인스턴스를 추가하고 있고, 이어서 39~40번 라인에서 이 과정까지 오면서 생긴 Change 를 적용하고 있습니다.

Composition.apply[Late]Change 모두 Composition#applyChangesInLocked 를 호출합니다.

Composition#applyChangesInLocked 를 보면 19~21번 라인을 통해 Change 가 반영되고 27~28번 라인을 통해 RememberObserver 를 호출하고 있습니다.

이제 ControlledComposition#composeContent 를 보겠습니다.

ControlledComposition#composeContent

Composer#updateValue 과정은 비교적 덜 중요해서 생략했습니다. content 를 캐싱하는 과정입니다.

컴포즈 런타임 전체에서 가장 중요한 부분이자, 그래프가 끝나는 지점 입니다.

ControlledComposition#composeContent 도 결국은 Composer#doCompose 로 델리게이트 하고 있으며, 인자로 takeInvalidations() 를 주고 있습니다.

takeInvalidations() 는 현재 Composition 에 있는 무효화 목록을 반환하고 이어서 초기화 하는 함수 입니다. 이는 Composition 에서 관리하던 RecomposeScope(리컴포지션이 진행될 컴포지션 영역을 의미합니다) 를 Composer 로 이전하기 위해 사용됩니다.

doCompose 가 하는 첫 번째 일은 인자로 받은 Composition 의 무효화 목록을 Invalidation 으로 래핑해서 Recomposer 가 관리하는 무효화 목록인 invalidations 에 추가하고 있습니다. 이어서 무효화마다 RecomposeScope 를 기준으로 정렬을 해주고 있습니다.

그 후 startRoot 로 슬릇 테이블에서 Composition 에 대한 루트 그룹을 시작하고 다른 필수 필드 및 구조를 초기화 하여 Composition 이 작동하기 위한 조건을 만들어 줍니다.

다음으로 startGroup 으로 컴포저블이 들어갈 그룹을 열어주고 invokeComposble 로 컴포저블 함수를 실행함으로써 컴포지션을 진행합니다.

마지막으로 endGroup 과 endRoot 로 해당 컴포저블의 그룹을 닫고 있습니다.

우리가 사용하는 content 는 람다식(content: @Composable () -> Unit)으로 content 를 배치하기 위해선 content() 로 content 람다를 실행하는 부분이 필요합니다. 이 역할을 invokeComposable 에서 해줍니다.

invokeComposable 로 인해 컴포저블 함수들이 실행되면서 UI 과정이 시작됩니다. invokeComposable 은 content 람다에 인자 2개를 추가로 넣은 Function2 로 캐스팅하여 실행하고 있습니다. 람다에 인자를 강제로 추가하는데 이게 어떻게 예외 없이 작동하는 걸까요?

정답은 컴파일 과정에서의 IR Transform 에 있습니다.

https://sungbin.land/a-fully-diving-into-jetpack-compose-compiler-4f0fd7bead0c

컴파일의 람다 최적화 단계에서 컴포저블 람다는 값을 캡처하는 경우에는 composableLambda 로 래핑되고, 값을 캡처하지 않는 경우에는 composableLambdaInstance 로 래핑됩니다.

두 개의 함수 모두 ComposableLambdaImpl 를 이용해 구현되고 있습니다.

ComposableLambdaImpl 의 핵심인 invoke 부분을 보면 인자로 composer 와 changed 를 받고 있습니다. 이 덕분에 content 를 Function2 로 캐스팅해도 잘 됐던 것입니다.

또한 함수의 시작과 끝에 startRestartGroup, endRestartGroup 으로 RestartGroup 을 생성하고 있다는 것도 중요합니다. 이 과정으로 각각 content 마다 RecomposeScope 가 만들어 집니다. 이 원리로 도넛홀 건너뛰기를 비롯한 smart-recompose 이 가능한 것입니다.

이렇게 해서 ControlledComposition#setContent 가 끝납니다.

이제 우리에게 익숙하기만 했지 내부는 어떻게 진행되는지는 다뤄본 적 없는 Recomposer#composing 을 보겠습니다.

Recomposer#composing

readObserverOf 와 writeObserverOf 를 통해 각각 이벤트에 맞는 람다를 만들어 주고 있습니다. readObserverOf 는 Composition#readObserverOf, writeObserverOf 는 Composition#recordWirteOf 를 사용하여 구현됩니다.

Composition#recordReadOf 는 읽힌 값을 observations 에 RecomposeScope 와 함께 더해주고 있고, Composition#recordWriteOf 는 observations 을 순회하며 value 가 쓰인 RecomposeScope 를 찾아 invalidate 를 호출하고 있습니다.

recordWriteOf 로 인해 마지막에 호출되는 Recomposer#invalidate 를 보면 deriveStateLocked()?.resume(Unit) 을 해주고 있고 덕분에 무효화가 진행됩니다.

마무리

이렇게 Composition#setContent 가 구현되고, 컴포즈 런타임의 핵심 흐름이 끝납니다.

끝!

총 2편에 걸쳐 이 그래프의 모든 영역을 살펴 보았습니다. 이번 글 역시 많은 코드가 생략됐습니다. 직접 컴포즈 코드를 봐보시는걸 추천합니다.

런타임을 다룬 2개의 글 모두 무효화에 대해선 많은 내용이 생략됐습니다. 무효화가 워낙 과정이 많아서 런타임 파헤치기의 3번째 글로 작성될 예정입니다. 간단하게 무효화가 진행되는 원리를 말씀해 드리면 해당 RecomposeScope 가 사용하는 슬릇 테이블의 위치로 들어가서 invokeComposable 을 호출합니다. 이렇게 하면 해당 컴포저블이 사용중인 정보를 효율적으로 덮어 쓸 수 있습니다.

이 글을 쓰기 위해 컴포즈 내부 흐름을 이해하기 까지 약 6개월이 걸렸으며, 각각 개념들을 정리하는 게시글이 5월 말부터 총 53개가 작성됐습니다.

끝까지 봐주셔서 감사합니다. 다음 아티클은 “Jetpack Compose 최고의 성능을 위한 Best Practice” 가 될 예정입니다.

[목차로 돌아가기]

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

--

--

Ji Sungbin
성빈랜드

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